Repository navigation
feat(proforge): dual-purpose Core Capability Layer + MCP server + Global AI Copilot #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
39a7590
feat(proforge): dual-purpose Core Capability Layer + MCP server + Glo…
qnbs 9a3fecf
fix(proforge): remove new biome-ignore suppressions to satisfy ratche…
qnbs 520195b
docs(proforge): backlog of CodeAnt PR #107 follow-ups for later execu…
qnbs 2386166
fix(e2e): stop Copilot FAB from eating mobile bottom-nav taps
qnbs 4e9329a
fix(proforge): resolve all 6 CodeAnt PR #107 follow-ups
qnbs 737bb71
chore: remove experimental ci-analyzer
qnbs 599eeb3
i18n(proforge): localize all pipeline user-facing text
qnbs 5714a66
test(writer): update ProForge toggle aria-label assertions to i18n keys
qnbs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| node_modules/ | ||
| package-lock.json | ||
| runs.json | ||
| *.log |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| # ProForge MCP Server | ||
|
|
||
| Exposes the StoryCraft Studio **ProForge** 8-stage agentic editing pipeline to external AI agents | ||
| (Claude Desktop, Cline, Cursor, GitHub Copilot, Kimi, …) over the | ||
| [Model Context Protocol](https://modelcontextprotocol.io). | ||
|
|
||
| This server is a **thin adapter**. All business logic lives in the repo's single source of truth — | ||
| [`services/proForge/proForgeCapabilityLayer.ts`](../../services/proForge/proForgeCapabilityLayer.ts) — | ||
| consumed here through the Node adapter | ||
| ([`adapters/nodeProForgeCapability.ts`](../../services/proForge/adapters/nodeProForgeCapability.ts)). | ||
| The same Core Capability Layer powers the in-app Global Copilot, so external agents and in-app | ||
| features share identical behaviour with zero logic duplication. | ||
|
|
||
| ## Architecture | ||
|
|
||
| ``` | ||
| AI agent ──stdio MCP──> ProForge MCP server (this folder) | ||
| │ thin tool adapters | ||
| ▼ | ||
| ProForge Core Capability Layer (services/proForge/) ── SSOT | ||
| │ Node ports | ||
| ▼ | ||
| Node InferenceGateway (@google/genai) · in-process memory bank · file run-history | ||
| ``` | ||
|
|
||
| Because StoryCraft is a backend-less PWA, this server cannot read the browser's live IndexedDB | ||
| project. It operates on a **portable project payload** passed per call (`project` argument) or loaded | ||
| once at startup (`--project <file>`). See [`examples/sample-project.json`](./examples/sample-project.json). | ||
|
|
||
| ## Tools | ||
|
|
||
| | Tool | AI key? | Description | | ||
| |------|:------:|-------------| | ||
| | `proforge_run_stage` | **yes** | Run one pipeline stage agent (`intake`…`analytics`) + heuristic supervisor gate. | | ||
| | `proforge_apply_edits` | no | Offset-safe application of accepted edits to manuscript text (`dryRun` supported). | | ||
| | `proforge_rag_query` | no¹ | Retrieve relevant memory-bank entries (lexical / semantic / hybrid). | | ||
| | `proforge_get_history` | no | List completed/aborted runs (requires `--history <file>`). | | ||
| | `proforge_get_supervisor_status` | no | Per-stage supervisor decisions for a run (requires `--history <file>`). | | ||
|
|
||
| ¹ `semantic`/`hybrid` modes fall back to lexical in Node when no embedding model is available. | ||
|
|
||
| ## Environment | ||
|
|
||
| | Var | Purpose | | ||
| |-----|---------| | ||
| | `GEMINI_API_KEY` (or `STORYCRAFT_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`) | Required only for `proforge_run_stage`. Never logged, never bundled. | | ||
|
|
||
| CLI flags: `--project <file>` (default payload), `--history <file>` (run-history JSON store). | ||
|
|
||
| ## Run | ||
|
|
||
| This server reuses the repo's TypeScript sources and their dependencies (`zod`, `@google/genai`), | ||
| so it must live inside the StoryCraft repo. Only the MCP SDK + `tsx` are installed locally. | ||
|
|
||
| ```bash | ||
| cd .mcp/proforge-mcp-server | ||
| npm install # installs @modelcontextprotocol/sdk + tsx | ||
| npm run smoke # round-trip self-test (no API key needed) | ||
|
|
||
| # Start the server: | ||
| GEMINI_API_KEY=… npx tsx src/index.ts --project ./examples/sample-project.json --history ./runs.json | ||
| ``` | ||
|
|
||
| ## Client configuration | ||
|
|
||
| ### Claude Desktop (`claude_desktop_config.json`) | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "proforge": { | ||
| "command": "npx", | ||
| "args": ["tsx", "src/index.ts", "--project", "./examples/sample-project.json"], | ||
| "cwd": "/absolute/path/to/StoryCraft-Studio/.mcp/proforge-mcp-server", | ||
| "env": { "GEMINI_API_KEY": "your-key-here" } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Cline / Cursor / generic `mcpServers` | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "proforge": { | ||
| "command": "npx", | ||
| "args": ["tsx", "/absolute/path/to/StoryCraft-Studio/.mcp/proforge-mcp-server/src/index.ts"], | ||
| "env": { "GEMINI_API_KEY": "your-key-here" } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Trust & safety | ||
|
|
||
| - The Node adapter treats the project payload as **untrusted input** — every op re-validates with the | ||
| repo's Zod schemas (`proForgeCapabilitySchemas.ts`) before touching the capability layer. | ||
| - No filesystem writes occur outside the explicit `--history <file>`. | ||
| - API keys are read from the environment only; they are never written to logs or responses. | ||
| - Errors are returned as structured `{ error: { code, message, details? } }` envelopes | ||
| (`code ∈ VALIDATION | PERMISSION_DENIED | NOT_FOUND | STAGE_FAILED | INTERNAL`). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| { | ||
| "projectId": "demo-novel", | ||
| "title": "The Northern Pass", | ||
| "logline": "A reluctant cartographer must map a dragon-guarded mountain range before winter seals it forever.", | ||
| "manuscript": [ | ||
| { | ||
| "id": "ch1", | ||
| "title": "Chapter 1 — The Commission", | ||
| "content": "Mira had never wanted to draw the northern pass. The quick brown ink dried slow in the cold, and her hands shook as the magistrate slid the contract across the table. 'No one has mapped it and returned,' he said. She signed anyway." | ||
| }, | ||
| { | ||
| "id": "ch2", | ||
| "title": "Chapter 2 — Vex", | ||
| "content": "The dragon was smaller than the legends, and far more bored. Vex watched her unroll her vellum and sharpen her quills with the patience of something that had outlived every prior mapmaker." | ||
| } | ||
| ], | ||
| "characters": [{ "id": "c1", "name": "Mira" }, { "id": "c2", "name": "Vex" }], | ||
| "worlds": [{ "id": "w1", "name": "The Northern Pass" }], | ||
| "memoryEntries": [ | ||
| { | ||
| "category": "lore", | ||
| "key": "dragon-vex", | ||
| "content": "Vex is an ancient dragon who guards the northern pass and tolerates cartographers." | ||
| }, | ||
| { | ||
| "category": "style", | ||
| "key": "tone", | ||
| "content": "Wry, grounded fantasy; understated emotion; short declarative sentences." | ||
| } | ||
| ], | ||
| "config": { | ||
| "genrePreset": "fantasy", | ||
| "ragMode": "lexical", | ||
| "creativity": "Balanced", | ||
| "language": "en" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
| "name": "@storycraft/proforge-mcp-server", | ||
| "version": "1.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "description": "MCP server exposing the StoryCraft ProForge Core Capability Layer to external AI agents", | ||
| "bin": { | ||
| "proforge-mcp-server": "src/index.ts" | ||
| }, | ||
| "scripts": { | ||
| "dev": "tsx src/index.ts", | ||
| "start": "tsx src/index.ts", | ||
| "smoke": "tsx scripts/smoke.ts" | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.12.0" | ||
| }, | ||
| "devDependencies": { | ||
| "tsx": "^4.22.4" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * ProForge MCP server smoke test. | ||
| * QNBS-v3: Spawns the server over stdio and exercises initialize → tools/list → two no-AI tools | ||
| * (apply_edits, rag_query). Validates the MCP round-trip without needing an API key. | ||
| * Run from this folder: `npm run smoke` (ensures tsx is on PATH). | ||
| */ | ||
|
|
||
| import { Client } from '@modelcontextprotocol/sdk/client/index.js'; | ||
| import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; | ||
|
|
||
| function assert(cond: unknown, msg: string): void { | ||
| if (!cond) throw new Error(`SMOKE FAIL: ${msg}`); | ||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| const transport = new StdioClientTransport({ command: 'tsx', args: ['src/index.ts'] }); | ||
| const client = new Client({ name: 'proforge-smoke', version: '1.0.0' }); | ||
| await client.connect(transport); | ||
|
|
||
| // 1) tools/list | ||
| const { tools } = await client.listTools(); | ||
| const names = tools.map((t) => t.name).sort(); | ||
| console.error('tools:', names.join(', ')); | ||
| for (const expected of [ | ||
| 'proforge_apply_edits', | ||
| 'proforge_get_history', | ||
| 'proforge_get_supervisor_status', | ||
| 'proforge_rag_query', | ||
| 'proforge_run_stage', | ||
| ]) { | ||
| assert(names.includes(expected), `missing tool ${expected}`); | ||
| } | ||
|
|
||
| // 2) apply_edits (pure, no AI key) | ||
| const apply = await client.callTool({ | ||
| name: 'proforge_apply_edits', | ||
| arguments: { | ||
| manuscript: [{ id: 's1', content: 'the quick brown fox' }], | ||
| items: [{ id: 'e1', sectionId: 's1', original: 'quick', proposed: 'slow' }], | ||
| }, | ||
| }); | ||
| const applyContent = (apply.content as Array<{ type: string; text: string }>)[0]; | ||
| assert(applyContent?.text.includes('the slow brown fox'), 'apply_edits did not apply edit'); | ||
| console.error('apply_edits OK'); | ||
|
|
||
| // 3) rag_query (lexical, inline project payload + memory entries) | ||
| const rag = await client.callTool({ | ||
| name: 'proforge_rag_query', | ||
| arguments: { | ||
| project: { | ||
| projectId: 'p1', | ||
| title: 'Demo', | ||
| memoryEntries: [ | ||
| { category: 'lore', key: 'dragon', content: 'The dragon Vex guards the northern pass.' }, | ||
| { category: 'style', key: 'tone', content: 'Wry, fast-paced.' }, | ||
| ], | ||
| }, | ||
| query: 'dragon', | ||
| mode: 'lexical', | ||
| }, | ||
| }); | ||
| const ragContent = (rag.content as Array<{ type: string; text: string }>)[0]; | ||
| assert(ragContent?.text.includes('dragon'), 'rag_query did not return the dragon entry'); | ||
| console.error('rag_query OK'); | ||
|
|
||
| await client.close(); | ||
| console.error('\n✅ SMOKE PASS'); | ||
| } | ||
|
|
||
| main().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /** | ||
| * Shared capability resolver for the ProForge MCP server. | ||
| * QNBS-v3: Thin glue — parses CLI args, loads an optional default project payload, and builds a | ||
| * ProForge Capability Layer (Node adapter) per request. All business logic lives in the repo's | ||
| * Core Capability Layer; this file only wires ports for the MCP runtime. | ||
| */ | ||
|
|
||
| import { readFileSync } from 'node:fs'; | ||
| import { | ||
| createNodeProForgeCapability, | ||
| type NodeCapabilityOptions, | ||
| } from '../../../services/proForge/adapters/nodeProForgeCapability'; | ||
| import type { ProForgeCapabilityLayer } from '../../../services/proForge/proForgeCapabilityLayer'; | ||
| import type { ProjectPayload } from '../../../services/proForge/proForgeCapabilitySchemas'; | ||
|
|
||
| interface CliOptions { | ||
| projectFile?: string; | ||
| historyFile?: string; | ||
| } | ||
|
|
||
| function parseArgs(argv: string[]): CliOptions { | ||
| const opts: CliOptions = {}; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const arg = argv[i]; | ||
| if ((arg === '--project' || arg === '-p') && argv[i + 1]) { | ||
| opts.projectFile = argv[++i]; | ||
| } else if ((arg === '--history' || arg === '-h') && argv[i + 1]) { | ||
| opts.historyFile = argv[++i]; | ||
| } | ||
| } | ||
| return opts; | ||
| } | ||
|
|
||
| const cli = parseArgs(process.argv.slice(2)); | ||
|
|
||
| /** The project payload loaded from `--project <file>` at startup, if any. */ | ||
| export const startupPayload: unknown = | ||
| cli.projectFile !== undefined ? JSON.parse(readFileSync(cli.projectFile, 'utf8')) : null; | ||
|
|
||
| export const historyFile: string | undefined = cli.historyFile; | ||
|
|
||
| const baseOpts: NodeCapabilityOptions = { | ||
| ...(historyFile !== undefined && { historyFile }), | ||
| }; | ||
|
|
||
| /** | ||
| * Resolve a capability layer + the effective payload for a request. Prefers an inline `project` | ||
| * argument; falls back to the startup payload. Throws a clear error when neither is present. | ||
| */ | ||
| export async function resolveCapability( | ||
| inlineProject?: unknown, | ||
| ): Promise<{ cap: ProForgeCapabilityLayer; payload: ProjectPayload }> { | ||
| const raw = inlineProject ?? startupPayload; | ||
| if (!raw) { | ||
| throw new Error( | ||
| 'No project payload available. Pass a `project` argument or start the server with --project <file>.', | ||
| ); | ||
| } | ||
| const cap = await createNodeProForgeCapability(raw, baseOpts); | ||
|
qnbs marked this conversation as resolved.
Outdated
|
||
| // createNodeProForgeCapability validated the payload; re-read it for projectId/config access. | ||
| const payload = raw as ProjectPayload; | ||
| return { cap, payload }; | ||
| } | ||
|
|
||
| /** Build a capability with no project context — for the pure `applyEdits` op. */ | ||
| export async function inlineCapability(): Promise<ProForgeCapabilityLayer> { | ||
| return createNodeProForgeCapability({ projectId: 'inline' }, baseOpts); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * ProForge MCP Server — exposes the StoryCraft ProForge Core Capability Layer to external AI agents. | ||
| * QNBS-v3: A thin stdio MCP adapter. All business logic lives in the repo's Core Capability Layer | ||
| * (services/proForge/proForgeCapabilityLayer.ts) via the Node adapter — this file only registers the | ||
| * 5 tools and connects the transport. | ||
| * | ||
| * Run (from the repo root or this folder): | ||
| * GEMINI_API_KEY=… npx tsx src/index.ts --project ./examples/sample-project.json --history ./runs.json | ||
| */ | ||
|
|
||
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | ||
| import { registerApplyEdits } from './tools/applyEdits'; | ||
| import { registerGetHistory } from './tools/getHistory'; | ||
| import { registerGetSupervisorStatus } from './tools/getSupervisorStatus'; | ||
| import { registerRagQuery } from './tools/ragQuery'; | ||
| import { registerRunStage } from './tools/runStage'; | ||
|
|
||
| async function main(): Promise<void> { | ||
| const server = new McpServer({ | ||
| name: 'proforge', | ||
| version: '1.0.0', | ||
| }); | ||
|
|
||
| registerRunStage(server); | ||
| registerApplyEdits(server); | ||
| registerRagQuery(server); | ||
| registerGetHistory(server); | ||
| registerGetSupervisorStatus(server); | ||
|
|
||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| // QNBS-v3: stderr only — stdout is reserved for the MCP JSON-RPC stream. | ||
| process.stderr.write('ProForge MCP server ready (stdio).\n'); | ||
| } | ||
|
|
||
| main().catch((err) => { | ||
| process.stderr.write(`ProForge MCP server failed to start: ${String(err)}\n`); | ||
|
qnbs marked this conversation as resolved.
|
||
| process.exit(1); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.