Skip to content

Commit ce9d900

Browse files
committed
feat(mcp): show + snippet MCP tools (Tracer 5 of 6)
Wires the show + snippet CLI verbs as MCP tools per Q-1 settled. Both follow the established cmd-* ↔ register*Tool pattern from PR #35; both reuse the same engine helpers (findSymbolsByName, buildShowResult, buildSnippetResult) so output shape is verbatim from each tool's CLI counterpart's --json envelope. - registerShowTool — args {name, kind?, in?}, returns the {matches, disambiguation?} envelope. Tool description teaches: 'Use snippet for source text; use query with LIKE for fuzzy lookup' so agents know when to reach for which tool. - registerSnippetTool — args {name, kind?, in?}, returns the same envelope with source/stale/missing on each match. Description spells out the stale semantics (read + flag, agent decides) since that's the one non-obvious bit. Both tools route the in arg through toProjectRelative(opts.root, args.in) so MCP callers get the same path-shape leniency as the CLI (--in ./src/cli/, --in src/cli, --in src/cli/cmd-show.ts all work identically). 8 new in-process MCP tests via @modelcontextprotocol/sdk's InMemoryTransport: tools/list lists both, single-match envelope, multi-match disambiguation, in-filter narrows, unknown-name returns empty, snippet source on fresh file (stale: false), stale flag on hash drift, missing flag on rm'd file. Total now 38 MCP tests pass.
1 parent 4f02a4d commit ce9d900

2 files changed

Lines changed: 270 additions & 1 deletion

File tree

