Skip to content

Commit fd37094

Browse files
authored
test(mcp): cover remaining cli gaps
Replace remaining todo tests with real MCP contract coverage and clean detect_changes no-git errors.
1 parent 9941b0b commit fd37094

6 files changed

Lines changed: 183 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **MCP contract coverage now exercises the remaining explicit todo stories.**
13+
Search responses are verified as file-grouped with symbol locations and
14+
`nextSteps`; `symbol_context` is verified for `AuthService`; `detect_changes`
15+
is verified against a real non-git directory.
16+
17+
### Fixed
18+
19+
- **`detect_changes` no-git errors no longer leak raw `git diff` usage output.**
20+
The command now suppresses git stderr and returns the structured
21+
`Git not available or not in a git repository` error with recovery steps.
22+
1023
## [2.5.0-canary] - 2026-06-30
1124

1225
> Canary base for 2.5.0. Merging to `main` publishes `2.5.0-canary.<sha>` with

src/core/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,12 @@ export function computeChanges(
404404
default: diffCmd = "git diff --relative HEAD --name-only"; break;
405405
}
406406

407-
const output = execSync(diffCmd, { cwd: path.resolve(rootDir), encoding: "utf-8", timeout: 5000 }).trim();
407+
const output = execSync(diffCmd, {
408+
cwd: path.resolve(rootDir),
409+
encoding: "utf-8",
410+
timeout: 5000,
411+
stdio: ["ignore", "pipe", "ignore"],
412+
}).trim();
408413
const changedFiles = output ? output.split("\n").filter((f) => f.length > 0) : [];
409414

410415
const changedSymbols: Array<{ file: string; symbols: string[] }> = [];

tests/helpers/mcp.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import { registerTools } from "../../src/mcp/index.js";
5+
import { setGraph, setIndexedHead, setRoot } from "../../src/server/graph-store.js";
6+
import { getFixturePipeline, getFixtureSrcPath } from "./pipeline.js";
7+
8+
export interface ToolPayload {
9+
payload: Record<string, unknown>;
10+
isError: boolean;
11+
}
12+
13+
export interface FixtureMcp {
14+
callTool(name: string, args?: Record<string, unknown>): Promise<Record<string, unknown>>;
15+
callToolWithMeta(name: string, args?: Record<string, unknown>): Promise<ToolPayload>;
16+
}
17+
18+
function hasText(value: unknown): value is { text: string } {
19+
return typeof value === "object" && value !== null && "text" in value && typeof value.text === "string";
20+
}
21+
22+
function isRecord(value: unknown): value is Record<string, unknown> {
23+
return typeof value === "object" && value !== null && !Array.isArray(value);
24+
}
25+
26+
function firstTextContent(result: unknown): string {
27+
if (!isRecord(result) || !Array.isArray(result.content) || !hasText(result.content[0])) {
28+
throw new Error("MCP tool result did not include text content");
29+
}
30+
return result.content[0].text;
31+
}
32+
33+
function parsePayload(text: string): Record<string, unknown> {
34+
const parsed: unknown = JSON.parse(text);
35+
if (!isRecord(parsed)) {
36+
throw new Error("MCP tool result was not a JSON object");
37+
}
38+
return parsed;
39+
}
40+
41+
export async function createFixtureMcp(rootDir = getFixtureSrcPath()): Promise<FixtureMcp> {
42+
const { codebaseGraph } = getFixturePipeline();
43+
setGraph(codebaseGraph);
44+
setRoot(rootDir);
45+
setIndexedHead("abc123-test");
46+
47+
const server = new McpServer({ name: "test", version: "0.1.0" });
48+
registerTools(server, codebaseGraph);
49+
50+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
51+
await server.connect(serverTransport);
52+
53+
const client = new Client({ name: "test-client", version: "0.1.0" });
54+
await client.connect(clientTransport);
55+
56+
return {
57+
async callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
58+
const result = await client.callTool({ name, arguments: args });
59+
return parsePayload(firstTextContent(result));
60+
},
61+
async callToolWithMeta(name: string, args: Record<string, unknown> = {}): Promise<ToolPayload> {
62+
const result = await client.callTool({ name, arguments: args });
63+
return {
64+
payload: parsePayload(firstTextContent(result)),
65+
isError: result.isError === true,
66+
};
67+
},
68+
};
69+
}

tests/phase1-mcp-api.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, beforeAll } from "vitest";
22
import { getFixturePipeline } from "./helpers/pipeline.js";
33
import { setGraph } from "../src/server/graph-store.js";
4+
import { createFixtureMcp } from "./helpers/mcp.js";
45

