Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions .mcp/proforge-mcp-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
package-lock.json
runs.json
*.log
102 changes: 102 additions & 0 deletions .mcp/proforge-mcp-server/README.md
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`).
37 changes: 37 additions & 0 deletions .mcp/proforge-mcp-server/examples/sample-project.json
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"
}
}
21 changes: 21 additions & 0 deletions .mcp/proforge-mcp-server/package.json
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"
}
}
73 changes: 73 additions & 0 deletions .mcp/proforge-mcp-server/scripts/smoke.ts
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);
});
68 changes: 68 additions & 0 deletions .mcp/proforge-mcp-server/src/capability.ts
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;
Comment thread
qnbs marked this conversation as resolved.
Outdated

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);
Comment thread
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);
}
41 changes: 41 additions & 0 deletions .mcp/proforge-mcp-server/src/index.ts
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`);
Comment thread
qnbs marked this conversation as resolved.
process.exit(1);
});
Loading
Loading