Skip to content

Commit ff89d8c

Browse files
feat(mcp): trace, explore, and node MCP/HTTP tools (PR 6)
Add MCP/HTTP trace, explore, and node tools composing call-path and symbol-neighborhood recipes with output budget, allowlist wiring, and docs.
1 parent 386ffa6 commit ff89d8c

17 files changed

Lines changed: 1535 additions & 60 deletions

docs/architecture.md

Lines changed: 11 additions & 9 deletions
Large diffs are not rendered by default.

docs/plans/agent-surface-delivery.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010

1111
## Quick resume
1212

13-
| Next action | Detail |
14-
| -------------------- | --------------------------------------------------------------------------- |
15-
| **Review / merge** | [#133](https://github.com/stainless-code/codemap/pull/133) — MCP `affected` |
16-
| **Start next** | **PR 6** — MCP trace tools (`trace` / `explore` / `node`) |
17-
| **Do not start yet** | PR 9 (eval harness) until PR 8 |
13+
| Next action | Detail |
14+
| -------------------- | ----------------------------------------------------------------------------------- |
15+
| **Review / merge** | [#134](https://github.com/stainless-code/codemap/pull/134) — MCP trace tools (PR 6) |
16+
| **Recently merged** | [#133](https://github.com/stainless-code/codemap/pull/133) — MCP `affected` |
17+
| **Do not start yet** | PR 9 (eval harness) until PR 8 |
1818

1919
Update the table below when a PR merges or a new branch opens.
2020

@@ -40,7 +40,7 @@ Max **3 parallel tracks** at once.
4040
| **3** | [`index-lock-and-error-log`](./index-lock-and-error-log.md)[`parse-worker-hardening`](./parse-worker-hardening.md) (stack) | merged | [#129](https://github.com/stainless-code/codemap/pull/129), [#130](https://github.com/stainless-code/codemap/pull/130) | 4, 5 |
4141
| **4** | Recipe half of [`mcp-trace-explore-tools`](./mcp-trace-explore-tools.md) (`call-path`, `symbol-neighborhood` SQL + tests) | merged | [#131](https://github.com/stainless-code/codemap/pull/131) | 3, 5 |
4242
| **5** | [`affected-tests-recipe`](./affected-tests-recipe.md) (+ Phase 2 MCP `affected` in [#133](https://github.com/stainless-code/codemap/pull/133)) | merged | [#132](https://github.com/stainless-code/codemap/pull/132), [#133](https://github.com/stainless-code/codemap/pull/133) | 3, 4 |
43-
| **6** | MCP half of trace (`trace` / `explore` / `node` tools) + update instructions | planned | PR 1, PR 4 | |
43+
| **6** | MCP half of trace (`trace` / `explore` / `node` tools) + update instructions | open | [#134](https://github.com/stainless-code/codemap/pull/134) | PR 1, PR 4 |
4444
| **7** | [`field-qualified-search`](./field-qualified-search.md) | planned | PR 1 | 4, 5 if `mcp-server.ts` untouched |
4545
| **8** | [`agents-init-mcp-wiring`](./agents-init-mcp-wiring.md) | planned | PR 1 | 3–5 |
4646
| **9** | [`agent-eval-harness`](./agent-eval-harness.md) | planned | PR 1, PR 8, allowlist | **last P1** |
Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# MCP trace & explore tools — plan
22

3-
> **Status:** open · **Priority:** P1 · **Effort:** M (~2 weeks)
3+
> **Status:** shipped · **Priority:** P1 · **Effort:** M (~2 weeks)
44
>
55
> **Motivator:** Agents often need call-path and multi-symbol survey answers in one round-trip. Codemap has `impact` (radius walk) and `snippet` but no shortest-path or budget-capped multi-file survey. MCP wrappers must not erode Moat A — every wrapper ships with a recipe twin.
66
>
77
> **Roadmap:** [§ Backlog — Agent surface & ops](./agent-surface-and-ops.md#p1) · related [call-path-type-hierarchy-recipes](./call-path-type-hierarchy-recipes.md)
8+
>
9+
> **Shipped:** recipes [#131](https://github.com/stainless-code/codemap/pull/131); MCP tools [#134](https://github.com/stainless-code/codemap/pull/134)
810
911
---
1012

@@ -14,12 +16,12 @@
1416
| --- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
1517
| L.1 | **Recipe twins required** before MCP tools: `call-path`, `symbol-neighborhood` (bundled SQL). | [Moat A](../roadmap.md#moats-load-bearing) |
1618
| L.2 | MCP tools **`trace`**, **`explore`**, **`node`** are thin composers over recipes + `snippet` + existing engines — not opaque graph APIs. | Moat A |
17-
| L.3 | **Output budgets** — cap total response chars (e.g. 15k); truncate with explicit `truncated: true` in JSON. | Agent context economics |
18-
| L.4 | No NL task parsing — `trace` takes `from` / `to` symbol names; `explore` takes symbol list or recipe result rows. | [Floor — No LLM in the box](../roadmap.md#floors-v1-product-shape) |
19+
| L.3 | **Output budgets** — cap snippet `source` chars (default 15k) + explore row cap (500); `truncated: true` with `truncation` detail. | Agent context economics |
20+
| L.4 | No NL task parsing — `trace` takes `from` / `to` symbol names; `explore` takes symbol name list. | [Floor — No LLM in the box](../roadmap.md#floors-v1-product-shape) |
1921

2022
---
2123

22-
## Recipe specs (ship first)
24+
## Recipe specs (shipped #131)
2325

2426
### `call-path`
2527

@@ -35,38 +37,27 @@
3537

3638
---
3739

38-
## MCP tool specs (ship second)
39-
40-
| Tool | Composes |
41-
| --------- | ---------------------------------------------------------------------------- |
42-
| `trace` | `query_recipe call-path` + `snippet` for each hop |
43-
| `explore` | `query_recipe symbol-neighborhood` (multi-name) + `snippet` with char budget |
44-
| `node` | `show` + one-hop `symbol-neighborhood` + optional inline snippets |
45-
46-
Register in `mcp-server.ts`; document chains in [mcp-server-instructions](./mcp-server-instructions.md).
47-
48-
---
40+
## MCP tool specs (shipped #134)
4941

50-
## Implementation steps
42+
| Tool | Composes |
43+
| --------- | --------------------------------------------------------------------------------- |
44+
| `trace` | `query_recipe call-path` + cross-file `snippet` for hop symbols |
45+
| `explore` | `query_recipe symbol-neighborhood` (deduped multi-name) + snippet budget |
46+
| `node` | `show` + scoped one-hop `symbol-neighborhood` + optional center+neighbor snippets |
5147

52-
1. Add `templates/recipes/call-path.sql` + `.md` frontmatter
53-
2. Add `templates/recipes/symbol-neighborhood.sql` + `.md`
54-
3. Golden-query tests for both recipes
55-
4. Implement MCP handlers in `tool-handlers.ts` (or dedicated module)
56-
5. Output budget helper shared by explore/trace
57-
6. Update agent-content skill with SQL equivalents
48+
Register in `mcp-server.ts` + `http-server.ts`; document chains in [mcp-instructions](../templates/agent-content/mcp-instructions.md).
5849

5950
---
6051

6152
## Acceptance
6253

63-
- [ ] `codemap query --recipe call-path --params from=foo,to=bar` works
64-
- [ ] MCP `trace` returns same path + snippets, respects budget
65-
- [ ] Instructions document recipe-first fallback
54+
- [x] `codemap query --recipe call-path --params from=foo,to=bar` works ([#131](https://github.com/stainless-code/codemap/pull/131))
55+
- [x] MCP/HTTP `trace` returns path + snippets, respects budget ([#134](https://github.com/stainless-code/codemap/pull/134))
56+
- [x] Instructions document recipe-first fallback ([#134](https://github.com/stainless-code/codemap/pull/134))
6657

6758
---
6859

6960
## Dependencies
7061

71-
- [mcp-server-instructions](./mcp-server-instructions.md) should land first or in same PR
62+
- [mcp-server-instructions](./mcp-server-instructions.md) — landed [#126](https://github.com/stainless-code/codemap/pull/126)
7263
- [call-path-type-hierarchy-recipes](./call-path-type-hierarchy-recipes.md) may extend CTE patterns later

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Prioritized agent & indexing ops queue (2026-05). Index: [`plans/agent-surface-a
6565

6666
**P1 — medium**
6767

68-
- [ ] **MCP trace / explore / node** — recipe twins + thin MCP composers. Plan: [`plans/mcp-trace-explore-tools.md`](./plans/mcp-trace-explore-tools.md). Effort: M.
68+
- [x] **MCP trace / explore / node** — recipe twins + thin MCP composers. Plan: [`plans/mcp-trace-explore-tools.md`](./plans/mcp-trace-explore-tools.md). [#134](https://github.com/stainless-code/codemap/pull/134). Effort: M.
6969
- [ ] **Agents init MCP wiring**`agents init --mcp` + permissions. Plan: [`plans/agents-init-mcp-wiring.md`](./plans/agents-init-mcp-wiring.md). Effort: M.
7070
- [x] **Affected tests recipe** — dep-graph test selection + stdin + MCP `affected` tool. Plan: [`plans/affected-tests-recipe.md`](./plans/affected-tests-recipe.md). Shipped #132 + #133.
7171
- [ ] **Index lock + error log** — cross-process lock, `unlock`, `errors.log`. Plan: [`plans/index-lock-and-error-log.md`](./plans/index-lock-and-error-log.md). Effort: M.

src/application/http-server.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ describe("http-server — health + tools catalog", () => {
124124
expect(body.tools.map((t) => t.name)).toContain("query");
125125
expect(body.tools.map((t) => t.name)).toContain("audit");
126126
expect(body.tools.map((t) => t.name)).toContain("affected");
127+
expect(body.tools.map((t) => t.name)).toContain("trace");
127128
});
128129

129130
it("404 for unknown route", async () => {
@@ -405,6 +406,141 @@ describe("http-server — POST /tool/{other tools}", () => {
405406
expect(r.json.error).not.toContain("--changed-since");
406407
});
407408

409+
function seedTraceGraph() {
410+
writeFileSync(
411+
join(benchDir, "src", "trace.ts"),
412+
"export function alpha() {\n return beta();\n}\nexport function beta() {\n return 1;\n}\n",
413+
);
414+
const db = openDb();
415+
try {
416+
db.run(
417+
`INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at)
418+
VALUES ('src/trace.ts', 'ht', 100, 6, 'typescript', 1, 1)`,
419+
);
420+
db.run(
421+
`INSERT INTO symbols (name, kind, file_path, line_start, line_end, signature, is_exported, parent_name, visibility)
422+
VALUES ('alpha', 'function', 'src/trace.ts', 1, 3, 'alpha()', 1, NULL, 'export'),
423+
('beta', 'function', 'src/trace.ts', 4, 6, 'beta()', 1, NULL, 'export')`,
424+
);
425+
db.run(
426+
`INSERT INTO calls (file_path, caller_name, caller_scope, callee_name, line_start, column_start, column_end)
427+
VALUES ('src/trace.ts', 'alpha', 'alpha', 'beta', 2, 0, 0)`,
428+
);
429+
} finally {
430+
closeDb(db);
431+
}
432+
}
433+
434+
it("trace returns path and snippets", async () => {
435+
seedTraceGraph();
436+
serverHandle = await startServer();
437+
const r = await postTool(serverHandle.port, "trace", {
438+
from: "alpha",
439+
to: "beta",
440+
});
441+
expect(r.status).toBe(200);
442+
expect(r.json.path).toHaveLength(1);
443+
expect(r.json.snippets.length).toBeGreaterThan(0);
444+
expect(r.json.truncated).toBe(false);
445+
});
446+
447+
it("explore merges neighborhoods", async () => {
448+
seedTraceGraph();
449+
serverHandle = await startServer();
450+
const r = await postTool(serverHandle.port, "explore", {
451+
names: ["alpha", "beta"],
452+
});
453+
expect(r.status).toBe(200);
454+
expect(r.json.names).toEqual(["alpha", "beta"]);
455+
expect(r.json.rows.length).toBeGreaterThan(0);
456+
});
457+
458+
it("node returns center + neighborhood", async () => {
459+
seedTraceGraph();
460+
serverHandle = await startServer();
461+
const r = await postTool(serverHandle.port, "node", {
462+
name: "alpha",
463+
include_snippets: true,
464+
});
465+
expect(r.status).toBe(200);
466+
expect(r.json.center.matches[0]?.name).toBe("alpha");
467+
expect(
468+
r.json.neighborhood.some((row: { name: string }) => row.name === "beta"),
469+
).toBe(true);
470+
});
471+
472+
it("trace with non-integer max_depth → 400 (Zod rejects)", async () => {
473+
serverHandle = await startServer();
474+
const r = await postTool(serverHandle.port, "trace", {
475+
from: "a",
476+
to: "b",
477+
max_depth: 1.5,
478+
});
479+
expect(r.status).toBe(400);
480+
expect(r.json.error).toContain('"trace"');
481+
});
482+
483+
it("explore with empty names → 400 (Zod rejects)", async () => {
484+
serverHandle = await startServer();
485+
const r = await postTool(serverHandle.port, "explore", { names: [] });
486+
expect(r.status).toBe(400);
487+
expect(r.json.error).toContain('"explore"');
488+
});
489+
490+
it("trace sets truncated when budget_chars is tiny", async () => {
491+
seedTraceGraph();
492+
serverHandle = await startServer();
493+
const r = await postTool(serverHandle.port, "trace", {
494+
from: "alpha",
495+
to: "beta",
496+
budget_chars: 1,
497+
});
498+
expect(r.status).toBe(200);
499+
expect(r.json.truncated).toBe(true);
500+
expect(r.json.truncation?.snippets).toBe(true);
501+
});
502+
503+
it("records recipe recency after trace", async () => {
504+
seedTraceGraph();
505+
serverHandle = await startServer();
506+
const r = await postTool(serverHandle.port, "trace", {
507+
from: "alpha",
508+
to: "beta",
509+
});
510+
expect(r.status).toBe(200);
511+
const db = openDb();
512+
try {
513+
const row = db
514+
.query<{ run_count: number }>(
515+
"SELECT run_count FROM recipe_recency WHERE recipe_id = 'call-path'",
516+
)
517+
.get();
518+
expect(row?.run_count).toBeGreaterThanOrEqual(1);
519+
} finally {
520+
closeDb(db);
521+
}
522+
});
523+
524+
it("records recipe recency after explore", async () => {
525+
seedTraceGraph();
526+
serverHandle = await startServer();
527+
const r = await postTool(serverHandle.port, "explore", {
528+
names: ["alpha"],
529+
});
530+
expect(r.status).toBe(200);
531+
const db = openDb();
532+
try {
533+
const row = db
534+
.query<{ run_count: number }>(
535+
"SELECT run_count FROM recipe_recency WHERE recipe_id = 'symbol-neighborhood'",
536+
)
537+
.get();
538+
expect(row?.run_count).toBeGreaterThanOrEqual(1);
539+
} finally {
540+
closeDb(db);
541+
}
542+
});
543+
408544
it("list_baselines returns array (empty when none saved)", async () => {
409545
serverHandle = await startServer();
410546
const r = await postTool(serverHandle.port, "list_baselines", {});
@@ -582,6 +718,29 @@ describe("http-server — Zod input validation at HTTP boundary", () => {
582718
});
583719
expect(r.status).toBe(400);
584720
});
721+
722+
it("trace without from → 400 with structured error", async () => {
723+
serverHandle = await startServer();
724+
const r = await postTool(serverHandle.port, "trace", { to: "bar" });
725+
expect(r.status).toBe(400);
726+
expect(r.json.error).toContain('"trace"');
727+
expect(r.json.error).toContain("from");
728+
});
729+
730+
it("trace without to → 400 with structured error", async () => {
731+
serverHandle = await startServer();
732+
const r = await postTool(serverHandle.port, "trace", { from: "foo" });
733+
expect(r.status).toBe(400);
734+
expect(r.json.error).toContain('"trace"');
735+
expect(r.json.error).toContain("to");
736+
});
737+
738+
it("node with name=number → 400 (not deep handler crash)", async () => {
739+
serverHandle = await startServer();
740+
const r = await postTool(serverHandle.port, "node", { name: 1 });
741+
expect(r.status).toBe(400);
742+
expect(r.json.error).toContain("name");
743+
});
585744
});
586745

587746
describe("http-server — GET /resources", () => {

src/application/http-server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ import {
1919
auditArgsSchema,
2020
contextArgsSchema,
2121
dropBaselineArgsSchema,
22+
exploreArgsSchema,
2223
handleApply,
2324
handleAudit,
2425
handleAffected,
2526
handleContext,
2627
handleDropBaseline,
28+
handleExplore,
2729
handleImpact,
30+
handleNode,
31+
handleTrace,
2832
handleListBaselines,
2933
handleQuery,
3034
handleQueryBatch,
@@ -34,6 +38,8 @@ import {
3438
handleSnippet,
3539
handleValidate,
3640
impactArgsSchema,
41+
nodeArgsSchema,
42+
traceArgsSchema,
3743
listBaselinesArgsSchema,
3844
queryArgsSchema,
3945
queryBatchArgsSchema,
@@ -94,6 +100,9 @@ const TOOL_NAMES = [
94100
"snippet",
95101
"impact",
96102
"affected",
103+
"trace",
104+
"explore",
105+
"node",
97106
"apply",
98107
"save_baseline",
99108
"list_baselines",
@@ -489,6 +498,24 @@ async function dispatchTool(
489498
result = handleAffected(r.value, opts.root);
490499
break;
491500
}
501+
case "trace": {
502+
const r = validate(traceArgsSchema, args, "trace");
503+
if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version);
504+
result = handleTrace(r.value, opts.root);
505+
break;
506+
}
507+
case "explore": {
508+
const r = validate(exploreArgsSchema, args, "explore");
509+
if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version);
510+
result = handleExplore(r.value, opts.root);
511+
break;
512+
}
513+
case "node": {
514+
const r = validate(nodeArgsSchema, args, "node");
515+
if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version);
516+
result = handleNode(r.value, opts.root);
517+
break;
518+
}
492519
case "apply": {
493520
const r = validate(applyArgsSchema, args, "apply");
494521
if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version);

0 commit comments

Comments
 (0)