From 49248e25ebce4690fff5dafc5d7dadc8b0f53bd2 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 2 Oct 2026 04:13:42 +0000 Subject: [PATCH] fix(mcp): answer server/discover locally in the stdio proxy (#2084) (#2282) * fix(mcp): answer server/discover locally in the stdio proxy (#2084) Clients that probe `server/discover` before the legacy `initialize` (Antigravity 2.5) wait for its answer. The proxy forwarded it to the shared daemon, so no reply was written until the daemon connected: a client that closed stdin first got nothing, and with the daemon unavailable the reply came ~6s later as -32603 "daemon unavailable". Answer it in the proxy with -32601 Method not found, which is what sends such a client on to `initialize`. The direct and daemon sessions already answer unknown methods with -32601; the new test pins that too. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 77db6c659cbd4f404952440a80289610e1650175) * docs(changelog): credit the server/discover fix Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: danusha2345 Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/mcp-server-discover.test.ts | 135 ++++++++++++++++++++++++++ src/mcp/proxy.ts | 7 ++ 3 files changed, 143 insertions(+) create mode 100644 __tests__/mcp-server-discover.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42adc334f..d20a33d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading. - A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading. - The Claude Code prompt hook no longer runs on the messages Claude Code uses to hand a subagent's report back to the main session. Before, such a long report could keep the hook busy past Claude Code's 30-second hook timeout and inject context unrelated to what you asked. Thanks @danusha2345, and @tippmar-nr for the report. (#2184) +- Newer MCP clients such as Antigravity 2.5 connect again: the server now answers their `server/discover` probe right away with "method not found", so they go straight on to the regular handshake instead of waiting on a reply that could take seconds or never come. Thanks @danusha2345, and @samem26 for the report. (#2084) ## [1.6.1] - 2026-09-29 diff --git a/__tests__/mcp-server-discover.test.ts b/__tests__/mcp-server-discover.test.ts new file mode 100644 index 000000000..c464a5770 --- /dev/null +++ b/__tests__/mcp-server-discover.test.ts @@ -0,0 +1,135 @@ +/** + * `server/discover` probe regression tests (issue #2084). + * + * Newer MCP clients (Antigravity 2.5) send `server/discover` before the legacy + * `initialize` and wait for the answer. A legacy server is expected to reply + * `-32601 Method not found`, which sends the client on to `initialize`. The + * stdio proxy forwarded the probe to the shared daemon instead, so nothing was + * written until the daemon connected: a client that closed stdin first got no + * reply at all, and with the daemon unavailable the reply came ~6s later as a + * `-32603` the client can't read as "use initialize". + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import { once } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; +import { recordSpawns, removeSpawnLog, settleLosingCandidates, spawnLogFor } from './daemon-candidates'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); +const DISCOVER = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server/discover' }) + '\n'; + +function readLockPid(root: string): number | null { + try { + const info = JSON.parse(fs.readFileSync(path.join(root, '.codegraph', 'daemon.pid'), 'utf8')); + return typeof info === 'number' ? info : typeof info.pid === 'number' ? info.pid : null; + } catch { return null; } +} + +function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +/** + * Send one `server/discover` line and resolve with everything the server wrote + * to stdout once it has exited. `closeStdin: 'at-once'` is the shape of the + * `printf … | codegraph serve --mcp` repro; `'after-reply'` is a client that + * waits for the answer (stdin is closed on the first stdout line). + */ +async function discover( + cwd: string, + env: NodeJS.ProcessEnv, + children: ChildProcessWithoutNullStreams[], + closeStdin: 'at-once' | 'after-reply' = 'at-once', +): Promise { + const recorder = recordSpawns(cwd); + const child = spawn(process.execPath, [...WASM_RUNTIME_FLAGS, ...recorder.args, BIN, 'serve', '--mcp', '--no-watch'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...recorder.env, CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '1000', ...env }, + }) as ChildProcessWithoutNullStreams; + children.push(child); + child.on('error', () => { /* ignore */ }); + child.stdin.on('error', () => { /* ignore */ }); + let stdout = ''; + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + if (closeStdin === 'after-reply' && stdout.includes('\n')) child.stdin.end(); + }); + child.stderr.resume(); + const exited = once(child, 'exit'); + if (closeStdin === 'at-once') child.stdin.end(DISCOVER); + else child.stdin.write(DISCOVER); + await exited; + return stdout; +} + +function expectMethodNotFound(stdout: string): void { + const replies = stdout.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l)); + expect(replies).toEqual([ + { jsonrpc: '2.0', id: 1, error: expect.objectContaining({ code: -32601 }) }, + ]); +} + +describe('server/discover probe (issue #2084)', () => { + let tempDir: string; + let realRoot: string; + const children: ChildProcessWithoutNullStreams[] = []; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-discover-')); + realRoot = fs.realpathSync(tempDir); + }); + + afterEach(async () => { + await Promise.all(children.map(async (child) => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + })); + children.length = 0; + // The proxy spawns a detached daemon candidate; wait for the losers, then + // stop the lock holder — never the vitest worker, which one test plants as + // the holder. + if (fs.existsSync(spawnLogFor(tempDir))) { + await settleLosingCandidates(tempDir, () => readLockPid(realRoot)); + const daemonPid = readLockPid(realRoot); + if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) { + try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ } + for (let i = 0; i < 100 && isAlive(daemonPid); i++) await new Promise((r) => setTimeout(r, 50)); + } + removeSpawnLog(tempDir); + } + await fs.promises.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }, 45_000); + + it('proxy mode answers -32601 even when the client closes stdin right after the probe', async () => { + const cg = await CodeGraph.init(tempDir); + cg.close(); + expectMethodNotFound(await discover(tempDir, {}, children)); + }, 20_000); + + it('proxy mode answers -32601, not "daemon unavailable", when the daemon cannot start', async () => { + const cg = await CodeGraph.init(tempDir); + cg.close(); + // A legacy plain-pid lock held by a live process (this worker) makes every + // daemon candidate step aside, so the proxy never gets a daemon. + fs.writeFileSync(path.join(realRoot, '.codegraph', 'daemon.pid'), `${process.pid}\n`); + expectMethodNotFound(await discover(tempDir, {}, children, 'after-reply')); + }, 20_000); + + it.each([ + ['CODEGRAPH_NO_DAEMON=1 on an indexed project', true, { CODEGRAPH_NO_DAEMON: '1' }], + ['no .codegraph/ in the working directory', false, {}], + ])('direct mode answers -32601 (%s)', async (_label, indexed, env) => { + if (indexed) { + const cg = await CodeGraph.init(tempDir); + cg.close(); + } + expectMethodNotFound(await discover(tempDir, env, children)); + }, 20_000); +}); diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index bdcb997d2..17c5a59cb 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -30,6 +30,7 @@ import { CodeGraphPackageVersion } from './version'; import { SERVER_INFO, PROTOCOL_VERSION, initializeInstructions } from './session'; import { SERVER_INSTRUCTIONS } from './server-instructions'; import { getStaticTools } from './tools'; +import { ErrorCodes } from './transport'; import { ExploreSessionState } from './explore-session-state'; import { getTelemetry, ClientInfo } from '../telemetry'; import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog'; @@ -332,6 +333,12 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< writeClient({ jsonrpc: '2.0', id: msg.id, result: { resourceTemplates: [] } }); } else if (msg.method === 'prompts/list') { writeClient({ jsonrpc: '2.0', id: msg.id, result: { prompts: [] } }); + } else if (msg.method === 'server/discover' && msg.id !== undefined) { + // Newer clients (Antigravity 2.5) probe this before `initialize` and wait + // for the answer. We don't implement it; Method-not-found is what sends + // them on to `initialize`. Answer locally — forwarded, it waited on the + // daemon connection and was lost if the client closed stdin first. (#2084) + writeClient({ jsonrpc: '2.0', id: msg.id, error: { code: ErrorCodes.MethodNotFound, message: 'Method not found: server/discover' } }); } else { routeToDaemon(line); }