feat(codegraph): codegraph_explore 改为自有 stdio JSON-RPC 客户端直连 codegraph serve

一次性 `codegraph explore` CLI 不会同步索引,新增符号在手动 sync 前
无法被搜到。改为按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,
由组合包自有的逐行 JSON-RPC 客户端(extensions/codegraph-client.ts,
不依赖 MCP adapter/SDK)收发,serve 内置文件监听自动同步索引,删除
mtime 结果缓存。子进程闲置 10 分钟或会话结束关闭,子进程死亡时透明
重试一次;用户拒绝初始化在会话内记住;用户中止调用不关闭子进程;
所有失败以文本打回引导改用 find/grep/read/LSP。新增模拟服务与真实
CLI 集成测试。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-20 02:02:47 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent da9908fb6e
commit dc4fe92e27
6 changed files with 990 additions and 104 deletions
+474 -90
View File
@@ -1,49 +1,124 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
import {
execFileSync,
spawn,
type ChildProcess,
} from "node:child_process";
import {
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
buildExploreArgs,
CodegraphRpcClient,
type CodegraphRpcClientOptions,
} from "../extensions/codegraph-client.ts";
import {
createCodegraphExploreHandler,
findIndexedRoot,
isNotIndexedOutput,
type CodegraphExec,
type CodegraphExploreClient,
type ExploreParams,
} from "../extensions/codegraph.ts";
async function makeIndexedProject(): Promise<{ root: string; cleanup: () => Promise<void> }> {
const FIXTURE = fileURLToPath(
new URL("./fixtures/fake-codegraph-mcp.mjs", import.meta.url),
);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ── fixtures ────────────────────────────────────────────────────────────────
async function makeIndexedProject(): Promise<{
root: string;
cleanup: () => Promise<void>;
}> {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
await mkdir(join(root, ".codegraph"));
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
return { root, cleanup: () => rm(root, { recursive: true, force: true }) };
}
function mockCtx(cwd: string, confirm?: () => Promise<boolean>) {
/** Spawn the .mjs fixture under process.execPath with the given env. */
function fixtureSpawn(extraEnv: Record<string, string>, children?: ChildProcess[]) {
return (_command: string, _args: string[], options: object) => {
const child = spawn(process.execPath, [FIXTURE], {
...(options as object),
env: { ...process.env, ...extraEnv },
});
children?.push(child);
return child;
};
}
function fixtureClient(
root: string,
extraEnv: Record<string, string> = {},
overrides: Partial<CodegraphRpcClientOptions> = {},
children?: ChildProcess[],
): CodegraphRpcClient {
return new CodegraphRpcClient({
root,
requestTimeoutMs: 5_000,
spawn: fixtureSpawn(
{ FAKE_CG_MODE: "ok", ...extraEnv },
children,
),
...overrides,
});
}
function mockCtx(
cwd: string,
ui?: { confirm?: () => Promise<boolean>; notify?: (m: string, t?: string) => void },
) {
return {
cwd,
hasUI: confirm !== undefined,
ui: { confirm: confirm ?? (async () => false) },
hasUI: ui !== undefined,
ui: {
confirm: ui?.confirm ?? (async () => false),
notify: ui?.notify ?? (() => {}),
},
} as never;
}
function mockExec(handler: (command: string, args: string[]) => { code?: number; stdout?: string; stderr?: string }) {
function mockExec(
handler: (command: string, args: string[]) => {
code?: number;
stdout?: string;
stderr?: string;
},
) {
const calls: Array<{ command: string; args: string[] }> = [];
const exec: CodegraphExec = async (command, args) => {
calls.push({ command, args });
const result = handler(command, args);
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", killed: false };
return {
code: result.code ?? 0,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
killed: false,
};
};
return { exec, calls };
}
function makeHandler(exec: CodegraphExec) {
return {
handle: createCodegraphExploreHandler(exec, new Map(), new Map()),
};
function makeHandler(
exec: CodegraphExec,
createClient: (root: string) => CodegraphExploreClient,
) {
return createCodegraphExploreHandler({ exec, createClient });
}
// ── findIndexedRoot (unchanged behavior) ────────────────────────────────────
test("findIndexedRoot locates the nearest ancestor index", async () => {
const { root, cleanup } = await makeIndexedProject();
try {
@@ -65,71 +140,229 @@ test("findIndexedRoot returns undefined when no index exists", async () => {
}
});
test("buildExploreArgs maps params to CLI argv", () => {
assert.deepEqual(buildExploreArgs({ query: "auth flow" }, "/repo"), ["explore", "-p", "/repo", "auth flow"]);
assert.deepEqual(
buildExploreArgs({ query: "q", maxFiles: 5, projectPath: "/ignored" }, "/repo"),
["explore", "-p", "/repo", "--max-files", "5", "q"],
);
});
// ── CodegraphRpcClient against the fixture ──────────────────────────────────
test("isNotIndexedOutput detects the upstream marker", () => {
assert.equal(isNotIndexedOutput("✗ CodeGraph isn't available here — no .codegraph/ index exists in /x."), true);
assert.equal(isNotIndexedOutput("Found 3 symbols"), false);
});
test("explore runs the CLI and caches by database mtime", async () => {
test("client handshake + explore returns upstream content verbatim", async () => {
const { root, cleanup } = await makeIndexedProject();
const { exec, calls } = mockExec(() => ({ stdout: "explore output" }));
const { handle } = makeHandler(exec);
const client = fixtureClient(root);
try {
const first = await handle({ query: "auth" }, mockCtx(root));
assert.equal(first.content[0].text, "explore output");
const second = await handle({ query: "auth" }, mockCtx(root));
assert.equal(second.content[0].text, "explore output");
assert.equal(calls.length, 1, "identical query on unchanged index must hit the cache");
// Simulate a reindex: bump the db mtime.
await utimes(join(root, ".codegraph", "codegraph.db"), new Date(), new Date(Date.now() + 5000));
await handle({ query: "auth" }, mockCtx(root));
assert.equal(calls.length, 2, "mtime change must invalidate the cached result");
await client.start();
const result = await client.callExplore({
query: "alphaOne",
maxFiles: 5,
projectPath: "/some/dir",
});
const content = result.content as Array<{ type: string; text: string }>;
const args = JSON.parse(content[0].text.slice("explore:".length));
assert.deepEqual(args, {
query: "alphaOne",
maxFiles: 5,
projectPath: "/some/dir",
});
} finally {
await client.close();
await cleanup();
}
});
test("uninitialized project asks the user and indexes on approval", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec, calls } = mockExec((_command, args) => {
if (args[0] === "init") {
// Emulate codegraph init creating the index.
return { stdout: "indexed" };
}
return { stdout: "explore output" };
test("client forwards only properties the upstream schema declares", async () => {
const { root, cleanup } = await makeIndexedProject();
const client = fixtureClient(root, {
FAKE_CG_SCHEMA: '["query","maxFiles"]',
});
const { handle } = makeHandler(exec);
try {
const result = await handle({ query: "auth" }, mockCtx(root, async () => true));
assert.equal(result.content[0].text, "explore output");
assert.deepEqual(
calls.map(({ args }) => args[0]),
["init", "explore"],
);
assert.deepEqual(calls[0].args, ["init", root]);
await client.start();
const result = await client.callExplore({
query: "q",
maxFiles: 3,
projectPath: "/should/not/forward",
});
const content = result.content as Array<{ text: string }>;
const args = JSON.parse(content[0].text.slice("explore:".length));
assert.deepEqual(args, { query: "q", maxFiles: 3 });
} finally {
await client.close();
await cleanup();
}
});
test("client start fails when the server lacks codegraph_explore", async () => {
const { root, cleanup } = await makeIndexedProject();
const client = fixtureClient(root, { FAKE_CG_MODE: "no-tool" });
try {
await assert.rejects(client.start(), /未提供 codegraph_explore 工具/);
} finally {
await client.close();
await cleanup();
}
});
test("client abort sends notifications/cancelled and rejects", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const logFile = join(dir, "rpc.log");
const client = fixtureClient(dir, {
FAKE_CG_MODE: "hang",
FAKE_CG_LOG: logFile,
});
try {
await client.start();
const controller = new AbortController();
const call = client.callExplore({ query: "q" }, controller.signal);
setTimeout(() => controller.abort(), 50);
await assert.rejects(call);
await client.close();
const lines = (await readFile(logFile, "utf8"))
.trim()
.split("\n")
.map((l) => JSON.parse(l));
const cancelled = lines.find(
(m) => m.method === "notifications/cancelled",
);
assert.ok(cancelled, "server must see notifications/cancelled");
assert.equal(cancelled.params.requestId, 3);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("client request timeout rejects a hung call", async () => {
const { root, cleanup } = await makeIndexedProject();
const client = fixtureClient(
root,
{ FAKE_CG_MODE: "hang" },
{ requestTimeoutMs: 300 },
);
try {
await client.start();
await assert.rejects(client.callExplore({ query: "q" }), /超时/);
} finally {
await client.close();
await cleanup();
}
});
test("client exit rejects the pending call with the stderr tail", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const client = fixtureClient(dir, {
FAKE_CG_MODE: "crash-first",
FAKE_CG_MARKER: join(dir, "crash.marker"),
});
try {
await client.start();
await assert.rejects(
client.callExplore({ query: "q" }),
/index corrupted/,
);
assert.equal(client.dead, true);
} finally {
await client.close();
await rm(dir, { recursive: true, force: true });
}
});
test("client close() terminates the child process", async () => {
const { root, cleanup } = await makeIndexedProject();
const children: ChildProcess[] = [];
const client = fixtureClient(root, {}, {}, children);
try {
await client.start();
await client.close();
assert.equal(children.length, 1);
assert.ok(
children[0].exitCode !== null || children[0].signalCode !== null,
"child must be gone after close()",
);
} finally {
await client.close();
await cleanup();
}
});
// ── handler ─────────────────────────────────────────────────────────────────
test("indexed project explores through the serve child", async () => {
const { root, cleanup } = await makeIndexedProject();
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => fixtureClient(r));
try {
const result = await handler.handle({ query: "auth" }, mockCtx(root));
const content = result.content as Array<{ text: string }>;
assert.match(content[0].text, /^explore:\{/);
assert.equal(calls.length, 0, "indexed project never runs codegraph init");
} finally {
await handler.close();
await cleanup();
}
});
test("declined init is remembered for the rest of the session", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
let confirmCalls = 0;
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => fixtureClient(r));
const ctx = mockCtx(root, {
confirm: async () => {
confirmCalls += 1;
return false;
},
});
try {
const first = await handler.handle({ query: "a" }, ctx);
const second = await handler.handle({ query: "b" }, ctx);
assert.match(first.content[0].text ?? "", /find\/grep\/read\/LSP/);
assert.match(second.content[0].text ?? "", /find\/grep\/read\/LSP/);
assert.equal(confirmCalls, 1, "decline is remembered — no second prompt");
assert.equal(calls.length, 0, "declined init must not spawn the CLI");
} finally {
await handler.close();
await rm(root, { recursive: true, force: true });
}
});
test("uninitialized project bounces to built-in tools when declined", async () => {
test("no UI bounces to built-in tools without prompting", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
const { handle } = makeHandler(exec);
const handler = makeHandler(exec, (r) => fixtureClient(r));
try {
const result = await handle({ query: "auth" }, mockCtx(root, async () => false));
const result = await handler.handle({ query: "auth" }, mockCtx(root));
assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/);
assert.equal(calls.length, 0, "declined init must not spawn the CLI at all");
assert.equal(calls.length, 0);
} finally {
await handler.close();
await rm(root, { recursive: true, force: true });
}
});
test("uninitialized project inits on approval then explores", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec, calls } = mockExec((_command, args) => {
if (args[0] === "init") return { stdout: "indexed" };
return { stdout: "unreachable" };
});
// Emulate codegraph init materializing the index.
const execWithSideEffect: CodegraphExec = async (command, args, options) => {
const result = await exec(command, args, options);
if (args[0] === "init") {
await mkdir(join(root, ".codegraph"));
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
}
return result;
};
const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r));
try {
const result = await handler.handle(
{ query: "auth" },
mockCtx(root, { confirm: async () => true }),
);
const content = result.content as Array<{ text: string }>;
assert.match(content[0].text, /^explore:\{/);
assert.deepEqual(
calls.map(({ args }) => args[0]),
["init"],
);
assert.deepEqual(calls[0].args, ["init", root]);
} finally {
await handler.close();
await rm(root, { recursive: true, force: true });
}
});
@@ -138,21 +371,28 @@ test("concurrent calls on an uninitialized project share one init decision", asy
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
let confirmCalls = 0;
const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
const pending = new Map<string, Promise<string | undefined>>();
const handle = createCodegraphExploreHandler(exec, new Map(), pending);
const execWithSideEffect: CodegraphExec = async (command, args, options) => {
const result = await exec(command, args, options);
if (args[0] === "init") {
await mkdir(join(root, ".codegraph"));
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
}
return result;
};
const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r));
try {
const ctx = mockCtx(root, {
confirm: async () => {
confirmCalls += 1;
return true;
},
});
const [a, b] = await Promise.all([
handle({ query: "one" }, mockCtx(root, async () => {
confirmCalls += 1;
return true;
})),
handle({ query: "two" }, mockCtx(root, async () => {
confirmCalls += 1;
return true;
})),
handler.handle({ query: "one" }, ctx),
handler.handle({ query: "two" }, ctx),
]);
assert.equal(a.content[0].text, "ok");
assert.equal(b.content[0].text, "ok");
assert.equal(a.content[0].type, "text");
assert.equal(b.content[0].type, "text");
assert.equal(confirmCalls, 1, "parallel callers must not double-prompt");
assert.equal(
calls.filter(({ args }) => args[0] === "init").length,
@@ -160,49 +400,193 @@ test("concurrent calls on an uninitialized project share one init decision", asy
"parallel callers must share a single init",
);
} finally {
await handler.close();
await rm(root, { recursive: true, force: true });
}
});
test("init failure is reported instead of exploring", async () => {
test("init failure bounces with exit code instead of exploring", async () => {
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec } = mockExec((_command, args) =>
args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" },
);
const { handle } = makeHandler(exec);
const handler = makeHandler(exec, (r) => fixtureClient(r));
try {
const result = await handle({ query: "auth" }, mockCtx(root, async () => true));
assert.match(result.content[0].text ?? "", /codegraph init 失败/);
const result = await handler.handle(
{ query: "auth" },
mockCtx(root, { confirm: async () => true }),
);
assert.match(result.content[0].text ?? "", /codegraph init 失败(退出码 1)/);
} finally {
await handler.close();
await rm(root, { recursive: true, force: true });
}
});
test("a dead serve child is transparently replaced once", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
await mkdir(join(dir, ".codegraph"));
await writeFile(join(dir, ".codegraph", "codegraph.db"), "");
const env = {
FAKE_CG_MODE: "crash-first",
FAKE_CG_MARKER: join(dir, "crash.marker"),
};
let created = 0;
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => {
created += 1;
return fixtureClient(r, env);
});
try {
const result = await handler.handle({ query: "auth" }, mockCtx(dir));
const content = result.content as Array<{ text: string }>;
assert.match(content[0].text, /^explore:\{/);
assert.equal(created, 2, "the crashed client is replaced exactly once");
} finally {
await handler.close();
await rm(dir, { recursive: true, force: true });
}
});
test("a persistently crashing child bounces after one retry", async () => {
const { root, cleanup } = await makeIndexedProject();
let created = 0;
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => {
created += 1;
return fixtureClient(r, { FAKE_CG_MODE: "crash" });
});
try {
const result = await handler.handle({ query: "auth" }, mockCtx(root));
assert.match(result.content[0].text ?? "", /CodeGraph 服务调用失败/);
assert.equal(created, 2, "one retry, then bounce");
} finally {
await handler.close();
await cleanup();
}
});
test("missing CLI surfaces an install hint instead of throwing", async () => {
const { root, cleanup } = await makeIndexedProject();
const exec: CodegraphExec = async () => {
throw new Error("spawn codegraph ENOENT");
};
const { handle } = makeHandler(exec);
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, () => ({
dead: false,
start: async () => {
throw new Error("spawn codegraph ENOENT");
},
callExplore: async () => ({}),
close: async () => {},
schemaPropertyNames: () => [],
}));
try {
const result = await handle({ query: "auth" }, mockCtx(root));
assert.match(result.content[0].text ?? "", /无法执行 codegraph CLI/);
const result = await handler.handle({ query: "auth" }, mockCtx(root));
assert.match(result.content[0].text ?? "", /未找到 codegraph CLI/);
assert.match(result.content[0].text ?? "", /install\.sh/);
} finally {
await handler.close();
await cleanup();
}
});
test("projectPath overrides the session cwd", async () => {
const { root, cleanup } = await makeIndexedProject();
const other = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
const { handle } = makeHandler(exec);
test("projectPath targets a different indexed root with its own client", async () => {
const main = await makeIndexedProject();
const other = await makeIndexedProject();
const roots: string[] = [];
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) => {
roots.push(r);
return fixtureClient(r);
});
try {
const params: ExploreParams = { query: "auth", projectPath: root };
await handle(params, mockCtx(other));
assert.deepEqual(calls[0].args, ["explore", "-p", root, "auth"]);
await handler.handle({ query: "a" }, mockCtx(main.root));
await handler.handle(
{ query: "b", projectPath: other.root } satisfies ExploreParams,
mockCtx(main.root),
);
assert.deepEqual(roots, [main.root, other.root]);
} finally {
await cleanup();
await rm(other, { recursive: true, force: true });
await handler.close();
await main.cleanup();
await other.cleanup();
}
});
test("schema drift notifies once per process", async () => {
const { root, cleanup } = await makeIndexedProject();
const notices: string[] = [];
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
const handler = makeHandler(exec, (r) =>
fixtureClient(r, { FAKE_CG_SCHEMA: '["query","maxFiles"]' }),
);
const ctx = mockCtx(root, {
confirm: async () => true,
notify: (message: string) => {
notices.push(message);
},
});
try {
await handler.handle({ query: "a" }, ctx);
await handler.handle({ query: "b" }, ctx);
assert.equal(notices.length, 1);
assert.match(notices[0], /参数已变化/);
} finally {
await handler.close();
await cleanup();
}
});
// ── real CLI integration ────────────────────────────────────────────────────
const codegraphAvailable = (() => {
try {
execFileSync("codegraph", ["--version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
})();
test(
"real codegraph serve --mcp keeps the index synced via its watcher",
{ skip: !codegraphAvailable },
async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-real-"));
const client = new CodegraphRpcClient({ root: dir });
try {
execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
await writeFile(
join(dir, "a.ts"),
"export function alphaOne() { return 1; }\n",
);
execFileSync("codegraph", ["init", dir], {
cwd: dir,
stdio: "ignore",
timeout: 120_000,
});
const first = await client.callExplore({ query: "alphaOne" });
const firstText = (first.content as Array<{ text?: string }>)
.map((c) => c.text ?? "")
.join("\n");
assert.match(firstText, /alphaOne/);
await writeFile(
join(dir, "a.ts"),
"export function alphaOne() { return 1; }\nexport function betaTwo() { return 2; }\n",
);
const deadline = Date.now() + 15_000;
let found = false;
while (Date.now() < deadline && !found) {
const res = await client.callExplore({ query: "betaTwo" });
found = (res.content as Array<{ text?: string }>).some((c) =>
(c.text ?? "").includes("betaTwo"),
);
if (!found) await sleep(500);
}
assert.ok(found, "watcher should make betaTwo searchable within 15 s");
} finally {
await client.close();
await rm(dir, { recursive: true, force: true });
}
},
);
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Fake `codegraph serve --mcp`: newline-delimited JSON-RPC 2.0 on stdio, for
* tests of extensions/codegraph-client.ts.
*
* Env:
* FAKE_CG_MODE "ok" (default) | "no-tool" | "crash-first" | "crash" | "hang"
* no-tool: tools/list returns no codegraph_explore
* crash-first: on the first tools/call, write FAKE_CG_MARKER
* and exit(1) — a second spawned process sees
* the marker and serves normally
* crash: exit(1) on every tools/call
* hang: never reply to tools/call
* FAKE_CG_SCHEMA JSON array of inputSchema property names
* (default ["query","maxFiles","projectPath"])
* FAKE_CG_MARKER marker file path for crash-first
* FAKE_CG_LOG file to append every inbound message as a JSON line
*/
import fs from "node:fs";
import readline from "node:readline";
const mode = process.env.FAKE_CG_MODE || "ok";
const marker = process.env.FAKE_CG_MARKER;
const logFile = process.env.FAKE_CG_LOG;
const schemaProps = (() => {
try {
return JSON.parse(process.env.FAKE_CG_SCHEMA || "");
} catch {
return ["query", "maxFiles", "projectPath"];
}
})();
function log(entry) {
if (logFile) fs.appendFileSync(logFile, JSON.stringify(entry) + "\n");
}
function send(msg) {
process.stdout.write(JSON.stringify(msg) + "\n");
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
const text = line.trim();
if (!text) return;
let msg;
try {
msg = JSON.parse(text);
} catch {
return;
}
if (!msg || typeof msg !== "object") return;
log(msg);
if (typeof msg.method !== "string") return;
switch (msg.method) {
case "initialize":
send({
jsonrpc: "2.0",
id: msg.id,
result: {
protocolVersion: "2025-06-18",
capabilities: {},
serverInfo: { name: "fake-codegraph", version: "0.0.0" },
},
});
return;
case "tools/list": {
const tools =
mode === "no-tool"
? []
: [
{
name: "codegraph_explore",
description: "fake explore",
inputSchema: {
type: "object",
properties: Object.fromEntries(
schemaProps.map((name) => [name, { type: "string" }]),
),
required: ["query"],
},
},
];
send({ jsonrpc: "2.0", id: msg.id, result: { tools } });
return;
}
case "tools/call": {
const shouldCrash =
mode === "crash" ||
(mode === "crash-first" && marker && !fs.existsSync(marker));
if (shouldCrash) {
if (mode === "crash-first") fs.writeFileSync(marker, "crashed");
// Synchronous write — process.exit() can truncate pending pipe output.
fs.writeSync(2, "fatal: codegraph index corrupted\n");
process.exit(1);
}
if (mode === "hang") return; // Never reply — timeout/abort test.
const args = (msg.params && msg.params.arguments) || {};
send({
jsonrpc: "2.0",
id: msg.id,
result: {
content: [{ type: "text", text: `explore:${JSON.stringify(args)}` }],
},
});
return;
}
default:
// Notifications (initialized, cancelled) need no reply; unknown
// requests get -32601.
if (msg.id !== undefined) {
send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32601, message: "Method not found" },
});
}
}
});