feat(#3072): ship the deferred MCP served catalog (resources + prompts) (#3083)

* test(#3072): add failing-first coverage for the mcp served catalog

55 input-class rows from the phase test matrix, across four suites: the
catalog module over injected readFile/readDir seams, the protocol surface
through handleMessage, the install-vs-catalog parity gate, and fast-check
properties for uri round-trip, traversal refusal, and pagination partition.

src/mcp-catalog.cts lands as a skeleton whose functions throw, so the suites
fail on BEHAVIOR rather than on a missing module. The REASON enum is real so
tests assert typed codes instead of message prose.

Hostile coverage for the one client-controlled path surface (resources/read):
dot-dot and backslash traversal, percent- and double-encoded traversal,
absolute posix and windows paths, file:// scheme, null byte, symlink escape,
unindexed sibling, non-string and empty uri, wrong root segment.

IO faults are injected by monkeypatching the seam, never chmod 0o000 - root
bypasses mode bits, so a permission-based test silently passes with zero
coverage in root CI.

Refs #3072

* feat(#3072): serve the mcp catalog as resources and prompts

gsd-mcp-server now serves GSD's own content alongside its three tools: the
workflow, reference and command tree as MCP resources (resources/list, cursor
paginated, and resources/read over gsd://<segment>/<relpath> uris) and the 71
commands/gsd/*.md as MCP prompts keyed by bare command name. initialize
advertises resources and prompts, and deliberately does not advertise
subscribe or listChanged - the catalog is fixed for a server process lifetime,
so declaring a notification we never send would be a lie a host acts on.

Composition scope is SHARED, not re-declared. shouldCompose lives in
src/mcp-catalog.cts and bin/install.js now imports it instead of carrying its
own regex, so the served catalog and the installed file floor cannot drift on
what gets composed. Proven behavior-preserving across all 2871 tracked paths
plus windows-backslash, absolute and near-miss-prefix cases: zero mismatches.
tests/mcp-catalog-parity.test.cjs asserts served text equals the installer
composition-stage text over the real tree, with anti-vacuity guards requiring
both a marker-bearing workflow and a non-composed file in the comparison set.

Two measurements corrected the literal issue text. Composition is scoped to
gsd-core/workflows/ only, because a reference or command that documents marker
syntax with an unfenced example would otherwise be parsed as carrying a real
marker and have that line lossily dropped. And parity is asserted at the
composition stage rather than against an emitted runtime tree, since install
applies per-runtime path rewrites afterwards and the catalog is host-agnostic,
so byte equality with any one runtime would be false by construction.

resources/read is the one client-controlled path surface and is guarded in two
independent layers: the uri must be an exact key in the prebuilt index, which
defeats every traversal string by construction, and the mapped path is then
re-checked with validatePath so a symlink planted inside a root after indexing
is still refused.

Also fixes a real drift defect found while here: SERVER_VERSION was hardcoded
1.7.0 while the package is at 1.9.1. It now resolves lazily from VERSION or
package.json, reusing the precedent in runtime-artifact-conversion.

Closes #3072

* test(#3072): make the catalog parity gate drive the real installer

Review found the parity gate vacuous: it never imported or spawned
bin/install.js, and recomputed the installer side with the SAME shouldCompose
and composeWorkflow the catalog calls internally. It therefore proved only
that src/mcp-catalog.cts is self-consistent. The old row 52 compared
shouldCompose against a regex literal frozen in the test file rather than
against the installer at all. An inline divergent regex re-added to
bin/install.js - the exact regression ADR-1671 asks this gate to catch - would
have left the suite green.

The gate now spawns a real bin/install.js and compares the composition
DECISION, observed as gsd:section marker survival, against what the catalog
serves for the same files. Marker presence is the right observable because the
installer applies per-runtime path rewrites after composing while the catalog
applies none, so raw byte equality between the two surfaces is false by
construction and must not be asserted.

Sensitivity was proven, not assumed: overlaying the shouldCompose export that
bin/install.js imports so it always returns false makes a real spawned install
leave autonomous.md's markers in place while the catalog still strips them,
and the row 48 assertion diverges.

Anti-vacuity guards are kept and extended - the comparison set must be
non-empty, must contain a workflow that actually carries markers, must contain
a file the predicate declines to compose, and the install must have emitted a
non-zero file count. The marker-documenting reference case has no instance in
the real tree, so it uses an overlay fixture built with the same technique
workflow-fragments-emission.install.test.cjs already uses.

Renamed to .install.test.cjs so it lands in the install suite it now belongs to.

Refs #3072

* test(#3072): retarget the unknown-method assertion off a now-implemented method

tests/gsd-mcp-server.test.cjs used 'resources/read' as its example of an
UNKNOWN JSON-RPC method. The served catalog implements that method, so it now
returns -32602 (no uri supplied) rather than -32601. The remote runner caught
it deterministically on both linux lanes: -32602 !== -32601.

The test's intent is still correct and worth keeping, so it is corrected
rather than deleted or weakened. It now uses 'resources/subscribe', which the
server deliberately does not implement and deliberately does not advertise in
initialize's capabilities, because it never sends the corresponding
notification. That turns the assertion into a real contract - the advertised
capability surface and the implemented method surface agree - instead of an
arbitrary method name a future feature could invalidate the same way.

Swept the rest of the suite for other assertions pinning the newly implemented
methods; this was the only one.

Refs #3072

* chore(#3072): backfill changeset PR number 3083

* test(#3072): make the catalog fake fs separator-agnostic for windows

CI caught this on windows-latest (22 and 24): every catalog fixture indexed
ZERO entries, surfaced by the anti-vacuity guards as 'fixture catalog must
actually index resources for this property to mean anything'.

Mechanism: makeFakeFs keyed its dirMap/fileMap on POSIX-joined paths
(${root}/${rel}), while production buildCatalog looks paths up with
path.join, which is backslash-separated on Windows. Every lookup missed,
tryReadDir returned null, and the catalog came back empty.

Production is NOT at fault and is unchanged. The same CI run proves it: on
windows-latest the real-filesystem tests all passed, including 'installer
composition decision matches the served catalog for every file in the real
installed tree' and the row-51 non-vacuity proof against a real spawned
installer. A real Windows fs accepts both separators; the FAKE did not, so the
fake was the unfaithful one and is what changed.

Lookup keys are now normalized unconditionally with .replace(/\\/g,'/') in
readDir and readFile - never path.sep-conditional, never platform-gated. The
row-42/43 injected-fault wrappers got the same treatment, since they compared
raw production paths against POSIX-literal fixtures.

No assertion was weakened, and the anti-vacuity guards that caught this are
untouched - they are the reason this surfaced as a loud failure instead of a
suite that silently asserted nothing on Windows.

Refs #3072

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-05 13:30:55 -04:00
committed by GitHub
parent 2bc53baa03
commit 7203011400
14 changed files with 2192 additions and 13 deletions

View File

@@ -395,6 +395,7 @@
"loop-resolver.cjs",
"markdown-sectionizer.cjs",
"markdown-table.cjs",
"mcp-catalog.cjs",
"mcp-server.cjs",
"milestone.cjs",
"model-adapter.cjs",

View File

@@ -52,6 +52,34 @@ Adopt a **dynamic context management platform** built on a hybrid of build-time
*This defers a content catalog, not MCP itself.* A companion MCP server shipped 2026-06-28 under [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (#1681 / PR #1809) — `package.json` bin `gsd-mcp-server` → `bin/gsd-mcp-server.js`, module `src/mcp-server.cts` — exposing three **tools**: `gsd_invoke_command`, `gsd_read_state`, `gsd_write_state`. ADR-1239 owns that surface; this ADR defers a different one over the same protocol. What is genuinely unbuilt is the served **resources** and **prompts** catalog, tracked in #3072.
**Amended by #3072 — the deferral is lifted and the catalog ships.** `gsd-mcp-server` now serves
the workflow/reference/command tree as MCP **resources** (`resources/list` cursor-paginated,
`resources/read`, `gsd://<segment>/<relpath>` uris) and the `commands/gsd/*.md` set as MCP
**prompts**. Decision 6's binding constraint is unchanged and was honored: the catalog is purely
additive, the file-copy floor is still written for every runtime, and no install behavior moved.
*The composition scope is shared, not re-declared.* Served workflow content passes through
`composeWorkflow`, and — critically — through the **same** scope predicate the installer uses.
That predicate (`shouldCompose`) now lives in one place, `src/mcp-catalog.cts`, and
`bin/install.js` imports it rather than re-declaring its own regex. This is the direct answer to
the "Dual-surface drift … requires parity assertions" risk this ADR records below: the two
channels cannot disagree about *what* gets composed, because there is only one predicate, and
`tests/mcp-catalog-parity.install.test.cjs` spawns a REAL `bin/install.js` and asserts its
composition decision (marker-token presence, which survives every per-runtime rewrite) matches
the catalog's, with executable anti-vacuity guards (the comparison set must contain a
marker-bearing workflow AND a non-composed file, and the gate must fail if the predicate stops
discriminating).
*Two things measurement corrected in the migration-step wording.* First, the scope predicate is
**not** "compose everything" — install deliberately composes only under `gsd-core/workflows/`,
because a reference or command that *documents* marker syntax with an unfenced example would
otherwise be parsed as carrying a real marker and have that line lossily dropped (the reason
recorded at `bin/install.js`'s call site, from #2930's review). The catalog inherits that scope
exactly; references and commands are served verbatim. Second, parity is asserted at the
**composition stage, not against an emitted runtime tree** — install applies per-runtime path
rewrites after composing, and the catalog is host-agnostic, so byte-equality with any one
runtime's output would be false by construction.
*"deferred-tools" is not deferred; it is unbuildable.* The **External practice** section above names "resources/prompts/deferred-tools" as the external list-then-fetch analog, and that phrase propagated into this decision. MCP defines exactly three server primitives — resources, prompts, and tools — and the tools surface is `tools/list` (cursor-paginated) plus `tools/call`. There is no server-side deferred-tools primitive; deferring tool *schemas* is host behavior, not a server capability. The list-then-fetch property this ADR wants is delivered by resources and prompts. Recorded in #3075 rather than carried here as a deliverable.
### Options considered
@@ -279,7 +307,7 @@ Sequenced to de-risk — prove the pattern on the smallest surface first, scale
roughly nineteen, which is how a gate becomes something contributors route around.
6. **Wire the init bundle (C)** to emit a per-invocation sections manifest; workflows consume it.
7. **Roll out across LARGE/XL tiers**; update INVENTORY families + parity tests.
8. **(Deferred)** MCP served catalog — resources + prompts, served through the same composition seam as the file floor so the two channels cannot drift (#3072). Additive for MCP-capable hosts only; the file-copy floor stays the default (Decision 6).
8. **MCP served catalog** — resources + prompts, served through the same composition seam as the file floor so the two channels cannot drift (#3072). Additive for MCP-capable hosts only; the file-copy floor stays the default (Decision 6). **Shipped by #3072** — see the amendment under Decision 6.
**Ordering landmine:** any generator consuming compiled output must run *after* `build:lib` (tsc), like `gen-plugin-skills` / `gen-capability-registry`; regenerating before `build:lib` silently drops unbuilt modules (`gsd-inventory-manifest-regen-needs-build`).
@@ -314,6 +342,16 @@ Sequenced to de-risk — prove the pattern on the smallest surface first, scale
- Build-order fragility (must run after `build:lib`).
- Dual-surface drift if any future MCP channel is added — requires parity assertions.
**Discharged by #3072 (the served catalog).** The channel this warned about now exists, and the
mitigation shipped with it rather than being promised alongside it. The composition-scope
predicate is shared (`shouldCompose`, one definition, consumed by both `bin/install.js` and the
catalog) instead of duplicated, so the two surfaces cannot independently drift on what gets
composed; `tests/mcp-catalog-parity.install.test.cjs` spawns a real installer and asserts its
composition decision matches the catalog's across the real content tree. The gate carries two executable anti-vacuity
guards — the comparison set must include a workflow that actually carries markers and a file the
predicate declines to compose — so it cannot pass by comparing nothing, which is the failure mode
a parity assertion is most prone to.
## Prototype (step 2, Option E) — non-shipping reference example
A working prototype proves the platform pattern end-to-end. It ships as a **reference example only**, under `examples/dynamic-context-management/` — deliberately outside the build (`src/` → `bin/lib/`), the npm package `files[]`, the installer, and the CI test suite (`tests/`). Nothing in it is compiled into or installed with GSD; the production implementation lands in a later phase.

View File

@@ -7,10 +7,14 @@ 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).)
`gsd_invoke_command`, `gsd_read_state`, `gsd_write_state`. The server also
serves a read-only **catalog** of GSD's own content — workflows and
references as MCP resources, and the `/gsd-*` commands as MCP prompts — so a
host can browse and pull that content directly instead of shelling out to the
CLI. (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
@@ -71,8 +75,12 @@ OpenCode (and Kilo, which shares OpenCode's config schema) use a
## 2. Restart the host
On startup the host performs the MCP `initialize` handshake, lists tools, and
the three GSD tools become callable.
On startup the host performs the MCP `initialize` handshake. The response
advertises `tools`, `resources`, and `prompts` capabilities, so the three GSD
tools become callable and the host can also list the served catalog
(resources and prompts) described below. The server never advertises
`resources.subscribe` or `listChanged` — the catalog is fixed for the life of
the server process, so there is nothing to subscribe to.
## 3. Verify
@@ -86,6 +94,63 @@ 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).
## 4. Browse the catalog (resources and prompts)
The server also exposes GSD's own content tree as MCP resources and the
`commands/gsd/*.md` command set as MCP prompts. This is additive: the
file-copy install (the default for every runtime) is unchanged, and the
catalog only adds a way for an MCP-capable host to read the same content
directly over the protocol.
### List and read a resource
List available resources (paginated — ask the host to follow `nextCursor`
until it is absent):
```jsonc
{ "name": "resources/list", "arguments": { "cursor": null } }
```
Each entry has a `gsd://<segment>/<relpath>` URI, where `<segment>` is
`workflows`, `references`, or `commands`, and `<relpath>` is the file's path
within that segment (for example, `gsd://workflows/plan-phase.md`,
`gsd://references/untrusted-input-boundary.md`, or
`gsd://commands/plan-phase.md`). Commands appear in both surfaces: read one as
a resource to get its raw markdown, or get it as a prompt to have the host
treat it as an invocable message. Read one by URI:
```jsonc
{ "name": "resources/read", "arguments": { "uri": "gsd://workflows/plan-phase.md" } }
```
An unknown or unrecognized URI (including any path-traversal or absolute-path
attempt) returns a JSON-RPC error rather than an empty or partial result.
### List and get a prompt
```jsonc
{ "name": "prompts/list", "arguments": {} }
```
Each entry is keyed by its bare command name — `plan-phase`, not a path. Get
one:
```jsonc
{ "name": "prompts/get", "arguments": { "name": "plan-phase" } }
```
An unknown prompt name returns a JSON-RPC error. `prompts/get` accepts an
`arguments` object but ignores it — no shipped command template takes
injected arguments today.
### Composed vs. verbatim content
Workflow resources (`gsd://workflows/…`) are served **composed** — with
`<!-- gsd:section -->` markers stripped — exactly as the installed file tree
gets them, while reference and command content is served **verbatim**, because
some reference and command docs use that marker syntax as a documented example
rather than a real marker.
## If something does not work
- **`command not found: gsd-mcp-server`** — invoke via `npx` as shown above, or
@@ -108,3 +173,16 @@ result (the same shape `gsd-tools` produces).
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.
## Reference — the served catalog
| Method | Arguments | Returns |
|--------|-----------|---------|
| `resources/list` | `{cursor?: string}` | `{resources: [{uri, name, title, description, mimeType}], nextCursor?: string}` |
| `resources/read` | `{uri: string}` | `{contents: [{uri, mimeType, text}]}` |
| `prompts/list` | `{}` | `{prompts: [{name, title, description}]}` |
| `prompts/get` | `{name: string, arguments?: object}` | `{description, messages: [{role: "user", content: {type: "text", text}}]}` |
Errors from the catalog (unknown URI, unknown prompt name, malformed cursor,
a refused path-traversal attempt) are returned as JSON-RPC protocol errors,
not MCP tool errors — unlike the three tools above.