Skip to content

Commit b1e3d58

Browse files
feat(cli): MCP parity verbs for shell-only consumers (#160)
* feat(cli): add MCP parity verbs for shell-only consumers Wire CLI commands that call the same tool-handlers and resource builders as MCP/HTTP so consumers without a transport still get identical JSON. * fix(cli): whitelist parity verbs and tighten query batch validation validateIndexModeArgs blocked trace/explore/node/file/schema before dispatch. Validate query batch input with queryBatchArgsSchema; add dispatch smoke tests. * fix(cli): align query batch JSON validation with MCP schema Reject invalid group_by/changed_since in batch JSON instead of silently coercing to CLI defaults. Extend dispatch smoke tests and architecture docs. * test(cli): restore file --help dispatch smoke test * fix(cli): polish MCP parity wiring and close review gaps Route context through handleContext, dedupe resource builders, align composer validation with MCP zod schemas, emit JSON parse errors for always-JSON verbs, and sync cmd-mcp help plus agent-content docs. * fix(cli): compact JSON errors on file/schema runtime failures * fix(cli): close remaining parity gaps (symbols, errors, tests) Add codemap symbols resource twin, align file-not-indexed errors with MCP, add query batch --no-summary, and extend dispatch smoke tests. * test(cli): add parity e2e tests and sync remaining docs Golden-path subprocess tests on fixtures/minimal; glossary/bootstrap/mcp help and context-engine comments updated for symbols and --no-summary. * test(cli): tighten parity e2e and glossary wording * test(cli): complete parity e2e and context doc sync Add explore, node, and context --include-snippets e2e coverage; align architecture and glossary with handleContext and CLI --include-snippets. * chore(changeset): bump CLI parity release to patch Align with repo convention: additive CLI twins ship as patch, not minor.
1 parent 68799ef commit b1e3d58

26 files changed

Lines changed: 2137 additions & 177 deletions

.changeset/cli-mcp-parity.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
Add CLI twins for MCP query composers and resources so shell-only consumers get the same JSON payloads: `codemap query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, and `context --include-snippets`.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,10 @@ codemap rule # full codemap r
222222

223223
# MCP server (Model Context Protocol) — for agent hosts (Claude Code, Cursor, Codex, generic MCP clients)
224224
codemap mcp # JSON-RPC on stdio (17 tools; watcher default-ON)
225-
# Tools (17): query, query_batch (no CLI verb), query_recipe, audit, save_baseline,
225+
# Tools (17): query, query_batch, query_recipe, audit, save_baseline,
226226
# list_baselines, drop_baseline, context, validate, show, snippet, impact,
227227
# affected, trace, explore, node, apply
228-
# No CLI verb (MCP + HTTP): query_batch, trace, explore, node. Other tools mirror a CLI --json envelope.
228+
# CLI twins: query batch, trace, explore, node, file, schema, symbols, context --include-snippets (same JSON as MCP/HTTP).
229229
# Resources: codemap://schema, codemap://skill, codemap://rule, codemap://mcp-instructions (lazy-cached);
230230
# codemap://recipes, codemap://recipes/{id} (live read-per-call — recency fields stay fresh);
231231
# codemap://files/{path}, codemap://symbols/{name} (live read-per-call)

docs/architecture.md

Lines changed: 5 additions & 3 deletions
Large diffs are not rendered by default.

docs/glossary.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Codemap-managed `.gitignore` inside `<state-dir>/` (blacklist of generated artif
105105

106106
### `codemap context`
107107

108-
CLI subcommand emitting a JSON envelope (`ContextEnvelope`) with project metadata, top hubs (full bundled `fan-in` recipe limit), sample markers, recipe catalog, optional intent via `--for "<intent>"`, and non-compact **`start_here`** shortcuts (index summary, intent-ranked recipe cards, budget-capped hub leaders with export signatures). `--compact` drops `hubs`, `sample_markers`, and `start_here`, emitting minified JSON; default mode pretty-prints with 2-space indent. Whitespace-only `--for` values are rejected.
108+
CLI subcommand emitting a JSON envelope (`ContextEnvelope`) with project metadata, top hubs (full bundled `fan-in` recipe limit), sample markers, recipe catalog, optional intent via `--for "<intent>"`, and non-compact **`start_here`** shortcuts (index summary, intent-ranked recipe cards, budget-capped hub leaders with export signatures). `--include-snippets` adds optional one-line hub-leader snippets (no-op with `--compact`). `--compact` drops `hubs`, `sample_markers`, and `start_here`, emitting minified JSON; default mode pretty-prints with 2-space indent. Whitespace-only `--for` values are rejected.
109109

110110
### `--ci` (CLI flag)
111111

@@ -165,7 +165,7 @@ Column on the `files` table. Lowercase SHA-256 hex of file bytes computed by `sr
165165

166166
### `ContextEnvelope`
167167

168-
TS shape for the JSON emitted by `codemap context`. Stable contract; agents can key off field names. Non-compact payloads include **`start_here`**: inline **`index_summary`**, intent-ranked recipe cards (`tool: "query_recipe"`), and **`hub_leaders`** (budget-capped top fan-in files with exported-symbol signatures; optional **`snippet`** / **`stale`** / **`missing`** when MCP/HTTP `include_snippets: true`). Legacy **`hubs`** keeps the bundled `fan-in` recipe default limit; prefer **`hub_leaders`** for signatures. **`start_here.classified_as`** is `"default"` when no user intent was supplied.
168+
TS shape for the JSON emitted by `codemap context`. Stable contract; agents can key off field names. Non-compact payloads include **`start_here`**: inline **`index_summary`**, intent-ranked recipe cards (`tool: "query_recipe"`), and **`hub_leaders`** (budget-capped top fan-in files with exported-symbol signatures; optional **`snippet`** / **`stale`** / **`missing`** when CLI `--include-snippets` or MCP/HTTP `include_snippets: true`). Legacy **`hubs`** keeps the bundled `fan-in` recipe default limit; prefer **`hub_leaders`** for signatures. **`start_here.classified_as`** is `"default"` when no user intent was supplied.
169169

170170
### covering index
171171

@@ -353,11 +353,11 @@ Rust-based CSS parser (NAPI bindings). Codemap's `src/css-parser.ts` uses its vi
353353

354354
### `codemap mcp` / MCP server
355355

356-
Stdio MCP (Model Context Protocol) server exposing codemap's structural-query surface to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) as JSON-RPC tools — eliminates the bash round-trip on every agent invocation. **17 tools:** `query`, `query_batch` (no CLI verb), `query_recipe`, `audit`, `save_baseline`, `list_baselines`, `drop_baseline`, `context`, `validate`, `show`, `snippet`, `impact`, `affected`, `trace`, `explore`, `node`, `apply`. Subset via **`CODEMAP_MCP_TOOLS`** ([agents.md § MCP tool allowlist](./agents.md#mcp-tool-allowlist)). **Resources:** `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{path}`, `codemap://symbols/{name}`. Resource freshness is split by contract: schema / skill / rule / mcp-instructions are lazy-cached per server process; recipes, files, and symbols are live read-per-call so inline recency fields and index mutations under `--watch` don't freeze at first read. HTTP's `GET /resources/{encoded-uri}` uses the same resource handler. **Baseline tools** (`save_baseline`, `list_baselines`, `drop_baseline`) mirror `query --save-baseline` / `--baselines` / `--drop-baseline`. **`query_batch`**, **`trace`**, **`explore`**, and **`node`** have no CLI verb (MCP/HTTP composers only). Tool input/output keys are snake_case — Codemap's convention, matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. Output shape matches each tool's CLI `--json` payload where a CLI verb exists (no CLI verb: `query_batch`, `trace`, `explore`, `node`); MCP wraps payloads in `{content: [{type: "text", text: …}]}`. Bootstrap once at server boot; tool handlers (in `application/tool-handlers.ts`) and resource handlers (in `application/resource-handlers.ts`) are pure transport-agnostic — the same handlers serve `codemap serve` (HTTP) via `POST /tool/{name}` and `GET /resources/{encoded-uri}`. **Session lifecycle:** exits on client disconnect (stdin EOF, stdout broken pipe, parent process exit, SIGINT/SIGTERM) via `session-lifecycle.ts`; **no idle timeout** — the process stays up while the pipe is open even without tool calls (see [§ Session lifecycle](./architecture.md#cli-usage)). With `--watch`, the watcher starts before connect and drains on exit. Implementation: `src/cli/cmd-mcp.ts` (CLI shell) + `src/application/mcp-server.ts` (engine). See [`architecture.md` § MCP wiring](./architecture.md#cli-usage).
356+
Stdio MCP (Model Context Protocol) server exposing codemap's structural-query surface to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) as JSON-RPC tools — eliminates the bash round-trip on every agent invocation. **17 tools:** `query`, `query_batch`, `query_recipe`, `audit`, `save_baseline`, `list_baselines`, `drop_baseline`, `context`, `validate`, `show`, `snippet`, `impact`, `affected`, `trace`, `explore`, `node`, `apply`. Each has a CLI twin with the same JSON payload except transport-only MCP resources (`codemap://mcp-instructions`, initialize `instructions`). Subset via **`CODEMAP_MCP_TOOLS`** ([agents.md § MCP tool allowlist](./agents.md#mcp-tool-allowlist)). **Resources:** `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{path}`, `codemap://symbols/{name}`. Resource freshness is split by contract: schema / skill / rule / mcp-instructions are lazy-cached per server process; recipes, files, and symbols are live read-per-call so inline recency fields and index mutations under `--watch` don't freeze at first read. HTTP's `GET /resources/{encoded-uri}` uses the same resource handler. **Baseline tools** (`save_baseline`, `list_baselines`, `drop_baseline`) mirror `query --save-baseline` / `--baselines` / `--drop-baseline`. **CLI twins:** `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context --include-snippets`. Tool input/output keys are snake_case on MCP/HTTP — Codemap's convention; CLI stays kebab. Output shape matches each tool's CLI JSON payload; MCP wraps payloads in `{content: [{type: "text", text: …}]}`. Bootstrap once at server boot; tool handlers (in `application/tool-handlers.ts`) and resource handlers (in `application/resource-handlers.ts`) are pure transport-agnostic — the same handlers serve `codemap serve` (HTTP) via `POST /tool/{name}` and `GET /resources/{encoded-uri}`. **Session lifecycle:** exits on client disconnect (stdin EOF, stdout broken pipe, parent process exit, SIGINT/SIGTERM) via `session-lifecycle.ts`; **no idle timeout** — the process stays up while the pipe is open even without tool calls (see [§ Session lifecycle](./architecture.md#cli-usage)). With `--watch`, the watcher starts before connect and drains on exit. Implementation: `src/cli/cmd-mcp.ts` (CLI shell) + `src/application/mcp-server.ts` (engine). See [`architecture.md` § MCP wiring](./architecture.md#cli-usage).
357357

358-
### `query_batch` (no CLI verb; MCP + HTTP)
358+
### `query_batch`
359359

360-
MCP/HTTP tool with no CLI counterpart — runs N read-only SQL statements in one round-trip. Items are `string | {sql, summary?, changed_since?, group_by?}`: bare strings inherit batch-wide flag defaults; object form overrides on a per-key basis. Output is an N-element array; per-element shape mirrors single-`query`'s output for that statement's effective flag set. Per-statement errors are isolated (failed statement returns `{error}` in its slot; siblings still execute). Distinct from making `query` accept `;`-delimited batches (rejected — would need a SQL tokenizer and would diverge `query`'s output shape from its CLI counterpart). SQL-only (no `recipe` polymorphism); `query_recipe_batch` is an additive future change if a real consumer asks.
360+
MCP/HTTP tool and **`codemap query batch`** (`--stdin` / `--file`, optional `--summary` / `--no-summary`, `--changed-since`, `--group-by`, `--compact`) — runs N read-only SQL statements in one round-trip. Items are `string | {sql, summary?, changed_since?, group_by?}`: bare strings inherit batch-wide flag defaults; object form overrides on a per-key basis. Output is an N-element array; per-element shape mirrors single-`query`'s output for that statement's effective flag set. Per-statement errors are isolated (failed statement returns `{error}` in its slot; siblings still execute). Distinct from making `query` accept `;`-delimited batches (rejected — would need a SQL tokenizer and would diverge `query`'s output shape from its CLI counterpart). SQL-only (no `recipe` polymorphism); `query_recipe_batch` is an additive future change if a real consumer asks.
361361

362362
### `file_metrics` (table)
363363

@@ -536,7 +536,7 @@ Long-running process that subscribes to filesystem changes via [chokidar v5](htt
536536

537537
### `codemap serve` / HTTP server
538538

539-
Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token <secret>` requires `Authorization: Bearer <secret>` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope (baseline save/compare is separate tools — not on MCP/HTTP `query` / `query_recipe`); other tools match their CLI `--json` payloads; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: <semver>`. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage).
539+
Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token <secret>` requires `Authorization: Bearer <secret>` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope (baseline save/compare is separate tools — not on MCP/HTTP `query` / `query_recipe`); parity twins (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context`) always emit JSON on CLI without `--json`; other tools match their CLI `--json` payloads when that flag is set; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: <semver>`. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage).
540540

541541
### SARIF
542542

src/application/context-engine.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export interface ContextStartHere {
135135
export interface BuildContextEnvelopeOpts {
136136
compact: boolean;
137137
intent: string | null;
138-
/** MCP/HTTP only — one-line export previews on hub leader signatures. */
138+
/** One-line export previews on hub leader signatures (CLI `--include-snippets`, MCP/HTTP `include_snippets`). */
139139
include_snippets?: boolean;
140140
}
141141

@@ -221,7 +221,7 @@ export function normalizeContextIntent(
221221
/**
222222
* Build the envelope from an open DB. Reads SQLite + optional git for
223223
* `index_freshness`; `include_snippets: true` adds bounded disk reads for hub
224-
* leader one-liners (MCP/HTTP only — ignored when `compact: true`).
224+
* leader one-liners (CLI `--include-snippets` / MCP `include_snippets` — ignored when `compact: true`).
225225
*/
226226
export function buildContextEnvelope(
227227
db: CodemapDatabase,

src/application/mcp-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ import {
8282
* MCP server engine — owns the tool / resource registry. CLI shell
8383
* (`src/cli/cmd-mcp.ts`) handles argv + lifecycle only; this module is
8484
* the thin wrapper around `@modelcontextprotocol/sdk` that registers
85-
* 17 JSON-RPC tools (CLI mirrors plus no-CLI-verb helpers on MCP/HTTP) and MCP resources
85+
* 17 JSON-RPC tools (CLI mirrors plus MCP/HTTP resource URIs) and MCP resources
8686
* (static + templates). Tool bodies are pure handlers in
8787
* `application/tool-handlers.ts` — same handlers `codemap serve` (HTTP)
8888
* dispatches. See [`docs/architecture.md` § MCP wiring].

src/application/resource-handlers.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { closeDb, createTables, openDb } from "../db";
88
import { initCodemap } from "../runtime";
99
import {
1010
_resetResourceCachesForTests,
11+
buildFileRollup,
12+
buildSchemaCatalog,
13+
buildSymbolLookup,
1114
listResources,
1215
readResource,
1316
} from "./resource-handlers";
@@ -229,3 +232,36 @@ describe("readResource — codemap://recipes/{id} (recency inline)", () => {
229232
expect(entry.run_count).toBe(4);
230233
});
231234
});
235+
236+
describe("CLI builder parity with readResource", () => {
237+
it("buildSchemaCatalog matches codemap://schema", () => {
238+
_resetResourceCachesForTests();
239+
const fromBuilder = buildSchemaCatalog();
240+
const fromResource = JSON.parse(readResource("codemap://schema")!.text);
241+
expect(fromBuilder).toEqual(fromResource);
242+
});
243+
244+
it("buildFileRollup matches codemap://files/{path}", () => {
245+
const fromBuilder = buildFileRollup("src/foo.ts");
246+
const fromResource = JSON.parse(
247+
readResource("codemap://files/src/foo.ts")!.text,
248+
);
249+
expect(fromBuilder).toEqual(fromResource);
250+
});
251+
252+
it("buildSymbolLookup matches codemap://symbols/{name}", () => {
253+
const fromBuilder = buildSymbolLookup("foo");
254+
const fromResource = JSON.parse(
255+
readResource("codemap://symbols/foo")!.text,
256+
);
257+
expect(fromBuilder).toEqual(fromResource);
258+
});
259+
260+
it("buildSymbolLookup matches codemap://symbols/{name}?in=", () => {
261+
const fromBuilder = buildSymbolLookup("foo", "src/");
262+
const fromResource = JSON.parse(
263+
readResource("codemap://symbols/foo?in=src%2F")!.text,
264+
);
265+
expect(fromBuilder).toEqual(fromResource);
266+
});
267+
});

0 commit comments

Comments
 (0)