From 41193a44bdeddd214b32ed28a3ea640d0092fd92 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 28 Jun 2026 14:27:43 -0400 Subject: [PATCH] =?UTF-8?q?feat(#1681):=20ADR-1239=20Phase=20C-2=20?= =?UTF-8?q?=E2=80=94=20gsd-mcp-server=20bin=20entry=20+=20lifecycle=20test?= =?UTF-8?q?=20[slice=203b]=20(#1810)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1681): ADR-1239 Phase C-2 — gsd-mcp-server bin entry + lifecycle test [slice 3b] Phase 4 slice 3b (closes #1681). The companion MCP server bin entry so any MCP-consuming host connects via 'npx gsd-mcp-server' (or its bin on PATH) and gets GSD command (point 1) + state IO (point 5) with no bespoke plugin. - gsd-core/bin/gsd-mcp-server.cjs: #!/usr/bin/env node shim requiring ./lib/mcp-server.cjs + runServer({stdin, stdout}); non-zero exit on fatal error (justified n/no-process-exit disable). Mirrors gsd-tools.cjs. - package.json: add 'gsd-mcp-server' bin entry. - tests/gsd-mcp-server-bin.test.cjs: 3 process-lifecycle tests — initialize + tools/list round-trip + clean exit, malformed-line -> parse error + server keeps running, empty stdin -> clean exit. Synchronous spawnSync (bounded; server exits on stdin EOF, no orphan). Phase 4 trust-gate (#1806) + loader wiring (#1808) + server module (#1809) + this bin/lifecycle slice = all of #1681's deliverables. Concrete host binding -> Phase 5 (#1682). npm-integrity + eslint + security + inventory all clean. * docs(#1681)+chore(changeset): how-to for the companion MCP server + Added fragment docs/how-to/connect-gsd-mcp-server.md — Diataxis how-to guide for connecting any MCP-capable host to gsd-mcp-server: goal-oriented flow (add config → restart → verify), real-world per-host conditionals, troubleshooting, and a trimmed reference table. Explanation/reference linked out (ADR-1239, capability-trust- model) per Diataxis boundary rules rather than mixed in. .changeset/humble-seals-rest.md — type: Added (first user-reachable surface of the epic: a new bin command). The how-to doc satisfies the docs-required gate. * fix(#1681): move gsd-mcp-server shim to top-level bin/ (out of the runtime-copied tree) The shim at gsd-core/bin/gsd-mcp-server.cjs was inside the tree the installer copies into every runtime config dir, so it leaked into all 16 runtimes and broke golden-install-parity. The MCP server is a PACKAGE bin the host spawns (npx gsd-mcp-server), not a per-runtime artifact — so it belongs at top-level bin/ alongside install.js (which is also never copied into a runtime config). - gsd-core/bin/gsd-mcp-server.cjs -> bin/gsd-mcp-server.js (require path now ../gsd-core/bin/lib/mcp-server.cjs). - package.json: bin entry -> bin/gsd-mcp-server.js. - tests/gsd-mcp-server-bin.test.cjs: SHIM path updated. - eslint.config.mjs: add bin/gsd-mcp-server.js to the bin/install.js block (drops the n/no-process-exit disable — the n plugin isn't loaded for that block, so the disable referenced an undefined rule). golden-install-parity 16/16 restored; lifecycle + unit tests green; eslint 0; lint:ci all ok. --- .changeset/humble-seals-rest.md | 5 ++ bin/gsd-mcp-server.js | 31 +++++++++++ docs/how-to/connect-gsd-mcp-server.md | 75 +++++++++++++++++++++++++++ eslint.config.mjs | 2 +- package.json | 3 +- tests/gsd-mcp-server-bin.test.cjs | 56 ++++++++++++++++++++ 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 .changeset/humble-seals-rest.md create mode 100644 bin/gsd-mcp-server.js create mode 100644 docs/how-to/connect-gsd-mcp-server.md create mode 100644 tests/gsd-mcp-server-bin.test.cjs diff --git a/.changeset/humble-seals-rest.md b/.changeset/humble-seals-rest.md new file mode 100644 index 000000000..e5392f53a --- /dev/null +++ b/.changeset/humble-seals-rest.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1810 +--- +**`gsd-mcp-server` — companion MCP server (interface points 1 + 5)** — a new bin command (`npx @opengsd/gsd-core gsd-mcp-server`) runs a stdio JSON-RPC 2.0 MCP server exposing `gsd_invoke_command` (→ the GSD command-routing hub) + `gsd_read_state` / `gsd_write_state` (→ `.planning/` state), so any MCP-consuming host (Claude Code, Codex, OpenCode, VS Code, Gemini CLI, Cursor, Cline, Hermes) can drive GSD with no bespoke plugin (ADR-1239 Phase C-2 / #1681). Dependency-free (hand-rolled JSON-RPC). How-to: `docs/how-to/connect-gsd-mcp-server.md`. diff --git a/bin/gsd-mcp-server.js b/bin/gsd-mcp-server.js new file mode 100644 index 000000000..49a197aed --- /dev/null +++ b/bin/gsd-mcp-server.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +'use strict'; +/** + * gsd-mcp-server — companion MCP server bin entry (ADR-1239 Phase C-2 / #1681). + * + * Lives at top-level bin/ (alongside install.js) — it is a PACKAGE bin the host + * spawns via `npx gsd-mcp-server` (or the global bin), NOT a per-runtime + * artifact copied into a host's config dir. (Placing it under gsd-core/bin/ + * would leak it into every runtime install + break golden parity.) + * + * A stdio JSON-RPC 2.0 server exposing GSD interface points 1 (command) + 5 + * (state IO) so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/ + * Cursor/Cline/Hermes) can drive GSD with no bespoke plugin. Delegates to the + * tested server module (gsd-core/bin/lib/mcp-server.cjs runServer). Reads + * line-delimited JSON-RPC from stdin, writes one response + newline per + * request, exits cleanly when stdin closes. + * + * The protocol logic (handleMessage) + the injectable-stream loop (runServer) + * are unit-tested in tests/gsd-mcp-server.test.cjs; the process lifecycle + * (spawn → JSON-RPC → clean exit) in tests/gsd-mcp-server-bin.test.cjs. + */ +const { runServer } = require('../gsd-core/bin/lib/mcp-server.cjs'); + +runServer({ + input: process.stdin, + output: process.stdout, + ctx: { cwd: process.cwd() }, +}).catch((err) => { + process.stderr.write(String((err && err.message) || err) + '\n'); + process.exit(1); +}); diff --git a/docs/how-to/connect-gsd-mcp-server.md b/docs/how-to/connect-gsd-mcp-server.md new file mode 100644 index 000000000..a036419cd --- /dev/null +++ b/docs/how-to/connect-gsd-mcp-server.md @@ -0,0 +1,75 @@ +# How to connect a host to the GSD companion MCP server + +This guide shows you how to make a MCP-capable host (Claude Code, Codex, +OpenCode, VS Code, Gemini CLI, Cursor, Cline, Hermes) drive GSD — run GSD +commands and read/write `.planning/` state — through the companion MCP server, +with no bespoke plugin. + +Once connected, three tools appear in the host alongside its others: +`gsd_invoke_command`, `gsd_read_state`, `gsd_write_state`. (For the tool +contracts, see the reference section below; for *why* this server exists and +its trust model, see [ADR-1239](../adr/1239-gsd-embeddable-orchestration-engine.md) +and the [capability trust model](../explanation/capability-trust-model.md).) + +## 1. Add the server to your host's MCP config + +The entry shape is the same everywhere; only the config file and key differ by +host. + +```jsonc +{ + "gsd": { + "command": "npx", + "args": ["-y", "@opengsd/gsd-core", "gsd-mcp-server"], + "cwd": "/abs/path/to/your/project" + } +} +``` + +- **Claude Code / Codex / OpenCode / Cursor / Cline / Hermes** — under the + host's `mcpServers` object (project or user config). +- **VS Code** — in the workspace MCP servers list. +- **Gemini CLI** — under its `mcpServers` block. + +Set `cwd` to the project whose `.planning/` you want GSD to manage — the server +resolves state paths against it. + +## 2. Restart the host + +On startup the host performs the MCP `initialize` handshake, lists tools, and +the three GSD tools become callable. + +## 3. Verify + +Ask the host to read an existing planning file: + +```jsonc +{ "name": "gsd_read_state", "arguments": { "path": "/abs/path/to/your/project/.planning/STATE.md" } } +``` + +It returns the file's contents. `gsd_invoke_command` takes +`{family, subcommand, args}` and returns the command-routing hub's structured +result (the same shape `gsd-tools` produces). + +## If something does not work + +- **`command not found: gsd-mcp-server`** — invoke via `npx` as shown above, or + install the package globally first (`npm i -g @opengsd/gsd-core`). +- **`gsd_read_state` fails with ENOENT** — the path is resolved literally; pass + an absolute path under the project's `.planning/`. +- **The host lists no GSD tools** — confirm the server starts in isolation: + `npx @opengsd/gsd-core gsd-mcp-server` then send an `initialize` request on + stdin; it writes a `protocolVersion` response and exits on EOF. +- **You manage multiple projects** — register one `gsd` entry per project with a + distinct name and `cwd`; the server is stateless across projects. + +## Reference — the three tools + +| Tool | Arguments | Returns | +|------|-----------|---------| +| `gsd_invoke_command` | `{family: string, subcommand: string, args?: unknown[]}` | the command-routing hub result (`{ok, …}`) as JSON text | +| `gsd_read_state` | `{path: string}` | the file contents as text | +| `gsd_write_state` | `{path: string, content: string}` | `{ok: true, path}` as JSON text | + +Errors from a tool are returned as MCP tool errors (`isError: true`), not as +JSON-RPC protocol errors — the host surfaces them in its normal tool-failure UX. diff --git a/eslint.config.mjs b/eslint.config.mjs index 062f99f50..1ef8c1083 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -256,7 +256,7 @@ export default tseslint.config( // bin/install.js is ~12k lines of generated code; the ADR's mandate is the // portability defect surface, not a broader generated-code style sweep. { - files: ['bin/install.js', 'scripts/build-hooks.js'], + files: ['bin/install.js', 'bin/gsd-mcp-server.js', 'scripts/build-hooks.js'], plugins: { local: localPlugin, }, diff --git a/package.json b/package.json index b5e44b550..8c91d6607 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "bin": { "gsd-core": "bin/install.js", "gsd-tools": "gsd-core/bin/gsd-tools.cjs", - "gsd_run": "gsd-core/bin/gsd_run" + "gsd_run": "gsd-core/bin/gsd_run", + "gsd-mcp-server": "bin/gsd-mcp-server.js" }, "files": [ "bin", diff --git a/tests/gsd-mcp-server-bin.test.cjs b/tests/gsd-mcp-server-bin.test.cjs new file mode 100644 index 000000000..cee622b0c --- /dev/null +++ b/tests/gsd-mcp-server-bin.test.cjs @@ -0,0 +1,56 @@ +'use strict'; +/** + * Process-lifecycle test for the gsd-mcp-server bin entry (ADR-1239 Phase C-2, + * #1681 slice 3b / AC4). Spawns the shim, feeds line-delimited JSON-RPC over + * stdin, asserts stdout responses + clean exit on stdin EOF. Synchronous + + * bounded (the server exits when stdin closes — no orphan process). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const { PROTOCOL_VERSION } = require('../gsd-core/bin/lib/mcp-server.cjs'); + +const SHIM = path.join(__dirname, '..', 'bin', 'gsd-mcp-server.js'); + +function run(stdin) { + return spawnSync(process.execPath, [SHIM], { + input: stdin, + encoding: 'utf-8', + timeout: 15000, + env: { ...process.env, GSD_TEST_MODE: '1' }, + }); +} + +test('gsd-mcp-server bin: initialize handshake + tools/list over stdio, then clean exit', () => { + const stdin = [ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), + ].join('\n') + '\n'; + const res = run(stdin); + assert.strictEqual(res.status, 0, `clean exit; stderr: ${res.stderr}`); + const lines = res.stdout.trim().split('\n').map((l) => JSON.parse(l)); + assert.strictEqual(lines.length, 2, 'one response per request'); + assert.strictEqual(lines[0].id, 1); + assert.strictEqual(lines[0].result.protocolVersion, PROTOCOL_VERSION, 'initialize returns the protocol version'); + assert.ok(Array.isArray(lines[1].result.tools) && lines[1].result.tools.length === 3, 'tools/list advertises the 3 tools'); +}); + +test('gsd-mcp-server bin: a malformed line surfaces a JSON-RPC parse error; the server keeps running', () => { + const stdin = [ + 'this is not json', + JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'initialize' }), + ].join('\n') + '\n'; + const res = run(stdin); + assert.strictEqual(res.status, 0, `server survives the bad line; stderr: ${res.stderr}`); + const lines = res.stdout.trim().split('\n').map((l) => JSON.parse(l)); + assert.strictEqual(lines[0].error.code, -32700, 'bad line → JSON-RPC parse error'); + assert.strictEqual(lines[1].result.protocolVersion, PROTOCOL_VERSION, 'subsequent valid request still handled'); +}); + +test('gsd-mcp-server bin: empty/whitespace-only stdin → clean exit, no output', () => { + const res = run('\n \n'); + assert.strictEqual(res.status, 0); + assert.strictEqual(res.stdout.trim(), '', 'no requests → no responses'); +});