56
beforeAll(() => {
67
const { codebaseGraph } = getFixturePipeline();
@@ -16,5 +17,29 @@ describe("1.3 — analyze_forces responds to threshold params", () => {
1617
});
1718

1819
describe("1.8 — symbol_context MCP tool", () => {
19-
it.todo("calling with 'AuthService' returns callers, callees, metrics, nextSteps");
20+
it("calling with 'AuthService' returns callers, callees, metrics, nextSteps", async () => {
21+
const { callTool } = await createFixtureMcp();
22+
const result = await callTool("symbol_context", { name: "AuthService" });
23+
24+
expect(result).toHaveProperty("name", "AuthService");
25+
expect(result).toHaveProperty("file", "auth/auth-service.ts");
26+
expect(result).toHaveProperty("type", "class");
27+
expect(result).toHaveProperty("callers");
28+
expect(result).toHaveProperty("callees");
29+
expect(result).toHaveProperty("fanIn");
30+
expect(result).toHaveProperty("fanOut");
31+
expect(result).toHaveProperty("pageRank");
32+
expect(result).toHaveProperty("betweenness");
33+
expect(result).toHaveProperty("nextSteps");
34+
35+
expect(Array.isArray(result.callers)).toBe(true);
36+
expect(Array.isArray(result.callees)).toBe(true);
37+
expect(typeof result.fanIn).toBe("number");
38+
expect(typeof result.fanOut).toBe("number");
39+
expect(typeof result.pageRank).toBe("number");
40+
expect(typeof result.betweenness).toBe("number");
41+
const nextSteps = result.nextSteps;
42+
expect(Array.isArray(nextSteps)).toBe(true);
43+
if (Array.isArray(nextSteps)) expect(nextSteps.length).toBeGreaterThan(0);
44+
});
2045
});

tests/phase2-red.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, beforeAll } from "vitest";
22
import { getFixturePipeline } from "./helpers/pipeline.js";
33
import { setGraph } from "../src/server/graph-store.js";
4+
import { createFixtureMcp } from "./helpers/mcp.js";
45

56
beforeAll(() => {
67
const { codebaseGraph } = getFixturePipeline();
@@ -71,7 +72,53 @@ describe("2.2 — BM25 empty results return suggestions", () => {
7172
});
7273

7374
describe("2.3 — search MCP tool", () => {
74-
it.todo("search tool returns file-grouped results with symbol locations and nextSteps");
75+
it("search tool returns file-grouped results with symbol locations and nextSteps", async () => {
76+
const { callTool } = await createFixtureMcp();
77+
const result = await callTool("search", { query: "auth", limit: 5 });
78+
79+
expect(result).toHaveProperty("query", "auth");
80+
expect(result).toHaveProperty("results");
81+
expect(result).toHaveProperty("nextSteps");
82+
83+
const results = result.results;
84+
expect(Array.isArray(results)).toBe(true);
85+
if (!Array.isArray(results)) return;
86+
expect(results.length).toBeGreaterThan(0);
87+
88+
const files = new Set<string>();
89+
const first = results[0];
90+
expect(typeof first).toBe("object");
91+
expect(first).not.toBeNull();
92+
if (typeof first !== "object" || first === null) return;
93+
expect(first).toHaveProperty("file");
94+
expect(first).toHaveProperty("symbols");
95+
96+
for (const item of results) {
97+
expect(typeof item).toBe("object");
98+
expect(item).not.toBeNull();
99+
if (typeof item !== "object" || item === null) continue;
100+
const file = "file" in item ? item.file : undefined;
101+
const symbols = "symbols" in item ? item.symbols : undefined;
102+
expect(typeof file).toBe("string");
103+
if (typeof file === "string") files.add(file);
104+
expect(Array.isArray(symbols)).toBe(true);
105+
if (!Array.isArray(symbols) || symbols.length === 0) continue;
106+
const symbol = symbols[0];
107+
expect(typeof symbol).toBe("object");
108+
expect(symbol).not.toBeNull();
109+
if (typeof symbol !== "object" || symbol === null) continue;
110+
expect(symbol).toHaveProperty("name");
111+
expect(symbol).toHaveProperty("type");
112+
expect(symbol).toHaveProperty("loc");
113+
const loc = "loc" in symbol ? symbol.loc : undefined;
114+
expect(typeof loc).toBe("number");
115+
}
116+
117+
expect(files.size).toBe(results.length);
118+
const nextSteps = result.nextSteps;
119+
expect(Array.isArray(nextSteps)).toBe(true);
120+
if (Array.isArray(nextSteps)) expect(nextSteps.length).toBeGreaterThan(0);
121+
});
75122
});
76123

77124
describe("2.6 — existing tools include nextSteps", () => {

tests/phase3-red.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, beforeAll } from "vitest";
22
import { getFixturePipeline } from "./helpers/pipeline.js";
3+
import { createFixtureMcp } from "./helpers/mcp.js";
34
import { setGraph } from "../src/server/graph-store.js";
45
import path from "path";
56
import fs from "fs";
@@ -160,6 +161,24 @@ describe("3.7 — detect_changes MCP tool", () => {
160161
});
161162

162163
describe("3.8 — detect_changes without git", () => {
163-
it.todo("detect_changes returns clear error when git unavailable");
164+
it("detect_changes returns clear error when git unavailable", async () => {
165+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-detect-no-git-"));
166+
try {
167+
const { callToolWithMeta } = await createFixtureMcp(dir);
168+
const { payload, isError } = await callToolWithMeta("detect_changes");
169+
170+
expect(isError).toBe(true);
171+
expect(payload).toHaveProperty("scope", "all");
172+
expect(payload).toHaveProperty("error");
173+
expect(String(payload.error)).toContain("Git not available");
174+
expect(payload).toHaveProperty("nextSteps");
175+
const nextSteps = payload.nextSteps;
176+
expect(Array.isArray(nextSteps)).toBe(true);
177+
if (Array.isArray(nextSteps)) {
178+
expect(nextSteps.join(" ")).toContain("git repository");
179+
}
180+
} finally {
181+
fs.rmSync(dir, { recursive: true, force: true });
182+
}
183+
});
164184
});
165-

0 commit comments

Comments
 (0)