src/application/mcp-server.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,193 @@ describe("MCP server — resources", () => {
625625
}
626626
});
627627
});
628+
629+
describe("MCP server — show + snippet tools", () => {
630+
function seedSymbol(opts: {
631+
file: string;
632+
name: string;
633+
kind?: string;
634+
lineStart?: number;
635+
lineEnd?: number;
636+
}) {
637+
const db = openDb();
638+
try {
639+
db.run(
640+
`INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export)
641+
VALUES (?, ?, ?, ?, ?, ?, 1, 0)`,
642+
[
643+
opts.file,
644+
opts.name,
645+
opts.kind ?? "function",
646+
opts.lineStart ?? 1,
647+
opts.lineEnd ?? 1,
648+
`${opts.kind ?? "function"} ${opts.name}(): void`,
649+
],
650+
);
651+
} finally {
652+
closeDb(db);
653+
}
654+
}
655+
656+
it("lists show + snippet in tools/list", async () => {
657+
const { client, server } = await makeClient();
658+
try {
659+
const tools = await client.listTools();
660+
const names = tools.tools.map((t) => t.name);
661+
expect(names).toContain("show");
662+
expect(names).toContain("snippet");
663+
} finally {
664+
await server.close();
665+
}
666+
});
667+
668+
it("show returns {matches} envelope for single match", async () => {
669+
seedSymbol({ file: "src/a.ts", name: "myFn", lineStart: 5, lineEnd: 10 });
670+
const { client, server } = await makeClient();
671+
try {
672+
const r = await client.callTool({
673+
name: "show",
674+
arguments: { name: "myFn" },
675+
});
676+
const json = readJson(r);
677+
expect(json.matches).toHaveLength(1);
678+
expect(json.matches[0]).toMatchObject({
679+
name: "myFn",
680+
file_path: "src/a.ts",
681+
line_start: 5,
682+
line_end: 10,
683+
});
684+
expect(json.disambiguation).toBeUndefined();
685+
} finally {
686+
await server.close();
687+
}
688+
});
689+
690+
it("show adds disambiguation block for multi-match", async () => {
691+
seedSymbol({ file: "src/a.ts", name: "shared", kind: "function" });
692+
seedSymbol({ file: "src/b.ts", name: "shared", kind: "const" });
693+
const { client, server } = await makeClient();
694+
try {
695+
const r = await client.callTool({
696+
name: "show",
697+
arguments: { name: "shared" },
698+
});
699+
const json = readJson(r);
700+
expect(json.matches).toHaveLength(2);
701+
expect(json.disambiguation).toMatchObject({
702+
n: 2,
703+
by_kind: { function: 1, const: 1 },
704+
files: ["src/a.ts", "src/b.ts"],
705+
});
706+
} finally {
707+
await server.close();
708+
}
709+
});
710+
711+
it("show with `in` filter narrows to one file", async () => {
712+
seedSymbol({ file: "src/a.ts", name: "shared" });
713+
seedSymbol({ file: "src/b.ts", name: "shared" });
714+
const { client, server } = await makeClient();
715+
try {
716+
const r = await client.callTool({
717+
name: "show",
718+
arguments: { name: "shared", in: "src/a.ts" },
719+
});
720+
const json = readJson(r);
721+
expect(json.matches).toHaveLength(1);
722+
expect(json.matches[0].file_path).toBe("src/a.ts");
723+
} finally {
724+
await server.close();
725+
}
726+
});
727+
728+
it("show returns empty matches when name unknown", async () => {
729+
const { client, server } = await makeClient();
730+
try {
731+
const r = await client.callTool({
732+
name: "show",
733+
arguments: { name: "definitely-not-a-real-symbol-xyz" },
734+
});
735+
const json = readJson(r);
736+
expect(json.matches).toEqual([]);
737+
} finally {
738+
await server.close();
739+
}
740+
});
741+
742+
it("snippet returns source text from disk + stale: false on fresh file", async () => {
743+
// Write a real file matching the seeded `files` row in the bench setup
744+
// (src/a.ts already exists with hash 'h1' but content "export const A = 1;\n").
745+
// Seed a symbol pointing at line 1.
746+
seedSymbol({
747+
file: "src/a.ts",
748+
name: "A",
749+
kind: "const",
750+
lineStart: 1,
751+
lineEnd: 1,
752+
});
753+
// The bench uses content_hash = 'h1' which DOES NOT match hashContent("export const A = 1;\n"),
754+
// so the engine will report stale: true. To test stale: false we'd need to update the row's hash.
755+
const db = openDb();
756+
try {
757+
const realHash = (
758+
require("../hash") as typeof import("../hash")
759+
).hashContent("export const A = 1;\n");
760+
db.run("UPDATE files SET content_hash = ? WHERE path = ?", [
761+
realHash,
762+
"src/a.ts",
763+
]);
764+
} finally {
765+
closeDb(db);
766+
}
767+
const { client, server } = await makeClient();
768+
try {
769+
const r = await client.callTool({
770+
name: "snippet",
771+
arguments: { name: "A" },
772+
});
773+
const json = readJson(r);
774+
expect(json.matches).toHaveLength(1);
775+
expect(json.matches[0].source).toBe("export const A = 1;");
776+
expect(json.matches[0].stale).toBe(false);
777+
expect(json.matches[0].missing).toBe(false);
778+
} finally {
779+
await server.close();
780+
}
781+
});
782+
783+
it("snippet flags stale: true when on-disk content drifts from indexed hash", async () => {
784+
// Bench file content is "export const A = 1;\n" but indexed hash is 'h1' (mismatch).
785+
seedSymbol({ file: "src/a.ts", name: "A", lineStart: 1, lineEnd: 1 });
786+
const { client, server } = await makeClient();
787+
try {
788+
const r = await client.callTool({
789+
name: "snippet",
790+
arguments: { name: "A" },
791+
});
792+
const json = readJson(r);
793+
expect(json.matches[0].stale).toBe(true);
794+
// Source is still returned per Q-6 settled.
795+
expect(json.matches[0].source).toBe("export const A = 1;");
796+
} finally {
797+
await server.close();
798+
}
799+
});
800+
801+
it("snippet flags missing: true when file is gone on disk", async () => {
802+
seedSymbol({ file: "src/b.ts", name: "B", lineStart: 1, lineEnd: 1 });
803+
// src/b.ts is in the indexed `files` but no actual file on disk in bench setup.
804+
const { client, server } = await makeClient();
805+
try {
806+
const r = await client.callTool({
807+
name: "snippet",
808+
arguments: { name: "B" },
809+
});
810+
const json = readJson(r);
811+
expect(json.matches[0].missing).toBe(true);
812+
expect(json.matches[0].source).toBeUndefined();
813+
} finally {
814+
await server.close();
815+
}
816+
});
817+
});

