fix(mcp): don't block initialize handshake on heavy init (#172) (#177)

The MCP `initialize` handler was awaiting `tryInitializeDefault` —
which opens the SQLite DB and runs `await initGrammars()` (tree-sitter
WASM bootstrap) — before sending the JSON-RPC response. On slow
filesystems (Docker Desktop VirtioFS on macOS, WSL2) this could exceed
Claude Code's ~30s handshake timeout, leaving the codegraph child
process alive and unresponsive with no tools visible in the client.

Send the response first; defer the open to a tracked background
promise. The lazy retry path used by `tools/list` and `tools/call`
now awaits that promise instead of racing it with `openSync`, so we
never double-open the SQLite file.

Adds a subprocess-based regression test that asserts the JSON-RPC
response arrives on stdout before `startWatching()` logs to stderr.
This ordering check catches the regression on any filesystem, not
just slow ones where the timing matters in practice.

Reported by @sashanclrp; isolated by @sgrimm's wire capture.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-19 10:20:02 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 9b6a917d32
commit 36c8dbc404
3 changed files with 200 additions and 10 deletions
+33 -10
View File
@@ -64,6 +64,9 @@ export class MCPServer {
private cg: CodeGraph | null = null;
private toolHandler: ToolHandler;
private projectPath: string | null;
// In-flight background init kicked off from handleInitialize. Tracked so the
// sync retry path doesn't race against it (double-opening the SQLite file).
private initPromise: Promise<void> | null = null;
constructor(projectPath?: string) {
this.projectPath = projectPath || null;
@@ -130,8 +133,16 @@ export class MCPServer {
* Called lazily on tool calls that need the default project.
* Re-walks parent directories each time so it picks up projects
* initialized after the MCP server started.
*
* Awaits any in-flight background init (kicked off by handleInitialize) so
* we never open the SQLite file twice concurrently.
*/
private retryInitIfNeeded(): void {
private async retryInitIfNeeded(): Promise<void> {
// Wait for the background init started during handleInitialize, if any.
if (this.initPromise) {
try { await this.initPromise; } catch { /* errored init falls through to retry */ }
}
// Already initialized successfully
if (this.toolHandler.hasDefaultCodeGraph()) return;
// No project path to retry with
@@ -266,13 +277,17 @@ export class MCPServer {
projectPath = process.cwd();
}
// Try to initialize the default project (non-fatal if it fails)
await this.tryInitializeDefault(projectPath);
// We accept the client's protocol version but respond with our supported version.
// The `instructions` field is surfaced by MCP clients in the agent's system
// prompt automatically — it's the right place for the universal tool-selection
// playbook, ahead of individual tool descriptions.
// Respond to the handshake BEFORE doing any heavy initialization. Loading
// the SQLite DB and the tree-sitter WASM runtime can take many seconds on
// slow filesystems (Docker Desktop VirtioFS on macOS, WSL2). Clients like
// Claude Code time out the handshake at ~30s, which manifested as
// "MCP tools never appear" — the child was alive and had received the
// initialize but was still awaiting initGrammars(). See issue #172.
//
// We accept the client's protocol version but respond with our supported
// version. The `instructions` field is surfaced by MCP clients in the
// agent's system prompt automatically — it's the right place for the
// universal tool-selection playbook, ahead of individual tool descriptions.
this.transport.sendResult(request.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
@@ -281,13 +296,21 @@ export class MCPServer {
serverInfo: SERVER_INFO,
instructions: SERVER_INSTRUCTIONS,
});
// Kick off the default-project init in the background. Tool calls that
// arrive before it finishes will see the "not initialized yet" path and
// fall through to `retryInitIfNeeded`, which now waits for this promise
// rather than racing against it with a second open.
this.initPromise = this.tryInitializeDefault(projectPath).finally(() => {
this.initPromise = null;
});
}
/**
* Handle tools/list request
*/
private async handleToolsList(request: JsonRpcRequest): Promise<void> {
this.retryInitIfNeeded();
await this.retryInitIfNeeded();
this.transport.sendResult(request.id, {
tools: this.toolHandler.getTools(),
});
@@ -327,7 +350,7 @@ export class MCPServer {
// If the default project isn't initialized yet, retry in case it was
// initialized after the MCP server started (e.g. user ran codegraph init)
this.retryInitIfNeeded();
await this.retryInitIfNeeded();
const result = await this.toolHandler.execute(toolName, toolArgs);