Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/deploy-mcp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Deploy MCP

on:
push:
branches: [main]
paths:
- 'packages/mcp/**'
workflow_dispatch:

jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read

steps:
- uses: actions/checkout@v4

- name: Login to ACR
uses: docker/login-action@v3
with:
registry: docforgeregistry2.azurecr.io
username: ${{ secrets.DOCFORGEBACKEND_REGISTRY_USERNAME }}
password: ${{ secrets.DOCFORGEBACKEND_REGISTRY_PASSWORD }}

- name: Build and Push
run: |
docker build \
-t docforgeregistry2.azurecr.io/docforge-mcp:${{ github.sha }} \
-t docforgeregistry2.azurecr.io/docforge-mcp:latest \
packages/mcp
docker push docforgeregistry2.azurecr.io/docforge-mcp --all-tags

- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.DOCFORGEBACKEND_AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.DOCFORGEBACKEND_AZURE_TENANT_ID }}
subscription-id: ${{ secrets.DOCFORGEBACKEND_AZURE_SUBSCRIPTION_ID }}

- name: Deploy to Container Apps
run: |
az containerapp update \
--name docforge-mcp \
--resource-group rg-docforge \
--image docforgeregistry2.azurecr.io/docforge-mcp:${{ github.sha }}
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
![Azure OpenAI](https://img.shields.io/badge/Azure_OpenAI-gpt--4o--mini-orange)
![Inngest](https://img.shields.io/badge/Inngest-Orchestration-purple)
![Clerk](https://img.shields.io/badge/Auth-Clerk-black)
![MCP](https://img.shields.io/badge/MCP-Server-blueviolet)

DocForge supports two document creation modes. The **AI-guided** mode drives users through a structured six-phase workflow (discovery → alignment → generation → refinement → audit → completed) that produces documents which are internally consistent and grounded in the user's actual intent. The **free editor** mode gives users a full-featured Markdown editor with version history and heading-based navigation — no AI workflow required.

A built-in **MCP server** lets AI agents (e.g. Claude Code) read and write to DocForge editor documents in real-time — the agent reads local files using its own tools, writes structured content into a shared document, and the user watches it fill in live via SSE.

**[Live Demo](https://doc-forge.dev)**

<div align="center">
Expand Down Expand Up @@ -41,6 +44,16 @@ Documents flow through six sequential phases, each with defined inputs, outputs,

A single-pane Markdown editor for writing without AI assistance. Features a heading-derived navigation tree (h1–h5), source/preview toggle, auto-save with 1.5 s debounce, manual version snapshots, inline version diff viewer, and Markdown export.

### MCP Agent Mode

An external AI agent creates an editor document via the MCP server, writes content section by section, and the user watches it appear in real-time in the browser. Every write and snapshot is attributed in the Activity panel. The agent and user share the same live document — no copy-pasting required.

```
Claude Code → docforge MCP server → DocForge REST API → SSE → browser
```

See [packages/mcp/README.md](packages/mcp/README.md) for setup and [packages/mcp/TOOLS.md](packages/mcp/TOOLS.md) for the full tool reference.

---

## Features
Expand All @@ -53,6 +66,8 @@ A single-pane Markdown editor for writing without AI assistance. Features a head
- **Interactive refinement** — Per-section AI editing loop with full version history and one-click rollback.
- **Automated audit** — Cross-section consistency check that catches terminology drift, technology contradictions, and scope violations.
- **Free editor with version history** — Full Markdown editor with heading-derived navigation (h1–h5), auto-save, manual snapshots, inline diff viewer, and Markdown export.
- **MCP server** — AI agents connect via the Model Context Protocol to create and write editor documents in real-time. Supports both stdio (local) and Streamable HTTP (hosted) transports. Every agent action is attributed by API key name in the Activity panel.
- **API key management** — Users generate named API keys (one per agent/environment) from the DocForge UI. Keys appear in the activity log and version history, making multi-agent usage fully traceable.
- **Input & output guardrails** — User inputs are validated before any AI call; malformed AI responses are retried automatically.
- **Token-aware context management** — Char-based token estimation with per-phase budgets truncates context intelligently to avoid exceeding model limits.
- **Multi-document type support** — Document types, section definitions, and AI prompt templates are database-driven, making it straightforward to add new document types beyond RFC.
Expand All @@ -68,9 +83,10 @@ A single-pane Markdown editor for writing without AI assistance. Features a head
|---|---|
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4, Zustand, React Router 7 |
| Backend | FastAPI 0.115, SQLAlchemy 2 (async), Alembic, Pydantic v2 |
| MCP Server | Python, `mcp` SDK, httpx, uvicorn — stdio + Streamable HTTP transports |
| Orchestration | Inngest (event-driven durable workflows) |
| AI | Azure OpenAI (gpt-4o-mini), structured JSON output + tool calling |
| Auth | Clerk (RS256 JWT, JWKS validation) |
| Auth | Clerk (RS256 JWT, JWKS validation) + API key (SHA-256, `X-API-Key` header) |
| Infra | PostgreSQL 16, Docker Compose |

---
Expand All @@ -83,12 +99,17 @@ The system has four runtime services: a React SPA, a FastAPI backend, an Inngest
graph TD
FE["React SPA\n(polling + SSE)"]
API["FastAPI :8000\n(routers + auth + guardrails)"]
MCP["MCP Server :8001\n(stdio or Streamable HTTP)"]
INN["Inngest :8288\n(durable workflow engine)"]
PG[("PostgreSQL 16")]
AI["Azure OpenAI\n(structured output + tools)"]
Clerk["Clerk\n(JWT / JWKS)"]
Agent["AI Agent\n(e.g. Claude Code)"]

FE -- "REST + SSE (Clerk JWT)" --> API
Agent -- "MCP tools (X-API-Key)" --> MCP
MCP -- "REST (X-API-Key)" --> API
API -- "SSE push" --> FE
API -- "send event" --> INN
INN -- "step callbacks" --> API
API -- "AsyncSession" --> PG
Expand Down Expand Up @@ -155,12 +176,27 @@ npm run dev:backend

Frontend: `http://localhost:5173` — Backend: `http://localhost:8000`

**6. (Optional) Run the MCP server locally**

```bash
cd packages/mcp
pip install -e .
export DOCFORGE_API_KEY="your-key" # generate one in the DocForge UI
export DOCFORGE_API_BASE="http://localhost:8000/api"
docforge-mcp # stdio — add to Claude Code config
# or
docforge-mcp-server # HTTP on :8001
```

See [packages/mcp/README.md](packages/mcp/README.md) for Claude Code integration details.

### Environment Variables

| File | Variables |
| File / Scope | Variables |
|---|---|
| `packages/backend/.env` | `DATABASE_URL`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT`, `INNGEST_EVENT_KEY`, `INNGEST_SIGNING_KEY`, `CLERK_JWKS_URL`, `WEEKLY_CREDITS` (default `5`), `GUIDED_DOCUMENT_COST` (default `3`), `EDITOR_DOCUMENT_COST` (default `1`) |
| `packages/frontend/.env` | `VITE_API_BASE`, `VITE_CLERK_PUBLISHABLE_KEY` |
| MCP server (env) | `DOCFORGE_API_KEY` (stdio mode), `DOCFORGE_API_BASE` (default `http://localhost:8000/api`), `DOCFORGE_FRONTEND_BASE` (default `http://localhost:5173`), `HOST`, `PORT` (HTTP mode, default `8001`) |

See [`packages/backend/.env.example`](packages/backend/.env.example) and [`packages/frontend/.env.example`](packages/frontend/.env.example) for full reference.

Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,18 @@ services:
environment:
VITE_API_BASE: http://localhost:8000/api

mcp:
build:
context: ./packages/mcp
ports:
- "8001:8001"
depends_on:
- backend
environment:
DOCFORGE_API_BASE: http://backend:8000/api
DOCFORGE_FRONTEND_BASE: http://localhost:5173
PORT: "8001"
# DOCFORGE_API_KEY is not set here — each agent authenticates via X-API-Key header

volumes:
pgdata:
37 changes: 37 additions & 0 deletions packages/backend/alembic/versions/015_add_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""add api_keys table

Revision ID: 015
Revises: 014
Create Date: 2026-05-21

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID


revision: str = "015"
down_revision: Union[str, None] = "014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", sa.String(255), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("key_hash", sa.String(64), nullable=False, unique=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])


def downgrade() -> None:
op.drop_index("ix_api_keys_user_id", table_name="api_keys")
op.drop_table("api_keys")
38 changes: 38 additions & 0 deletions packages/backend/alembic/versions/016_add_document_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""add document_activity table

Revision ID: 016
Revises: 015
Create Date: 2026-05-21

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID


revision: str = "016"
down_revision: Union[str, None] = "015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"document_activity",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("document_id", UUID(as_uuid=True), sa.ForeignKey("documents.id", ondelete="CASCADE"), nullable=False),
sa.Column("api_key_id", UUID(as_uuid=True), sa.ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True),
sa.Column("action_type", sa.String(30), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("bytes_delta", sa.Integer(), nullable=True),
sa.Column("version_id", UUID(as_uuid=True), sa.ForeignKey("section_versions.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_document_activity_document_id", "document_activity", ["document_id"])


def downgrade() -> None:
op.drop_index("ix_document_activity_document_id", table_name="document_activity")
op.drop_table("document_activity")
25 changes: 25 additions & 0 deletions packages/backend/alembic/versions/017_add_api_key_harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""add harness column to api_keys

Revision ID: 017
Revises: 016
Create Date: 2026-05-24

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "017"
down_revision: Union[str, None] = "016"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column("api_keys", sa.Column("harness", sa.String(50), nullable=True))


def downgrade() -> None:
op.drop_column("api_keys", "harness")
63 changes: 58 additions & 5 deletions packages/backend/app/auth.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
"""JWT authentication via Clerk JWKS."""
"""JWT authentication via Clerk JWKS, with X-API-Key fallback."""

import hashlib
import time
from typing import Optional

import httpx
from fastapi import Depends, HTTPException, status
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from loguru import logger
from sqlalchemy import select
from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession

from app.config import settings
from app.database import get_db
from app.models.user import User

security = HTTPBearer()
security = HTTPBearer(auto_error=False)

# ─── JWKS cache ──────────────────────────────────────────────

Expand Down Expand Up @@ -90,13 +91,65 @@ async def _decode_token(token: str) -> dict:
return payload


# ─── API key auth ────────────────────────────────────────────

async def _get_user_by_api_key(raw_key: str, db: AsyncSession) -> User | None:
from app.models.api_key import ApiKey
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
result = await db.execute(
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.revoked_at.is_(None))
)
api_key = result.scalar_one_or_none()
if api_key is None:
return None
await db.execute(
update(ApiKey).where(ApiKey.id == api_key.id).values(last_used_at=func.now())
)
await db.commit()
result = await db.execute(select(User).where(User.id == api_key.user_id))
return result.scalar_one_or_none()


# ─── Dependency ──────────────────────────────────────────────

async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_db),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> User:
logger.debug("[auth] get_current_user called")

# API key path — takes priority when X-API-Key header is present
if x_api_key:
user = await _get_user_by_api_key(x_api_key, db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked API key",
headers={"WWW-Authenticate": "X-API-Key"},
)
request.state.api_key_id = None
# Resolve the api_key_id for activity logging
from app.models.api_key import ApiKey
key_hash = hashlib.sha256(x_api_key.encode()).hexdigest()
result = await db.execute(
select(ApiKey.id).where(ApiKey.key_hash == key_hash, ApiKey.revoked_at.is_(None))
)
api_key_id = result.scalar_one_or_none()
request.state.api_key_id = api_key_id
logger.debug("[auth] API key auth succeeded | user={} key_id={}", user.id, api_key_id)
return user

# JWT path
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

request.state.api_key_id = None
payload = await _decode_token(credentials.credentials)

user_id: str | None = payload.get("sub")
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from app.models.document_type import DocumentType, SectionDefinition
from app.models.prompt_template import PromptTemplate
from app.models.document_contract import DocumentContract
from app.models.api_key import ApiKey
from app.models.document_activity import DocumentActivity
from app.models.base import Base

__all__ = [
Expand All @@ -22,4 +24,6 @@
"SectionDefinition",
"PromptTemplate",
"DocumentContract",
"ApiKey",
"DocumentActivity",
]
Loading
Loading