diff --git a/.changeset/agile-goats-fly.md b/.changeset/agile-goats-fly.md new file mode 100644 index 000000000..857852f0c --- /dev/null +++ b/.changeset/agile-goats-fly.md @@ -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) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 22fbe9bec..ddbd5fb42 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/bin/install.js b/bin/install.js index ed383e3c4..25ac46f9c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -48,6 +48,9 @@ const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperat // #2930 (epic #1671 Phase 3): strips `` 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 }); } diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ae0c5e9bc..05f07a741 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -395,6 +395,7 @@ "loop-resolver.cjs", "markdown-sectionizer.cjs", "markdown-table.cjs", + "mcp-catalog.cjs", "mcp-server.cjs", "milestone.cjs", "model-adapter.cjs", diff --git a/docs/adr/1671-dynamic-context-management-platform.md b/docs/adr/1671-dynamic-context-management-platform.md index e890f7872..1475f0535 100644 --- a/docs/adr/1671-dynamic-context-management-platform.md +++ b/docs/adr/1671-dynamic-context-management-platform.md @@ -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:///` 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. diff --git a/docs/how-to/connect-gsd-mcp-server.md b/docs/how-to/connect-gsd-mcp-server.md index 468289f8a..e0f6723c6 100644 --- a/docs/how-to/connect-gsd-mcp-server.md +++ b/docs/how-to/connect-gsd-mcp-server.md @@ -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:///` URI, where `` is +`workflows`, `references`, or `commands`, and `` 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 +`` 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. diff --git a/eslint.config.mjs b/eslint.config.mjs index d6ffc66a4..72317bf49 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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. diff --git a/src/mcp-catalog.cts b/src/mcp-catalog.cts new file mode 100644 index 000000000..a4aec05ae --- /dev/null +++ b/src/mcp-catalog.cts @@ -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* `` 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; + readonly prompts: ReadonlyMap; + 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 = 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 = new Set(['workflows', 'references']); + +function indexResourceSegments( + root: string, + readDir: (absPath: string) => DirEntryLike[], + out: Map +): 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, + resourcesOut: Map +): 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(); + const prompts = new Map(); + + 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 } }], + }; +} diff --git a/src/mcp-server.cts b/src/mcp-server.cts index c7f54a18d..23699a27c 100644 --- a/src/mcp-server.cts +++ b/src/mcp-server.cts @@ -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; 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; + 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; + 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; + 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)'}.`); diff --git a/tests/gsd-mcp-server.test.cjs b/tests/gsd-mcp-server.test.cjs index fde999b8d..c931b90ee 100644 --- a/tests/gsd-mcp-server.test.cjs +++ b/tests/gsd-mcp-server.test.cjs @@ -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/); }); diff --git a/tests/mcp-catalog-parity.install.test.cjs b/tests/mcp-catalog-parity.install.test.cjs new file mode 100644 index 000000000..215779d2a --- /dev/null +++ b/tests/mcp-catalog-parity.install.test.cjs @@ -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 + * `` 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\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', + ); + }); +}); diff --git a/tests/mcp-catalog.property.test.cjs b/tests/mcp-catalog.property.test.cjs new file mode 100644 index 000000000..05b33595f --- /dev/null +++ b/tests/mcp-catalog.property.test.cjs @@ -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`); + }) + ); + }); +}); diff --git a/tests/mcp-catalog.test.cjs b/tests/mcp-catalog.test.cjs new file mode 100644 index 000000000..b9c421699 --- /dev/null +++ b/tests/mcp-catalog.test.cjs @@ -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:///` 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} 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', + '', + 'body line', + '', + '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:', + '', + '', + 'example body', + '', + '', + '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', '', '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); + }); +}); diff --git a/tests/mcp-server-catalog.test.cjs b/tests/mcp-server-catalog.test.cjs new file mode 100644 index 000000000..16d6b45b7 --- /dev/null +++ b/tests/mcp-server-catalog.test.cjs @@ -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)'); + }); +});