* 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:
5
.changeset/agile-goats-fly.md
Normal file
5
.changeset/agile-goats-fly.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3083
|
||||
---
|
||||
**MCP-capable hosts can now browse GSD's own workflows, references, and commands through the companion server** — the workflow and reference tree is served as MCP resources and the `/gsd-*` commands as MCP prompts, so a host lists and fetches just the content it needs instead of relying on the copied file tree alone. Workflow resources arrive composed exactly as the installer writes them; the file-copy install is unchanged and stays the default on every runtime. (#3072)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -83,6 +83,7 @@ build/
|
||||
/gsd-core/bin/lib/hook-bus.cjs
|
||||
/gsd-core/bin/lib/state-io.cjs
|
||||
/gsd-core/bin/lib/mcp-server.cjs
|
||||
/gsd-core/bin/lib/mcp-catalog.cjs
|
||||
/gsd-core/bin/lib/external-descriptor-trust.cjs
|
||||
/gsd-core/bin/lib/cli-skew-check.cjs
|
||||
/gsd-core/bin/lib/context-composer.cjs
|
||||
|
||||
@@ -48,6 +48,9 @@ const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperat
|
||||
// #2930 (epic #1671 Phase 3): strips `<!-- gsd:section -->` markers from
|
||||
// workflow .md content at emit time, before any per-runtime rewrite runs.
|
||||
const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
|
||||
// #3072: THE shared composition-scope predicate (also consumed by the served
|
||||
// MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
|
||||
const { shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
|
||||
const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
// #2544: the CommonJS marker's single source of truth. classifyMarker() backs
|
||||
// BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
|
||||
@@ -7727,8 +7730,18 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
||||
// Linux too — CONTEXT.md path-separator rule) and checked as a
|
||||
// path-segment match so the recursive descent (srcPath may be several
|
||||
// directory levels below gsd-core/workflows/) is still caught.
|
||||
const normalizedSrcPath = srcPath.replace(/\\/g, '/');
|
||||
if (/(?:^|\/)gsd-core\/workflows\//.test(normalizedSrcPath)) {
|
||||
//
|
||||
// #3072: the scoping predicate itself now lives in ONE place —
|
||||
// shouldCompose (src/mcp-catalog.cts, imported above) — rather than
|
||||
// being re-declared inline here. The MCP served catalog calls the
|
||||
// SAME function to decide what it composes vs serves verbatim, so this
|
||||
// install path and the catalog can never independently drift on what
|
||||
// gets composed (ADR-1671:309, DEFECT.GENERATIVE-FIX; the parity gate
|
||||
// is tests/mcp-catalog-parity.test.cjs). shouldCompose normalizes with
|
||||
// the identical unconditional `.replace(/\\/g, '/')` internally, so
|
||||
// this call is behavior-preserving byte-for-behavior with the regex it
|
||||
// replaces.
|
||||
if (shouldCompose(srcPath)) {
|
||||
content = composeWorkflow(content, { sourcePath: srcPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -395,6 +395,7 @@
|
||||
"loop-resolver.cjs",
|
||||
"markdown-sectionizer.cjs",
|
||||
"markdown-table.cjs",
|
||||
"mcp-catalog.cjs",
|
||||
"mcp-server.cjs",
|
||||
"milestone.cjs",
|
||||
"model-adapter.cjs",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -242,6 +242,8 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/state-io.cjs',
|
||||
'gsd-core/bin/lib/external-descriptor-trust.cjs',
|
||||
'gsd-core/bin/lib/mcp-server.cjs',
|
||||
// #3072: tsc-generated runtime artifact — lint the src/mcp-catalog.cts source.
|
||||
'gsd-core/bin/lib/mcp-catalog.cjs',
|
||||
// ADR-1671 (#2928): tsc-generated runtime artifact — lint the src/context-predicates.cts source.
|
||||
'gsd-core/bin/lib/context-predicates.cjs',
|
||||
// #2929: tsc-generated runtime artifact — lint the src/context-composer.cts source.
|
||||
|
||||
635
src/mcp-catalog.cts
Normal file
635
src/mcp-catalog.cts
Normal file
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* MCP served catalog — Phase B, issue #3072 (ADR-1671 epic #1671).
|
||||
* Design: `.gsd/phase/feat-3072-mcp-served-catalog/40-design.md`.
|
||||
*
|
||||
* Serves GSD's content tree as MCP **resources** (`gsd-core/workflows/*.md`,
|
||||
* `gsd-core/references/*.md`) and the 71 `commands/gsd/*.md` files as MCP
|
||||
* **prompts**, through the SAME composition rule the installer applies
|
||||
* (`bin/install.js`'s `copyWithPathReplacement`) — additive to the file-copy
|
||||
* floor, which stays untouched (ADR-1671 Decision 6).
|
||||
*
|
||||
* ## The two findings that shape this module's shape (40-design.md)
|
||||
*
|
||||
* **F1 — composition is scoped to `gsd-core/workflows/`, and that scoping is
|
||||
* load-bearing.** `bin/install.js` runs `composeWorkflow` (from
|
||||
* `workflow-fragments.cts`) only when the normalized source path matches
|
||||
* `(?:^|\/)gsd-core\/workflows\//`. A reference/command doc that merely
|
||||
* *documents* `<!-- gsd:section -->` marker syntax with an unfenced example
|
||||
* would otherwise be mis-parsed as a real marker and have that line lossily
|
||||
* dropped. {@link shouldCompose} is the ONE exported predicate both this
|
||||
* module and `bin/install.js` call — never a second, hand-duplicated regex
|
||||
* (ADR-1671:309, `DEFECT.GENERATIVE-FIX`).
|
||||
*
|
||||
* **F2 — parity cannot mean byte-equality with an emitted runtime tree.**
|
||||
* Install applies per-runtime path rewrites AFTER composition. The served
|
||||
* catalog is host-agnostic and rewrites for no runtime, so served bytes
|
||||
* equal the post-composition, pre-rewrite stage — the parity assertion is
|
||||
* that the catalog and the installer apply the SAME composition rule to the
|
||||
* SAME source, not that final bytes match an emitted tree.
|
||||
*
|
||||
* ## Shape
|
||||
*
|
||||
* ```
|
||||
* buildCatalog({root?, readFile?, readDir?}) -> Catalog {resources, prompts}
|
||||
* readResource(catalog, uri) -> {uri, mimeType, text} | throws CatalogError
|
||||
* getPrompt(catalog, name, args?) -> {description, messages} | throws CatalogError
|
||||
* shouldCompose(relPath) -> boolean // THE shared F1 predicate
|
||||
* listResources(catalog, {cursor?, pageSize?}) -> {resources, nextCursor?} | throws CatalogError
|
||||
* ```
|
||||
*
|
||||
* `listResources` is a delegation surface beyond the four primitives named in
|
||||
* the design's "Shape" block: `src/mcp-server.cts`'s `handleMessage` is
|
||||
* documented PURE and must stay thin (design "Shape" section), so cursor
|
||||
* pagination over the resource index is catalog-module responsibility, not
|
||||
* protocol-handler responsibility, mirroring how `shouldCompose` centralizes
|
||||
* the F1 predicate rather than letting it leak into the protocol layer.
|
||||
*
|
||||
* `buildCatalog`'s `root` is optional: omitted, the real implementation must
|
||||
* resolve the package root from THIS MODULE's own location (`__dirname`),
|
||||
* never from `ctx.cwd` (design row 16 / test-matrix rows 46-47) — `ctx.cwd`
|
||||
* is the user's *project* (state IO), the catalog lives in the *package*.
|
||||
*
|
||||
* Pure over injected `readFile`/`readDir` seams so tests inject IO faults by
|
||||
* monkeypatching the seam (never `chmod 0o000` — root bypasses mode bits and
|
||||
* the test would silently pass with zero coverage in CI).
|
||||
*
|
||||
* Every throw carries a stable {@link REASON} code via a typed `CatalogError`
|
||||
* (mirrors `workflow-fragments.cts`'s `REASON`/`fail()` idiom) so tests assert
|
||||
* `err.reason === REASON.X` rather than regex-/substring-matching the
|
||||
* human-readable message (CONTRIBUTING.md "Prohibited: Raw Text Matching on
|
||||
* Test Outputs").
|
||||
*
|
||||
* `src/mcp-server.cts` gains `resources/*` + `prompts/*` `handleMessage`
|
||||
* cases delegating to this module. The catalog is built once per process,
|
||||
* lazily, and is immutable for the process's lifetime (Gall's Law — no
|
||||
* watching, no invalidation, no subscriptions; see design "Known limits").
|
||||
*
|
||||
* ADR-457 build-at-publish: compiled by tsc to
|
||||
* gsd-core/bin/lib/mcp-catalog.cjs (gitignored).
|
||||
*
|
||||
* ## Directory-walk fail-open/fail-closed split (buildCatalog)
|
||||
*
|
||||
* The three catalog roots (`gsd-core/workflows/`, `gsd-core/references/`,
|
||||
* `commands/gsd/`) are each reached through exactly ONE "speculative" probe —
|
||||
* `readDir(root/gsd-core)` and `readDir(root/commands)` — whose failure is
|
||||
* TOLERATED as "this segment has zero entries" (never fatal, never a crash;
|
||||
* design row 45 / row 16 "absent root does not fall back to cwd"). Every
|
||||
* directory reached AFTER that, because a parent's successful listing already
|
||||
* reported it as a real subdirectory entry, is CONFIRMED to exist; a read
|
||||
* failure for a confirmed directory is fatal (`REASON.READ_FAILED`, design
|
||||
* row 43 "a directory read failure fails closed" — "no partial catalog
|
||||
* silently served"). This is what lets an absent `commands/` tree (row 45 /
|
||||
* many unit fixtures that only populate `gsd-core/workflows/`) build an empty
|
||||
* segment silently, while an INJECTED failure on an already-listed
|
||||
* `gsd-core/workflows/` directory (row 43) still fails the whole build.
|
||||
*
|
||||
* `readFile` is never called at build time — only `readDir`, for discovery.
|
||||
* A single resource's content is read (and, for a workflow, composed) lazily
|
||||
* inside {@link readResource}/{@link getPrompt}, so a bad `readFile` for ONE
|
||||
* entry never prevents that entry from being *listed*, only from being
|
||||
* *read* (design row 14 / test-matrix row 42).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { validatePath } from './security.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- workflow-fragments.cjs is a CommonJS module compiled from a sibling .cts source; `import x = require()` reads its module.exports namespace directly.
|
||||
import workflowFragments = require('./workflow-fragments.cjs');
|
||||
const { composeWorkflow } = workflowFragments;
|
||||
|
||||
/**
|
||||
* Frozen, stable reason codes for every typed throw this module's real
|
||||
* implementation will produce. Tests assert `err.reason === REASON.X`
|
||||
* (CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs") — shape
|
||||
* copied from `workflow-fragments.cts`'s own `REASON` enum.
|
||||
*
|
||||
* - UNKNOWN_RESOURCE — uri (or a real-but-unindexed sibling path) is not a
|
||||
* key in the prebuilt resource index; index membership is the sole
|
||||
* authority (design "Hostile inputs" gate 1 / negative-space bullets).
|
||||
* - UNKNOWN_PROMPT — prompt name is not a key in the prebuilt prompt index.
|
||||
* - INVALID_URI — uri is syntactically malformed but not string-typed
|
||||
* traversal shape: empty string, wrong scheme (e.g. `file://`).
|
||||
* - TRAVERSAL_REFUSED — uri is shaped like a path-traversal or absolute-path
|
||||
* escape attempt (`../`, `..\\`, percent/double-encoded, absolute posix/
|
||||
* windows paths, null byte, a symlink caught by the second `validatePath`
|
||||
* gate) — refused by defense-in-depth, index-first (design "Hostile
|
||||
* inputs").
|
||||
* - UNKNOWN_CURSOR — an unrecognized/malformed pagination cursor.
|
||||
* - READ_FAILED — the injected `readFile`/`readDir` seam threw for one
|
||||
* resource (or a directory) at build or read time; the failure is
|
||||
* contained to that one entry (design row 14).
|
||||
* - UNKNOWN_ROOT — uri's scheme is `gsd://` but its root segment names
|
||||
* neither `workflows` nor `references`.
|
||||
* - INVALID_PARAMS — a required parameter is missing or wrong-typed (e.g. a
|
||||
* non-string uri/name passed to `readResource`/`getPrompt`).
|
||||
*
|
||||
* Adding a new reason requires updating this map AND the test that locks
|
||||
* `Object.keys(REASON).sort()` as a coordinated change.
|
||||
*/
|
||||
export const REASON = Object.freeze({
|
||||
UNKNOWN_RESOURCE: 'unknown_resource',
|
||||
UNKNOWN_PROMPT: 'unknown_prompt',
|
||||
INVALID_URI: 'invalid_uri',
|
||||
TRAVERSAL_REFUSED: 'traversal_refused',
|
||||
UNKNOWN_CURSOR: 'unknown_cursor',
|
||||
READ_FAILED: 'read_failed',
|
||||
UNKNOWN_ROOT: 'unknown_root',
|
||||
INVALID_PARAMS: 'invalid_params',
|
||||
});
|
||||
|
||||
/** A directory entry as returned by the injected `readDir` seam (mirrors `fs.Dirent`'s relevant surface). */
|
||||
export interface DirEntryLike {
|
||||
readonly name: string;
|
||||
isDirectory(): boolean;
|
||||
}
|
||||
|
||||
/** One indexed, servable resource (a `gsd-core/workflows/` or `gsd-core/references/` `.md` file). */
|
||||
export interface CatalogResourceEntry {
|
||||
readonly uri: string;
|
||||
readonly name: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly mimeType: string;
|
||||
/** POSIX-normalized path relative to `root` (e.g. `gsd-core/workflows/plan-phase.md`). */
|
||||
readonly relPath: string;
|
||||
}
|
||||
|
||||
/** One indexed, servable prompt (a `commands/gsd/*.md` file), keyed by its bare command name. */
|
||||
export interface CatalogPromptEntry {
|
||||
readonly name: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
/** POSIX-normalized path relative to `root` (e.g. `commands/gsd/plan-phase.md`). */
|
||||
readonly relPath: string;
|
||||
}
|
||||
|
||||
/** Build-time-only extension of {@link CatalogResourceEntry} carrying the absolute path so `readResource` never re-joins one from client-influenced data. */
|
||||
interface CatalogResourceEntryInternal extends CatalogResourceEntry {
|
||||
readonly absPath: string;
|
||||
}
|
||||
|
||||
/** Build-time-only extension of {@link CatalogPromptEntry} carrying the absolute path. */
|
||||
interface CatalogPromptEntryInternal extends CatalogPromptEntry {
|
||||
readonly absPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The built, immutable catalog index. `root`/`readFile` are internal
|
||||
* implementation state (the resolved package root and the injected-or-default
|
||||
* read seam) carried on the returned object so `readResource`/`getPrompt`
|
||||
* need no second handshake — not part of the list/read *contract* callers
|
||||
* rely on, but present on every value this module returns.
|
||||
*/
|
||||
export interface Catalog {
|
||||
readonly resources: ReadonlyMap<string, CatalogResourceEntry>;
|
||||
readonly prompts: ReadonlyMap<string, CatalogPromptEntry>;
|
||||
readonly root: string;
|
||||
readonly readFile: (absPath: string) => string;
|
||||
}
|
||||
|
||||
export interface BuildCatalogOptions {
|
||||
/** Package root to index from. Omitted: MUST resolve from this module's own location, never `ctx.cwd` (rows 46-47). */
|
||||
root?: string;
|
||||
readFile?: (absPath: string) => string;
|
||||
readDir?: (absPath: string) => DirEntryLike[];
|
||||
}
|
||||
|
||||
export interface ReadResourceResult {
|
||||
readonly uri: string;
|
||||
readonly mimeType: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface PromptMessage {
|
||||
readonly role: 'user';
|
||||
readonly content: { readonly type: 'text'; readonly text: string };
|
||||
}
|
||||
|
||||
export interface GetPromptResult {
|
||||
readonly description: string;
|
||||
readonly messages: readonly PromptMessage[];
|
||||
}
|
||||
|
||||
export interface ListResourcesOptions {
|
||||
readonly cursor?: string;
|
||||
readonly pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ListResourcesResult {
|
||||
readonly resources: readonly CatalogResourceEntry[];
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
/** A `TypeError` carrying a stable {@link REASON} code alongside the human-readable message. */
|
||||
export interface CatalogError extends TypeError {
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default page size for {@link listResources}. ~326 real entries is past the
|
||||
* point where a single unpaginated response is polite (design row 3).
|
||||
*/
|
||||
export const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Throws a `TypeError` carrying `reason` (one of {@link REASON}) as a typed
|
||||
* property, mirroring `workflow-fragments.cts`'s own `fail()` idiom so
|
||||
* callers/tests never pattern-match message prose. `cause`, when given, is
|
||||
* attached via the standard ES2022 `Error` cause chain (never string-baked
|
||||
* into the message) so the original injected-seam/compose failure stays
|
||||
* inspectable without shape-locking this module's own message text.
|
||||
*/
|
||||
function fail(reason: string, message: string, cause?: unknown): never {
|
||||
const options = cause === undefined ? undefined : { cause };
|
||||
const err = new TypeError(`mcp-catalog: ${message}`, options) as TypeError & { reason: string };
|
||||
err.reason = reason;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ─── shouldCompose — the F1 predicate ───────────────────────────────────────
|
||||
|
||||
// Mirrors bin/install.js's pre-refactor inline regex EXACTLY (see that file's
|
||||
// comment block, preserved, for the two-reviewer rationale). `bin/install.js`
|
||||
// now imports THIS function rather than re-declaring the regex (task D).
|
||||
const WORKFLOWS_SCOPE_RE = /(?:^|\/)gsd-core\/workflows\//;
|
||||
|
||||
/**
|
||||
* THE shared F1 predicate: does `relPath` (POSIX-normalized, relative to the
|
||||
* catalog root) fall under `gsd-core/workflows/`? Only such paths are ever
|
||||
* run through `composeWorkflow` — everything else (references, commands) is
|
||||
* served verbatim. `bin/install.js` imports and calls this SAME function
|
||||
* (replacing its inline regex) so the catalog and the installer can never
|
||||
* independently drift on what gets composed (ADR-1671:309,
|
||||
* `DEFECT.GENERATIVE-FIX`; the parity gate in
|
||||
* `tests/mcp-catalog-parity.test.cjs` asserts exactly this).
|
||||
*/
|
||||
export function shouldCompose(relPath: unknown): boolean {
|
||||
if (typeof relPath !== 'string') return false;
|
||||
// Path is normalized UNCONDITIONALLY (backslash paths arrive on Linux too —
|
||||
// CONTEXT.md path-separator rule), matching bin/install.js's own note.
|
||||
const normalized = relPath.replace(/\\/g, '/');
|
||||
return WORKFLOWS_SCOPE_RE.test(normalized);
|
||||
}
|
||||
|
||||
// ─── buildCatalog — directory discovery ─────────────────────────────────────
|
||||
|
||||
/** Resolve the package root from THIS MODULE's own compiled location (`gsd-core/bin/lib/mcp-catalog.cjs`), never `ctx.cwd` (design row 16). */
|
||||
function defaultPackageRoot(): string {
|
||||
return path.resolve(__dirname, '..', '..', '..');
|
||||
}
|
||||
|
||||
function defaultReadFile(absPath: string): string {
|
||||
return fs.readFileSync(absPath, 'utf8');
|
||||
}
|
||||
|
||||
function defaultReadDir(absPath: string): DirEntryLike[] {
|
||||
return fs.readdirSync(absPath, { withFileTypes: true });
|
||||
}
|
||||
|
||||
/** Speculative directory probe: failure (missing, unreadable) is tolerated as "zero entries", never fatal — nothing has confirmed this path exists yet. */
|
||||
function tryReadDir(readDir: (absPath: string) => DirEntryLike[], absPath: string): DirEntryLike[] | null {
|
||||
try {
|
||||
return readDir(absPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk `dirAbs` for `.md` files, calling `onFile(relPathFromDirAbs, absPath)`
|
||||
* for each in listing order (callers re-sort as needed — see `listResources`).
|
||||
* `dirAbs` is assumed CONFIRMED to exist (a parent's successful listing
|
||||
* already reported it as a directory entry), so any `readDir` failure here —
|
||||
* at this level or deeper — is fail-closed (`REASON.READ_FAILED`, design row
|
||||
* 43): "a directory listing failure must surface as a typed error, never a
|
||||
* silently partial catalog." Directory symlinks are inert here by
|
||||
* construction: {@link DirEntryLike} exposes only `isDirectory()`, and a
|
||||
* symlink's dirent type is never reported as a directory, so a symlinked
|
||||
* subtree is neither recursed into nor silently skipped-with-a-lie — it is
|
||||
* simply not `.md`-matched either, and falls out of the walk (the ONE
|
||||
* client-controlled path surface, `resources/read`, still gets the
|
||||
* `validatePath` second gate for a symlinked *file*; see `readIndexedResource`).
|
||||
*/
|
||||
function walkMarkdownFilesFatal(
|
||||
dirAbs: string,
|
||||
readDir: (absPath: string) => DirEntryLike[],
|
||||
onFile: (relPath: string, absPath: string) => void,
|
||||
relPrefix = ''
|
||||
): void {
|
||||
let entries: DirEntryLike[];
|
||||
try {
|
||||
entries = readDir(dirAbs);
|
||||
} catch (err) {
|
||||
fail(REASON.READ_FAILED, `directory listing failed: ${relPrefix || dirAbs}`, err);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
||||
const childAbs = path.join(dirAbs, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkMarkdownFilesFatal(childAbs, readDir, onFile, childRel);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
onFile(childRel, childAbs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Basename of a POSIX-joined relative path (never touches the OS path separator — every rel path this module builds is joined with literal `/`). */
|
||||
function posixBaseName(relPath: string): string {
|
||||
const idx = relPath.lastIndexOf('/');
|
||||
return idx === -1 ? relPath : relPath.slice(idx + 1);
|
||||
}
|
||||
|
||||
/** `plan-phase` / `docs-update` -> `Plan Phase` / `Docs Update`; purely cosmetic, not asserted by any test. */
|
||||
function titleFromBaseName(baseName: string): string {
|
||||
return baseName.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Every segment served as an MCP **resource**. `commands` sits alongside
|
||||
* `workflows`/`references` here because a host may legitimately want to READ
|
||||
* a command's markdown without invoking it as a prompt: `resources/read`
|
||||
* returns raw text while `prompts/get` returns wrapped messages — two MCP
|
||||
* *shapes* over the SAME one file read through the SAME one read path, not a
|
||||
* second source of truth. Drives both the resource listing walk and
|
||||
* unindexed-uri classification (`UNKNOWN_ROOT` vs `UNKNOWN_RESOURCE`).
|
||||
*/
|
||||
const RESOURCE_SEGMENTS: ReadonlySet<string> = new Set(['workflows', 'references', 'commands']);
|
||||
|
||||
/** The subset of {@link RESOURCE_SEGMENTS} that lives under `gsd-core/` (as opposed to `commands/gsd/`, which is rooted elsewhere). */
|
||||
const GSD_CORE_RESOURCE_SEGMENTS: ReadonlySet<string> = new Set(['workflows', 'references']);
|
||||
|
||||
function indexResourceSegments(
|
||||
root: string,
|
||||
readDir: (absPath: string) => DirEntryLike[],
|
||||
out: Map<string, CatalogResourceEntryInternal>
|
||||
): void {
|
||||
const gsdCoreAbs = path.join(root, 'gsd-core');
|
||||
const gsdCoreEntries = tryReadDir(readDir, gsdCoreAbs);
|
||||
if (gsdCoreEntries === null) return;
|
||||
for (const segmentEntry of gsdCoreEntries) {
|
||||
if (!segmentEntry.isDirectory() || !GSD_CORE_RESOURCE_SEGMENTS.has(segmentEntry.name)) continue;
|
||||
const segment = segmentEntry.name;
|
||||
const segmentAbs = path.join(gsdCoreAbs, segment);
|
||||
walkMarkdownFilesFatal(segmentAbs, readDir, (withinSegmentRelPath, absPath) => {
|
||||
const relPath = `gsd-core/${segment}/${withinSegmentRelPath}`;
|
||||
const uri = `gsd://${segment}/${withinSegmentRelPath}`;
|
||||
const name = withinSegmentRelPath.replace(/\.md$/, '');
|
||||
out.set(uri, {
|
||||
uri,
|
||||
name,
|
||||
title: titleFromBaseName(posixBaseName(name)),
|
||||
description: `GSD ${segment} resource: ${relPath}`,
|
||||
mimeType: 'text/markdown',
|
||||
relPath,
|
||||
absPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function indexPromptSegment(
|
||||
root: string,
|
||||
readDir: (absPath: string) => DirEntryLike[],
|
||||
out: Map<string, CatalogPromptEntryInternal>,
|
||||
resourcesOut: Map<string, CatalogResourceEntryInternal>
|
||||
): void {
|
||||
const commandsAbs = path.join(root, 'commands');
|
||||
const commandsEntries = tryReadDir(readDir, commandsAbs);
|
||||
if (commandsEntries === null) return;
|
||||
for (const entry of commandsEntries) {
|
||||
if (!entry.isDirectory() || entry.name !== 'gsd') continue;
|
||||
const gsdAbs = path.join(commandsAbs, 'gsd');
|
||||
walkMarkdownFilesFatal(gsdAbs, readDir, (withinRelPath, absPath) => {
|
||||
const relPath = `commands/gsd/${withinRelPath}`;
|
||||
// Bare command name — never a path (design row 8 / test-matrix row 31).
|
||||
const name = posixBaseName(withinRelPath).replace(/\.md$/, '');
|
||||
out.set(name, {
|
||||
name,
|
||||
title: titleFromBaseName(name),
|
||||
description: `GSD command: ${name}`,
|
||||
relPath,
|
||||
absPath,
|
||||
});
|
||||
// Same file, second MCP shape: also indexed as a resource (see
|
||||
// RESOURCE_SEGMENTS doc comment) so a host can `resources/read` a
|
||||
// command's raw markdown without invoking it as a prompt.
|
||||
const resourceUri = `gsd://commands/${withinRelPath}`;
|
||||
const resourceName = withinRelPath.replace(/\.md$/, '');
|
||||
resourcesOut.set(resourceUri, {
|
||||
uri: resourceUri,
|
||||
name: resourceName,
|
||||
title: titleFromBaseName(posixBaseName(resourceName)),
|
||||
description: `GSD commands resource: ${relPath}`,
|
||||
mimeType: 'text/markdown',
|
||||
relPath,
|
||||
absPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable catalog index over `root` (or, if omitted, this
|
||||
* module's own package location — never `ctx.cwd`; design row 16).
|
||||
* Pure over the injected `readFile`/`readDir` seams so IO faults are tested
|
||||
* by monkeypatching them, never `chmod 0o000`. Only `readDir` is called here
|
||||
* (discovery) — `readFile` is never invoked at build time; see the module
|
||||
* doc comment's "Directory-walk fail-open/fail-closed split" section.
|
||||
*/
|
||||
export function buildCatalog(opts: BuildCatalogOptions = {}): Catalog {
|
||||
const root = opts.root !== undefined ? opts.root : defaultPackageRoot();
|
||||
const readFile = opts.readFile || defaultReadFile;
|
||||
const readDir = opts.readDir || defaultReadDir;
|
||||
|
||||
const resources = new Map<string, CatalogResourceEntryInternal>();
|
||||
const prompts = new Map<string, CatalogPromptEntryInternal>();
|
||||
|
||||
indexResourceSegments(root, readDir, resources);
|
||||
indexPromptSegment(root, readDir, prompts, resources);
|
||||
|
||||
return { resources, prompts, root, readFile };
|
||||
}
|
||||
|
||||
// ─── readResource — the one client-controlled path surface ─────────────────
|
||||
|
||||
/** Percent-decode up to a few passes (double-encoding, design "Hostile inputs"), stopping early once decoding is a no-op or throws on malformed escapes. */
|
||||
function decodePassesFor(value: string): string[] {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
const passes = [normalized];
|
||||
let current = normalized;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(current);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (decoded === current) break;
|
||||
passes.push(decoded);
|
||||
current = decoded;
|
||||
}
|
||||
return passes;
|
||||
}
|
||||
|
||||
/** Does any decode pass of `value` contain a literal `..` path segment? */
|
||||
function hasTraversalSegment(value: string): boolean {
|
||||
return decodePassesFor(value).some((pass) => pass.split('/').some((seg) => seg === '..'));
|
||||
}
|
||||
|
||||
const GSD_URI_RE = /^gsd:\/\/([^/]*)\/(.*)$/;
|
||||
const WINDOWS_ABS_RE = /^[A-Za-z]:\//;
|
||||
|
||||
/**
|
||||
* Classify a uri NOT present in the index, to pick the right {@link REASON}
|
||||
* for the refusal. This is diagnostic only — the actual access decision is
|
||||
* the prebuilt index-membership check in {@link readResource}, which already
|
||||
* rejects every one of these shapes by construction (design "Hostile inputs"
|
||||
* gate 1: "a traversal uri is simply not a key").
|
||||
*/
|
||||
function classifyUnindexedUri(uri: string): string {
|
||||
if (uri.includes('\0')) return REASON.TRAVERSAL_REFUSED;
|
||||
if (uri.startsWith('file://')) return REASON.INVALID_URI;
|
||||
const normalized = uri.replace(/\\/g, '/');
|
||||
if (!normalized.startsWith('gsd://')) {
|
||||
if (normalized.startsWith('/')) return REASON.TRAVERSAL_REFUSED;
|
||||
if (WINDOWS_ABS_RE.test(normalized)) return REASON.TRAVERSAL_REFUSED;
|
||||
return REASON.INVALID_URI;
|
||||
}
|
||||
const match = GSD_URI_RE.exec(normalized);
|
||||
if (!match) return REASON.INVALID_URI;
|
||||
const [, rootSegment, rest] = match;
|
||||
if (!RESOURCE_SEGMENTS.has(rootSegment)) return REASON.UNKNOWN_ROOT;
|
||||
if (hasTraversalSegment(rest)) return REASON.TRAVERSAL_REFUSED;
|
||||
return REASON.UNKNOWN_RESOURCE;
|
||||
}
|
||||
|
||||
/** Second, independent gate (design "Hostile inputs" gate 2): re-validate an INDEXED entry's relPath against the catalog root, catching a symlink planted after the index was built. */
|
||||
function readIndexedResource(catalog: Catalog, entry: CatalogResourceEntry): ReadResourceResult {
|
||||
const check = validatePath(entry.relPath, catalog.root);
|
||||
if (!check.safe) {
|
||||
fail(REASON.TRAVERSAL_REFUSED, `indexed resource escapes catalog root: ${entry.relPath}`);
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = catalog.readFile((entry as CatalogResourceEntryInternal).absPath);
|
||||
} catch (err) {
|
||||
fail(REASON.READ_FAILED, `failed to read resource: ${entry.relPath}`, err);
|
||||
}
|
||||
let text = raw;
|
||||
if (shouldCompose(entry.relPath)) {
|
||||
try {
|
||||
text = composeWorkflow(raw, { sourcePath: entry.relPath });
|
||||
} catch (err) {
|
||||
fail(REASON.READ_FAILED, `failed to compose resource: ${entry.relPath}`, err);
|
||||
}
|
||||
}
|
||||
return { uri: entry.uri, mimeType: entry.mimeType, text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one resource by uri. Index-membership-then-validate (design "Hostile
|
||||
* inputs"): the uri must be an exact key in `catalog.resources`; a workflow
|
||||
* entry is served through `shouldCompose`-gated composition (F1), a
|
||||
* reference entry is served verbatim. Throws a {@link CatalogError} for any
|
||||
* uri not present in the index — never an empty success.
|
||||
*/
|
||||
export function readResource(catalog: Catalog, uri: unknown): ReadResourceResult {
|
||||
if (typeof uri !== 'string') {
|
||||
fail(REASON.INVALID_PARAMS, `uri must be a string, got ${typeof uri}`);
|
||||
}
|
||||
if (uri.length === 0) {
|
||||
fail(REASON.INVALID_URI, 'uri must not be empty');
|
||||
}
|
||||
// Gate 1 — index membership, exact-match, never a path join.
|
||||
const entry = catalog.resources.get(uri);
|
||||
if (entry) {
|
||||
return readIndexedResource(catalog, entry);
|
||||
}
|
||||
fail(classifyUnindexedUri(uri), `unknown or refused resource uri: ${uri}`);
|
||||
}
|
||||
|
||||
// ─── listResources — pagination ─────────────────────────────────────────────
|
||||
|
||||
interface DecodedCursor {
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
function encodeCursor(index: number): string {
|
||||
return Buffer.from(JSON.stringify({ i: index }), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): DecodedCursor | null {
|
||||
try {
|
||||
const json = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
Number.isInteger((parsed as { i?: unknown }).i) &&
|
||||
(parsed as { i: number }).i >= 0
|
||||
) {
|
||||
return { index: (parsed as { i: number }).i };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List resources, sorted deterministically by uri, optionally paginated.
|
||||
* Throws a {@link CatalogError} (`REASON.UNKNOWN_CURSOR`) for a malformed or
|
||||
* unrecognized cursor rather than silently resetting to page 1.
|
||||
*/
|
||||
export function listResources(catalog: Catalog, opts: ListResourcesOptions = {}): ListResourcesResult {
|
||||
const pageSize = opts.pageSize !== undefined && Number.isFinite(opts.pageSize) && opts.pageSize > 0 ? Math.floor(opts.pageSize) : DEFAULT_PAGE_SIZE;
|
||||
|
||||
const sorted = [...catalog.resources.values()].sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
||||
|
||||
let startIndex = 0;
|
||||
if (opts.cursor !== undefined) {
|
||||
const decoded = decodeCursor(opts.cursor);
|
||||
if (decoded === null) {
|
||||
fail(REASON.UNKNOWN_CURSOR, `unrecognized pagination cursor: ${opts.cursor}`);
|
||||
}
|
||||
startIndex = decoded.index;
|
||||
}
|
||||
|
||||
const page = sorted.slice(startIndex, startIndex + pageSize);
|
||||
const nextIndex = startIndex + page.length;
|
||||
const result: { resources: CatalogResourceEntry[]; nextCursor?: string } = { resources: page };
|
||||
if (nextIndex < sorted.length) {
|
||||
result.nextCursor = encodeCursor(nextIndex);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── getPrompt ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get one prompt by its bare command name (never a path). `args`, if
|
||||
* supplied, is accepted and ignored (design row 11 — no command template
|
||||
* takes injected arguments today). Throws a {@link CatalogError} for any
|
||||
* name not present in the index.
|
||||
*/
|
||||
export function getPrompt(catalog: Catalog, name: unknown, args?: unknown): GetPromptResult {
|
||||
void args;
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
fail(REASON.INVALID_PARAMS, `prompt name must be a non-empty string, got ${typeof name}`);
|
||||
}
|
||||
const entry = catalog.prompts.get(name);
|
||||
if (!entry) {
|
||||
fail(REASON.UNKNOWN_PROMPT, `unknown prompt: ${name}`);
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = catalog.readFile((entry as CatalogPromptEntryInternal).absPath);
|
||||
} catch (err) {
|
||||
fail(REASON.READ_FAILED, `failed to read prompt: ${entry.relPath}`, err);
|
||||
}
|
||||
return {
|
||||
description: entry.description,
|
||||
messages: [{ role: 'user', content: { type: 'text', text: raw } }],
|
||||
};
|
||||
}
|
||||
@@ -33,10 +33,66 @@ import stateIo = require('./state-io.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import shellCommandProjection = require('./shell-command-projection.cjs');
|
||||
const { dispatchGsdCommand } = shellCommandProjection;
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildCatalog,
|
||||
readResource,
|
||||
listResources,
|
||||
getPrompt,
|
||||
REASON,
|
||||
type Catalog,
|
||||
} from './mcp-catalog.cjs';
|
||||
|
||||
export const PROTOCOL_VERSION = '2024-11-05';
|
||||
export const SERVER_NAME = 'gsd-core';
|
||||
const SERVER_VERSION = '1.7.0';
|
||||
|
||||
// #3072: resolve SERVER_VERSION from package.json (source tree) or the
|
||||
// installed gsd-core/VERSION marker, WITHOUT a top-level `require(...)` —
|
||||
// mirrors the established, already-precedented resolver in
|
||||
// `runtime-artifact-conversion.cts`'s `gsdVersion()` (#1383): a top-level
|
||||
// require would throw on any runtime whose root has no package.json/VERSION,
|
||||
// and `scripts/sync-manifest-versions.cjs` is not a fit here — its
|
||||
// `VERSIONED_MANIFESTS` list stamps a `version` FIELD into hand-authored JSON
|
||||
// manifests (plugin.json, marketplace.json, vscode/package.json); a `.cts`
|
||||
// source constant is not a JSON document that script can round-trip through
|
||||
// `readJson`/`setByPath`, and teaching it to text-edit TypeScript source
|
||||
// would be a much larger footprint than this lazy, cached, defensive read.
|
||||
// Resolved once per process (never per-request) and cached; a failed lookup
|
||||
// degrades to '0.0.0' (never `undefined`, never a crash) rather than making
|
||||
// `initialize`'s `serverInfo.version` field absent or malformed.
|
||||
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
|
||||
let cachedServerVersion: string | undefined;
|
||||
function resolveServerVersion(): string {
|
||||
if (cachedServerVersion !== undefined) return cachedServerVersion;
|
||||
let version = '0.0.0';
|
||||
try {
|
||||
const v = fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
|
||||
if (SEMVER_PREFIX.test(v)) version = v;
|
||||
} catch {
|
||||
/* not an installed tree (no gsd-core/VERSION) */
|
||||
}
|
||||
if (version === '0.0.0') {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy, defensive: a top-level require would throw on a runtime root with no package.json.
|
||||
const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json')) as { version?: string };
|
||||
if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) version = pkg.version;
|
||||
} catch {
|
||||
/* runtime root has no package.json */
|
||||
}
|
||||
}
|
||||
cachedServerVersion = version;
|
||||
return version;
|
||||
}
|
||||
|
||||
// Catalog is built once per process, lazily, and cached (design "Shape" /
|
||||
// Gall's Law — no watching, no invalidation; Known limits: the package is
|
||||
// immutable in every install mode we ship).
|
||||
let cachedCatalog: Catalog | undefined;
|
||||
function getCatalog(): Catalog {
|
||||
if (cachedCatalog === undefined) cachedCatalog = buildCatalog();
|
||||
return cachedCatalog;
|
||||
}
|
||||
|
||||
// JSON-RPC 2.0 error codes.
|
||||
const PARSE_ERROR = -32700;
|
||||
@@ -107,6 +163,37 @@ function asString(v: unknown): string | null {
|
||||
return typeof v === 'string' ? v : null;
|
||||
}
|
||||
|
||||
/** Extract the {@link REASON} carried by a `CatalogError`, if any (see `mcp-catalog.cts`). */
|
||||
function catalogErrorReason(err: unknown): string | undefined {
|
||||
const reason = err && typeof err === 'object' ? (err as { reason?: unknown }).reason : undefined;
|
||||
return typeof reason === 'string' ? reason : undefined;
|
||||
}
|
||||
|
||||
function catalogErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `mcp-catalog.cts` `CatalogError.reason` to a JSON-RPC error code.
|
||||
* `READ_FAILED` is a server-side IO/compose failure (`INTERNAL_ERROR`);
|
||||
* every other reason (unknown uri/prompt/cursor/root, malformed/traversal
|
||||
* uri, wrong-typed param) is a client-supplied-value problem (`INVALID_PARAMS`)
|
||||
* — deliberately never `METHOD_NOT_FOUND`, so a refusal is never
|
||||
* indistinguishable from the method simply not existing (test-matrix rows
|
||||
* 33/35).
|
||||
*/
|
||||
function catalogErrorCode(err: unknown): number {
|
||||
return catalogErrorReason(err) === REASON.READ_FAILED ? INTERNAL_ERROR : INVALID_PARAMS;
|
||||
}
|
||||
|
||||
function wireResource(entry: { uri: string; name: string; title: string; description: string; mimeType: string }) {
|
||||
return { uri: entry.uri, name: entry.name, title: entry.title, description: entry.description, mimeType: entry.mimeType };
|
||||
}
|
||||
|
||||
function wirePrompt(entry: { name: string; title: string; description: string }) {
|
||||
return { name: entry.name, title: entry.title, description: entry.description };
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
const cwd = asString(ctx.cwd) || process.cwd();
|
||||
@@ -161,8 +248,12 @@ export function handleMessage(request: JsonRpcRequest, ctx: McpContext = {}): Re
|
||||
case 'initialize':
|
||||
result = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
||||
// #3072: resources/prompts declared alongside tools. Deliberately NO
|
||||
// subscribe/listChanged — nothing ever sends those notifications
|
||||
// (design row 2 / Hyrum's Law: advertising an unimplemented
|
||||
// notification is a lie a host would act on).
|
||||
capabilities: { tools: {}, resources: {}, prompts: {} },
|
||||
serverInfo: { name: SERVER_NAME, version: resolveServerVersion() },
|
||||
};
|
||||
break;
|
||||
case 'tools/list':
|
||||
@@ -175,6 +266,48 @@ export function handleMessage(request: JsonRpcRequest, ctx: McpContext = {}): Re
|
||||
result = callTool(toolName, params.arguments, ctx);
|
||||
break;
|
||||
}
|
||||
case 'resources/list': {
|
||||
const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>;
|
||||
try {
|
||||
const cursor = typeof params.cursor === 'string' ? params.cursor : undefined;
|
||||
const pageSize = typeof params.pageSize === 'number' ? params.pageSize : undefined;
|
||||
const page = listResources(getCatalog(), { cursor, pageSize });
|
||||
result = page.nextCursor === undefined
|
||||
? { resources: page.resources.map(wireResource) }
|
||||
: { resources: page.resources.map(wireResource), nextCursor: page.nextCursor };
|
||||
} catch (e) {
|
||||
return errorResponse(id, catalogErrorCode(e), catalogErrorMessage(e));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'resources/read': {
|
||||
const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>;
|
||||
try {
|
||||
const read = readResource(getCatalog(), params.uri);
|
||||
result = { contents: [{ uri: read.uri, mimeType: read.mimeType, text: read.text }] };
|
||||
} catch (e) {
|
||||
return errorResponse(id, catalogErrorCode(e), catalogErrorMessage(e));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'prompts/list':
|
||||
result = {
|
||||
prompts: [...getCatalog().prompts.values()]
|
||||
.map(wirePrompt)
|
||||
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
||||
};
|
||||
break;
|
||||
case 'prompts/get': {
|
||||
const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>;
|
||||
const name = asString(params.name);
|
||||
if (!name) return errorResponse(id, INVALID_PARAMS, 'prompts/get requires string "name".');
|
||||
try {
|
||||
result = getPrompt(getCatalog(), name, params.arguments);
|
||||
} catch (e) {
|
||||
return errorResponse(id, catalogErrorCode(e), catalogErrorMessage(e));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (isNotification) return null;
|
||||
return errorResponse(id, METHOD_NOT_FOUND, `Method not found: ${method || '(empty)'}.`);
|
||||
|
||||
@@ -98,8 +98,16 @@ test('tools/call: missing tool name is a JSON-RPC invalid-params error', () => {
|
||||
assert.match(res.error.message, /requires string "name"/);
|
||||
});
|
||||
|
||||
// Previously used 'resources/read' as the example unknown method, but that
|
||||
// stopped being unknown once the served catalog shipped in #3072.
|
||||
// 'resources/subscribe' is DELIBERATELY not implemented and deliberately NOT
|
||||
// advertised in initialize's capabilities, because the server never sends the
|
||||
// corresponding notification. So this assertion now pins a real contract --
|
||||
// the advertised capability surface and the implemented method surface agree
|
||||
// -- rather than an arbitrary method name that a future feature could
|
||||
// invalidate the same way.
|
||||
test('unknown method: JSON-RPC method-not-found (-32601)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 8, method: 'resources/read' });
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 8, method: 'resources/subscribe' });
|
||||
assert.strictEqual(res.error.code, -32601);
|
||||
assert.match(res.error.message, /Method not found/);
|
||||
});
|
||||
|
||||
327
tests/mcp-catalog-parity.install.test.cjs
Normal file
327
tests/mcp-catalog-parity.install.test.cjs
Normal file
@@ -0,0 +1,327 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* mcp-catalog-parity.install.test.cjs — the anti-drift gate mandated by
|
||||
* ADR-1671 ("Dual-surface drift if any future MCP channel is added —
|
||||
* requires parity assertions"), issue #3072 (epic #1671 Phase B),
|
||||
* `.gsd/phase/feat-3072-mcp-served-catalog/40-design.md` "The parity
|
||||
* assertion".
|
||||
*
|
||||
* ## PRIOR DEFECT (review BLOCKER, fixed by this rewrite)
|
||||
*
|
||||
* The original `tests/mcp-catalog-parity.test.cjs` never imported, spawned,
|
||||
* or otherwise exercised `bin/install.js`. It recomputed the "installer
|
||||
* side" by calling `shouldCompose`/`composeWorkflow` directly — the SAME
|
||||
* functions the catalog itself calls — so it only proved `src/mcp-catalog.cts`
|
||||
* is self-consistent with itself. Its row-52 assertion compared
|
||||
* `shouldCompose` against a regex literal frozen inside the test file, never
|
||||
* against `bin/install.js`'s real behavior. Net effect: a re-introduced,
|
||||
* divergent inline composition-scope regex in `bin/install.js` (the exact
|
||||
* regression `ADR-1671:309` `DEFECT.GENERATIVE-FIX` warns about) would have
|
||||
* stayed GREEN.
|
||||
*
|
||||
* This file instead drives a REAL spawned `bin/install.js` (via
|
||||
* `tests/helpers/install-shared.cjs`'s `runMinimalInstall` — the same driver
|
||||
* `tests/workflow-fragments-emission.install.test.cjs` and
|
||||
* `tests/agent-fragments-emission.install.test.cjs` use) and compares its
|
||||
* REAL emitted output against the catalog's served content, renamed to
|
||||
* `.install.test.cjs` to land in the slow `install` suite (`npm run
|
||||
* test:install`) alongside those files.
|
||||
*
|
||||
* ## Why marker PRESENCE, not byte-equality
|
||||
*
|
||||
* `bin/install.js` applies per-runtime path rewrites (`~/.claude/` -> the
|
||||
* runtime's prefix, attribution stamping, per-runtime `.md` converters)
|
||||
* AFTER composition (`shouldCompose`/`composeWorkflow`, just below in
|
||||
* `copyWithPathReplacement`). The catalog is host-agnostic and applies none
|
||||
* of those rewrites. Raw byte-equality between an emitted file and served
|
||||
* content is therefore FALSE BY CONSTRUCTION for every file whose content
|
||||
* embeds a rewritten path or attribution stamp. What DOES survive every
|
||||
* rewrite untouched is the COMPOSITION DECISION itself: did this file's
|
||||
* `<!-- gsd:section` markers get stripped or not? Marker-token presence is
|
||||
* that observable (the same detector
|
||||
* `tests/workflow-fragments-emission.install.test.cjs`'s
|
||||
* `noSectionMarkerLeaksIntoEmittedArtifacts` already uses), and it is
|
||||
* insensitive to every rewrite that runs after composition.
|
||||
*
|
||||
* ## Single representative runtime
|
||||
*
|
||||
* `shouldCompose`/`composeWorkflow` do not depend on runtime — only the
|
||||
* REWRITES applied after composition do, and this gate's marker-presence
|
||||
* comparison is deliberately blind to those. A multi-runtime loop would
|
||||
* therefore just re-run the identical composition decision N times; one real
|
||||
* spawned install (`claude`, the canonical host per `src/mcp-catalog.cts`'s
|
||||
* own module doc) is sufficient real-install evidence for what this gate
|
||||
* checks, and keeps this already-slow suite bounded.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { runMinimalInstall, installerEnv } = require('./helpers/install-shared.cjs');
|
||||
const { buildOverlayRepo } = require('./helpers/overlay-repo.cjs');
|
||||
|
||||
const { buildCatalog, readResource, shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const MARKER_TOKEN = 'gsd:section';
|
||||
|
||||
/** Recursively collect `.md` file paths under `absDir`, relative to `REPO_ROOT`, POSIX-normalized. */
|
||||
function collectMarkdownFiles(absDir, out = []) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(absDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectMarkdownFiles(abs, out);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
out.push(path.relative(REPO_ROOT, abs).replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function realParitySet() {
|
||||
return [
|
||||
...collectMarkdownFiles(path.join(REPO_ROOT, 'gsd-core', 'workflows')),
|
||||
...collectMarkdownFiles(path.join(REPO_ROOT, 'gsd-core', 'references')),
|
||||
];
|
||||
}
|
||||
|
||||
/** `gsd-core/workflows/x.md` -> `gsd://workflows/x.md`; `gsd-core/references/y.md` -> `gsd://references/y.md`. */
|
||||
function toResourceUri(relPath) {
|
||||
const m = /^gsd-core\/(workflows|references)\/(.+)$/.exec(relPath);
|
||||
if (!m) throw new Error(`fixture bug: unexpected relPath shape ${relPath}`);
|
||||
return `gsd://${m[1]}/${m[2]}`;
|
||||
}
|
||||
|
||||
function hasMarker(text) {
|
||||
return text.includes(MARKER_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a (possibly overlaid) `bin/install.js` at global scope and assert it
|
||||
* succeeded. Mirrors `workflow-fragments-emission.install.test.cjs`'s and
|
||||
* `fragment-single-edit-propagation.install.test.cjs`'s own `spawnGlobalInstall`
|
||||
* / `installOverlay` — kept local per those files' own documented rationale:
|
||||
* it is a thin spawn wrapper with no independent mechanism (unlike
|
||||
* `buildOverlayRepo`, which IS imported/reused), so a local copy carries none
|
||||
* of the "generative fix divergence" risk.
|
||||
*/
|
||||
function installOverlay(overlayRoot, runtime, extraArgs = []) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-3072-parity-dest-${runtime}-`));
|
||||
const installScript = path.join(overlayRoot, 'bin', 'install.js');
|
||||
const args = [
|
||||
'--preserve-symlinks',
|
||||
'--preserve-symlinks-main',
|
||||
installScript,
|
||||
`--${runtime}`,
|
||||
'--global',
|
||||
'--config-dir',
|
||||
root,
|
||||
...extraArgs,
|
||||
];
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
||||
});
|
||||
return { configDir: root, root, result };
|
||||
}
|
||||
|
||||
// ─── row 48 — the real gate ─────────────────────────────────────────────────
|
||||
|
||||
describe('the parity gate — real installer vs real catalog', () => {
|
||||
test('installer composition decision matches the served catalog for every file in the real installed tree (row 48/52)', (t) => {
|
||||
const catalog = buildCatalog({ root: REPO_ROOT });
|
||||
const install = runMinimalInstall({ runtime: 'claude', scope: 'global' });
|
||||
t.after(() => cleanup(install.root));
|
||||
|
||||
// Anti-vacuity guard: the installer must actually have emitted files —
|
||||
// proves the spawned install really ran (not a silent no-op / early exit).
|
||||
const emittedFileCount = install.manifest && install.manifest.files
|
||||
? Object.keys(install.manifest.files).length
|
||||
: 0;
|
||||
assert.ok(
|
||||
emittedFileCount > 0,
|
||||
'the installer must have emitted at least one file — proves the real install actually ran',
|
||||
);
|
||||
|
||||
const relPaths = realParitySet();
|
||||
assert.ok(relPaths.length > 0, 'the real parity set must be non-empty');
|
||||
|
||||
let comparedCount = 0;
|
||||
let markerBearingWorkflowSeen = false;
|
||||
let nonComposedFileSeen = false;
|
||||
|
||||
for (const relPath of relPaths) {
|
||||
const emittedPath = path.join(install.configDir, relPath);
|
||||
const uri = toResourceUri(relPath);
|
||||
if (!fs.existsSync(emittedPath) || !catalog.resources.has(uri)) continue; // outside this gate's install/catalog intersection
|
||||
|
||||
comparedCount += 1;
|
||||
const sourceText = fs.readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||
const emittedText = fs.readFileSync(emittedPath, 'utf8');
|
||||
const servedText = readResource(catalog, uri).text;
|
||||
|
||||
const markersPresentInEmitted = hasMarker(emittedText);
|
||||
const markersPresentInServed = hasMarker(servedText);
|
||||
|
||||
// row 48: the two independently-produced surfaces (a real spawned
|
||||
// installer vs. the catalog's own composition) must agree on whether
|
||||
// this file's markers were stripped.
|
||||
assert.equal(
|
||||
markersPresentInEmitted,
|
||||
markersPresentInServed,
|
||||
`${relPath}: composition decision diverged between the real installer (markers present=${markersPresentInEmitted}) and the served catalog (markers present=${markersPresentInServed})`,
|
||||
);
|
||||
|
||||
// row 52 replacement: the installer's OBSERVABLE composition behavior
|
||||
// (not a re-declared regex) must match shouldCompose's own verdict —
|
||||
// composed files never retain markers; declined files are untouched.
|
||||
const composedAccordingToPredicate = shouldCompose(relPath);
|
||||
const expectedEmittedHasMarker = composedAccordingToPredicate ? false : hasMarker(sourceText);
|
||||
assert.equal(
|
||||
markersPresentInEmitted,
|
||||
expectedEmittedHasMarker,
|
||||
`${relPath}: real installer output does not match shouldCompose's verdict (shouldCompose=${composedAccordingToPredicate})`,
|
||||
);
|
||||
|
||||
if (composedAccordingToPredicate && hasMarker(sourceText)) markerBearingWorkflowSeen = true;
|
||||
if (!composedAccordingToPredicate) nonComposedFileSeen = true;
|
||||
}
|
||||
|
||||
assert.ok(comparedCount > 0, 'the comparison set must be non-empty — else the gate proves nothing');
|
||||
assert.ok(
|
||||
markerBearingWorkflowSeen,
|
||||
'the comparison set must include >=1 workflow that actually carries markers in source, else every comparison is a byte-identical no-op',
|
||||
);
|
||||
assert.ok(
|
||||
nonComposedFileSeen,
|
||||
'the comparison set must include >=1 file the predicate declines to compose, else a predicate that always returns true would still pass',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── row 50 — a marker-documenting non-workflow composes on neither surface ─
|
||||
//
|
||||
// No real gsd-core/references/*.md or commands/gsd/*.md file documents the
|
||||
// `<!-- gsd:section -->` marker syntax as of this change (verified: `grep -rl
|
||||
// "gsd:section" gsd-core/references/ commands/gsd/` is empty). This test
|
||||
// therefore uses a SYNTHETIC fixture: an overlay (`buildOverlayRepo`, the
|
||||
// established technique — see `nonWorkflowMarkdownWithMarkerShapedLineIsNot
|
||||
// Composed` in `workflow-fragments-emission.install.test.cjs`, which
|
||||
// pioneered this exact fixture content) that overrides the real, existing
|
||||
// `gsd-core/references/context-budget.md` leaf with a doc that documents
|
||||
// marker syntax via a deliberately UNCLOSED marker-shaped example line — so
|
||||
// if a regression ever ran composeWorkflow over it, parsing would THROW
|
||||
// loudly (never silently mis-parse), which is what makes this a real
|
||||
// negative control rather than a fixture that would coincidentally pass
|
||||
// either way.
|
||||
|
||||
describe('a marker-documenting non-workflow composes on neither surface (row 50)', () => {
|
||||
test('reference doc content is present verbatim in source, in the real installed tree, and in the catalog served over the same tree', (t) => {
|
||||
const nonWorkflowDoc =
|
||||
'# Marker syntax\n\nExample (deliberately unfenced and unclosed to prove non-composition):\n\n<!-- gsd:section id="x" when="always" -->\nnever closed on purpose\n';
|
||||
const target = 'gsd-core/references/context-budget.md';
|
||||
|
||||
assert.equal(shouldCompose(target), false, 'precondition: a references/ path must never be composed');
|
||||
|
||||
const overlayRepo = buildOverlayRepo({ [target]: nonWorkflowDoc });
|
||||
t.after(() => cleanup(overlayRepo));
|
||||
|
||||
const dest = installOverlay(overlayRepo, 'claude');
|
||||
t.after(() => cleanup(dest.root));
|
||||
assert.equal(
|
||||
dest.result.status,
|
||||
0,
|
||||
`install must succeed: a non-workflow doc's marker-shaped line must never reach composeWorkflow\nstderr: ${dest.result.stderr}`,
|
||||
);
|
||||
|
||||
const emittedPath = path.join(dest.configDir, target);
|
||||
assert.ok(fs.existsSync(emittedPath), 'emitted context-budget.md is missing');
|
||||
assert.equal(
|
||||
fs.readFileSync(emittedPath, 'utf8'),
|
||||
nonWorkflowDoc,
|
||||
'a marker-documenting reference doc must be emitted byte-identical by the real installer (never composed)',
|
||||
);
|
||||
|
||||
// Catalog served over the SAME tree the installer just read from (the
|
||||
// overlay), not REPO_ROOT — REPO_ROOT has no such fixture on disk.
|
||||
const catalog = buildCatalog({ root: overlayRepo });
|
||||
const servedText = readResource(catalog, toResourceUri(target)).text;
|
||||
assert.equal(
|
||||
servedText,
|
||||
nonWorkflowDoc,
|
||||
'a marker-documenting reference doc must be served byte-identical by the catalog (never composed)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── row 51 — the gate is non-vacuous against a REAL installer regression ──
|
||||
//
|
||||
// Simulates the exact regression class this gate exists to catch: a
|
||||
// composition-scope predicate that diverges reaching the REAL bin/install.js
|
||||
// emit path — not a hand-duplicated regex living only in this test file (the
|
||||
// prior defect this rewrite fixes). Overlays
|
||||
// `gsd-core/bin/lib/mcp-catalog.cjs`'s `shouldCompose` export — the ONE thing
|
||||
// `bin/install.js` imports from that module (`const { shouldCompose } =
|
||||
// require('../gsd-core/bin/lib/mcp-catalog.cjs')`) — with one that never
|
||||
// composes anything, exactly what a reverted or independently-diverged
|
||||
// inline regex in `bin/install.js` would produce. `composeWorkflow` itself
|
||||
// is left untouched, so the install still succeeds; it simply never gets
|
||||
// called for any file.
|
||||
|
||||
describe('the parity gate is non-vacuous against a real installer regression (row 51)', () => {
|
||||
test('a broken shouldCompose reaching the real bin/install.js produces a detectable installer/catalog divergence', (t) => {
|
||||
const brokenPredicateRepo = buildOverlayRepo({
|
||||
'gsd-core/bin/lib/mcp-catalog.cjs': 'module.exports = { shouldCompose: () => false };\n',
|
||||
});
|
||||
t.after(() => cleanup(brokenPredicateRepo));
|
||||
|
||||
const dest = installOverlay(brokenPredicateRepo, 'claude');
|
||||
t.after(() => cleanup(dest.root));
|
||||
assert.equal(
|
||||
dest.result.status,
|
||||
0,
|
||||
`broken-predicate install must still succeed (composeWorkflow simply never runs)\nstderr: ${dest.result.stderr}`,
|
||||
);
|
||||
|
||||
const target = 'gsd-core/workflows/autonomous.md';
|
||||
const sourceText = fs.readFileSync(path.join(REPO_ROOT, target), 'utf8');
|
||||
assert.ok(
|
||||
hasMarker(sourceText),
|
||||
'precondition: the fixture workflow must actually carry markers, or this simulation proves nothing',
|
||||
);
|
||||
|
||||
const emittedPath = path.join(dest.configDir, target);
|
||||
assert.ok(fs.existsSync(emittedPath), 'broken-predicate install is missing autonomous.md');
|
||||
const emittedText = fs.readFileSync(emittedPath, 'utf8');
|
||||
|
||||
// The real, non-overlaid catalog is unaffected by the overlay — it still
|
||||
// composes autonomous.md and strips its markers.
|
||||
const catalog = buildCatalog({ root: REPO_ROOT });
|
||||
const servedText = readResource(catalog, toResourceUri(target)).text;
|
||||
|
||||
assert.equal(hasMarker(emittedText), true, 'broken-predicate install unexpectedly composed anyway');
|
||||
assert.equal(hasMarker(servedText), false, 'the real, non-overlaid catalog unexpectedly failed to compose');
|
||||
|
||||
// This is row 48's own assertion, run against a REAL spawned installer
|
||||
// that regressed exactly the way ADR-1671:309 warns about: it must
|
||||
// DIVERGE here, proving row 48 would have gone RED had this shipped.
|
||||
assert.notEqual(
|
||||
hasMarker(emittedText),
|
||||
hasMarker(servedText),
|
||||
'a broken installer-side predicate must produce a detectable emitted/served divergence, or row 48 would stay green through this exact regression',
|
||||
);
|
||||
});
|
||||
});
|
||||
179
tests/mcp-catalog.property.test.cjs
Normal file
179
tests/mcp-catalog.property.test.cjs
Normal file
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Failing-first property-based tests for src/mcp-catalog.cts (compiled to
|
||||
* gsd-core/bin/lib/mcp-catalog.cjs) — issue #3072 (epic #1671 Phase B),
|
||||
* `.gsd/phase/feat-3072-mcp-served-catalog/40-design.md`.
|
||||
*
|
||||
* Covers 50-test-matrix.md rows 53-55:
|
||||
* (a) every indexed uri round-trips through readResource
|
||||
* (b) no generated traversal-shaped string ever resolves to a read
|
||||
* (c) pagination partitions the resource list for any page size
|
||||
*
|
||||
* Uses the shared `fast-check` global config (`tests/helpers/
|
||||
* fast-check-setup.cjs`: numRuns 200, seed 42, overridable via
|
||||
* `GSD_FC_SEED`) — same pattern as `tests/adr-parser.property.test.cjs`.
|
||||
* Fixtures are built over the SAME injected `readFile`/`readDir` seam used
|
||||
* throughout `tests/mcp-catalog.test.cjs` — never real filesystem
|
||||
* permission tricks.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const { buildCatalog, readResource, listResources, getPrompt } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
|
||||
|
||||
// ─── shared fixture: a small, fixed catalog over injected seams ────────────
|
||||
|
||||
function dirEntry(name, isDir) {
|
||||
return { name, isDirectory: () => isDir };
|
||||
}
|
||||
|
||||
function makeFakeFs(root, files) {
|
||||
const abs = (rel) => (rel ? `${root}/${rel}` : root);
|
||||
const fileMap = new Map(Object.entries(files).map(([rel, content]) => [abs(rel), content]));
|
||||
const dirMap = new Map();
|
||||
for (const rel of Object.keys(files)) {
|
||||
const parts = rel.split('/');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const dirRel = parts.slice(0, i).join('/');
|
||||
const dirAbs = abs(dirRel);
|
||||
const isLast = i === parts.length - 1;
|
||||
if (!dirMap.has(dirAbs)) dirMap.set(dirAbs, new Map());
|
||||
dirMap.get(dirAbs).set(parts[i], dirEntry(parts[i], !isLast));
|
||||
}
|
||||
}
|
||||
// Production (`buildCatalog`) resolves paths via `path.join`, which is
|
||||
// backslash-separated on Windows; this fake's maps are keyed POSIX. A real
|
||||
// `fs` accepts both separators, so the fake must too — normalize the
|
||||
// incoming lookup key unconditionally (never process.platform-gated).
|
||||
// Caught by CI on windows-latest: every lookup missed, the fake indexed
|
||||
// zero entries, and the anti-vacuity guards correctly flagged it.
|
||||
const norm = (p) => String(p).replace(/\\/g, '/');
|
||||
const readDir = (absPath) => {
|
||||
const m = dirMap.get(norm(absPath));
|
||||
if (!m) throw new Error(`ENOENT (fake fs): ${absPath}`);
|
||||
return [...m.values()];
|
||||
};
|
||||
const readFile = (absPath) => {
|
||||
const key = norm(absPath);
|
||||
if (!fileMap.has(key)) throw new Error(`ENOENT (fake fs): ${absPath}`);
|
||||
return fileMap.get(key);
|
||||
};
|
||||
return { root, readFile, readDir };
|
||||
}
|
||||
|
||||
const FIXTURE_RESOURCE_COUNT = 9;
|
||||
const FIXTURE_PROMPT_COUNT = 4;
|
||||
|
||||
function buildFixtureCatalog() {
|
||||
const files = {};
|
||||
for (let i = 0; i < FIXTURE_RESOURCE_COUNT; i++) {
|
||||
const segment = i % 2 === 0 ? 'workflows' : 'references';
|
||||
files[`gsd-core/${segment}/entry-${i}.md`] = `# entry ${i}\n\nbody text for entry ${i}\n`;
|
||||
}
|
||||
for (let i = 0; i < FIXTURE_PROMPT_COUNT; i++) {
|
||||
files[`commands/gsd/cmd-${i}.md`] = `# cmd ${i}\n\nprompt body ${i}\n`;
|
||||
}
|
||||
const fake = makeFakeFs('/fake-root-property', files);
|
||||
return buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
}
|
||||
|
||||
// ─── row 53: every indexed uri round-trips ──────────────────────────────────
|
||||
|
||||
describe('property: every indexed uri round-trips (row 53)', () => {
|
||||
test('readResource succeeds and returns the same uri for every indexed resource', () => {
|
||||
const catalog = buildFixtureCatalog();
|
||||
const uris = [...catalog.resources.keys()];
|
||||
assert.ok(uris.length > 0, 'fixture catalog must actually index resources for this property to mean anything');
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.constantFrom(...uris), (uri) => {
|
||||
const result = readResource(catalog, uri);
|
||||
assert.equal(result.uri, uri);
|
||||
assert.equal(typeof result.text, 'string');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('getPrompt succeeds and returns the same name for every indexed prompt', () => {
|
||||
const catalog = buildFixtureCatalog();
|
||||
const names = [...catalog.prompts.keys()];
|
||||
assert.ok(names.length > 0, 'fixture catalog must actually index prompts for this property to mean anything');
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.constantFrom(...names), (name) => {
|
||||
const result = getPrompt(catalog, name);
|
||||
assert.equal(typeof result.description, 'string');
|
||||
assert.ok(Array.isArray(result.messages) && result.messages.length >= 1);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── row 54: no generated traversal string ever resolves ───────────────────
|
||||
|
||||
describe('property: generated traversal strings never resolve (row 54)', () => {
|
||||
test('readResource always refuses arbitrary traversal-shaped uris', () => {
|
||||
const catalog = buildFixtureCatalog();
|
||||
|
||||
const traversalSegment = fc.constantFrom('..', '..%2f', '%2e%2e', '..\\', '%252e%252e%252f');
|
||||
const rootSegment = fc.constantFrom('workflows', 'references');
|
||||
const suffix = fc.stringMatching(/^[a-z0-9-]{0,12}$/);
|
||||
|
||||
const traversalUri = fc
|
||||
.tuple(rootSegment, fc.array(traversalSegment, { minLength: 1, maxLength: 6 }), suffix)
|
||||
.map(([root, segments, tail]) => `gsd://${root}/${segments.join('/')}${tail}.md`);
|
||||
|
||||
fc.assert(
|
||||
fc.property(traversalUri, (uri) => {
|
||||
assert.throws(
|
||||
() => readResource(catalog, uri),
|
||||
undefined,
|
||||
`a traversal-shaped uri must never resolve to a successful read: ${uri}`
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('readResource always refuses arbitrary absolute-path-shaped uris', () => {
|
||||
const catalog = buildFixtureCatalog();
|
||||
|
||||
const absolutePath = fc.oneof(
|
||||
fc.stringMatching(/^\/[a-z0-9/_-]{1,40}$/),
|
||||
fc.stringMatching(/^[A-Z]:\\[A-Za-z0-9\\_-]{1,40}$/)
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(absolutePath, (p) => {
|
||||
assert.throws(() => readResource(catalog, p), undefined, `an absolute-path-shaped uri must never resolve to a successful read: ${p}`);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── row 55: pagination is a partition ──────────────────────────────────────
|
||||
|
||||
describe('property: pagination partitions the list for any page size (row 55)', () => {
|
||||
test('pages concatenate to the full list with no dupes and no gaps, for any page size >= 1', () => {
|
||||
const catalog = buildFixtureCatalog();
|
||||
const full = listResources(catalog, { pageSize: 10_000 }).resources.map((r) => r.uri);
|
||||
assert.ok(full.length > 0);
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: full.length + 3 }), (pageSize) => {
|
||||
const collected = [];
|
||||
let cursor;
|
||||
for (let guard = 0; guard < full.length + 5; guard++) {
|
||||
const page = listResources(catalog, { pageSize, cursor });
|
||||
assert.ok(page.resources.length <= pageSize, `a page must never exceed the requested pageSize ${pageSize}`);
|
||||
collected.push(...page.resources.map((r) => r.uri));
|
||||
if (page.nextCursor === undefined) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
assert.deepEqual(collected, full, `pages for pageSize=${pageSize} must reassemble the full list with no dupes/gaps`);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
648
tests/mcp-catalog.test.cjs
Normal file
648
tests/mcp-catalog.test.cjs
Normal file
@@ -0,0 +1,648 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Failing-first unit tests for src/mcp-catalog.cts (compiled to
|
||||
* gsd-core/bin/lib/mcp-catalog.cjs) — issue #3072 (epic #1671 Phase B),
|
||||
* `.gsd/phase/feat-3072-mcp-served-catalog/40-design.md`.
|
||||
*
|
||||
* Covers 50-test-matrix.md rows 4-30 and 37-47: the catalog module's own
|
||||
* surface (buildCatalog / readResource / listResources / getPrompt /
|
||||
* shouldCompose) exercised over INJECTED `readFile`/`readDir` seams, never
|
||||
* real filesystem permission tricks (`chmod 0o000` is forbidden — root
|
||||
* bypasses mode bits and the test would silently pass with zero coverage in
|
||||
* CI; CONTRIBUTING.md, `40-design.md` "Known-defect gauntlet").
|
||||
*
|
||||
* `listResources` is a delegation surface this suite requires beyond the
|
||||
* design's four headline exports (`buildCatalog`, `readResource`,
|
||||
* `getPrompt`, `shouldCompose`) — see the module doc comment in
|
||||
* `src/mcp-catalog.cts` for why: pagination over the resource index is
|
||||
* catalog-module responsibility so `handleMessage` stays thin (design
|
||||
* "Shape" section).
|
||||
*
|
||||
* The module is a SKELETON right now — every function throws
|
||||
* `'not implemented'` — so every test below fails on BEHAVIOR, not on
|
||||
* `MODULE_NOT_FOUND`. No source-grep (CONTRIBUTING.md): every assertion is
|
||||
* on typed values (`REASON` codes, structured Map/array shapes) — never on
|
||||
* rendered text via `.includes()`/`.match()`.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
buildCatalog,
|
||||
readResource,
|
||||
listResources,
|
||||
shouldCompose,
|
||||
REASON,
|
||||
} = require('../gsd-core/bin/lib/mcp-catalog.cjs');
|
||||
const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
|
||||
|
||||
// ─── Fixture helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a `gsd://<segment>/<relPath>` resource uri, matching the design's `gsd://workflows/...` hostile-input examples. */
|
||||
function resourceUri(segment, relPath) {
|
||||
return `gsd://${segment}/${relPath}`;
|
||||
}
|
||||
|
||||
function dirEntry(name, isDir) {
|
||||
return { name, isDirectory: () => isDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an injected `readFile`/`readDir` seam pair over an in-memory file
|
||||
* map (POSIX relative paths -> content), so IO faults are testable by
|
||||
* monkeypatching the returned functions directly — never `chmod 0o000`.
|
||||
*
|
||||
* @param {string} root - a POSIX-style fake root path, never touched on real disk
|
||||
* @param {Record<string, string>} files - relative-path -> file content
|
||||
*/
|
||||
function makeFakeFs(root, files) {
|
||||
const abs = (rel) => (rel ? `${root}/${rel}` : root);
|
||||
const fileMap = new Map(Object.entries(files).map(([rel, content]) => [abs(rel), content]));
|
||||
const dirMap = new Map();
|
||||
for (const rel of Object.keys(files)) {
|
||||
const parts = rel.split('/');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const dirRel = parts.slice(0, i).join('/');
|
||||
const dirAbs = abs(dirRel);
|
||||
const isLast = i === parts.length - 1;
|
||||
if (!dirMap.has(dirAbs)) dirMap.set(dirAbs, new Map());
|
||||
dirMap.get(dirAbs).set(parts[i], dirEntry(parts[i], !isLast));
|
||||
}
|
||||
}
|
||||
// Production (`buildCatalog`) resolves paths via `path.join`, which is
|
||||
// backslash-separated on Windows; this fake's maps are keyed POSIX. A real
|
||||
// `fs` accepts both separators, so the fake must too — normalize the
|
||||
// incoming lookup key unconditionally (never process.platform-gated).
|
||||
// Caught by CI on windows-latest: every lookup missed, the fake indexed
|
||||
// zero entries, and the anti-vacuity guards correctly flagged it.
|
||||
const norm = (p) => String(p).replace(/\\/g, '/');
|
||||
const readDir = (absPath) => {
|
||||
const m = dirMap.get(norm(absPath));
|
||||
if (!m) {
|
||||
const err = new Error(`ENOENT (fake fs): ${absPath}`);
|
||||
throw err;
|
||||
}
|
||||
return [...m.values()];
|
||||
};
|
||||
const readFile = (absPath) => {
|
||||
const key = norm(absPath);
|
||||
if (!fileMap.has(key)) {
|
||||
const err = new Error(`ENOENT (fake fs): ${absPath}`);
|
||||
throw err;
|
||||
}
|
||||
return fileMap.get(key);
|
||||
};
|
||||
return { root, readFile, readDir };
|
||||
}
|
||||
|
||||
/** Save/restore `process.cwd` around `fn`. Standalone helper — try/finally is permitted here (CONTRIBUTING.md "Setup and Cleanup"). */
|
||||
function withMockedCwd(fakeCwd, fn) {
|
||||
const original = process.cwd;
|
||||
process.cwd = () => fakeCwd;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
process.cwd = original;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to build a root containing a symlink that escapes it. Standalone
|
||||
* helper — try/catch for environment-capability probing (not cleanup
|
||||
* masking) lives here, out of the test body.
|
||||
*/
|
||||
function setupSymlinkEscapeFixture() {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
try {
|
||||
const root = createTempDir('mcp-catalog-symlink-root-');
|
||||
const outsideTarget = createTempDir('mcp-catalog-symlink-outside-');
|
||||
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(outsideTarget, 'secret.md'), 'top secret');
|
||||
fs.symlinkSync(path.join(outsideTarget, 'secret.md'), path.join(root, 'gsd-core', 'workflows', 'evil.md'));
|
||||
return { ok: true, root, outsideTarget };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
// A small marked workflow + a small marked-syntax-documenting reference,
|
||||
// reused by several rows below.
|
||||
const markedWorkflow = [
|
||||
'before prose',
|
||||
'<!-- gsd:section id="sec-a" when="always" -->',
|
||||
'body line',
|
||||
'<!-- /gsd:section -->',
|
||||
'after prose',
|
||||
].join('\n');
|
||||
|
||||
const unmarkedContent = ['# Plain document', '', 'Ordinary prose, no markers here.', ''].join('\n');
|
||||
|
||||
// A reference doc that DOCUMENTS marker syntax with an unfenced example —
|
||||
// the exact F1 defect class (design "F1" / row 13).
|
||||
const documentsMarkerSyntax = [
|
||||
'# How markers work',
|
||||
'',
|
||||
'A section marker pair looks like this:',
|
||||
'',
|
||||
'<!-- gsd:section id="example" when="always" -->',
|
||||
'example body',
|
||||
'<!-- /gsd:section -->',
|
||||
'',
|
||||
'That is the whole grammar.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// ─── resources/list (rows 4-10) ─────────────────────────────────────────────
|
||||
|
||||
describe('resources/list — catalog module', () => {
|
||||
test('lists every catalog resource with required fields (row 4)', () => {
|
||||
const fake = makeFakeFs('/fake-root', {
|
||||
'gsd-core/workflows/a.md': unmarkedContent,
|
||||
'gsd-core/references/b.md': unmarkedContent,
|
||||
});
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
assert.equal(catalog.resources.size, 2);
|
||||
for (const entry of catalog.resources.values()) {
|
||||
assert.equal(typeof entry.uri, 'string');
|
||||
assert.equal(typeof entry.name, 'string');
|
||||
assert.equal(typeof entry.title, 'string');
|
||||
assert.equal(typeof entry.description, 'string');
|
||||
assert.equal(typeof entry.mimeType, 'string');
|
||||
assert.ok(entry.mimeType.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('listing order is deterministic across calls (row 5)', () => {
|
||||
const fake = makeFakeFs('/fake-root', {
|
||||
'gsd-core/workflows/z.md': unmarkedContent,
|
||||
'gsd-core/workflows/a.md': unmarkedContent,
|
||||
'gsd-core/references/m.md': unmarkedContent,
|
||||
});
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const first = listResources(catalog).resources.map((r) => r.uri);
|
||||
const second = listResources(catalog).resources.map((r) => r.uri);
|
||||
assert.deepEqual(first, second);
|
||||
assert.deepEqual(first, [...first].sort(), 'order must be sorted by uri, not readdir order');
|
||||
});
|
||||
|
||||
test('pagination at page-1 / page / page+1 (row 6)', () => {
|
||||
const makeCatalogOfSize = (n) => {
|
||||
const files = {};
|
||||
for (let i = 0; i < n; i++) files[`gsd-core/workflows/w${i}.md`] = unmarkedContent;
|
||||
const fake = makeFakeFs('/fake-root', files);
|
||||
return buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
};
|
||||
|
||||
const pageSize = 2;
|
||||
|
||||
const below = listResources(makeCatalogOfSize(pageSize - 1), { pageSize });
|
||||
assert.equal(below.resources.length, pageSize - 1);
|
||||
assert.equal(below.nextCursor, undefined, 'a page under the page size is already the last page');
|
||||
|
||||
const exact = listResources(makeCatalogOfSize(pageSize), { pageSize });
|
||||
assert.equal(exact.resources.length, pageSize);
|
||||
assert.equal(exact.nextCursor, undefined, 'a full final page omits nextCursor');
|
||||
|
||||
const over = listResources(makeCatalogOfSize(pageSize + 1), { pageSize });
|
||||
assert.equal(over.resources.length, pageSize);
|
||||
assert.notEqual(over.nextCursor, undefined, 'a page short of the total must carry nextCursor');
|
||||
});
|
||||
|
||||
test('paginated pages reassemble the full list (row 7)', () => {
|
||||
const files = {};
|
||||
for (let i = 0; i < 7; i++) files[`gsd-core/workflows/w${i}.md`] = unmarkedContent;
|
||||
const fake = makeFakeFs('/fake-root', files);
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
|
||||
const full = listResources(catalog, { pageSize: 100 }).resources.map((r) => r.uri);
|
||||
|
||||
const collected = [];
|
||||
let cursor;
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const page = listResources(catalog, { pageSize: 3, cursor });
|
||||
collected.push(...page.resources.map((r) => r.uri));
|
||||
if (page.nextCursor === undefined) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
|
||||
assert.deepEqual(collected, full, 'concatenated pages must equal the unpaginated list exactly once, no dupes, no gaps');
|
||||
});
|
||||
|
||||
test('unknown cursor errors rather than restarting (row 8)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/workflows/a.md': unmarkedContent });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
assert.throws(
|
||||
() => listResources(catalog, { cursor: 'not-a-real-cursor' }),
|
||||
(err) => err.reason === REASON.UNKNOWN_CURSOR
|
||||
);
|
||||
});
|
||||
|
||||
test('empty catalog lists empty (row 9)', () => {
|
||||
const fake = makeFakeFs('/fake-root', {});
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const page = listResources(catalog);
|
||||
assert.deepEqual(page.resources, []);
|
||||
assert.equal(page.nextCursor, undefined);
|
||||
});
|
||||
|
||||
test('single-entry catalog needs no cursor (row 10)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/only.md': unmarkedContent });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const page = listResources(catalog);
|
||||
assert.equal(page.resources.length, 1);
|
||||
assert.equal(page.nextCursor, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resources/read (rows 11-30) ────────────────────────────────────────────
|
||||
|
||||
describe('resources/read — catalog module (client-controlled path surface)', () => {
|
||||
test('reads a workflow with markers stripped (row 11)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/workflows/w.md': markedWorkflow });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('workflows', 'w.md'));
|
||||
assert.equal(result.text, composeWorkflow(markedWorkflow, { sourcePath: 'gsd-core/workflows/w.md' }));
|
||||
assert.equal(result.uri, resourceUri('workflows', 'w.md'));
|
||||
});
|
||||
|
||||
test('reads a reference verbatim (row 12)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/r.md': unmarkedContent });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('references', 'r.md'));
|
||||
assert.equal(result.text, unmarkedContent);
|
||||
});
|
||||
|
||||
test('a doc documenting marker syntax is never composed (row 13 — the F1 defect)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/documents-markers.md': documentsMarkerSyntax });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('references', 'documents-markers.md'));
|
||||
assert.equal(result.text, documentsMarkerSyntax, 'a reference that documents marker syntax must be served byte-identical, never composed');
|
||||
});
|
||||
|
||||
test('markers are stripped only under workflows/ (row 14)', () => {
|
||||
const fake = makeFakeFs('/fake-root', {
|
||||
'gsd-core/workflows/w.md': markedWorkflow,
|
||||
'gsd-core/references/r.md': markedWorkflow,
|
||||
});
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const workflowText = readResource(catalog, resourceUri('workflows', 'w.md')).text;
|
||||
const referenceText = readResource(catalog, resourceUri('references', 'r.md')).text;
|
||||
assert.notEqual(workflowText, markedWorkflow, 'the workflow copy must be composed (markers stripped)');
|
||||
assert.equal(referenceText, markedWorkflow, 'the identical text under references/ must be served verbatim');
|
||||
});
|
||||
|
||||
test('unmarked workflow composes byte-identical (row 15)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/workflows/w.md': unmarkedContent });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('workflows', 'w.md'));
|
||||
assert.equal(result.text, unmarkedContent, 'composing an unmarked document is a structural no-op, not a budget trick');
|
||||
});
|
||||
|
||||
test('unknown uri errors rather than returning empty (row 16)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/workflows/a.md': unmarkedContent });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
assert.throws(
|
||||
() => readResource(catalog, resourceUri('workflows', 'totally-made-up.md')),
|
||||
(err) => err.reason === REASON.UNKNOWN_RESOURCE
|
||||
);
|
||||
});
|
||||
|
||||
const catalogForHostileUris = () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/workflows/a.md': unmarkedContent });
|
||||
return buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
};
|
||||
|
||||
test('rejects dot-dot traversal (row 17)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'gsd://workflows/../../../etc/passwd'),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects backslash traversal (row 18)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'gsd://workflows/..\\..\\secrets.md'),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects percent-encoded traversal (row 19)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'gsd://workflows/%2e%2e%2fsecrets.md'),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects double-encoded traversal (row 20)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'gsd://workflows/%252e%252e%252fsecrets.md'),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects absolute posix path (row 21)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, '/etc/passwd'),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects absolute windows path (row 22)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'C:\\Windows\\win.ini'),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a file:// uri (row 23)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'file:///etc/passwd'),
|
||||
(err) => err.reason === REASON.INVALID_URI
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a uri containing a null byte (row 24)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'gsd://workflows/a.md\u0000.txt'),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a symlink escaping the root (row 25)', (t) => {
|
||||
const fixture = setupSymlinkEscapeFixture();
|
||||
if (!fixture.ok) {
|
||||
t.skip('symlink creation not permitted on this platform/environment');
|
||||
return;
|
||||
}
|
||||
t.after(() => {
|
||||
cleanup(fixture.root);
|
||||
cleanup(fixture.outsideTarget);
|
||||
});
|
||||
|
||||
const catalog = buildCatalog({ root: fixture.root });
|
||||
assert.throws(
|
||||
() => readResource(catalog, resourceUri('workflows', 'evil.md')),
|
||||
(err) => err.reason === REASON.TRAVERSAL_REFUSED
|
||||
);
|
||||
});
|
||||
|
||||
test('an unindexed sibling is not readable (row 26)', () => {
|
||||
// "c.md" is never part of the build-time file map — it is not on the
|
||||
// catalog's disk snapshot at all, simulating a file added post-startup.
|
||||
// Index membership, not disk existence, is the sole authority.
|
||||
const fake = makeFakeFs('/fake-root', {
|
||||
'gsd-core/workflows/a.md': unmarkedContent,
|
||||
'gsd-core/workflows/b.md': unmarkedContent,
|
||||
});
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
assert.throws(
|
||||
() => readResource(catalog, resourceUri('workflows', 'c.md')),
|
||||
(err) => err.reason === REASON.UNKNOWN_RESOURCE
|
||||
);
|
||||
});
|
||||
|
||||
test('non-indexed file types are not served (row 27)', () => {
|
||||
const fake = makeFakeFs('/fake-root', {
|
||||
'gsd-core/workflows/a.md': unmarkedContent,
|
||||
'gsd-core/workflows/section-manifest.json': '{}',
|
||||
});
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
assert.equal(catalog.resources.size, 1, 'the .json sibling must not be indexed');
|
||||
assert.throws(
|
||||
() => readResource(catalog, resourceUri('workflows', 'section-manifest.json')),
|
||||
(err) => err.reason === REASON.UNKNOWN_RESOURCE
|
||||
);
|
||||
});
|
||||
|
||||
test('non-string uri is rejected as invalid params (row 28)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
for (const bad of [42, {}, null, undefined, [], true]) {
|
||||
assert.throws(
|
||||
() => readResource(catalog, bad),
|
||||
(err) => err.reason === REASON.INVALID_PARAMS,
|
||||
`expected INVALID_PARAMS for uri=${JSON.stringify(bad)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty uri is rejected (row 29)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, ''),
|
||||
(err) => err.reason === REASON.INVALID_URI
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown root segment is rejected (row 30)', () => {
|
||||
const catalog = catalogForHostileUris();
|
||||
assert.throws(
|
||||
() => readResource(catalog, 'gsd://bogus-root-segment/a.md'),
|
||||
(err) => err.reason === REASON.UNKNOWN_ROOT
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── content correctness / CRLF (rows 37-41) ────────────────────────────────
|
||||
|
||||
describe('content correctness — catalog module', () => {
|
||||
test('CRLF content survives serving (row 37)', () => {
|
||||
const content = ['line one', 'line two', 'line three', ''].join('\r\n');
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/r.md': content });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('references', 'r.md'));
|
||||
assert.equal(result.text, content);
|
||||
});
|
||||
|
||||
test('mixed line endings survive serving (row 38)', () => {
|
||||
const content = 'line one\r\nline two\nline three\r\nline four\n';
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/r.md': content });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('references', 'r.md'));
|
||||
assert.equal(result.text, content);
|
||||
});
|
||||
|
||||
test('missing trailing newline is preserved (row 39)', () => {
|
||||
const content = ['line one', 'line two, no trailing newline after this'].join('\n');
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/r.md': content });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('references', 'r.md'));
|
||||
assert.equal(result.text, content);
|
||||
assert.equal(result.text.endsWith('\n'), false);
|
||||
});
|
||||
|
||||
test('an empty file serves as empty text (row 40)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/empty.md': '' });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('references', 'empty.md'));
|
||||
assert.equal(result.text, '');
|
||||
});
|
||||
|
||||
test('unicode content is preserved (row 41)', () => {
|
||||
const content = ['# 日本語のタイトル', '', 'emoji check: 🎉🚀 café naïve', ''].join('\n');
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/references/r.md': content });
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
const result = readResource(catalog, resourceUri('references', 'r.md'));
|
||||
assert.equal(result.text, content);
|
||||
assert.equal(Buffer.byteLength(result.text, 'utf8'), Buffer.byteLength(content, 'utf8'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IO faults, injected via the seam (rows 42-45) ──────────────────────────
|
||||
|
||||
describe('IO faults — injected via the seam, never chmod', () => {
|
||||
test('one unreadable file does not break the catalog (row 42)', () => {
|
||||
const fake = makeFakeFs('/fake-root', {
|
||||
'gsd-core/workflows/good.md': unmarkedContent,
|
||||
'gsd-core/workflows/bad.md': unmarkedContent,
|
||||
});
|
||||
const badAbsPath = `${fake.root}/gsd-core/workflows/bad.md`;
|
||||
const realReadFile = fake.readFile;
|
||||
const faultyReadFile = (absPath) => {
|
||||
// Same separator-normalization as makeFakeFs: a production-supplied
|
||||
// absPath may be backslash-joined on Windows.
|
||||
if (String(absPath).replace(/\\/g, '/') === badAbsPath) throw new Error('injected read failure');
|
||||
return realReadFile(absPath);
|
||||
};
|
||||
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: faultyReadFile, readDir: fake.readDir });
|
||||
|
||||
const goodUri = resourceUri('workflows', 'good.md');
|
||||
const badUri = resourceUri('workflows', 'bad.md');
|
||||
|
||||
assert.doesNotThrow(() => readResource(catalog, goodUri), 'the healthy resource must remain readable');
|
||||
assert.throws(
|
||||
() => readResource(catalog, badUri),
|
||||
(err) => err.reason === REASON.READ_FAILED
|
||||
);
|
||||
|
||||
const listedUris = listResources(catalog).resources.map((r) => r.uri);
|
||||
assert.ok(listedUris.includes(goodUri));
|
||||
assert.ok(listedUris.includes(badUri), 'the failing resource must stay listable even though it cannot be read');
|
||||
});
|
||||
|
||||
test('a directory read failure fails closed (row 43)', () => {
|
||||
const fake = makeFakeFs('/fake-root', { 'gsd-core/workflows/a.md': unmarkedContent });
|
||||
const workflowsDirAbs = `${fake.root}/gsd-core/workflows`;
|
||||
const realReadDir = fake.readDir;
|
||||
const faultyReadDir = (absPath) => {
|
||||
// Same separator-normalization as makeFakeFs: a production-supplied
|
||||
// absPath may be backslash-joined on Windows.
|
||||
if (String(absPath).replace(/\\/g, '/') === workflowsDirAbs) throw new Error('injected directory read failure');
|
||||
return realReadDir(absPath);
|
||||
};
|
||||
assert.throws(
|
||||
() => buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: faultyReadDir }),
|
||||
(err) => err.reason === REASON.READ_FAILED,
|
||||
'a directory listing failure must surface as a typed error, never a silently partial catalog'
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed marker is contained to its file (row 44)', () => {
|
||||
const malformed = ['before', '<!-- gsd:section id="x" when="always" -->', 'never closed'].join('\n');
|
||||
const fake = makeFakeFs('/fake-root', {
|
||||
'gsd-core/workflows/good.md': unmarkedContent,
|
||||
'gsd-core/workflows/bad.md': malformed,
|
||||
});
|
||||
const catalog = buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
||||
|
||||
const goodUri = resourceUri('workflows', 'good.md');
|
||||
const badUri = resourceUri('workflows', 'bad.md');
|
||||
|
||||
assert.doesNotThrow(() => readResource(catalog, goodUri));
|
||||
assert.throws(
|
||||
() => readResource(catalog, badUri),
|
||||
(err) => err.reason === REASON.READ_FAILED
|
||||
);
|
||||
|
||||
const listedUris = listResources(catalog).resources.map((r) => r.uri);
|
||||
assert.ok(listedUris.includes(goodUri));
|
||||
assert.ok(listedUris.includes(badUri), 'the other 325 resources must stay listable and readable — one bad file must not take down the catalog');
|
||||
});
|
||||
|
||||
test('absent root does not fall back to cwd (row 45)', () => {
|
||||
const calledPaths = [];
|
||||
const missingRoot = '/fake-root-that-does-not-exist-anywhere';
|
||||
const recordingReadDir = (absPath) => {
|
||||
calledPaths.push(absPath);
|
||||
throw new Error(`ENOENT (fake fs): ${absPath}`);
|
||||
};
|
||||
const recordingReadFile = (absPath) => {
|
||||
calledPaths.push(absPath);
|
||||
throw new Error(`ENOENT (fake fs): ${absPath}`);
|
||||
};
|
||||
|
||||
let threw = false;
|
||||
let catalog;
|
||||
try {
|
||||
catalog = buildCatalog({ root: missingRoot, readFile: recordingReadFile, readDir: recordingReadDir });
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
assert.equal(typeof err.reason, 'string', 'a build failure for an absent root must be a typed CatalogError, never a bare crash');
|
||||
}
|
||||
if (!threw) {
|
||||
assert.equal(catalog.resources.size, 0);
|
||||
assert.equal(catalog.prompts.size, 0);
|
||||
}
|
||||
|
||||
const realCwd = process.cwd();
|
||||
for (const p of calledPaths) {
|
||||
assert.notEqual(String(p).replace(/\\/g, '/'), realCwd.replace(/\\/g, '/'), 'buildCatalog must never fall back to reading process.cwd()');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolution / scoping (rows 46-47) ──────────────────────────────────────
|
||||
|
||||
describe('resolution / scoping — module location vs cwd', () => {
|
||||
test('catalog resolves from module location not cwd (row 46)', (t) => {
|
||||
const decoyCwd = createTempDir('mcp-catalog-decoy-cwd-');
|
||||
t.after(() => cleanup(decoyCwd));
|
||||
|
||||
const catalog = withMockedCwd(decoyCwd, () => buildCatalog({}));
|
||||
assert.ok(
|
||||
catalog.resources.size > 50,
|
||||
`expected the real package's ~110 workflows + ~103 references regardless of a decoy cwd, got ${catalog.resources.size}`
|
||||
);
|
||||
});
|
||||
|
||||
test('a project-local gsd-core is never a catalog root (row 47)', (t) => {
|
||||
const decoyCwd = createTempDir('mcp-catalog-decoy-project-');
|
||||
t.after(() => cleanup(decoyCwd));
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
fs.mkdirSync(path.join(decoyCwd, 'gsd-core', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(decoyCwd, 'gsd-core', 'workflows', 'trap.md'), 'decoy content that must never be served');
|
||||
|
||||
const catalog = withMockedCwd(decoyCwd, () => buildCatalog({}));
|
||||
|
||||
const uris = [...catalog.resources.keys()];
|
||||
assert.equal(
|
||||
uris.includes(resourceUri('workflows', 'trap.md')),
|
||||
false,
|
||||
"a project-local gsd-core/ present at ctx.cwd's decoy location must never become a catalog root"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shouldCompose — the F1 predicate (exercised directly, supplements rows 12-15) ──
|
||||
|
||||
describe('shouldCompose — the shared F1 predicate', () => {
|
||||
test('shouldCompose is true only for paths under gsd-core/workflows/', () => {
|
||||
assert.equal(shouldCompose('gsd-core/workflows/plan-phase.md'), true);
|
||||
assert.equal(shouldCompose('gsd-core/references/foo.md'), false);
|
||||
assert.equal(shouldCompose('commands/gsd/plan-phase.md'), false);
|
||||
});
|
||||
});
|
||||
111
tests/mcp-server-catalog.test.cjs
Normal file
111
tests/mcp-server-catalog.test.cjs
Normal file
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Failing-first protocol-surface tests for the MCP served catalog — issue
|
||||
* #3072 (epic #1671 Phase B), `.gsd/phase/feat-3072-mcp-served-catalog/
|
||||
* 40-design.md`.
|
||||
*
|
||||
* Covers 50-test-matrix.md rows 1-3 and 31-36: `resources`/`prompts`
|
||||
* capability advertisement, tool-surface independence, and the
|
||||
* `prompts/list` / `prompts/get` JSON-RPC surface — all exercised through
|
||||
* `handleMessage` (`src/mcp-server.cts`, compiled to
|
||||
* `gsd-core/bin/lib/mcp-server.cjs`), mirroring `tests/gsd-mcp-server.test.cjs`'s
|
||||
* own structure and require path.
|
||||
*
|
||||
* `handleMessage` does not yet route `resources/*`/`prompts/*` at all — every
|
||||
* such request currently falls through to the generic `METHOD_NOT_FOUND`
|
||||
* (-32601) default case. Rows that expect a SPECIFIC refusal (not just "any
|
||||
* error") therefore assert the JSON-RPC error code is NOT -32601, so the
|
||||
* assertion cannot pass by accident against today's blanket fallthrough
|
||||
* (rows 33, 35). No source-grep (CONTRIBUTING.md): every assertion is on
|
||||
* typed JSON-RPC response fields, never on rendered prose.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { handleMessage } = require('../gsd-core/bin/lib/mcp-server.cjs');
|
||||
|
||||
const METHOD_NOT_FOUND = -32601;
|
||||
const INVALID_PARAMS = -32602;
|
||||
|
||||
// ─── initialize / capabilities (rows 1-3) ───────────────────────────────────
|
||||
|
||||
describe('initialize / capabilities', () => {
|
||||
test('initialize advertises resources and prompts (row 1)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' });
|
||||
assert.ok(res.result, 'initialize must succeed');
|
||||
const caps = res.result.capabilities;
|
||||
assert.ok(caps && typeof caps.tools === 'object', 'capabilities.tools must remain declared');
|
||||
assert.ok(caps && typeof caps.resources === 'object', 'capabilities.resources must be declared alongside tools');
|
||||
assert.ok(caps && typeof caps.prompts === 'object', 'capabilities.prompts must be declared alongside tools');
|
||||
});
|
||||
|
||||
test('initialize does not advertise unimplemented notifications (row 2)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' });
|
||||
const resourcesCap = res.result && res.result.capabilities && res.result.capabilities.resources;
|
||||
assert.ok(resourcesCap && typeof resourcesCap === 'object', 'capabilities.resources must exist before its shape can be checked');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(resourcesCap, 'subscribe'), false, 'resources capability must not declare subscribe — nothing ever sends the notification');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(resourcesCap, 'listChanged'), false, 'resources capability must not declare listChanged — nothing ever sends the notification');
|
||||
});
|
||||
|
||||
test('catalog addition does not disturb the tool surface (row 3)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
assert.ok(res.result, 'tools/list must succeed');
|
||||
const names = res.result.tools.map((tool) => tool.name).sort();
|
||||
assert.deepEqual(names, ['gsd_invoke_command', 'gsd_read_state', 'gsd_write_state']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── prompts (rows 31-36) ────────────────────────────────────────────────────
|
||||
|
||||
describe('prompts — protocol surface', () => {
|
||||
test('lists commands as prompts by bare name (row 31)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'prompts/list' });
|
||||
assert.equal(res.error, undefined, 'prompts/list must not error');
|
||||
assert.ok(res.result && Array.isArray(res.result.prompts), 'result.prompts must be an array');
|
||||
assert.equal(res.result.prompts.length, 71, 'must list all 71 commands/gsd/*.md as prompts');
|
||||
for (const p of res.result.prompts) {
|
||||
assert.equal(typeof p.name, 'string');
|
||||
assert.equal(p.name.includes('/'), false, 'name must be the bare command, not a path');
|
||||
assert.equal(p.name.endsWith('.md'), false, 'name must not carry the .md extension');
|
||||
}
|
||||
});
|
||||
|
||||
test('gets a prompt in message form (row 32)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: 'plan-phase' } });
|
||||
assert.equal(res.error, undefined, 'prompts/get on a known name must not error');
|
||||
assert.equal(typeof res.result.description, 'string');
|
||||
assert.ok(Array.isArray(res.result.messages) && res.result.messages.length >= 1);
|
||||
const msg = res.result.messages[0];
|
||||
assert.equal(msg.role, 'user');
|
||||
assert.equal(msg.content.type, 'text');
|
||||
assert.equal(typeof msg.content.text, 'string');
|
||||
});
|
||||
|
||||
test('unknown prompt name errors (row 33)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: 'not-a-real-command-xyz' } });
|
||||
assert.ok(res.error, 'an unknown prompt name must produce a JSON-RPC error, not a success');
|
||||
assert.equal(res.result, undefined);
|
||||
assert.notEqual(res.error.code, METHOD_NOT_FOUND, 'must be a semantic unknown-prompt refusal, not merely the current unimplemented-method fallthrough');
|
||||
});
|
||||
|
||||
test('arguments are accepted and ignored (row 34)', () => {
|
||||
const withoutArgs = handleMessage({ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: 'plan-phase' } });
|
||||
const withArgs = handleMessage({ jsonrpc: '2.0', id: 2, method: 'prompts/get', params: { name: 'plan-phase', arguments: { phase: '3' } } });
|
||||
assert.equal(withoutArgs.error, undefined, 'prompts/get without arguments must succeed');
|
||||
assert.equal(withArgs.error, undefined, 'prompts/get with arguments must be accepted, not rejected');
|
||||
assert.deepEqual(withArgs.result, withoutArgs.result, 'no command template takes injected arguments today — content must be unchanged');
|
||||
});
|
||||
|
||||
test('prompt name is not a path (row 35)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: '../../../etc/passwd' } });
|
||||
assert.ok(res.error, 'a path-shaped prompt name must be refused, not treated as a valid lookup key');
|
||||
assert.notEqual(res.error.code, METHOD_NOT_FOUND, 'must be a semantic name-is-not-a-path refusal, not merely the current unimplemented-method fallthrough');
|
||||
});
|
||||
|
||||
test('missing prompt name is invalid params (row 36)', () => {
|
||||
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: {} });
|
||||
assert.ok(res.error, 'a missing required "name" must error');
|
||||
assert.equal(res.error.code, INVALID_PARAMS, 'must be JSON-RPC INVALID_PARAMS (-32602)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user