src/application/mcp-server.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import { resolveAgentsTemplateDir } from "../agents-init";
1717
// once a second consumer (HTTP API) needs them.
1818
import { resolveAuditBaselines } from "../cli/cmd-audit";
1919
import { buildContextEnvelope } from "../cli/cmd-context";
20-
import { computeValidateRows } from "../cli/cmd-validate";
20+
import { buildShowResult } from "../cli/cmd-show";
21+
import { buildSnippetResult } from "../cli/cmd-snippet";
22+
import { computeValidateRows, toProjectRelative } from "../cli/cmd-validate";
2123
import {
2224
getQueryRecipeActions,
2325
getQueryRecipeCatalogEntry,
@@ -41,6 +43,7 @@ import { runAudit } from "./audit-engine";
4143
import { getCurrentCommit } from "./index-engine";
4244
import { executeQuery } from "./query-engine";
4345
import { runCodemapIndex } from "./run-index";
46+
import { findSymbolsByName } from "./show-engine";
4447

4548
/**
4649
* MCP server engine — owns the tool / resource registry. CLI shell
@@ -154,6 +157,8 @@ export function createMcpServer(opts: ServerOpts): McpServer {
154157
registerSaveBaselineTool(server, opts);
155158
registerListBaselinesTool(server, opts);
156159
registerDropBaselineTool(server, opts);
160+
registerShowTool(server, opts);
161+
registerSnippetTool(server, opts);
157162
registerResources(server);
158163

159164
return server;
@@ -570,6 +575,80 @@ function registerDropBaselineTool(server: McpServer, _opts: ServerOpts): void {
570575
);
571576
}
572577

578+
function registerShowTool(server: McpServer, opts: ServerOpts): void {
579+
server.registerTool(
580+
"show",
581+
{
582+
description:
583+
"Look up symbol(s) by exact name; returns {matches: [{name, kind, file_path, line_start, line_end, signature, ...}]} with structured `disambiguation` block when multiple matches. One-step lookup that beats composing `SELECT … FROM symbols WHERE name = ?` by hand. Use `snippet` for the actual source text; use `query` with `LIKE` for fuzzy lookup.",
584+
inputSchema: {
585+
name: z.string().min(1, "name must be a non-empty string"),
586+
kind: z.string().optional(),
587+
in: z.string().optional(),
588+
},
589+
},
590+
(args) => {
591+
try {
592+
const db = openDb();
593+
try {
594+
const inPath =
595+
args.in !== undefined && args.in.length > 0
596+
? toProjectRelative(opts.root, args.in)
597+
: undefined;
598+
const matches = findSymbolsByName(db, {
599+
name: args.name,
600+
kind: args.kind,
601+
inPath,
602+
});
603+
return jsonResult(buildShowResult(matches));
604+
} finally {
605+
closeDb(db, { readonly: true });
606+
}
607+
} catch (err) {
608+
return jsonError(err instanceof Error ? err.message : String(err));
609+
}
610+
},
611+
);
612+
}
613+
614+
function registerSnippetTool(server: McpServer, opts: ServerOpts): void {
615+
server.registerTool(
616+
"snippet",
617+
{
618+
description:
619+
"Same lookup as `show` but each match carries `source` (file lines from disk at line_start..line_end) plus `stale` (true when content_hash drifted since indexing — line range may have shifted; agent decides whether to act or re-index) and `missing` (true when file is gone). Per-execution shape mirrors `show`'s envelope; source/stale/missing are additive fields on each match.",
620+
inputSchema: {
621+
name: z.string().min(1, "name must be a non-empty string"),
622+
kind: z.string().optional(),
623+
in: z.string().optional(),
624+
},
625+
},
626+
(args) => {
627+
try {
628+
const db = openDb();
629+
try {
630+
const inPath =
631+
args.in !== undefined && args.in.length > 0
632+
? toProjectRelative(opts.root, args.in)
633+
: undefined;
634+
const matches = findSymbolsByName(db, {
635+
name: args.name,
636+
kind: args.kind,
637+
inPath,
638+
});
639+
return jsonResult(
640+
buildSnippetResult({ db, matches, projectRoot: opts.root }),
641+
);
642+
} finally {
643+
closeDb(db, { readonly: true });
644+
}
645+
} catch (err) {
646+
return jsonError(err instanceof Error ? err.message : String(err));
647+
}
648+
},
649+
);
650+
}
651+
573652
/**
574653
* MCP resources are addressable read-only data the host can fetch ahead of
575654
* tool calls. Plan § 7 + grill round Q3 settled on **lazy memoisation**:

0 commit comments

Comments
 (0)