From f1639213f7e8178fbd3ec85a5aa9e8e5248ae0ce Mon Sep 17 00:00:00 2001 From: prode Date: Sun, 31 May 2026 16:22:49 -0300 Subject: [PATCH 1/2] feat(config): add .npmignore and .gitignore; initialize TypeScript configuration --- .github/workflows/ci.yml | 24 + mcp-server/.gitignore | 3 + mcp-server/.npmignore | 6 + mcp-server/README.md | 235 +++++ mcp-server/package-lock.json | 1198 ++++++++++++++++++++++++++ mcp-server/package.json | 41 + mcp-server/src/csdd.ts | 116 +++ mcp-server/src/index.ts | 38 + mcp-server/src/registry.ts | 30 + mcp-server/src/tooldef.ts | 85 ++ mcp-server/src/tools/agent.ts | 65 ++ mcp-server/src/tools/init.ts | 19 + mcp-server/src/tools/mcp.ts | 95 ++ mcp-server/src/tools/skill.ts | 72 ++ mcp-server/src/tools/spec.ts | 97 +++ mcp-server/src/tools/steering.ts | 99 +++ mcp-server/test/csdd-missing.test.ts | 21 + mcp-server/test/csdd-run.test.ts | 67 ++ mcp-server/test/handler.test.ts | 58 ++ mcp-server/test/helpers.ts | 57 ++ mcp-server/test/tooldef.test.ts | 103 +++ mcp-server/test/tools.test.ts | 142 +++ mcp-server/tsconfig.json | 17 + 23 files changed, 2688 insertions(+) create mode 100644 mcp-server/.gitignore create mode 100644 mcp-server/.npmignore create mode 100644 mcp-server/README.md create mode 100644 mcp-server/package-lock.json create mode 100644 mcp-server/package.json create mode 100644 mcp-server/src/csdd.ts create mode 100644 mcp-server/src/index.ts create mode 100644 mcp-server/src/registry.ts create mode 100644 mcp-server/src/tooldef.ts create mode 100644 mcp-server/src/tools/agent.ts create mode 100644 mcp-server/src/tools/init.ts create mode 100644 mcp-server/src/tools/mcp.ts create mode 100644 mcp-server/src/tools/skill.ts create mode 100644 mcp-server/src/tools/spec.ts create mode 100644 mcp-server/src/tools/steering.ts create mode 100644 mcp-server/test/csdd-missing.test.ts create mode 100644 mcp-server/test/csdd-run.test.ts create mode 100644 mcp-server/test/handler.test.ts create mode 100644 mcp-server/test/helpers.ts create mode 100644 mcp-server/test/tooldef.test.ts create mode 100644 mcp-server/test/tools.test.ts create mode 100644 mcp-server/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c77dec6..1450133 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,27 @@ jobs: - name: test (race + coverage) run: go test -race -coverprofile=coverage.out ./... + + mcp-server: + name: mcp-server (build & test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp-server + steps: + - uses: actions/checkout@v4 + + # Node 24: the unit tests are TypeScript run through node:test with native + # type stripping (Node >= 22.18). The published package ships compiled JS + # (dist/) and still supports Node >= 18 — see package.json "engines". + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: mcp-server/package-lock.json + + - name: install + run: npm ci + + - name: test (build + node:test) + run: npm test diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/mcp-server/.npmignore b/mcp-server/.npmignore new file mode 100644 index 0000000..ed706fd --- /dev/null +++ b/mcp-server/.npmignore @@ -0,0 +1,6 @@ +src/ +test/ +tsconfig.json +node_modules/ +*.log +.gitignore diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..433ed18 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,235 @@ +# @protonspy/csdd-mcp + +**An [MCP](https://modelcontextprotocol.io) server that exposes the [`csdd`](https://github.com/protonspy/csdd) CLI as tools — one tool per subcommand.** + +`csdd` governs the Claude Code Spec-Driven Development workflow (steering, specs, +skills, sub-agents, MCP servers) and validates the contract mechanically. This +server lets an MCP-capable agent drive `csdd` **directly as tools**, instead of +shelling out to a terminal — same operations, same phase gates, same exit codes. + +``` +agent ──(MCP/stdio)──▶ csdd-mcp ──(execFile)──▶ csdd binary ──▶ .claude/ · specs/ +``` + +Each tool builds a `csdd` argv, runs the binary headlessly (`NO_COLOR=1`, no TTY +so confirmations auto-decline), and returns its `stdout`/`stderr`. A non-zero +exit becomes an MCP error result; **exit `2` (validation failure) is surfaced +distinctly** so the agent can tell "your spec is invalid" from "the command +broke". + +--- + +## Requirements + +- **Node.js ≥ 18** (the published package ships compiled JS). +- **The `csdd` binary**, reachable by the server — see [Locating the csdd binary](#locating-the-csdd-binary). + +--- + +## Install & configure + +The server runs over **stdio**; point your MCP client at it. + +### Claude Code + +```bash +# project scope (writes .mcp.json) — or use --scope user for all projects +claude mcp add csdd -- npx -y @protonspy/csdd-mcp +``` + +Or add it to `.mcp.json` by hand: + +```json +{ + "mcpServers": { + "csdd": { + "command": "npx", + "args": ["-y", "@protonspy/csdd-mcp"], + "env": { "CSDD_BIN": "/usr/local/bin/csdd" } + } + } +} +``` + +> `env.CSDD_BIN` is optional — drop it if `csdd` is on your `PATH`. See below. + +### Any MCP client + +Launch `npx -y @protonspy/csdd-mcp` (or `csdd-mcp` if installed globally) as a +stdio server. The process stays alive serving stdio until the client closes the +pipe. + +--- + +## Locating the csdd binary + +The server resolves `csdd` once, on first use, in this order (first hit wins): + +| # | Source | When it applies | +|---|--------|-----------------| +| 1 | **`$CSDD_BIN`** | Explicit absolute path. Always wins — use this if in doubt. | +| 2 | **Platform package** `@protonspy/csdd--` | When the matching prebuilt-binary package is installed alongside (e.g. co-installed with `@protonspy/csdd`). | +| 3 | **Sibling repo binary** (`../csdd`, `../../csdd`) | When running from a checkout of the csdd repo. | +| 4 | **`csdd` on `$PATH`** | Last resort, resolved by the OS at spawn time. | + +If none resolve, calls fail with **exit `127`** and a message telling you to set +`CSDD_BIN`, install `@protonspy/csdd`, or put `csdd` on your `PATH`. + +> **Recommended setup for end users:** `npm install -g @protonspy/csdd` (puts +> `csdd` on `PATH`, satisfying #4), or set `CSDD_BIN` to an absolute path. + +### Environment + +| Variable | Effect | +|----------|--------| +| `CSDD_BIN` | Absolute path to the `csdd` binary. Highest-priority resolution. | +| `NO_COLOR` | Forced to `1` for every call so output is ANSI-free (you don't set this). | + +--- + +## Result & error semantics + +Every tool returns a text result. The mapping from the `csdd` exit code is: + +| Exit | `isError` | Result text | +|------|-----------|-------------| +| `0` | `false` | `stdout` (and any `stderr` as an unlabelled warning); `(ok, no output)` if silent. | +| `2` | `true` | Prefixed `csdd validation failed (exit 2):` — a contract/validation problem. | +| other | `true` | Prefixed `csdd failed (exit ):`; `stderr` is labelled `[stderr]`. | +| `127` | `true` | Binary not found — includes guidance to set `CSDD_BIN` / install / fix `PATH`. | + +--- + +## Tool reference + +**35 tools**, grouped by the resource they manage. Conventions: + +- Every tool accepts an optional **`root`** — the workspace root (the directory + containing `.claude/`). Omit it to walk up from the server's working directory. +- Destructive / gate-breaking tools take **`force`** (boolean). Without it, + deletes are refused and phase gates hold. +- `?` marks an optional parameter; everything else is required. + +### Workspace + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_version` | — | Print the underlying `csdd` binary version (diagnostic / connectivity check). | +| `csdd_init` | `withBaseline?`, `root?` | Bootstrap a Claude Code workspace: `.claude/` layout, `CLAUDE.md`, `csdd.md`, `.mcp.json`, rules, shipped agents/skills/commands/hooks, guides. Idempotent. `withBaseline` also scaffolds the standard steering files and imports them into `CLAUDE.md`. | + +### 🧭 steering — project memory (`.claude/steering/*.md`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_steering_init` | `root?` | Create `.claude/steering/` and the 6 standard files (product, tech, structure, security, testing, api-conventions). | +| `csdd_steering_create` | `name`, `inclusion`, `pattern?[]`, `description?`, `title?`, `force?`, `root?` | Create a custom steering file. `inclusion` ∈ `always · fileMatch · manual · auto`. `fileMatch` requires ≥1 `pattern`; `auto` requires a `description`. | +| `csdd_steering_list` | `root?`, `inclusion?` | List steering files with inclusion mode; optionally filter by `inclusion`. | +| `csdd_steering_show` | `name`, `root?` | Print a steering file (frontmatter + body). | +| `csdd_steering_delete` | `name`, `force?`, `root?` | Delete a steering file (`force` required). Foundational files (product, tech, structure) are protected. | +| `csdd_steering_validate` | `name?`, `root?` | Validate frontmatter/structure. Omit `name` to validate all. Exit 2 on issues. | + +`inclusion` controls *when* the steering loads: `always` (always-on), `fileMatch` +(when files match a `pattern`), `manual` (`#name`), `auto` (when its `description` +matches the context). + +### 📐 spec — per-feature contract (`specs//`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_spec_init` | `feature`, `language?`, `root?` | Create `specs//spec.json` (phase = initial, no approvals). `language` defaults to `en`. | +| `csdd_spec_list` | `root?` | List specs with current phase, approved phases, and readiness. | +| `csdd_spec_show` | `feature`, `root?` | Show a spec's `spec.json` metadata and its artifacts. | +| `csdd_spec_status` | `feature`, `root?` | Combined `show` + `validate` for a spec. | +| `csdd_spec_generate` | `feature`, `artifact`, `force?`, `root?` | Generate an artifact. `artifact` ∈ `requirements · design · tasks · research · bugfix`. **Phase gates apply** (see below); `force` bypasses them. | +| `csdd_spec_approve` | `feature`, `phase`, `force?`, `root?` | Approve a phase. `phase` ∈ `requirements · design · tasks`. Validates first; `force` approves despite issues/missing prior approvals. | +| `csdd_spec_validate` | `feature`, `root?` | Validate EARS phrasing, traceability, task annotations, parallel safety. Exit 2 on issues. | +| `csdd_spec_delete` | `feature`, `force?`, `root?` | Delete `specs//` recursively (`force` required). | + +> **Phase gates (enforced, not advisory):** `design` needs `requirements` +> approved; `tasks` needs `design` approved. Generating out of order fails with +> **exit 2** unless `force` is passed. `ready_for_implementation` flips to `true` +> only when all three phases are approved. `research` and `bugfix` are ungated. + +### 🛠️ skill — workflow bundles (`.claude/skills//`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_skill_create` | `name`, `description`, `title?`, `root?` | Create `.claude/skills//` with `SKILL.md` (+ `references/`, `assets/`, `scripts/`). `description` is the one-line activation trigger. | +| `csdd_skill_list` | `root?` | List skills with their descriptions. | +| `csdd_skill_show` | `name`, `root?` | List a skill's files and print `SKILL.md`. | +| `csdd_skill_add_reference` | `skill`, `file`, `root?` | Add a reference file under `references/`. Path traversal is rejected. | +| `csdd_skill_add_script` | `skill`, `file`, `root?` | Add a script file under `scripts/`. Path traversal is rejected. | +| `csdd_skill_add_asset` | `skill`, `file`, `root?` | Add an asset file under `assets/`. Path traversal is rejected. | +| `csdd_skill_validate` | `name`, `root?` | Validate structure + frontmatter; report line/token counts. Exit 2 on issues. | +| `csdd_skill_delete` | `name`, `force?`, `root?` | Delete `.claude/skills//` recursively (`force` required). | + +### 🤖 agent — custom sub-agents (`.claude/agents/.md`) + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_agent_create` | `name`, `description`, `tools?[]`, `model?`, `title?`, `force?`, `root?` | Create a least-privilege sub-agent (default tools: `Read`, `Grep`). `description` tells the orchestrator when to pick it. `model` ∈ `sonnet · opus · haiku`. | +| `csdd_agent_list` | `root?` | List agents with their tools and descriptions. | +| `csdd_agent_show` | `name`, `root?` | Print an agent file. | +| `csdd_agent_delete` | `name`, `force?`, `root?` | Delete `.claude/agents/.md` (`force` required). | + +### 🔌 mcp — MCP servers in `.mcp.json` + +| Tool | Parameters | What it does | +|------|------------|--------------| +| `csdd_mcp_add` | `name`, `command?`, `arg?[]`, `url?`, `type?`, `env?[]`, `disabled?`, `autoApprove?[]`, `force?`, `root?` | Add a server. Provide **either** `command` (+`arg`) for stdio **or** `url` (+`type`) for remote — never both. `type` ∈ `sse · http` (default `http`). `env`/`autoApprove` are `KEY=VALUE` / tool-name lists. `force` replaces an existing entry. | +| `csdd_mcp_list` | `root?` | List servers with type, state, and endpoint. | +| `csdd_mcp_show` | `name`, `root?` | Print a server's config as pretty JSON. | +| `csdd_mcp_remove` | `name`, `force?`, `root?` | Remove a server (`force` required). | +| `csdd_mcp_enable` | `name`, `root?` | Enable a server (`disabled = false`). | +| `csdd_mcp_disable` | `name`, `root?` | Disable a server (`disabled = true`). | +| `csdd_mcp_validate` | `root?` | Validate `.mcp.json` for schema errors. Exit 2 on issues. | + +> Avoid `autoApprove` — it breaks least-privilege by auto-approving tool calls. + +--- + +## A typical agent flow + +Tool calls that take one feature from idea to ready-to-implement: + +```jsonc +csdd_init { "withBaseline": true } +csdd_spec_init { "feature": "photo-albums" } + +csdd_spec_generate { "feature": "photo-albums", "artifact": "requirements" } +csdd_spec_validate { "feature": "photo-albums" } // exit 2 → fix what it flags +csdd_spec_approve { "feature": "photo-albums", "phase": "requirements" } + +csdd_spec_generate { "feature": "photo-albums", "artifact": "design" } // gated on the approval above +csdd_spec_approve { "feature": "photo-albums", "phase": "design" } + +csdd_spec_generate { "feature": "photo-albums", "artifact": "tasks" } +csdd_spec_approve { "feature": "photo-albums", "phase": "tasks" } +// → spec.json: ready_for_implementation = true +``` + +`csdd_spec_status { "feature": "photo-albums" }` between steps shows phase, +approvals, and validation issues in one call. + +--- + +## Development + +```bash +npm install +npm run build # tsc → dist/ +npm test # build + Node's built-in test runner (node:test) +npm run test:run # tests only, against the current dist/ (no rebuild) +npm run dev # tsc --watch +``` + +Tests are TypeScript run through `node:test` with native **type stripping**, so +they need **Node ≥ 22.18** (dev-only — the published package still targets Node +≥ 18). They exercise the argv builders, result formatting, binary resolution, +`runCsdd` against a stub binary, and every tool's argv mapping. See `test/`. + +--- + +## License + +MIT diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 0000000..1423205 --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,1198 @@ +{ + "name": "@protonspy/csdd-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@protonspy/csdd-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.8" + }, + "bin": { + "csdd-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 0000000..bc30d21 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,41 @@ +{ + "name": "@protonspy/csdd-mcp", + "version": "0.1.0", + "description": "MCP server exposing the csdd CLI over stdio (one tool per subcommand).", + "type": "module", + "bin": { + "csdd-mcp": "dist/index.js" + }, + "main": "dist/index.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -w -p tsconfig.json", + "start": "node dist/index.js", + "test": "npm run build && node --test \"test/**/*.test.ts\"", + "test:run": "node --test \"test/**/*.test.ts\"", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "csdd", + "claude-code", + "sdd" + ], + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + } +} diff --git a/mcp-server/src/csdd.ts b/mcp-server/src/csdd.ts new file mode 100644 index 0000000..d74f526 --- /dev/null +++ b/mcp-server/src/csdd.ts @@ -0,0 +1,116 @@ +// Resolve the csdd binary and run it headlessly, capturing structured output. +// +// Resolution order (first hit wins): +// 1. $CSDD_BIN — explicit override +// 2. platform optionalDependency — @protonspy/csdd-- +// (the same packages the npm launcher uses) +// 3. sibling repo binary — ../csdd (when running from the repo) +// 4. "csdd" on $PATH — last-resort lookup at spawn time +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); + +// platform/arch -> npm package name (must match the npm launcher's TARGETS). +const PKG: Record = { + "linux-x64": "@protonspy/csdd-linux-x64", + "linux-arm64": "@protonspy/csdd-linux-arm64", + "darwin-x64": "@protonspy/csdd-darwin-x64", + "darwin-arm64": "@protonspy/csdd-darwin-arm64", + "win32-x64": "@protonspy/csdd-win32-x64", +}; + +const binName = process.platform === "win32" ? "csdd.exe" : "csdd"; + +function fromPlatformPackage(): string | null { + const pkgName = PKG[`${process.platform}-${process.arch}`]; + if (!pkgName) return null; + try { + const pkgJson = require.resolve(`${pkgName}/package.json`); + const candidate = join(pkgJson, "..", "bin", binName); + return existsSync(candidate) ? candidate : null; + } catch { + return null; + } +} + +function fromSiblingRepo(): string | null { + // dist/csdd.js -> packageRoot = mcp-server -> repo root holds ./csdd + const here = dirname(fileURLToPath(import.meta.url)); + for (const up of ["..", "../.."]) { + const candidate = join(here, up, binName); + if (existsSync(candidate)) return candidate; + } + return null; +} + +let cached: string | null = null; + +/** Locate the csdd binary, caching the result. Falls back to a bare "csdd". */ +export function resolveCsddBin(): string { + if (cached) return cached; + cached = + process.env.CSDD_BIN || + fromPlatformPackage() || + fromSiblingRepo() || + binName; // let the OS resolve it on $PATH at spawn time + return cached; +} + +export interface CsddResult { + ok: boolean; + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * Run `csdd` with the given argv. Never rejects on a non-zero exit — the exit + * code is returned so each tool can map it to an MCP error result. + * `cwd` controls workspace discovery when no --root flag is passed. + */ +export function runCsdd(argv: string[], cwd?: string): Promise { + const bin = resolveCsddBin(); + return new Promise((resolve) => { + execFile( + bin, + argv, + { + cwd: cwd || process.cwd(), + // NO_COLOR keeps output free of ANSI escapes; csdd is non-interactive + // (confirm() returns false) when stdin is not a TTY, which it isn't here. + env: { ...process.env, NO_COLOR: "1" }, + maxBuffer: 16 * 1024 * 1024, + windowsHide: true, + }, + (err, stdout, stderr) => { + const code = + err && typeof (err as NodeJS.ErrnoException).code === "string" + ? // spawn failure (ENOENT etc.) — surface as exit 127 + 127 + : ((err as { code?: number } | null)?.code ?? 0); + if (err && code === 127) { + resolve({ + ok: false, + exitCode: 127, + stdout: stdout?.toString() ?? "", + stderr: + (stderr?.toString() ?? "") + + `\ncsdd binary not found or not executable: ${bin}\n` + + `Set CSDD_BIN, install @protonspy/csdd, or put csdd on PATH.`, + }); + return; + } + resolve({ + ok: code === 0, + exitCode: typeof code === "number" ? code : 1, + stdout: stdout?.toString() ?? "", + stderr: stderr?.toString() ?? "", + }); + }, + ); + }); +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 0000000..4157f9e --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// csdd-mcp — an MCP server (stdio) that exposes the csdd CLI as one tool per +// subcommand. Each tool shells out to the csdd binary headlessly and returns +// its stdout/stderr; non-zero exits are surfaced as MCP errors (exit 2 = +// validation failure). +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { makeHandler } from "./tooldef.js"; +import { allTools } from "./registry.js"; + +const version = "0.1.0"; + +async function main() { + const server = new McpServer({ name: "csdd-mcp", version }); + + for (const def of allTools) { + server.registerTool( + def.name, + { + title: def.title, + description: def.description, + inputSchema: def.inputSchema, + }, + makeHandler(def), + ); + } + + const transport = new StdioServerTransport(); + await server.connect(transport); + // Never resolves; the process stays alive serving stdio until the client + // closes the pipe. +} + +main().catch((err) => { + process.stderr.write(`csdd-mcp fatal: ${err?.stack || err}\n`); + process.exit(1); +}); diff --git a/mcp-server/src/registry.ts b/mcp-server/src/registry.ts new file mode 100644 index 0000000..3c466f9 --- /dev/null +++ b/mcp-server/src/registry.ts @@ -0,0 +1,30 @@ +// The full set of MCP tools this server exposes — one per csdd subcommand, +// plus a diagnostic version tool. Kept separate from index.ts so it can be +// imported (e.g. by tests) without booting the stdio transport. +import { type ToolDef } from "./tooldef.js"; +import { initTools } from "./tools/init.js"; +import { steeringTools } from "./tools/steering.js"; +import { specTools } from "./tools/spec.js"; +import { skillTools } from "./tools/skill.js"; +import { agentTools } from "./tools/agent.js"; +import { mcpTools } from "./tools/mcp.js"; + +export const miscTools: ToolDef[] = [ + { + name: "csdd_version", + title: "csdd version", + description: "Print the version of the underlying csdd binary (diagnostic).", + inputSchema: {}, + toArgs: () => ["version"], + }, +]; + +export const allTools: ToolDef[] = [ + ...miscTools, + ...initTools, + ...steeringTools, + ...specTools, + ...skillTools, + ...agentTools, + ...mcpTools, +]; diff --git a/mcp-server/src/tooldef.ts b/mcp-server/src/tooldef.ts new file mode 100644 index 0000000..7b71f63 --- /dev/null +++ b/mcp-server/src/tooldef.ts @@ -0,0 +1,85 @@ +// Shared types + helpers for declaring csdd-backed MCP tools. +import { z, type ZodRawShape } from "zod"; +import { runCsdd, type CsddResult } from "./csdd.js"; + +export interface ToolDef { + /** MCP tool name, e.g. "csdd_spec_generate". */ + name: string; + /** Human title shown in clients. */ + title: string; + /** When/why an agent should call this tool. */ + description: string; + /** Zod raw shape (object of field schemas) describing the inputs. */ + inputSchema: ZodRawShape; + /** Map validated params to the csdd argv (excluding the binary itself). */ + toArgs: (params: Record) => string[]; +} + +// --- argv builders --------------------------------------------------------- + +/** `--flag value` when value is a non-empty string/number, else nothing. */ +export function flag(name: string, value: unknown): string[] { + if (value === undefined || value === null || value === "") return []; + return [name, String(value)]; +} + +/** `--flag` when truthy, else nothing. */ +export function bool(name: string, value: unknown): string[] { + return value ? [name] : []; +} + +/** Repeat `--flag value` for each item. */ +export function multi(name: string, values: unknown): string[] { + if (!Array.isArray(values)) return []; + return values.flatMap((v) => [name, String(v)]); +} + +/** Standard `--root` passthrough (most commands accept it). */ +export function rootArg(params: Record): string[] { + return flag("--root", params.root); +} + +// Reusable field schemas ----------------------------------------------------- +export const rootField = z + .string() + .optional() + .describe( + "Workspace root (the directory containing .claude/). Defaults to walking up from the current directory.", + ); + +export const forceField = z + .boolean() + .optional() + .describe("Bypass safety checks / overwrite or delete without confirmation."); + +// --- result formatting ----------------------------------------------------- + +/** Turn a csdd run into an MCP tool result, flagging non-zero exits. */ +export function toMcpResult(r: CsddResult) { + const parts: string[] = []; + if (r.stdout.trim()) parts.push(r.stdout.trimEnd()); + if (r.stderr.trim()) parts.push((r.ok ? "" : "[stderr] ") + r.stderr.trimEnd()); + if (parts.length === 0) { + parts.push(r.ok ? "(ok, no output)" : `csdd exited with code ${r.exitCode}`); + } + // Exit 2 = validation failure; surface it distinctly in the text. + const header = + r.exitCode === 2 + ? "csdd validation failed (exit 2):\n" + : r.ok + ? "" + : `csdd failed (exit ${r.exitCode}):\n`; + return { + content: [{ type: "text" as const, text: header + parts.join("\n\n") }], + isError: !r.ok, + }; +} + +/** Build the handler that runs a ToolDef's argv and formats the result. */ +export function makeHandler(def: ToolDef) { + return async (params: Record) => { + const argv = def.toArgs(params ?? {}); + const result = await runCsdd(argv, params?.root); + return toMcpResult(result); + }; +} diff --git a/mcp-server/src/tools/agent.ts b/mcp-server/src/tools/agent.ts new file mode 100644 index 0000000..70669df --- /dev/null +++ b/mcp-server/src/tools/agent.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { + bool, + flag, + forceField, + multi, + rootArg, + rootField, + type ToolDef, +} from "../tooldef.js"; + +const agentName = z.string().describe("Agent name (.claude/agents/.md)."); + +export const agentTools: ToolDef[] = [ + { + name: "csdd_agent_create", + title: "Agent create", + description: + "Create a custom sub-agent with least-privilege tools (default: Read, Grep). Description tells the orchestrator when to pick it.", + inputSchema: { + name: agentName, + description: z.string().describe("When the orchestrator should select this agent."), + tools: z + .array(z.string()) + .optional() + .describe("Tool names to grant (e.g. Read, Grep, Bash, Edit). Repeatable."), + model: z.string().optional().describe("Model override (e.g. sonnet, opus, haiku)."), + title: z.string().optional().describe("Document title (defaults to Title Case of name)."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "agent", + "create", + p.name, + ...flag("--description", p.description), + ...multi("--tools", p.tools), + ...flag("--model", p.model), + ...flag("--title", p.title), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_agent_list", + title: "Agent list", + description: "List agents with their tools and descriptions.", + inputSchema: { root: rootField }, + toArgs: (p) => ["agent", "list", ...rootArg(p)], + }, + { + name: "csdd_agent_show", + title: "Agent show", + description: "Print an agent file.", + inputSchema: { name: agentName, root: rootField }, + toArgs: (p) => ["agent", "show", p.name, ...rootArg(p)], + }, + { + name: "csdd_agent_delete", + title: "Agent delete", + description: "Delete .claude/agents/.md. Requires force.", + inputSchema: { name: agentName, force: forceField, root: rootField }, + toArgs: (p) => ["agent", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], + }, +]; diff --git a/mcp-server/src/tools/init.ts b/mcp-server/src/tools/init.ts new file mode 100644 index 0000000..fb4629a --- /dev/null +++ b/mcp-server/src/tools/init.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { bool, rootArg, rootField, type ToolDef } from "../tooldef.js"; + +export const initTools: ToolDef[] = [ + { + name: "csdd_init", + title: "Init workspace", + description: + "Bootstrap a Claude Code workspace: create .claude/ layout (steering, specs, skills, agents, commands, hooks), CLAUDE.md, csdd.md, .mcp.json, rules, shipped agents/skills/commands/hooks, and guides. Idempotent. Use withBaseline to also scaffold product/tech/structure/security/testing/api-conventions steering files.", + inputSchema: { + withBaseline: z + .boolean() + .optional() + .describe("Also scaffold the standard steering files (product, tech, structure, security, testing, api-conventions) and import them into CLAUDE.md."), + root: rootField, + }, + toArgs: (p) => ["init", ...bool("--with-baseline", p.withBaseline), ...rootArg(p)], + }, +]; diff --git a/mcp-server/src/tools/mcp.ts b/mcp-server/src/tools/mcp.ts new file mode 100644 index 0000000..4e5960d --- /dev/null +++ b/mcp-server/src/tools/mcp.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { + bool, + flag, + forceField, + multi, + rootArg, + rootField, + type ToolDef, +} from "../tooldef.js"; + +const serverName = z.string().describe("MCP server name (key in .mcp.json)."); + +export const mcpTools: ToolDef[] = [ + { + name: "csdd_mcp_add", + title: "MCP add server", + description: + "Add an MCP server to .mcp.json. Provide either command (+arg) for stdio, or url (+type) for a remote server. Use force to replace an existing entry.", + inputSchema: { + name: serverName, + command: z.string().optional().describe("Executable for a stdio server."), + arg: z.array(z.string()).optional().describe("Arguments for the command. Repeatable."), + url: z.string().optional().describe("Endpoint for a remote server (mutually exclusive with command)."), + type: z.enum(["sse", "http"]).optional().describe("Remote transport (default http when url set)."), + env: z + .array(z.string()) + .optional() + .describe("Environment variables as KEY=VALUE. Repeatable."), + disabled: z.boolean().optional().describe("Add in disabled state."), + autoApprove: z + .array(z.string()) + .optional() + .describe("Tools to auto-approve (avoid; breaks least privilege). Repeatable."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "mcp", + "add", + p.name, + ...flag("--command", p.command), + ...multi("--arg", p.arg), + ...flag("--url", p.url), + ...flag("--type", p.type), + ...multi("--env", p.env), + ...bool("--disabled", p.disabled), + ...multi("--auto-approve", p.autoApprove), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_mcp_list", + title: "MCP list", + description: "List MCP servers with type, state, and endpoint.", + inputSchema: { root: rootField }, + toArgs: (p) => ["mcp", "list", ...rootArg(p)], + }, + { + name: "csdd_mcp_show", + title: "MCP show", + description: "Print a server's config as pretty JSON.", + inputSchema: { name: serverName, root: rootField }, + toArgs: (p) => ["mcp", "show", p.name, ...rootArg(p)], + }, + { + name: "csdd_mcp_remove", + title: "MCP remove", + description: "Remove a server from .mcp.json. Requires force.", + inputSchema: { name: serverName, force: forceField, root: rootField }, + toArgs: (p) => ["mcp", "remove", p.name, ...bool("--force", p.force), ...rootArg(p)], + }, + { + name: "csdd_mcp_enable", + title: "MCP enable", + description: "Enable a server (disabled=false).", + inputSchema: { name: serverName, root: rootField }, + toArgs: (p) => ["mcp", "enable", p.name, ...rootArg(p)], + }, + { + name: "csdd_mcp_disable", + title: "MCP disable", + description: "Disable a server (disabled=true).", + inputSchema: { name: serverName, root: rootField }, + toArgs: (p) => ["mcp", "disable", p.name, ...rootArg(p)], + }, + { + name: "csdd_mcp_validate", + title: "MCP validate", + description: "Validate .mcp.json for schema errors. Exit 2 on issues.", + inputSchema: { root: rootField }, + toArgs: (p) => ["mcp", "validate", ...rootArg(p)], + }, +]; diff --git a/mcp-server/src/tools/skill.ts b/mcp-server/src/tools/skill.ts new file mode 100644 index 0000000..0f513df --- /dev/null +++ b/mcp-server/src/tools/skill.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { bool, flag, forceField, rootArg, rootField, type ToolDef } from "../tooldef.js"; + +const skillName = z.string().describe("Skill name (.claude/skills//)."); + +function addArtifact(action: string, kind: string): ToolDef { + return { + name: `csdd_skill_add_${action.replace("add-", "")}`, + title: `Skill add ${kind}`, + description: `Add a ${kind} file under .claude/skills//${kind}s/ with a placeholder. Path traversal is rejected.`, + inputSchema: { + skill: skillName, + file: z.string().describe(`File path relative to the ${kind}s/ subdir.`), + root: rootField, + }, + toArgs: (p) => ["skill", action, p.skill, p.file, ...rootArg(p)], + }; +} + +export const skillTools: ToolDef[] = [ + { + name: "csdd_skill_create", + title: "Skill create", + description: + "Create .claude/skills// with SKILL.md (+ references/, assets/, scripts/). Description is the one-line activation trigger.", + inputSchema: { + name: skillName, + description: z.string().describe("One-sentence activation trigger for the skill."), + title: z.string().optional().describe("Document title (defaults to Title Case of name)."), + root: rootField, + }, + toArgs: (p) => [ + "skill", + "create", + p.name, + ...flag("--description", p.description), + ...flag("--title", p.title), + ...rootArg(p), + ], + }, + { + name: "csdd_skill_list", + title: "Skill list", + description: "List skills with their descriptions.", + inputSchema: { root: rootField }, + toArgs: (p) => ["skill", "list", ...rootArg(p)], + }, + { + name: "csdd_skill_show", + title: "Skill show", + description: "List a skill's files and print SKILL.md.", + inputSchema: { name: skillName, root: rootField }, + toArgs: (p) => ["skill", "show", p.name, ...rootArg(p)], + }, + addArtifact("add-reference", "reference"), + addArtifact("add-script", "script"), + addArtifact("add-asset", "asset"), + { + name: "csdd_skill_validate", + title: "Skill validate", + description: "Validate skill structure + frontmatter; report line/token counts. Exit 2 on issues.", + inputSchema: { name: skillName, root: rootField }, + toArgs: (p) => ["skill", "validate", p.name, ...rootArg(p)], + }, + { + name: "csdd_skill_delete", + title: "Skill delete", + description: "Delete .claude/skills// recursively. Requires force.", + inputSchema: { name: skillName, force: forceField, root: rootField }, + toArgs: (p) => ["skill", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], + }, +]; diff --git a/mcp-server/src/tools/spec.ts b/mcp-server/src/tools/spec.ts new file mode 100644 index 0000000..2c719d4 --- /dev/null +++ b/mcp-server/src/tools/spec.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { bool, flag, forceField, rootArg, rootField, type ToolDef } from "../tooldef.js"; + +const feature = z.string().describe("Feature name (specs//)."); + +export const specTools: ToolDef[] = [ + { + name: "csdd_spec_init", + title: "Spec init", + description: + "Create specs//spec.json (phase=initial, no approvals, not ready for implementation).", + inputSchema: { + feature, + language: z.string().optional().describe("Spec language (default: en)."), + root: rootField, + }, + toArgs: (p) => ["spec", "init", p.feature, ...flag("--language", p.language), ...rootArg(p)], + }, + { + name: "csdd_spec_list", + title: "Spec list", + description: "List specs with current phase, approved phases, and readiness.", + inputSchema: { root: rootField }, + toArgs: (p) => ["spec", "list", ...rootArg(p)], + }, + { + name: "csdd_spec_show", + title: "Spec show", + description: "Show a spec's metadata (spec.json) and its artifacts.", + inputSchema: { feature, root: rootField }, + toArgs: (p) => ["spec", "show", p.feature, ...rootArg(p)], + }, + { + name: "csdd_spec_status", + title: "Spec status", + description: "Combined show + validate for a spec.", + inputSchema: { feature, root: rootField }, + toArgs: (p) => ["spec", "status", p.feature, ...rootArg(p)], + }, + { + name: "csdd_spec_generate", + title: "Spec generate artifact", + description: + "Generate a spec artifact. Phase gates apply: design needs requirements approved, tasks needs design approved (use force to bypass). research/bugfix are ungated.", + inputSchema: { + feature, + artifact: z + .enum(["requirements", "design", "tasks", "research", "bugfix"]) + .describe("Which artifact to generate."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "spec", + "generate", + p.feature, + ...flag("--artifact", p.artifact), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_spec_approve", + title: "Spec approve phase", + description: + "Approve a spec phase (requirements|design|tasks). Validates first; force approves despite issues/missing prior approvals. Sets ready_for_implementation only when all three are approved.", + inputSchema: { + feature, + phase: z.enum(["requirements", "design", "tasks"]).describe("Phase to approve."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "spec", + "approve", + p.feature, + ...flag("--phase", p.phase), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_spec_validate", + title: "Spec validate", + description: + "Validate a spec: EARS phrasing, traceability, task annotations, parallel safety. Exit 2 on issues.", + inputSchema: { feature, root: rootField }, + toArgs: (p) => ["spec", "validate", p.feature, ...rootArg(p)], + }, + { + name: "csdd_spec_delete", + title: "Spec delete", + description: "Delete specs// recursively. Requires force.", + inputSchema: { feature, force: forceField, root: rootField }, + toArgs: (p) => ["spec", "delete", p.feature, ...bool("--force", p.force), ...rootArg(p)], + }, +]; diff --git a/mcp-server/src/tools/steering.ts b/mcp-server/src/tools/steering.ts new file mode 100644 index 0000000..b36bc1d --- /dev/null +++ b/mcp-server/src/tools/steering.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; +import { + bool, + flag, + forceField, + multi, + rootArg, + rootField, + type ToolDef, +} from "../tooldef.js"; + +const inclusion = z + .enum(["always", "fileMatch", "manual", "auto"]) + .describe( + "When the steering loads: always (always-on), fileMatch (when files match --pattern), manual (#name), auto (when description matches context).", + ); + +export const steeringTools: ToolDef[] = [ + { + name: "csdd_steering_init", + title: "Steering init", + description: + "Create .claude/steering/ and populate the 6 standard files (product, tech, structure, security, testing, api-conventions).", + inputSchema: { root: rootField }, + toArgs: (p) => ["steering", "init", ...rootArg(p)], + }, + { + name: "csdd_steering_create", + title: "Steering create", + description: + "Create a custom steering file with inclusion metadata. fileMatch requires at least one pattern; auto requires a description.", + inputSchema: { + name: z.string().describe("Steering file name (no extension)."), + inclusion, + pattern: z + .array(z.string()) + .optional() + .describe("Glob pattern(s) for fileMatch inclusion. Repeatable."), + description: z + .string() + .optional() + .describe("One-line description; required for auto inclusion."), + title: z.string().optional().describe("Document title (defaults to Title Case of name)."), + force: forceField, + root: rootField, + }, + toArgs: (p) => [ + "steering", + "create", + p.name, + ...flag("--inclusion", p.inclusion), + ...multi("--pattern", p.pattern), + ...flag("--description", p.description), + ...flag("--title", p.title), + ...bool("--force", p.force), + ...rootArg(p), + ], + }, + { + name: "csdd_steering_list", + title: "Steering list", + description: "List steering files with inclusion mode and inclusion-specific extras.", + inputSchema: { + root: rootField, + inclusion: inclusion.optional().describe("Filter by inclusion mode."), + }, + toArgs: (p) => ["steering", "list", ...rootArg(p), ...flag("--inclusion", p.inclusion)], + }, + { + name: "csdd_steering_show", + title: "Steering show", + description: "Print a steering file (frontmatter + body).", + inputSchema: { name: z.string().describe("Steering file name."), root: rootField }, + toArgs: (p) => ["steering", "show", p.name, ...rootArg(p)], + }, + { + name: "csdd_steering_delete", + title: "Steering delete", + description: + "Delete a steering file. Requires force. Foundational files (product, tech, structure) are protected.", + inputSchema: { + name: z.string().describe("Steering file name."), + force: forceField, + root: rootField, + }, + toArgs: (p) => ["steering", "delete", p.name, ...bool("--force", p.force), ...rootArg(p)], + }, + { + name: "csdd_steering_validate", + title: "Steering validate", + description: + "Validate steering frontmatter and structure. Omit name to validate all. Exit 2 on issues.", + inputSchema: { + name: z.string().optional().describe("Validate only this file; omit for all."), + root: rootField, + }, + toArgs: (p) => ["steering", "validate", ...(p.name ? [p.name] : []), ...rootArg(p)], + }, +]; diff --git a/mcp-server/test/csdd-missing.test.ts b/mcp-server/test/csdd-missing.test.ts new file mode 100644 index 0000000..761aac3 --- /dev/null +++ b/mcp-server/test/csdd-missing.test.ts @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// A separate file (= separate process) so resolveCsddBin's cache starts empty +// and picks up this bogus path. A spawn failure (ENOENT) must surface as a +// graceful exit-127 result with guidance, never an unhandled rejection. +const missing = join(tmpdir(), "definitely-not-a-real-csdd-binary-xyz"); +process.env.CSDD_BIN = missing; + +const { runCsdd } = await import("../dist/csdd.js"); + +test("runCsdd maps a missing binary to exit 127 with guidance", async () => { + const r = await runCsdd(["version"]); + assert.equal(r.ok, false); + assert.equal(r.exitCode, 127); + assert.match(r.stderr, /not found or not executable/); + assert.match(r.stderr, /CSDD_BIN/); + assert.match(r.stderr, new RegExp(missing.replace(/[.\\]/g, "\\$&"))); +}); diff --git a/mcp-server/test/csdd-run.test.ts b/mcp-server/test/csdd-run.test.ts new file mode 100644 index 0000000..b01537f --- /dev/null +++ b/mcp-server/test/csdd-run.test.ts @@ -0,0 +1,67 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { runCsdd, resolveCsddBin } from "../dist/csdd.js"; +import { makeFakeCsdd } from "./helpers.ts"; + +// Resolve to the stub for the whole file. CSDD_BIN is the highest-priority +// source in resolveCsddBin, and the result is cached process-wide. +const fake = makeFakeCsdd(); +process.env.CSDD_BIN = fake.bin; + +after(() => fake.cleanup()); + +test("resolveCsddBin honours the CSDD_BIN override", () => { + assert.equal(resolveCsddBin(), fake.bin); +}); + +test("resolveCsddBin caches the first resolution", () => { + process.env.CSDD_BIN = "/some/other/path"; + assert.equal(resolveCsddBin(), fake.bin, "later CSDD_BIN changes are ignored"); + process.env.CSDD_BIN = fake.bin; // restore for the runCsdd cases below +}); + +test("runCsdd captures stdout and a zero exit as ok", async () => { + const r = await runCsdd(["hello", "world"]); + assert.equal(r.ok, true); + assert.equal(r.exitCode, 0); + assert.equal(r.stdout.trim(), JSON.stringify(["hello", "world"])); + assert.equal(r.stderr, ""); +}); + +test("runCsdd reports exit 2 (validation) without rejecting", async () => { + const r = await runCsdd(["exit2"]); + assert.equal(r.ok, false); + assert.equal(r.exitCode, 2); + assert.match(r.stderr, /validation boom/); +}); + +test("runCsdd reports a generic non-zero exit", async () => { + const r = await runCsdd(["fail"]); + assert.equal(r.ok, false); + assert.equal(r.exitCode, 3); + assert.match(r.stderr, /kaboom/); +}); + +test("runCsdd handles a silent success", async () => { + const r = await runCsdd(["silent"]); + assert.equal(r.ok, true); + assert.equal(r.stdout, ""); + assert.equal(r.stderr, ""); +}); + +test("runCsdd keeps stderr alongside stdout on success", async () => { + const r = await runCsdd(["stderr-ok"]); + assert.equal(r.ok, true); + assert.match(r.stdout, /out line/); + assert.match(r.stderr, /warn line/); +}); + +test("runCsdd forces NO_COLOR and runs in the given cwd", async () => { + const cwd = realpathSync(tmpdir()); + const r = await runCsdd(["env"], cwd); + assert.match(r.stdout, /NO_COLOR=1/); + assert.match(r.stdout, new RegExp(`CWD=${cwd}(\\n|$)`)); +}); diff --git a/mcp-server/test/handler.test.ts b/mcp-server/test/handler.test.ts new file mode 100644 index 0000000..d9276cb --- /dev/null +++ b/mcp-server/test/handler.test.ts @@ -0,0 +1,58 @@ +import test, { before, after } from "node:test"; +import assert from "node:assert/strict"; + +import { makeHandler, type ToolDef } from "../dist/tooldef.js"; +import { makeFakeCsdd, type FakeCsdd } from "./helpers.ts"; + +// makeHandler wires toArgs -> runCsdd -> toMcpResult. We point CSDD_BIN at the +// stub so the full chain runs without a real csdd build. resolveCsddBin caches +// on first use, so every handler in this process resolves to the same stub. +let fake: FakeCsdd; + +before(() => { + fake = makeFakeCsdd(); + process.env.CSDD_BIN = fake.bin; +}); + +after(() => fake.cleanup()); + +function defWith(toArgs: ToolDef["toArgs"]): ToolDef { + return { + name: "csdd_test", + title: "test", + description: "test", + inputSchema: {}, + toArgs, + }; +} + +test("handler runs the built argv and echoes stdout back as text", async () => { + const handler = makeHandler(defWith((p) => ["echo", p.feature])); + const res = await handler({ feature: "albums" }); + assert.equal(res.isError, false); + assert.equal(res.content[0].text, JSON.stringify(["echo", "albums"])); +}); + +test("handler surfaces a validation failure (exit 2) as an MCP error", async () => { + const handler = makeHandler(defWith(() => ["exit2"])); + const res = await handler({}); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /validation failed \(exit 2\)/); +}); + +test("handler tolerates being invoked with no params object", async () => { + const handler = makeHandler(defWith(() => ["silent"])); + const res = await handler(undefined as unknown as Record); + assert.equal(res.isError, false); + assert.equal(res.content[0].text, "(ok, no output)"); +}); + +test("handler runs csdd with params.root as the working directory", async () => { + const handler = makeHandler(defWith(() => ["env"])); + // Stub's "env" mode prints its cwd; the temp dir holding the stub is a real + // directory we can assert against. + const root = fake.bin.replace(/\/[^/]+$/, ""); + const res = await handler({ root }); + assert.match(res.content[0].text, new RegExp(`CWD=${root}(\\n|$)`)); + assert.match(res.content[0].text, /NO_COLOR=1/); +}); diff --git a/mcp-server/test/helpers.ts b/mcp-server/test/helpers.ts new file mode 100644 index 0000000..ee0c6ce --- /dev/null +++ b/mcp-server/test/helpers.ts @@ -0,0 +1,57 @@ +// Test helpers. The MCP server shells out to a `csdd` binary; rather than +// depend on a real build, these tests point CSDD_BIN at a tiny Node stub that +// mimics the bits of csdd behaviour the server cares about (exit codes, +// stdout/stderr, and that NO_COLOR / cwd are passed through). +import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// The stub dispatches on its first argument so a single cached binary can cover +// every runCsdd scenario: +// exit2 -> stderr + exit 2 (validation failure) +// fail -> stderr + exit 3 (generic failure) +// silent -> exit 0, no output +// stderr-ok -> stdout + stderr, exit 0 (warning on success) +// env -> print NO_COLOR and cwd so the caller can assert them +// -> echo the full argv as JSON, exit 0 +const STUB_SRC = `#!/usr/bin/env node +const args = process.argv.slice(2); +const mode = args[0]; +switch (mode) { + case "exit2": + process.stderr.write("validation boom\\n"); + process.exit(2); + case "fail": + process.stderr.write("kaboom\\n"); + process.exit(3); + case "silent": + process.exit(0); + case "stderr-ok": + process.stdout.write("out line\\n"); + process.stderr.write("warn line\\n"); + process.exit(0); + case "env": + process.stdout.write("NO_COLOR=" + (process.env.NO_COLOR ?? "") + "\\n"); + process.stdout.write("CWD=" + process.cwd() + "\\n"); + process.exit(0); + default: + process.stdout.write(JSON.stringify(args) + "\\n"); + process.exit(0); +} +`; + +export interface FakeCsdd { + /** Absolute path to the executable stub. */ + bin: string; + /** Remove the temp directory holding the stub. */ + cleanup: () => void; +} + +/** Write an executable csdd stub to a fresh temp dir and return its path. */ +export function makeFakeCsdd(): FakeCsdd { + const dir = mkdtempSync(join(tmpdir(), "csdd-mcp-test-")); + const bin = join(dir, "fake-csdd.mjs"); + writeFileSync(bin, STUB_SRC); + chmodSync(bin, 0o755); + return { bin, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} diff --git a/mcp-server/test/tooldef.test.ts b/mcp-server/test/tooldef.test.ts new file mode 100644 index 0000000..7a58ced --- /dev/null +++ b/mcp-server/test/tooldef.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + flag, + bool, + multi, + rootArg, + toMcpResult, +} from "../dist/tooldef.js"; +import type { CsddResult } from "../dist/csdd.js"; + +// --- argv builders --------------------------------------------------------- + +test("flag emits --name value for non-empty values", () => { + assert.deepEqual(flag("--language", "pt"), ["--language", "pt"]); + assert.deepEqual(flag("--n", 0), ["--n", "0"], "0 is a real value, not empty"); + assert.deepEqual(flag("--n", false), ["--n", "false"]); +}); + +test("flag omits empty / null / undefined", () => { + assert.deepEqual(flag("--x", ""), []); + assert.deepEqual(flag("--x", null), []); + assert.deepEqual(flag("--x", undefined), []); +}); + +test("bool emits the flag only when truthy", () => { + assert.deepEqual(bool("--force", true), ["--force"]); + assert.deepEqual(bool("--force", false), []); + assert.deepEqual(bool("--force", undefined), []); +}); + +test("multi repeats --name per array item, stringifying", () => { + assert.deepEqual(multi("--tools", ["Read", "Grep"]), [ + "--tools", + "Read", + "--tools", + "Grep", + ]); + assert.deepEqual(multi("--arg", [1, 2]), ["--arg", "1", "--arg", "2"]); +}); + +test("multi yields nothing for non-arrays / empty", () => { + assert.deepEqual(multi("--x", undefined), []); + assert.deepEqual(multi("--x", "scalar"), []); + assert.deepEqual(multi("--x", []), []); +}); + +test("rootArg passes --root through only when set", () => { + assert.deepEqual(rootArg({ root: "/proj" }), ["--root", "/proj"]); + assert.deepEqual(rootArg({}), []); + assert.deepEqual(rootArg({ root: "" }), []); +}); + +// --- result formatting ----------------------------------------------------- + +const ok = (over: Partial = {}): CsddResult => ({ + ok: true, + exitCode: 0, + stdout: "", + stderr: "", + ...over, +}); + +test("toMcpResult: success with stdout", () => { + const r = toMcpResult(ok({ stdout: "all good\n" })); + assert.equal(r.isError, false); + assert.equal(r.content[0].text, "all good"); +}); + +test("toMcpResult: success with no output gets a friendly placeholder", () => { + const r = toMcpResult(ok()); + assert.equal(r.isError, false); + assert.equal(r.content[0].text, "(ok, no output)"); +}); + +test("toMcpResult: success keeps stderr unlabelled (warnings)", () => { + const r = toMcpResult(ok({ stdout: "done", stderr: "heads up" })); + assert.equal(r.isError, false); + assert.match(r.content[0].text, /done/); + assert.match(r.content[0].text, /heads up/); + assert.doesNotMatch(r.content[0].text, /\[stderr\]/); +}); + +test("toMcpResult: exit 2 is flagged as a validation failure", () => { + const r = toMcpResult(ok({ ok: false, exitCode: 2, stderr: "bad EARS" })); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /^csdd validation failed \(exit 2\):/); + assert.match(r.content[0].text, /\[stderr\] bad EARS/); +}); + +test("toMcpResult: generic non-zero exit", () => { + const r = toMcpResult(ok({ ok: false, exitCode: 3, stderr: "boom" })); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /^csdd failed \(exit 3\):/); + assert.match(r.content[0].text, /\[stderr\] boom/); +}); + +test("toMcpResult: failure with no output names the exit code", () => { + const r = toMcpResult(ok({ ok: false, exitCode: 1 })); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /csdd exited with code 1/); +}); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts new file mode 100644 index 0000000..54cc62e --- /dev/null +++ b/mcp-server/test/tools.test.ts @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { allTools, miscTools } from "../dist/registry.js"; +import type { ToolDef } from "../dist/tooldef.js"; + +const byName = new Map(allTools.map((t) => [t.name, t] as const)); + +function argv(name: string, params: Record): string[] { + const def = byName.get(name); + assert.ok(def, `tool ${name} is registered`); + return (def as ToolDef).toArgs(params); +} + +// Every tool, with a representative set of params, mapped to the exact csdd +// argv it should produce. Keeping this exhaustive is the point: the coverage +// test below fails if a registered tool is missing from this table. +const CASES: Array<[name: string, params: Record, expected: string[]]> = [ + // misc + ["csdd_version", {}, ["version"]], + + // init + ["csdd_init", {}, ["init"]], + ["csdd_init", { withBaseline: false }, ["init"]], + ["csdd_init", { withBaseline: true, root: "/p" }, ["init", "--with-baseline", "--root", "/p"]], + + // steering + ["csdd_steering_init", {}, ["steering", "init"]], + ["csdd_steering_init", { root: "/p" }, ["steering", "init", "--root", "/p"]], + [ + "csdd_steering_create", + { name: "obs", inclusion: "auto", description: "d" }, + ["steering", "create", "obs", "--inclusion", "auto", "--description", "d"], + ], + [ + "csdd_steering_create", + { name: "api", inclusion: "fileMatch", pattern: ["a", "b"], title: "API", force: true, root: "/p" }, + ["steering", "create", "api", "--inclusion", "fileMatch", "--pattern", "a", "--pattern", "b", "--title", "API", "--force", "--root", "/p"], + ], + ["csdd_steering_list", {}, ["steering", "list"]], + ["csdd_steering_list", { inclusion: "always", root: "/p" }, ["steering", "list", "--root", "/p", "--inclusion", "always"]], + ["csdd_steering_show", { name: "tech" }, ["steering", "show", "tech"]], + ["csdd_steering_delete", { name: "x" }, ["steering", "delete", "x"]], + ["csdd_steering_delete", { name: "x", force: true, root: "/p" }, ["steering", "delete", "x", "--force", "--root", "/p"]], + ["csdd_steering_validate", {}, ["steering", "validate"]], + ["csdd_steering_validate", { name: "tech", root: "/p" }, ["steering", "validate", "tech", "--root", "/p"]], + + // spec + ["csdd_spec_init", { feature: "f" }, ["spec", "init", "f"]], + ["csdd_spec_init", { feature: "f", language: "pt", root: "/p" }, ["spec", "init", "f", "--language", "pt", "--root", "/p"]], + ["csdd_spec_list", {}, ["spec", "list"]], + ["csdd_spec_show", { feature: "f" }, ["spec", "show", "f"]], + ["csdd_spec_status", { feature: "f" }, ["spec", "status", "f"]], + ["csdd_spec_generate", { feature: "f", artifact: "design" }, ["spec", "generate", "f", "--artifact", "design"]], + ["csdd_spec_generate", { feature: "f", artifact: "tasks", force: true, root: "/p" }, ["spec", "generate", "f", "--artifact", "tasks", "--force", "--root", "/p"]], + ["csdd_spec_approve", { feature: "f", phase: "requirements" }, ["spec", "approve", "f", "--phase", "requirements"]], + ["csdd_spec_approve", { feature: "f", phase: "design", force: true }, ["spec", "approve", "f", "--phase", "design", "--force"]], + ["csdd_spec_validate", { feature: "f" }, ["spec", "validate", "f"]], + ["csdd_spec_delete", { feature: "f", force: true }, ["spec", "delete", "f", "--force"]], + + // skill + ["csdd_skill_create", { name: "s", description: "d" }, ["skill", "create", "s", "--description", "d"]], + ["csdd_skill_create", { name: "s", description: "d", title: "S", root: "/p" }, ["skill", "create", "s", "--description", "d", "--title", "S", "--root", "/p"]], + ["csdd_skill_list", {}, ["skill", "list"]], + ["csdd_skill_show", { name: "s" }, ["skill", "show", "s"]], + ["csdd_skill_add_reference", { skill: "s", file: "r.md" }, ["skill", "add-reference", "s", "r.md"]], + ["csdd_skill_add_script", { skill: "s", file: "x.sh", root: "/p" }, ["skill", "add-script", "s", "x.sh", "--root", "/p"]], + ["csdd_skill_add_asset", { skill: "s", file: "a.png" }, ["skill", "add-asset", "s", "a.png"]], + ["csdd_skill_validate", { name: "s" }, ["skill", "validate", "s"]], + ["csdd_skill_delete", { name: "s", force: true }, ["skill", "delete", "s", "--force"]], + + // agent + ["csdd_agent_create", { name: "rev", description: "d" }, ["agent", "create", "rev", "--description", "d"]], + [ + "csdd_agent_create", + { name: "rev", description: "d", tools: ["Read", "Grep"], model: "opus", title: "Rev", force: true, root: "/p" }, + ["agent", "create", "rev", "--description", "d", "--tools", "Read", "--tools", "Grep", "--model", "opus", "--title", "Rev", "--force", "--root", "/p"], + ], + ["csdd_agent_list", {}, ["agent", "list"]], + ["csdd_agent_show", { name: "rev" }, ["agent", "show", "rev"]], + ["csdd_agent_delete", { name: "rev", force: true }, ["agent", "delete", "rev", "--force"]], + + // mcp + ["csdd_mcp_add", { name: "fs", command: "npx", arg: ["-y", "@mcp/fs", "."] }, ["mcp", "add", "fs", "--command", "npx", "--arg", "-y", "--arg", "@mcp/fs", "--arg", "."]], + ["csdd_mcp_add", { name: "linear", url: "https://x", type: "http" }, ["mcp", "add", "linear", "--url", "https://x", "--type", "http"]], + [ + "csdd_mcp_add", + { name: "db", command: "run", env: ["A=1", "B=2"], disabled: true, autoApprove: ["q"], force: true, root: "/p" }, + ["mcp", "add", "db", "--command", "run", "--env", "A=1", "--env", "B=2", "--disabled", "--auto-approve", "q", "--force", "--root", "/p"], + ], + ["csdd_mcp_list", {}, ["mcp", "list"]], + ["csdd_mcp_show", { name: "fs" }, ["mcp", "show", "fs"]], + ["csdd_mcp_remove", { name: "fs", force: true }, ["mcp", "remove", "fs", "--force"]], + ["csdd_mcp_enable", { name: "fs" }, ["mcp", "enable", "fs"]], + ["csdd_mcp_disable", { name: "fs", root: "/p" }, ["mcp", "disable", "fs", "--root", "/p"]], + ["csdd_mcp_validate", {}, ["mcp", "validate"]], +]; + +for (const [name, params, expected] of CASES) { + test(`toArgs ${name} ${JSON.stringify(params)}`, () => { + assert.deepEqual(argv(name, params), expected); + }); +} + +// --- registry invariants --------------------------------------------------- + +test("every registered tool is exercised by the CASES table", () => { + const covered = new Set(CASES.map(([name]) => name)); + const missing = allTools.map((t) => t.name).filter((n) => !covered.has(n)); + assert.deepEqual(missing, [], "tools missing a toArgs test case"); +}); + +test("tool names are unique", () => { + const names = allTools.map((t) => t.name); + assert.equal(new Set(names).size, names.length); +}); + +test("every tool has the required, well-formed fields", () => { + for (const t of allTools) { + assert.match(t.name, /^csdd(_[a-z]+)+$|^csdd_version$|^csdd_init$/, `name shape: ${t.name}`); + assert.ok(t.title && t.title.length > 0, `${t.name} has a title`); + assert.ok(t.description && t.description.length > 0, `${t.name} has a description`); + assert.equal(typeof t.inputSchema, "object", `${t.name} inputSchema is an object`); + assert.equal(typeof t.toArgs, "function", `${t.name} toArgs is a function`); + } +}); + +test("miscTools are included in allTools", () => { + for (const m of miscTools) { + assert.ok(byName.has(m.name), `${m.name} present in allTools`); + } +}); + +test("the first argv token is always a known csdd resource/command", () => { + const roots = new Set(["version", "init", "steering", "spec", "skill", "agent", "mcp"]); + for (const t of allTools) { + // Call with empty params; we only inspect the leading command token, which + // never depends on user input. + const head = t.toArgs({})[0]; + assert.ok(roots.has(head), `${t.name} -> ${head}`); + } +}); diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 0000000..42561d4 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} From c8f6bb4b285d4588242e518947497d4756700328 Mon Sep 17 00:00:00 2001 From: prode Date: Sun, 31 May 2026 16:52:50 -0300 Subject: [PATCH 2/2] feat(mcp): focus the MCP server on the dev flow and wire it into init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim the csdd MCP server to the iterative development-flow resources (steering, spec, skill, agent) plus a version diagnostic — 27 tools. Remove the setup/management tools (init, mcp; export was already gone): those stay CLI-only, one-time human operations rather than agent-loop tools. - csdd init now registers the `csdd` MCP server in .mcp.json by default (stdio via `npx -y @protonspy/csdd-mcp`); `--no-mcp` opts out. Idempotent. - csdd-mcp declares the per-platform csdd binaries as optionalDependencies, so `npx @protonspy/csdd-mcp` self-fetches the matching binary (zero-config). - Document the MCP-first dev flow in CLAUDE.md and csdd.md: prefer the typed `csdd_*` tools (more precise than hand-written commands); setup and management remain on the CLI. - Update READMEs and trim the tool tests; adjust two CLI tests that assumed an empty .mcp.json now that init registers a server. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 26 +++++ cmd/cmd_test.go | 48 ++++++++++ cmd/export_test.go | 2 + cmd/init.go | 37 ++++++++ cmd/mcp_test.go | 2 + .../templater/templates/root/CLAUDE.md.tmpl | 24 ++++- .../templater/templates/root/csdd.md.tmpl | 12 +++ mcp-server/README.md | 39 ++++---- mcp-server/package-lock.json | 72 ++++++++++++++ mcp-server/package.json | 7 ++ mcp-server/src/registry.ts | 13 ++- mcp-server/src/tools/init.ts | 19 ---- mcp-server/src/tools/mcp.ts | 95 ------------------- mcp-server/test/tools.test.ts | 24 +---- 14 files changed, 255 insertions(+), 165 deletions(-) delete mode 100644 mcp-server/src/tools/init.ts delete mode 100644 mcp-server/src/tools/mcp.ts diff --git a/README.md b/README.md index c80bc35..2b15b59 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,32 @@ Creating a steering automatically inserts `@.claude/steering/` into a mana --- +## 🔌 MCP server — drive csdd as native tools + +Prefer your agent to call **tools** over shelling out to a terminal? +[`@protonspy/csdd-mcp`](mcp-server/) is an MCP server (stdio) that exposes the csdd +**development flow** as tools — `csdd_spec_generate`, `csdd_steering_create`, +`csdd_spec_approve`, … **27 in total.** It wraps the same CLI, so the contract is +intact: phase gates still block, the validator still runs, and **exit 2 surfaces +as a distinct "validation failed" result** the agent can branch on. Typed +parameters (enums for `artifact`/`phase`/`inclusion`) mean the agent picks valid +inputs and the server builds the argv — more precise than hand-written commands. + +`csdd init` registers the server in `.mcp.json` for you (pass `--no-mcp` to skip): + +```bash +# already wired by `csdd init`; to add it to an existing workspace: +claude mcp add csdd -- npx -y @protonspy/csdd-mcp +``` + +- **Dev-flow only**, grouped by resource (steering · spec · skill · agent), plus `csdd_version`. **Setup and config management stay on the CLI** — `init`, `mcp`, and `export` are one-time human operations, not agent-loop tools. +- **Same binary, same rules.** The server just builds the argv and runs `csdd` headlessly (`NO_COLOR`, no TTY) — no logic of its own, so the CLI stays the single source of truth. +- **Zero-config binary** via `npx` (the matching prebuilt `csdd` is an `optionalDependency`); override with `CSDD_BIN`. + +Full tool reference and configuration: [`mcp-server/README.md`](mcp-server/README.md). + +--- + ## Interop — export to Kiro / Codex `csdd` is Claude Code-native, but the SDD artifacts aren't locked in. `csdd export` diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index c124f24..361162d 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -190,6 +190,54 @@ func TestInitWithoutBaseline(t *testing.T) { } } +func TestInitRegistersMCPServer(t *testing.T) { + dir := t.TempDir() + code, out, _ := run(t, "init", "--root", dir) + if code != 0 { + t.Fatalf("init failed: %d", code) + } + if !strings.Contains(out, "registered the csdd MCP server") { + t.Errorf("init should report registering the csdd MCP server: %s", out) + } + cfg, err := loadMCP(filepath.Join(dir, ".mcp.json")) + if err != nil { + t.Fatalf("load .mcp.json: %v", err) + } + srv, ok := cfg.MCPServers[csddMCPServerName] + if !ok { + t.Fatalf("csdd MCP server not registered: %+v", cfg.MCPServers) + } + if srv.Command != "npx" || strings.Join(srv.Args, " ") != "-y @protonspy/csdd-mcp" { + t.Errorf("unexpected server entry: command=%q args=%v", srv.Command, srv.Args) + } + if srv.URL != "" || srv.Disabled || len(srv.AutoApprove) > 0 { + t.Errorf("expected a plain enabled stdio server, got %+v", srv) + } + // Idempotent: a second init must not re-register (and so not re-announce it). + _, out2, _ := run(t, "init", "--root", dir) + if strings.Contains(out2, "registered the csdd MCP server") { + t.Errorf("second init should not re-register the MCP server: %s", out2) + } +} + +func TestInitNoMCP(t *testing.T) { + dir := t.TempDir() + code, out, _ := run(t, "init", "--root", dir, "--no-mcp") + if code != 0 { + t.Fatalf("init failed: %d", code) + } + if strings.Contains(out, "registered the csdd MCP server") { + t.Errorf("--no-mcp should not register the server: %s", out) + } + cfg, err := loadMCP(filepath.Join(dir, ".mcp.json")) + if err != nil { + t.Fatalf("load .mcp.json: %v", err) + } + if _, ok := cfg.MCPServers[csddMCPServerName]; ok { + t.Errorf("--no-mcp should leave .mcp.json without the csdd server: %+v", cfg.MCPServers) + } +} + // ---------- steering ---------- func TestSteeringInit(t *testing.T) { diff --git a/cmd/export_test.go b/cmd/export_test.go index 465b2b9..4a12705 100644 --- a/cmd/export_test.go +++ b/cmd/export_test.go @@ -124,6 +124,8 @@ func TestExportCodex(t *testing.T) { func TestExportCodexNoMCP(t *testing.T) { dir := freshWorkspace(t) + // init registers the csdd server by default; remove it to exercise the no-server path. + _, _, _ = run(t, "mcp", "remove", csddMCPServerName, "--force", "--root", dir) code, out, errOut := run(t, "export", "codex", "--root", dir) if code != 0 { t.Fatalf("export codex failed: %s", errOut) diff --git a/cmd/init.go b/cmd/init.go index 2efec27..7660730 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -18,8 +18,10 @@ func runInit(args []string, templates embed.FS) int { fs := flag.NewFlagSet("init", flag.ContinueOnError) var root string var withBaseline bool + var noMCP bool fs.StringVar(&root, "root", "", "Target directory (default: cwd).") fs.BoolVar(&withBaseline, "with-baseline", false, "Also scaffold product.md, tech.md, structure.md.") + fs.BoolVar(&noMCP, "no-mcp", false, "Do not register the csdd MCP server in .mcp.json.") if err := fs.Parse(args); err != nil { return failOnFlagParse(err) } @@ -47,6 +49,13 @@ func runInit(args []string, templates embed.FS) int { render.OK("Initialized Claude Code workspace at " + root) render.Info("directories created: " + intStr(created.dirs)) render.Info("files created: " + intStr(created.files)) + if !noMCP { + if added, err := ensureCsddMCPServer(root); err != nil { + render.Warn("could not register csdd MCP server: " + err.Error()) + } else if added { + render.Info("registered the csdd MCP server (drive the dev flow as tools) in " + workspace.Relative(root, paths.MCP(root))) + } + } offerGitignore(root) render.Info("Enable the pre-push test gate: `git config core.hooksPath .githooks`") if !withBaseline { @@ -78,6 +87,34 @@ func offerGitignore(root string) { } } +// csddMCPServerName is the key under which `csdd init` registers the csdd MCP +// server in .mcp.json. +const csddMCPServerName = "csdd" + +// ensureCsddMCPServer registers the csdd MCP server (a stdio server launched via +// npx) in .mcp.json, unless an entry of that name already exists. The npm +// package resolves the matching prebuilt csdd binary through its +// optionalDependencies, so the entry is portable across machines — no absolute +// paths. Idempotent; returns whether a new entry was written. +func ensureCsddMCPServer(root string) (bool, error) { + path := paths.MCP(root) + cfg, err := loadMCP(path) + if err != nil { + return false, err + } + if _, exists := cfg.MCPServers[csddMCPServerName]; exists { + return false, nil + } + cfg.MCPServers[csddMCPServerName] = MCPServer{ + Command: "npx", + Args: []string{"-y", "@protonspy/csdd-mcp"}, + } + if err := saveMCP(path, cfg); err != nil { + return false, err + } + return true, nil +} + type initCounts struct { dirs, files int } diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go index 28516e8..b5c7a8d 100644 --- a/cmd/mcp_test.go +++ b/cmd/mcp_test.go @@ -102,6 +102,8 @@ func TestMCPListAndShow(t *testing.T) { func TestMCPListEmpty(t *testing.T) { dir := freshWorkspace(t) + // init registers the csdd server by default; remove it to exercise the empty state. + _, _, _ = run(t, "mcp", "remove", csddMCPServerName, "--force", "--root", dir) code, out, _ := run(t, "mcp", "list", "--root", dir) if code != 0 || !strings.Contains(out, "no mcp servers") { t.Errorf("empty list should report none:\n%s", out) diff --git a/internal/templater/templates/root/CLAUDE.md.tmpl b/internal/templater/templates/root/CLAUDE.md.tmpl index c88b8df..c688b29 100644 --- a/internal/templater/templates/root/CLAUDE.md.tmpl +++ b/internal/templater/templates/root/CLAUDE.md.tmpl @@ -2,12 +2,34 @@ This repository follows a Spec-Driven Development (SDD) + Test-Driven Development (TDD) workflow, native to **Claude Code**. Steering, specs, skills, -and custom sub-agents are managed by the `csdd` CLI. +and custom sub-agents are managed by the `csdd` CLI — or, preferably, its MCP +tools (see *Driving csdd* below). **Read [`csdd.md`](./csdd.md) before producing any artifact.** It is the operational guide that tells you how to drive `csdd` without reading source. Everything below is a quick map; `csdd.md` is the contract. +## Driving csdd — prefer the MCP tools for the dev flow + +`csdd init` registers a **`csdd` MCP server** in `.mcp.json`. When it is +connected, drive the development flow with its **`csdd_*` tools** instead of +shelling out: + +| Resource | Tools | CLI fallback | +|---|---|---| +| steering | `csdd_steering_*` | `npx @protonspy/csdd steering …` | +| spec | `csdd_spec_*` | `npx @protonspy/csdd spec …` | +| skill | `csdd_skill_*` | `npx @protonspy/csdd skill …` | +| agent | `csdd_agent_*` | `npx @protonspy/csdd agent …` | + +Why prefer the tools: typed parameters (the `artifact`, `phase`, and `inclusion` +enums, required fields) stop you from passing invalid values, and the server +builds the exact argv — more precise than a hand-written command. The **phase +gates, validators, and exit codes are identical** either way. + +Use the **CLI** when the MCP server isn't connected, and always for **setup and +management** — `init`, `mcp`, and `export` are intentionally *not* tools. + ## Project memory (steering) Steering files are always-on project memory, loaded via the `@`-imports below. diff --git a/internal/templater/templates/root/csdd.md.tmpl b/internal/templater/templates/root/csdd.md.tmpl index 9c4111b..fe10ffa 100644 --- a/internal/templater/templates/root/csdd.md.tmpl +++ b/internal/templater/templates/root/csdd.md.tmpl @@ -47,6 +47,16 @@ The CLI enforces this mechanically. `npx @protonspy/csdd spec generate ## 2. Cheat sheet — every command +> **MCP tools (preferred when available).** If the `csdd` MCP server is connected +> (it is registered automatically by `csdd init`), call the development-flow +> commands as **tools** — `csdd_steering_*`, `csdd_spec_*`, `csdd_skill_*`, +> `csdd_agent_*` — instead of shelling out. Typed parameters keep the arguments +> valid and the server builds the argv, so the call is more precise than a +> hand-written command. **Same phase gates, same validation, same exit codes.** +> The commands below are the equivalent CLI surface and the source of truth for +> each command's semantics. **Setup and management — `init`, `mcp`, `export` — +> are CLI-only and are *not* exposed as tools.** + Run `npx @protonspy/csdd --help` for the top-level surface. The subcommands you will actually use: ```bash @@ -395,6 +405,8 @@ npx @protonspy/csdd agent create security-reviewer \ Model Context Protocol servers give the agent extra tools (filesystem access, issue trackers, search, internal APIs…). They are declared in `.mcp.json` and managed exclusively with `npx @protonspy/csdd mcp` — **do not hand-edit the JSON**; the CLI keeps it well-formed and reviewable. +> `csdd init` registers one server out of the box: **`csdd`** (stdio, `npx -y @protonspy/csdd-mcp`), which exposes the development-flow commands as the `csdd_*` tools described in §2. Pass `csdd init --no-mcp` to skip it. Manage it like any other server with `npx @protonspy/csdd mcp` (this command surface is **not** itself a tool). + ### Two transports | Transport | Use | Required flags | diff --git a/mcp-server/README.md b/mcp-server/README.md index 433ed18..62e2bb7 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -68,15 +68,16 @@ The server resolves `csdd` once, on first use, in this order (first hit wins): | # | Source | When it applies | |---|--------|-----------------| | 1 | **`$CSDD_BIN`** | Explicit absolute path. Always wins — use this if in doubt. | -| 2 | **Platform package** `@protonspy/csdd--` | When the matching prebuilt-binary package is installed alongside (e.g. co-installed with `@protonspy/csdd`). | +| 2 | **Platform package** `@protonspy/csdd--` | Declared as an `optionalDependency` of this package, so `npx`/`npm i` fetches the prebuilt binary for your OS/arch automatically — the zero-config path. | | 3 | **Sibling repo binary** (`../csdd`, `../../csdd`) | When running from a checkout of the csdd repo. | | 4 | **`csdd` on `$PATH`** | Last resort, resolved by the OS at spawn time. | If none resolve, calls fail with **exit `127`** and a message telling you to set `CSDD_BIN`, install `@protonspy/csdd`, or put `csdd` on your `PATH`. -> **Recommended setup for end users:** `npm install -g @protonspy/csdd` (puts -> `csdd` on `PATH`, satisfying #4), or set `CSDD_BIN` to an absolute path. +> **Zero-config:** running via `npx -y @protonspy/csdd-mcp` pulls the matching +> binary through #2 automatically — nothing to install. Set `CSDD_BIN` only to +> pin a specific build (e.g. a local dev binary). ### Environment @@ -102,7 +103,8 @@ Every tool returns a text result. The mapping from the `csdd` exit code is: ## Tool reference -**35 tools**, grouped by the resource they manage. Conventions: +**27 tools** covering the csdd **development flow**, grouped by resource. +Conventions: - Every tool accepts an optional **`root`** — the workspace root (the directory containing `.claude/`). Omit it to walk up from the server's working directory. @@ -110,12 +112,17 @@ Every tool returns a text result. The mapping from the `csdd` exit code is: deletes are refused and phase gates hold. - `?` marks an optional parameter; everything else is required. -### Workspace +> **Scope:** this server exposes only the iterative development-flow resources +> (steering · spec · skill · agent). **Workspace setup and config management are +> deliberately not tools** — `csdd init`, `csdd mcp …`, and `csdd export …` are +> one-time operations a human runs from the CLI, not part of the loop an agent +> drives. (In fact, `csdd init` is what registers *this* server.) + +### Diagnostic | Tool | Parameters | What it does | |------|------------|--------------| | `csdd_version` | — | Print the underlying `csdd` binary version (diagnostic / connectivity check). | -| `csdd_init` | `withBaseline?`, `root?` | Bootstrap a Claude Code workspace: `.claude/` layout, `CLAUDE.md`, `csdd.md`, `.mcp.json`, rules, shipped agents/skills/commands/hooks, guides. Idempotent. `withBaseline` also scaffolds the standard steering files and imports them into `CLAUDE.md`. | ### 🧭 steering — project memory (`.claude/steering/*.md`) @@ -172,28 +179,18 @@ matches the context). | `csdd_agent_show` | `name`, `root?` | Print an agent file. | | `csdd_agent_delete` | `name`, `force?`, `root?` | Delete `.claude/agents/.md` (`force` required). | -### 🔌 mcp — MCP servers in `.mcp.json` - -| Tool | Parameters | What it does | -|------|------------|--------------| -| `csdd_mcp_add` | `name`, `command?`, `arg?[]`, `url?`, `type?`, `env?[]`, `disabled?`, `autoApprove?[]`, `force?`, `root?` | Add a server. Provide **either** `command` (+`arg`) for stdio **or** `url` (+`type`) for remote — never both. `type` ∈ `sse · http` (default `http`). `env`/`autoApprove` are `KEY=VALUE` / tool-name lists. `force` replaces an existing entry. | -| `csdd_mcp_list` | `root?` | List servers with type, state, and endpoint. | -| `csdd_mcp_show` | `name`, `root?` | Print a server's config as pretty JSON. | -| `csdd_mcp_remove` | `name`, `force?`, `root?` | Remove a server (`force` required). | -| `csdd_mcp_enable` | `name`, `root?` | Enable a server (`disabled = false`). | -| `csdd_mcp_disable` | `name`, `root?` | Disable a server (`disabled = true`). | -| `csdd_mcp_validate` | `root?` | Validate `.mcp.json` for schema errors. Exit 2 on issues. | - -> Avoid `autoApprove` — it breaks least-privilege by auto-approving tool calls. +> **Not here:** managing the `.mcp.json` servers themselves (`csdd mcp add/list/ +> remove/enable/disable/validate`) stays on the CLI — same for `csdd init` and +> `csdd export`. Keeping setup off the tool surface is intentional (see Scope). --- ## A typical agent flow -Tool calls that take one feature from idea to ready-to-implement: +Setup is a one-time CLI step (`npx @protonspy/csdd init --with-baseline`, which +also registers this server). From there the agent drives the feature with tools: ```jsonc -csdd_init { "withBaseline": true } csdd_spec_init { "feature": "photo-albums" } csdd_spec_generate { "feature": "photo-albums", "artifact": "requirements" } diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 1423205..351afca 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -21,6 +21,13 @@ }, "engines": { "node": ">=18" + }, + "optionalDependencies": { + "@protonspy/csdd-darwin-arm64": "^0.1.0", + "@protonspy/csdd-darwin-x64": "^0.1.0", + "@protonspy/csdd-linux-arm64": "^0.1.0", + "@protonspy/csdd-linux-x64": "^0.1.0", + "@protonspy/csdd-win32-x64": "^0.1.0" } }, "node_modules/@hono/node-server": { @@ -75,6 +82,71 @@ } } }, + "node_modules/@protonspy/csdd-darwin-arm64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-darwin-arm64/-/csdd-darwin-arm64-0.1.1.tgz", + "integrity": "sha512-l6EPBMnP578H+ukzoHI4w12e7c3PnQsHoTm7HuFtk+pKbTWvPgaSc1OVubOFCZFsELypmUyS6uHKdyBbee0nWg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@protonspy/csdd-darwin-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-darwin-x64/-/csdd-darwin-x64-0.1.1.tgz", + "integrity": "sha512-oaMz9pUYno4B9OzDgbSwMBaH9XnJitXQhv29uQith3vwEnwTkNEFKgWuucRR3FXDW2GQFNVY8lSfmPYgrcu3Ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@protonspy/csdd-linux-arm64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-linux-arm64/-/csdd-linux-arm64-0.1.1.tgz", + "integrity": "sha512-eS9hnTDoUSvX++FBQYxwWGsp2vBfwQv5me1hsNAj8pOQha0ylwG9AFhqPgsOAUcvMkL8fmKSA1DWQwpN3YlVpw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@protonspy/csdd-linux-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-linux-x64/-/csdd-linux-x64-0.1.1.tgz", + "integrity": "sha512-TQk4JY/s9vRqoM4l3JJhyorQCDqxfcdycuaruDbX7E1UR3Em6eqKHHiBRX2PvXXzSY4wyGuhDR1B7GwABfReZQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@protonspy/csdd-win32-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@protonspy/csdd-win32-x64/-/csdd-win32-x64-0.1.1.tgz", + "integrity": "sha512-lOWyTZ3IPBV/NRgvKc5c1BZ2lr3DO27TbSkop8Jn7wQrTx8TNjGTudB7Fgrn+Tl9wYz6OhtDaypCXbqcpYTMJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index bc30d21..51c39f5 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -34,6 +34,13 @@ "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.23.8" }, + "optionalDependencies": { + "@protonspy/csdd-linux-x64": "^0.1.0", + "@protonspy/csdd-linux-arm64": "^0.1.0", + "@protonspy/csdd-darwin-x64": "^0.1.0", + "@protonspy/csdd-darwin-arm64": "^0.1.0", + "@protonspy/csdd-win32-x64": "^0.1.0" + }, "devDependencies": { "@types/node": "^20.14.0", "typescript": "^5.5.0" diff --git a/mcp-server/src/registry.ts b/mcp-server/src/registry.ts index 3c466f9..4b5d8e6 100644 --- a/mcp-server/src/registry.ts +++ b/mcp-server/src/registry.ts @@ -1,13 +1,14 @@ -// The full set of MCP tools this server exposes — one per csdd subcommand, -// plus a diagnostic version tool. Kept separate from index.ts so it can be -// imported (e.g. by tests) without booting the stdio transport. +// The MCP tools this server exposes — the csdd *development-flow* resources +// (steering, spec, skill, agent), plus a diagnostic version tool. Workspace +// setup and config management (init, mcp, export) are intentionally NOT exposed: +// those are one-time CLI operations a human runs, not part of the iterative +// loop the agent drives. Kept separate from index.ts so it can be imported +// (e.g. by tests) without booting the stdio transport. import { type ToolDef } from "./tooldef.js"; -import { initTools } from "./tools/init.js"; import { steeringTools } from "./tools/steering.js"; import { specTools } from "./tools/spec.js"; import { skillTools } from "./tools/skill.js"; import { agentTools } from "./tools/agent.js"; -import { mcpTools } from "./tools/mcp.js"; export const miscTools: ToolDef[] = [ { @@ -21,10 +22,8 @@ export const miscTools: ToolDef[] = [ export const allTools: ToolDef[] = [ ...miscTools, - ...initTools, ...steeringTools, ...specTools, ...skillTools, ...agentTools, - ...mcpTools, ]; diff --git a/mcp-server/src/tools/init.ts b/mcp-server/src/tools/init.ts deleted file mode 100644 index fb4629a..0000000 --- a/mcp-server/src/tools/init.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "zod"; -import { bool, rootArg, rootField, type ToolDef } from "../tooldef.js"; - -export const initTools: ToolDef[] = [ - { - name: "csdd_init", - title: "Init workspace", - description: - "Bootstrap a Claude Code workspace: create .claude/ layout (steering, specs, skills, agents, commands, hooks), CLAUDE.md, csdd.md, .mcp.json, rules, shipped agents/skills/commands/hooks, and guides. Idempotent. Use withBaseline to also scaffold product/tech/structure/security/testing/api-conventions steering files.", - inputSchema: { - withBaseline: z - .boolean() - .optional() - .describe("Also scaffold the standard steering files (product, tech, structure, security, testing, api-conventions) and import them into CLAUDE.md."), - root: rootField, - }, - toArgs: (p) => ["init", ...bool("--with-baseline", p.withBaseline), ...rootArg(p)], - }, -]; diff --git a/mcp-server/src/tools/mcp.ts b/mcp-server/src/tools/mcp.ts deleted file mode 100644 index 4e5960d..0000000 --- a/mcp-server/src/tools/mcp.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { z } from "zod"; -import { - bool, - flag, - forceField, - multi, - rootArg, - rootField, - type ToolDef, -} from "../tooldef.js"; - -const serverName = z.string().describe("MCP server name (key in .mcp.json)."); - -export const mcpTools: ToolDef[] = [ - { - name: "csdd_mcp_add", - title: "MCP add server", - description: - "Add an MCP server to .mcp.json. Provide either command (+arg) for stdio, or url (+type) for a remote server. Use force to replace an existing entry.", - inputSchema: { - name: serverName, - command: z.string().optional().describe("Executable for a stdio server."), - arg: z.array(z.string()).optional().describe("Arguments for the command. Repeatable."), - url: z.string().optional().describe("Endpoint for a remote server (mutually exclusive with command)."), - type: z.enum(["sse", "http"]).optional().describe("Remote transport (default http when url set)."), - env: z - .array(z.string()) - .optional() - .describe("Environment variables as KEY=VALUE. Repeatable."), - disabled: z.boolean().optional().describe("Add in disabled state."), - autoApprove: z - .array(z.string()) - .optional() - .describe("Tools to auto-approve (avoid; breaks least privilege). Repeatable."), - force: forceField, - root: rootField, - }, - toArgs: (p) => [ - "mcp", - "add", - p.name, - ...flag("--command", p.command), - ...multi("--arg", p.arg), - ...flag("--url", p.url), - ...flag("--type", p.type), - ...multi("--env", p.env), - ...bool("--disabled", p.disabled), - ...multi("--auto-approve", p.autoApprove), - ...bool("--force", p.force), - ...rootArg(p), - ], - }, - { - name: "csdd_mcp_list", - title: "MCP list", - description: "List MCP servers with type, state, and endpoint.", - inputSchema: { root: rootField }, - toArgs: (p) => ["mcp", "list", ...rootArg(p)], - }, - { - name: "csdd_mcp_show", - title: "MCP show", - description: "Print a server's config as pretty JSON.", - inputSchema: { name: serverName, root: rootField }, - toArgs: (p) => ["mcp", "show", p.name, ...rootArg(p)], - }, - { - name: "csdd_mcp_remove", - title: "MCP remove", - description: "Remove a server from .mcp.json. Requires force.", - inputSchema: { name: serverName, force: forceField, root: rootField }, - toArgs: (p) => ["mcp", "remove", p.name, ...bool("--force", p.force), ...rootArg(p)], - }, - { - name: "csdd_mcp_enable", - title: "MCP enable", - description: "Enable a server (disabled=false).", - inputSchema: { name: serverName, root: rootField }, - toArgs: (p) => ["mcp", "enable", p.name, ...rootArg(p)], - }, - { - name: "csdd_mcp_disable", - title: "MCP disable", - description: "Disable a server (disabled=true).", - inputSchema: { name: serverName, root: rootField }, - toArgs: (p) => ["mcp", "disable", p.name, ...rootArg(p)], - }, - { - name: "csdd_mcp_validate", - title: "MCP validate", - description: "Validate .mcp.json for schema errors. Exit 2 on issues.", - inputSchema: { root: rootField }, - toArgs: (p) => ["mcp", "validate", ...rootArg(p)], - }, -]; diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 54cc62e..f2bb511 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -19,11 +19,6 @@ const CASES: Array<[name: string, params: Record, expected: str // misc ["csdd_version", {}, ["version"]], - // init - ["csdd_init", {}, ["init"]], - ["csdd_init", { withBaseline: false }, ["init"]], - ["csdd_init", { withBaseline: true, root: "/p" }, ["init", "--with-baseline", "--root", "/p"]], - // steering ["csdd_steering_init", {}, ["steering", "init"]], ["csdd_steering_init", { root: "/p" }, ["steering", "init", "--root", "/p"]], @@ -79,21 +74,6 @@ const CASES: Array<[name: string, params: Record, expected: str ["csdd_agent_list", {}, ["agent", "list"]], ["csdd_agent_show", { name: "rev" }, ["agent", "show", "rev"]], ["csdd_agent_delete", { name: "rev", force: true }, ["agent", "delete", "rev", "--force"]], - - // mcp - ["csdd_mcp_add", { name: "fs", command: "npx", arg: ["-y", "@mcp/fs", "."] }, ["mcp", "add", "fs", "--command", "npx", "--arg", "-y", "--arg", "@mcp/fs", "--arg", "."]], - ["csdd_mcp_add", { name: "linear", url: "https://x", type: "http" }, ["mcp", "add", "linear", "--url", "https://x", "--type", "http"]], - [ - "csdd_mcp_add", - { name: "db", command: "run", env: ["A=1", "B=2"], disabled: true, autoApprove: ["q"], force: true, root: "/p" }, - ["mcp", "add", "db", "--command", "run", "--env", "A=1", "--env", "B=2", "--disabled", "--auto-approve", "q", "--force", "--root", "/p"], - ], - ["csdd_mcp_list", {}, ["mcp", "list"]], - ["csdd_mcp_show", { name: "fs" }, ["mcp", "show", "fs"]], - ["csdd_mcp_remove", { name: "fs", force: true }, ["mcp", "remove", "fs", "--force"]], - ["csdd_mcp_enable", { name: "fs" }, ["mcp", "enable", "fs"]], - ["csdd_mcp_disable", { name: "fs", root: "/p" }, ["mcp", "disable", "fs", "--root", "/p"]], - ["csdd_mcp_validate", {}, ["mcp", "validate"]], ]; for (const [name, params, expected] of CASES) { @@ -117,7 +97,7 @@ test("tool names are unique", () => { test("every tool has the required, well-formed fields", () => { for (const t of allTools) { - assert.match(t.name, /^csdd(_[a-z]+)+$|^csdd_version$|^csdd_init$/, `name shape: ${t.name}`); + assert.match(t.name, /^csdd(_[a-z]+)+$/, `name shape: ${t.name}`); assert.ok(t.title && t.title.length > 0, `${t.name} has a title`); assert.ok(t.description && t.description.length > 0, `${t.name} has a description`); assert.equal(typeof t.inputSchema, "object", `${t.name} inputSchema is an object`); @@ -132,7 +112,7 @@ test("miscTools are included in allTools", () => { }); test("the first argv token is always a known csdd resource/command", () => { - const roots = new Set(["version", "init", "steering", "spec", "skill", "agent", "mcp"]); + const roots = new Set(["version", "steering", "spec", "skill", "agent"]); for (const t of allTools) { // Call with empty params; we only inspect the leading command token, which // never depends on user input.