* 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.
This commit is contained in:
5
.changeset/humble-seals-rest.md
Normal file
5
.changeset/humble-seals-rest.md
Normal file
@@ -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`.
|
||||
31
bin/gsd-mcp-server.js
Normal file
31
bin/gsd-mcp-server.js
Normal file
@@ -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);
|
||||
});
|
||||
75
docs/how-to/connect-gsd-mcp-server.md
Normal file
75
docs/how-to/connect-gsd-mcp-server.md
Normal file
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
56
tests/gsd-mcp-server-bin.test.cjs
Normal file
56
tests/gsd-mcp-server-bin.test.cjs
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user