PRD Forge is a self-hosted sectional PRD management system. It stores documents in PostgreSQL split into independently addressable sections, and exposes 31 MCP tools for Claude to read/write individual sections with dependency-aware context loading. The web UI supports inline comments (Google Docs-style) with threaded replies, a vertical nav rail, per-project settings, project creation/switching, and an always-visible project-level Claude chat with optional selection context from sections plus text file attachments.
Four Docker Compose services:
- PostgreSQL 16 (
postgres:16-alpine) — 11 tables, 2 views, schema indb/01_init.sql, seed indb/02_seed.sql(SnapHabit sample, 12 sections), comments indb/03_comments.sql, replies+settings indb/04_replies_and_settings.sql, token stats indb/05_token_stats.sql, chat memory indb/06_chat.sql, MCP activity indb/08_mcp_activity.sql - MCP Server (
mcp_server/server.py, ~1560 lines) — FastMCP with 31 tools, asyncpg, stdio + HTTP transports - Python API (
api/app.py, ~1620 lines) — FastAPI backend, REST endpoints for projects/sections/chat/comments/token-stats - Frontend (
frontend/, Next.js 15) — React 19, Tailwind v4, shadcn/ui. Proxies/api/*to Python API - Shared (
shared/settings.py) — Settings schema + validation, imported by both MCP server and Python API
-
Dependency-aware context loading —
prd_read_sectionreturns full content for the target section plus only summaries of its dependencies. This is the core value proposition. -
Revision-before-update atomicity — When content is updated, the current content is saved as a revision BEFORE the update, inside the same transaction with
SELECT ... FOR UPDATE. No content is ever lost. -
JSON error responses — All tools return JSON. Errors are
{"error": "message"}. Tools never raise exceptions to the MCP transport layer. -
Partial UPDATE —
prd_update_sectiononly modifies fields that are explicitly provided. Dynamic SQL construction avoids overwriting fields the caller didn't intend to change. -
Slug validation — All slugs must match
^[a-z0-9]([a-z0-9-]*[a-z0-9])?$, max 100 chars. Validated at the application layer before DB queries. -
Same-project dependency enforcement —
section_dependencieshas aproject_idcolumn with composite foreign keysFOREIGN KEY (project_id, section_id) REFERENCES sections(project_id, id)on both sides. This gives schema-level enforcement that both sides of a dependency belong to the same project. Theprd_add_dependencytool also usesINSERT ... SELECTwith a same-project JOIN for friendly error messages.
| File | Purpose | ~Lines |
|---|---|---|
docker-compose.yml |
4-service stack definition | 75 |
db/01_init.sql |
Schema DDL (tables, indexes, triggers, views) | 154 |
db/02_seed.sql |
SnapHabit sample seed (12 sections, 12 deps) | 570 |
db/05_token_stats.sql |
Token usage estimates table | 12 |
db/06_chat.sql |
Chat tables migration (project chats + messages) | 41 |
db/08_mcp_activity.sql |
MCP write activity tracking | 12 |
docs/tool-reference.md |
MCP tool table and usage examples | 100 |
docs/data-model.md |
ER diagram, dependency types, statuses, tags | 141 |
docs/scaling.md |
Multi-user and scaling guidance | 90 |
db/03_comments.sql |
Inline comments table (section_comments) | 20 |
db/04_replies_and_settings.sql |
Comment replies + project settings tables | 40 |
shared/settings.py |
Settings schema, defaults, validation (shared) | 30 |
mcp_server/server.py |
MCP server with 31 tools + activity tracking | 1560 |
api/app.py |
FastAPI Python API (REST endpoints, chat, auth) | 1620 |
api/auth.py |
Python auth middleware (session validation, role resolution) | 100 |
api/auth_contract.py |
Better Auth table/column contract verification | 50 |
api/errors.py |
Structured error responses (9 error codes) | 70 |
api/ws.py |
WebSocket token minting + verification (HMAC-SHA256) | 80 |
frontend/ |
Next.js 15 frontend (React 19, Tailwind v4, shadcn/ui) | — |
frontend/server.ts |
Custom Node server with WS proxy | 45 |
frontend/prisma/schema.prisma |
Better Auth tables (7 models) | 110 |
tests/conftest.py |
Test fixtures (db pool, cleanup, monkeypatch) | 64 |
tests/test_mcp_tools.py |
MCP tool tests (53 tests) | 700+ |
tests/test_ui_api.py |
UI endpoint tests (71 tests) | 940+ |
tests/test_smoke.py |
CI smoke tests (MCP, DB, UI, seed data) | 85 |
.github/workflows/test.yml |
CI: runs 124 tests on every PR to main | 50 |
.github/workflows/build-and-push.yml |
CD: builds 3 Docker images on tag push | 65 |
Group 1 — Project Management (3): prd_list_projects, prd_create_project, prd_delete_project
Group 2 — Section CRUD (7): prd_list_sections, prd_read_section (primary tool — 3 queries), prd_create_section, prd_update_section (atomic revision), prd_delete_section, prd_move_section, prd_duplicate_section
Group 3a — Dependencies (3): prd_add_dependency (idempotent upsert, same-project validation), prd_remove_dependency, prd_suggest_dependencies (FTS-based content similarity suggestions)
Group 3b — Inline Comments (4): prd_list_comments (all comments across project with section pointers — use FIRST to find feedback), prd_add_comment (anchored to selected text with prefix/suffix context), prd_resolve_comment (mark as done after implementing, ownership-validated), prd_delete_comment (ownership-validated)
Group 3c — Comment Replies (1): prd_add_comment_reply (threaded replies with author 'user'/'claude', ownership-validated)
Group 4 — Context & Search (4): prd_get_overview (starting point), prd_search (FTS + tag:prefix), prd_get_changelog, prd_token_stats (cumulative token savings per project)
Group 5 — Revisions (3): prd_get_revisions, prd_read_revision, prd_rollback_section (atomic with backup)
Group 6 — Export/Import (2): prd_export_markdown (full doc, use sparingly), prd_import_markdown (configurable heading level or manual delimiter, fence-aware)
Group 7 — Batch (1): prd_bulk_status
Group 8 — Project Settings (2): prd_get_settings (merged defaults + DB overrides), prd_update_settings (validates against SETTINGS_SCHEMA)
- projects — id, name, slug (unique), description, version, organization_id, created_by, created_at, updated_at
- sections — id, project_id, parent_section_id, slug, title, section_type, sort_order, status, content, summary, tags[], notes, word_count (generated), updated_by, created_at, updated_at. UNIQUE(project_id, slug), UNIQUE(project_id, id)
- section_revisions — id, section_id, revision_number, content, summary, change_description, created_by, created_at. UNIQUE(section_id, revision_number)
- section_dependencies — id, project_id, section_id, depends_on_id, dependency_type, description. Composite FKs enforce same-project. UNIQUE(section_id, depends_on_id)
- section_comments — id, section_id, anchor_text, anchor_prefix, anchor_suffix, body, resolved, created_by, created_at, updated_at
- comment_replies — id, comment_id (FK section_comments), author ('user'|'claude' CHECK), body, created_at
- project_settings — project_id (PK, FK projects), settings (JSONB, merged with defaults at read time), updated_at
- token_estimates — id, project_id (FK projects), operation, full_doc_tokens, loaded_tokens, created_at
- project_chats — id, project_id, chat_type ('main'|section), section_id, created_by, created_at, updated_at. Multi-thread support.
- chat_messages — id, chat_id (FK project_chats), role, content, metadata (JSONB), created_by, created_at
- mcp_activity — id, project_id, tool_name, detail (JSONB), user_id, created_at. 12 mutating tools.
- project_members — id, project_id, user_id, role (owner/admin/editor/commenter/viewer), created_at, updated_at
- audit_events — id, project_id, user_id, action, resource, detail (JSONB), created_at
- password_reset_tokens — id, user_id, token, expires_at, used, created_by, created_at
- prdforge_bootstrap — id, setup_type (unique), completed, created_at
- Better Auth tables: user, session, account, verification, organization, member, invitation (managed by Prisma)
- section_tree (view) — sections + project_slug, parent_slug, parent_title, revision_count, dep_out_count, dep_in_count
- project_changelog (view) — revisions joined with section and project slugs
IMPORTANT: Design-first rule. When making ANY UI changes (new features, layout changes, component additions), ALWAYS update the Pencil design file (/Users/artem/git/design/main.pen) FIRST using the Pencil MCP tools, then implement the code changes in ui/app.py. This ensures the design stays in sync with the implementation.
- Open the
.penfile withget_editor_state() - Find the relevant screen frame and update/add the design
- Verify with
get_screenshot() - Then implement the code changes
When the user says "do tasks", "pick up tasks", "work on ready tasks", or similar — follow this workflow:
# Get all items in Ready status
gh project item-list 2 --owner @me --format json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for item in data.get('items', []):
if item.get('status') == 'Ready':
print(f\"#{item.get('number', '?')} — {item['title']} (Size: {item.get('size', '?')}, Priority: {item.get('priority', '?')})\")"Present the list to the user and ask which tasks to implement (or implement all if user says so).
Enter plan mode. For each task:
- Read the GitHub issue body (
gh issue view <number>) - Identify affected files and dependencies between tasks
- Determine implementation order (simplest first, dependencies respected)
For each task being worked on, first move it to "In progress":
# Move to "In progress" BEFORE starting implementation
gh project item-edit --project-id PVT_kwHOAcEVs84BR7UH --id <ITEM_ID> \
--field-id PVTSSF_lAHOAcEVs84BR7UHzg_nCMQ --single-select-option-id e56656c8Then:
- Implement the changes following existing code patterns
- Add tests in
tests/test_mcp_tools.pyand/ortests/test_ui_api.py - Run tests after each task:
.venv/bin/pytest tests/ -v -x - Update docs (
README.md,docs/tool-reference.md,AGENTS.md) if needed
After implementation + tests pass, move the task to In review (NOT Done):
# Move to "In review"
gh project item-edit --project-id PVT_kwHOAcEVs84BR7UH --id <ITEM_ID> \
--field-id PVTSSF_lAHOAcEVs84BR7UHzg_nCMQ --single-select-option-id 65241aafDo NOT close the issue yet. The user will verify and move to Done / close manually.
| Column | Option ID | Meaning |
|---|---|---|
| Backlog | bf802e7a |
Not prioritized yet |
| Ready | 83e6dfa4 |
Prioritized, ready to pick up |
| In progress | e56656c8 |
Currently being worked on |
| In review | 65241aaf |
Implemented, needs user verification before Done |
| Done | d0415fde |
Verified and complete |
Project ID: PVT_kwHOAcEVs84BR7UH
Status field ID: PVTSSF_lAHOAcEVs84BR7UHzg_nCMQ
# Find item IDs for specific issues
gh project item-list 2 --owner @me --format json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for item in data.get('items', []):
print(f\"{item['id']} #{item.get('number', '?')} {item['title']} [{item.get('status', '?')}]\")"Adding a new MCP tool:
- Add the tool function to the appropriate group in
server.py - Use
@mcp.tool(annotations={...})decorator with correct hints - Return
json.dumps(result)orjson.dumps({"error": "..."})— never raise - Add tests in
test_mcp_tools.py
Modifying the schema:
- Update
db/01_init.sql(this runs only on first boot) - For existing databases: write a migration SQL and run it manually
- Update
db/02_seed.sqlif the seed data format changed - Update AGENTS.md schema reference
Adding an API endpoint:
- Add the route to
api/app.py - Query the pool directly
- Add a test in
test_ui_api.py
After ANY changes:
- Update
README.md— architecture diagram, tool reference table, data model diagram, project structure tree, and any affected sections - Update
AGENTS.md— file map (line counts), tool groups, schema reference, gotchas - Keep tool counts, table counts, and line estimates accurate across both docs
Running tests:
docker compose up -d postgres
cd /Users/artem/git/personal/PRDforge
uvx --from pytest pytest tests/ -v --override asyncpg --override fastapi --override httpx --override uvicorn
# Or with a venv:
python -m venv .venv && .venv/bin/pip install -r tests/requirements.txt
.venv/bin/pytest tests/ -vImportant: Always use uvx or a virtual environment for running tests — never install packages into the global Python environment.
Always work on feature branches, never commit directly to main or multiuser.
| Type | Pattern | Example |
|---|---|---|
| New feature | feature/<short-name> |
feature/pdf-export |
| Bug fix | fix/<short-name> |
fix/sidebar-status-dot |
| Documentation | docs/<short-name> |
docs/token-stats-metrics |
| Refactor | refactor/<short-name> |
refactor/auth-middleware |
# 1. Create branch from main
git checkout -b feature/my-feature main
ONLY AFTER USER APPROVE
# 2. Make changes, commit
git add <files>
git commit -m "Description of changes"
# 3. Push and create PR targeting main
git push -u origin feature/my-feature
gh pr create --base main --title "Short title" --body "..."- One branch per logical change. Don't mix unrelated features in one branch.
- Base branch: Always
main. All PRs targetmain. - PR required: All changes go through pull requests — no direct pushes to
main. - Delete after merge: Feature branches are deleted after PR is merged.
- BACKUP BEFORE ANY DATABASE CHANGES. The PRDforge database is a live service storing user PRD projects, sections, revisions, comments, and dependencies. Before ANY operation that touches the database (schema migrations, ALTER TABLE, column type changes, docker compose down -v, volume operations), create a backup first:
This is non-negotiable. Lost data cannot be recreated — PRD content is user-authored.
docker compose exec postgres pg_dump -U prdforge prdforge > backup_$(date +%Y%m%d_%H%M%S).sql
- NEVER DROP THE DATABASE. Do not run
DROP SCHEMA,DROP DATABASE,DROP TABLE,docker compose down -v, or any destructive SQL/Docker command. For schema changes, useALTER TABLEwithIF NOT EXISTSguards. For restores, usepsql < backup.sql— never drop-and-restore. Always ask the user before any destructive database operation. - Never add AI/agent signatures to git commits. No "Co-Authored-By: Claude", "Generated by AI", etc.
- Never install packages globally. Always use
uvxor a virtual environment (.venv).
word_countis a GENERATED ALWAYS column — never include in INSERT or UPDATE- asyncpg returns
Recordobjects, not dicts — usedict(row)orrow['field'] - PostgreSQL
TEXT[]arrays: pass Python lists directly to asyncpg parent_section=""(empty string) means "move to root" (setparent_section_id = NULL)- Slug collisions:
prd_import_markdowngenerates slugs from headings — duplicates are skipped unlessreplace_existing=true - FastMCP lifespan: uses
@asynccontextmanagerpattern - Cross-project dependency guard: composite FK at schema level + INSERT...SELECT with JOIN at app level
- Import parser splits on configurable heading level (default
##) —###and deeper are part of the section body. Manual delimiter mode (<!-- split -->) also available. /healthendpoint is a v1.1 addition beyond the original PRD §5.1 spec (5 routes → 6)- Inline comments use text anchoring (prefix + anchor_text + suffix) not character offsets — survives minor content edits. If anchor text can't be found after major edits, comment becomes "orphaned" (shown in panel but not highlighted)
- Comment highlights use
range.surroundContents()which fails if selection spans multiple DOM elements — in that case the comment is still saved and shown in the panel, just without inline highlight - Comment ownership validation: ALL comment mutation routes/tools (resolve, delete, reply) MUST validate that the comment belongs to the specified project/section using a JOIN through
sections → projects. Useresolve_comment_id()helper in MCP server, or inline ownership JOIN in UI endpoints. Never mutate by comment_id alone. - Shared settings module:
shared/settings.pyis the single source of truth forSETTINGS_SCHEMAandvalidate_settings(). Bothserver.pyandapp.pyimport from it viasys.path.insert(0, "..") prd_update_sectionsupportsresolve_commentsparam — atomically resolves comments + auto-replies ifclaude_comment_repliessetting is enabled- Chat is experimental — disabled by default, gated behind
chat_enabledproject setting. All 4 chat endpoints return 403 when disabled. Enable in Settings → Experimental Features. - Chat model selector —
chat_modelsetting (sonnet/opus/haiku) per-project, stored inproject_settingsJSONB. Passed to CLI as--modelflag. For API provider, mapped viaAPI_MODEL_MAPdict. - Section status editor —
PATCH /api/projects/{slug}/sections/{section}supports updating status, tags, title, summary. Valid statuses:draft,in_progress,review,approved,outdated. - Web UI Claude chat uses Anthropic API key for authentication
- Chat tool execution uses an allowlist of MCP tool functions with project slug enforced server-side
- Web UI chat can attach selected section text as context; backend stores this in
chat_messages.metadata.selection_contextand rehydrates it into future model history turns - Web UI chat can attach local files (text payloads); backend stores them in
chat_messages.metadata.attachmentsand injects their content into future model history turns - Web UI chat provider can be overridden per project via settings (
chat_provider) - Web UI chat renders selected context inline inside user message bubbles and triggers best-effort live refresh of project/section views after each completed assistant turn
- Chat attachment limits are controlled via env vars:
CHAT_MAX_ATTACHMENTS,CHAT_ATTACHMENT_MAX_BYTES,CHAT_ATTACHMENT_MAX_CHARS,CHAT_ATTACHMENTS_MAX_TOTAL_CHARS GET /api/projects/{slug}now backfills missing initial revisions for sections; if a project has chat activity and zero dependencies, it backfills a linear references chain so Dependencies/Changelog tabs are populated for chat-generated projectsGET /api/projects/{slug}/token-statsnow includesproject_stats(sections,dependencies,revisions) in addition to token-savings metricsinstall.shnow auto-selects a free host PostgreSQL port (5432, else first free in5433-5500) and exportsPOSTGRES_PORTso Docker + Claude Desktop config stay in sync
- Markdown import parser is heuristic — fence-state tracking handles common code blocks but won't handle malformed or exotic markdown constructs. Now supports configurable heading level and manual delimiters.
- No latency/error-rate metrics — structured logging,
/health, andprd_token_statsprovide operability and token savings tracking - No reverse proxy hardening — localhost-only binding prevents accidental LAN exposure
Tests run against a real PostgreSQL database (no mocks). The conftest.py provides:
- Session-scoped connection pool
- Auto-cleanup that preserves seed data between tests
- Monkeypatched pool for MCP server tests
- httpx
AsyncClientwithASGITransportfor UI tests
Concurrency tests verify that concurrent prd_update_section calls don't produce revision_number collisions or content loss (using SELECT ... FOR UPDATE inside transactions).