From 2b38356275a42aa2f58628151bac294f940f56be Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 28 Jun 2026 12:45:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(#1681):=20ADR-1239=20Phase=20C-2=20?= =?UTF-8?q?=E2=80=94=20companion=20MCP=20server=20module=20(points=201=20+?= =?UTF-8?q?=205)=20[slice=203a]=20(#1809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1681): ADR-1239 Phase C-2 — companion MCP server module (points 1 + 5) [slice 3a] Phase 4 slice 3a. A minimal, dependency-free stdio JSON-RPC 2.0 server exposing two of the six interface points so any MCP-consuming host (Claude/Codex/OpenCode/ VS Code/Gemini/Cursor/Cline/Hermes) can drive GSD with no bespoke plugin: - point 1 (command): tool gsd_invoke_command -> createHub/dispatch. - point 5 (state IO): tools gsd_read_state / gsd_write_state -> the Phase 3 stateIO seam (filesystem default). - src/mcp-server.cts: handleMessage(request, ctx) pure JSON-RPC handler (initialize / tools/list / tools/call) + runServer({input, output}) thin line-delimited-JSON loop over injectable streams. 3 tools wired to the existing engine surfaces. NO new dependency (hand-rolled JSON-RPC; the repo ships only claude-agent-sdk + ws — an MCP SDK is a separate packaging call). - tests/gsd-mcp-server.test.cjs: 9 tests (initialize, tools/list, state read/write round-trip, command dispatch, unknown tool / missing name / unknown method / notification / parse error, injectable-stream round-trip). Bin entry / packaging / manifest-version-sync / process-lifecycle docs -> slice 3b. This slice ships the importable, tested server surface a host (or the bin shim) drives. Proactive CI gates: ADR-457 ignores + INVENTORY-MANIFEST + injection-scan audit. All clean locally (9 tests + security 15/15 + inventory + eslint 0 problems). * chore(changeset): add Changed fragment for companion MCP server module (#1681) --- .changeset/graceful-geese-click.md | 7 + docs/INVENTORY-MANIFEST.json | 1 + eslint.config.mjs | 1 + src/mcp-server.cts | 212 +++++++++++++++++++++++++++++ tests/gsd-mcp-server.test.cjs | 103 ++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 .changeset/graceful-geese-click.md create mode 100644 src/mcp-server.cts create mode 100644 tests/gsd-mcp-server.test.cjs diff --git a/.changeset/graceful-geese-click.md b/.changeset/graceful-geese-click.md new file mode 100644 index 000000000..6f65b9056 --- /dev/null +++ b/.changeset/graceful-geese-click.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1809 +--- +**Internal: companion MCP server module (interface points 1 + 5)** — `handleMessage`/`runServer` (new `src/mcp-server.cts`) is a minimal, dependency-free stdio JSON-RPC 2.0 server exposing `gsd_invoke_command` (→ the command-routing hub) + `gsd_read_state`/`gsd_write_state` (→ the Phase 3 stateIO seam), so any MCP-consuming host can drive GSD with no bespoke plugin (ADR-1239 Phase C-2 / #1681 slice 3a). Bin entry / packaging deferred to slice 3b. No user-facing change — the server is not yet wired to a bin entry. + + diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 722d81b15..b835db63b 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -353,6 +353,7 @@ "loop-host-contract.cjs", "loop-resolver.cjs", "markdown-sectionizer.cjs", + "mcp-server.cjs", "milestone.cjs", "model-adapter.cjs", "model-catalog.cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs index 069bd7285..062f99f50 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -208,6 +208,7 @@ export default tseslint.config( 'gsd-core/bin/lib/hook-bus.cjs', 'gsd-core/bin/lib/state-io.cjs', 'gsd-core/bin/lib/external-descriptor-trust.cjs', + 'gsd-core/bin/lib/mcp-server.cjs', ], }, diff --git a/src/mcp-server.cts b/src/mcp-server.cts new file mode 100644 index 000000000..9b5d00c97 --- /dev/null +++ b/src/mcp-server.cts @@ -0,0 +1,212 @@ +/** + * Companion MCP server (ADR-1239 Phase C-2, #1681 slice 3a). + * + * A minimal stdio JSON-RPC 2.0 server exposing two of the six interface points + * so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/Cursor/Cline/ + * Hermes) can drive GSD with NO bespoke plugin: + * + * - point 1 (command): tool `gsd_invoke_command` → the command-routing hub + * (`createHub`/`dispatch`, src/command-routing-hub.cts). + * - point 5 (state IO): tools `gsd_read_state` / `gsd_write_state` → the + * Phase 3 `stateIO` seam (src/state-io.cts, filesystem default). + * + * No new runtime dependency — the JSON-RPC stdio loop is hand-rolled (the repo + * ships only claude-agent-sdk + ws; adding an MCP SDK is a separate packaging + * decision). The protocol logic (`handleMessage`) is PURE and fully testable; + * `runServer` is a thin line-delimited-JSON loop over injectable streams. + * + * Bin entry / packaging / manifest-version-sync is slice 3b — this module is + * the additive, importable server surface a host (or the bin shim) drives. + */ +'use strict'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import commandRoutingHub = require('./command-routing-hub.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import stateIo = require('./state-io.cjs'); + +export const PROTOCOL_VERSION = '2024-11-05'; +export const SERVER_NAME = 'gsd-core'; +const SERVER_VERSION = '1.7.0'; + +// JSON-RPC 2.0 error codes. +const PARSE_ERROR = -32700; +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32603; + +export interface McpContext { + cwd?: string; +} + +export interface JsonRpcRequest { + jsonrpc?: string; + id?: unknown; + method?: string; + params?: unknown; +} + +const TOOLS = [ + { + name: 'gsd_invoke_command', + description: 'Invoke a GSD command via the command-routing hub (interface point 1).', + inputSchema: { + type: 'object', + properties: { + family: { type: 'string', description: 'Command family (e.g. "query", "state", "phase").' }, + subcommand: { type: 'string', description: 'Subcommand name.' }, + args: { type: 'array', items: {}, description: 'Positional args.' }, + }, + required: ['family', 'subcommand'], + }, + }, + { + name: 'gsd_read_state', + description: 'Read a .planning state file (interface point 5).', + inputSchema: { + type: 'object', + properties: { path: { type: 'string', description: 'Absolute path under .planning/.' } }, + required: ['path'], + }, + }, + { + name: 'gsd_write_state', + description: 'Write a .planning state file (interface point 5).', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Absolute path under .planning/.' }, + content: { type: 'string', description: 'File content.' }, + }, + required: ['path', 'content'], + }, + }, +]; + +function errorResponse(id: unknown, code: number, message: string, data?: unknown) { + const err: { code: number; message: string; data?: unknown } = { code, message }; + if (data !== undefined) err.data = data; + return { jsonrpc: '2.0', id, error: err }; +} + +function okResponse(id: unknown, result: unknown) { + return { jsonrpc: '2.0', id, result }; +} + +function asString(v: unknown): string | null { + return typeof v === 'string' ? v : null; +} + +function callTool(name: string, args: unknown, ctx: McpContext): { content: Array<{ type: string; text: string }>; isError?: boolean } { + const a = (args && typeof args === 'object' ? args : {}) as Record; + const cwd = asString(ctx.cwd) || process.cwd(); + try { + if (name === 'gsd_invoke_command') { + const family = asString(a.family); + const subcommand = asString(a.subcommand); + if (!family || !subcommand) { + return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "family" and "subcommand".' }] }; + } + const hub = commandRoutingHub.createHub(); + const res = hub.dispatch({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd, raw: undefined }); + return { content: [{ type: 'text', text: JSON.stringify(res) }] }; + } + if (name === 'gsd_read_state') { + const p = asString(a.path); + if (!p) return { isError: true, content: [{ type: 'text', text: 'gsd_read_state requires string "path".' }] }; + const io = stateIo.createStateIO({ io: 'filesystem' }); + return { content: [{ type: 'text', text: io.read(p) }] }; + } + if (name === 'gsd_write_state') { + const p = asString(a.path); + const content = asString(a.content); + if (!p || content === null) return { isError: true, content: [{ type: 'text', text: 'gsd_write_state requires string "path" and "content".' }] }; + const io = stateIo.createStateIO({ io: 'filesystem' }); + io.write(p, content); + return { content: [{ type: 'text', text: JSON.stringify({ ok: true, path: p }) }] }; + } + return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] }; + } catch (e) { + return { isError: true, content: [{ type: 'text', text: `Tool error: ${e instanceof Error ? e.message : String(e)}` }] }; + } +} + +/** + * Pure JSON-RPC handler. Takes a parsed request object + context, returns a + * JSON-RPC response object (or null for JSON-RPC notifications — no id). + */ +export function handleMessage(request: JsonRpcRequest, ctx: McpContext = {}): Record | null { + if (!request || typeof request !== 'object') { + return errorResponse(null, INVALID_REQUEST, 'Invalid Request: not an object.'); + } + const id = request.id; + // Notification (no id) → no response per JSON-RPC. + const isNotification = id === undefined || id === null; + const method = typeof request.method === 'string' ? request.method : ''; + + let result: unknown; + switch (method) { + case 'initialize': + result = { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }; + break; + case 'tools/list': + result = { tools: TOOLS }; + break; + case 'tools/call': { + const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record; + const toolName = asString(params.name); + if (!toolName) return errorResponse(id, INVALID_PARAMS, 'tools/call requires string "name".'); + result = callTool(toolName, params.arguments, ctx); + break; + } + default: + if (isNotification) return null; + return errorResponse(id, METHOD_NOT_FOUND, `Method not found: ${method || '(empty)'}.`); + } + if (isNotification) return null; + return okResponse(id, result); +} + +/** + * Thin stdio loop over injectable streams. Reads line-delimited JSON-RPC from + * `input`, writes responses (one JSON object + newline) to `output`. Stops when + * input ends. Errors in handleMessage are caught and emitted as JSON-RPC error + * responses (the loop never crashes). + */ +export async function runServer({ + input, + output, + ctx = {}, +}: { + input: NodeJS.ReadableStream; + output: NodeJS.WritableStream; + ctx?: McpContext; +}): Promise { + for await (const chunk of input as AsyncIterable) { + const lines = chunk.toString('utf-8').split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + output.write(JSON.stringify(errorResponse(null, PARSE_ERROR, 'Parse error.')) + '\n'); + continue; + } + try { + const response = handleMessage(parsed as JsonRpcRequest, ctx); + if (response) output.write(JSON.stringify(response) + '\n'); + } catch (e) { + output.write(JSON.stringify(errorResponse(null, INTERNAL_ERROR, e instanceof Error ? e.message : 'Internal error.')) + '\n'); + } + } + } +} + +// handleMessage + runServer are exported above (export function); PROTOCOL_VERSION +// + SERVER_NAME are exported above (export const). diff --git a/tests/gsd-mcp-server.test.cjs b/tests/gsd-mcp-server.test.cjs new file mode 100644 index 000000000..e76b6353c --- /dev/null +++ b/tests/gsd-mcp-server.test.cjs @@ -0,0 +1,103 @@ +'use strict'; +/** + * Tests for the companion MCP server (ADR-1239 Phase C-2, #1681 slice 3a). + * Pins: initialize handshake, tools/list, tools/call dispatch to the hub + + * stateIO seam, method-not-found, notification = no response, parse error in + * runServer, and a full injectable-stream round-trip. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { Readable } = require('node:stream'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + handleMessage, + runServer, + PROTOCOL_VERSION, + SERVER_NAME, +} = require('../gsd-core/bin/lib/mcp-server.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +test('initialize: returns protocolVersion + capabilities + serverInfo', () => { + const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + assert.strictEqual(res.jsonrpc, '2.0'); + assert.strictEqual(res.id, 1); + assert.strictEqual(res.result.protocolVersion, PROTOCOL_VERSION); + assert.ok(res.result.capabilities && res.result.capabilities.tools, 'must advertise tools capability'); + assert.strictEqual(res.result.serverInfo.name, SERVER_NAME); +}); + +test('tools/list: advertises the 3 interface-point tools', () => { + const res = handleMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const names = res.result.tools.map((t) => t.name); + assert.deepStrictEqual(names.sort(), ['gsd_invoke_command', 'gsd_read_state', 'gsd_write_state']); +}); + +test('tools/call gsd_read_state + gsd_write_state: round-trip through the stateIO seam (point 5)', () => { + const dir = createTempDir(); + try { + const file = path.join(dir, 'STATE.md'); + const writeRes = handleMessage({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'gsd_write_state', arguments: { path: file, content: '# State\n' } } }); + assert.strictEqual(writeRes.result.isError, undefined, 'write must succeed'); + assert.strictEqual(fs.readFileSync(file, 'utf-8'), '# State\n', 'write went through to fs'); + const readRes = handleMessage({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'gsd_read_state', arguments: { path: file } } }); + assert.strictEqual(readRes.result.content[0].text, '# State\n', 'read returns the written content'); + } finally { + cleanup(dir); + } +}); + +test('tools/call gsd_invoke_command: dispatches to the command hub (point 1); unknown family returns a hub error, not a crash', () => { + const res = handleMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'gsd_invoke_command', arguments: { family: 'no-such-family', subcommand: 'x' } } }, { cwd: createTempDirClean() }); + // The hub returns a structured result (ok:false unknown-command) surfaced as text content, not a JSON-RPC error. + assert.strictEqual(res.jsonrpc, '2.0'); + const payload = JSON.parse(res.result.content[0].text); + assert.strictEqual(payload.ok, false, 'an unknown command dispatches to the hub and returns ok:false'); +}); + +test('tools/call: unknown tool name surfaces a tool error (isError), not a JSON-RPC protocol error', () => { + const res = handleMessage({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'gsd_bogus' } }); + assert.strictEqual(res.result.isError, true); + assert.match(res.result.content[0].text, /Unknown tool/); +}); + +test('tools/call: missing tool name is a JSON-RPC invalid-params error', () => { + const res = handleMessage({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: {} }); + assert.strictEqual(res.error.code, -32602); + assert.match(res.error.message, /requires string "name"/); +}); + +test('unknown method: JSON-RPC method-not-found (-32601)', () => { + const res = handleMessage({ jsonrpc: '2.0', id: 8, method: 'resources/read' }); + assert.strictEqual(res.error.code, -32601); + assert.match(res.error.message, /Method not found/); +}); + +test('notification (no id): returns null (no response per JSON-RPC)', () => { + assert.strictEqual(handleMessage({ jsonrpc: '2.0', method: 'initialize' }), null); + assert.strictEqual(handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }), null); +}); + +test('runServer: line-delimited JSON-RPC round-trip over injectable streams', async () => { + const input = Readable.from([ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n', + 'not json\n', + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) + '\n', + ]); + const out = []; + const output = { write: (s) => { out.push(s); return true; } }; + await runServer({ input, output }); + const joined = out.join(''); + const responses = joined.trim().split('\n').map((l) => JSON.parse(l)); + assert.strictEqual(responses.length, 3); + assert.strictEqual(responses[0].result.protocolVersion, PROTOCOL_VERSION, 'initialize handled'); + assert.strictEqual(responses[1].error.code, -32700, 'parse error surfaced'); + assert.ok(Array.isArray(responses[2].result.tools), 'tools/list handled'); +}); + +// tiny helper to get a throwaway cwd without polluting the assertion helpers import above +function createTempDirClean() { + const os = require('node:os'); + return fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-cwd-')); +}