From fd07b514abd76ec50131a76ad80cb378aeccdf9f Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 5 Aug 2026 01:53:02 +0200 Subject: [PATCH] fix(mcp): handle initialize/notifications/initialized/ping handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WFDASH /api/mcp previously only handled tools/list and tools/call, which is fine for our internal curl-based testing but broke every spec-compliant MCP client (Hermes, Claude Desktop, Cursor, mcp-cli). The MCP protocol requires a three-step handshake before tools/list: 1. client → server: initialize 2. server → client: result with protocolVersion, capabilities, serverInfo 3. client → server: notifications/initialized (notification, no response) Before this fix, step 1 got HTTP 404 with error.code -32601, which MCP clients interpret as 'this isn't an MCP server' and terminate the session. Symptom: configured servers show 'CONFIGURED' in the Hermes WebUI but '0 tools', with the runtime logging 'unhandled errors in a TaskGroup ... Session terminated'. Three new handlers, each with the right response shape: initialize → 200 + plain result {protocolVersion, capabilities, serverInfo} notifications/initialized → 204 + empty body (notification, no envelope) ping → 200 + result {} Critical: the initialize response uses a PLAIN object for result, not the content/text envelope that our jsonRpcOk helper wraps tools/list and tools/call results in. Using jsonRpcOk here would have left the handshake broken in a subtler way. Four new Vitest cases: - initialize returns server info (regression: 2026-08-04) - initialize echoes client-provided protocolVersion (forward compat) - notifications/initialized returns 204 - ping returns {} Bug report with full timeline + lessons: see docs/bugs/2026-08-04-mcp-initialize-handshake-missing.md (same commit series; pushed separately as the second commit). All 46 tests pass. Co-authored-by: Orca --- src/app/api/mcp/route.test.ts | 64 +++++++++++++++++++++++++++++++++++ src/app/api/mcp/route.ts | 41 ++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 4b194b8..0bce544 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -288,6 +288,70 @@ describe("POST /api/mcp", () => { expect(body.error.message).toMatch(/Missing id/); }); + it("handles MCP initialize handshake and returns server info (regression: 2026-08-04)", async () => { + // Before this handler existed, /api/mcp returned HTTP 404 with + // "Method not found" on `initialize`, which made every spec-compliant + // MCP client (Hermes, Claude Desktop, Cursor, …) abort the session + // before `tools/list` ever ran. Spec-compliant clients expect HTTP 200 + // with a well-formed `result` containing `protocolVersion`, `capabilities`, + // and `serverInfo`. + const res = await POST( + mcpRequest({ + method: "initialize", + id: 100, + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" }, + }, + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.jsonrpc).toBe("2.0"); + expect(body.id).toBe(100); + expect(body.error).toBeUndefined(); + expect(body.result.protocolVersion).toBe("2024-11-05"); + expect(body.result.capabilities).toEqual({ tools: {} }); + expect(body.result.serverInfo).toMatchObject({ name: "wfdash-mcp" }); + }); + + it("initialize echoes back an unrecognised protocolVersion so older clients keep working", async () => { + // The spec says servers MAY support older protocol versions. We + // echo whatever the client sent (rather than forcing 2024-11-05) + // so a client on 2025-03-26 doesn't immediately drop us. + const res = await POST( + mcpRequest({ + method: "initialize", + id: 101, + params: { protocolVersion: "2025-06-18-future-version" }, + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.protocolVersion).toBe("2025-06-18-future-version"); + }); + + it("acknowledges notifications/initialized with HTTP 204 and no body", async () => { + // JSON-RPC notification: no `id`, server returns no JSON-RPC envelope. + // Clients shouldn't hang waiting for a reply that won't come. + const res = await POST( + mcpRequest({ method: "notifications/initialized" }) + ); + expect(res.status).toBe(204); + const text = await res.text(); + expect(text).toBe(""); + }); + + it("answers ping with an empty result (liveness probe)", async () => { + const res = await POST( + mcpRequest({ method: "ping", id: 102 }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result).toEqual({}); + }); + it("rejects unknown tools with a JSON-RPC error", async () => { const res = await POST( mcpRequest({ diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 838bb24..e0e1646 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1309,6 +1309,47 @@ export async function POST(req: NextRequest) { assertAgentAuth(req); const body = await req.json(); + // MCP protocol handshake. The spec requires every client to send + // `initialize` before any other method; clients that don't get a + // well-formed response (incl. HTTP 200) abort the session before + // `tools/list` ever fires. This block is what makes WFDASH reachable + // as a real MCP server from any spec-compliant client (Hermes, + // Claude Desktop, Cursor, mcp-cli, …). Before this fix, clients + // got HTTP 404 "Method not found" on `initialize` and terminated + // the session — see docs/bugs/2026-08-04-mcp-initialize-handshake.md. + // + // Result shape: the MCP `initialize` response is a PLAIN object, + // not wrapped in the `content: [{type:"text",...}]` envelope that + // `jsonRpcOk` (used by tools/list and tools/call) wraps its result + // in. Using jsonRpcOk here would put the protocolVersion under + // `result.content[0].text` and clients would still drop us. + if (body?.method === "initialize") { + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: { + protocolVersion: body?.params?.protocolVersion || "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "wfdash-mcp", version: "1.0.0" }, + }, + }); + } + // `notifications/initialized` is a JSON-RPC notification (no `id`, + // no response expected). We acknowledge with 204 so the client + // doesn't hang waiting for a reply that never comes. + if (body?.method === "notifications/initialized") { + return new NextResponse(null, { status: 204 }); + } + // Optional liveness probe — some clients send this before opening + // a real session. Result is an empty object per the spec. + if (body?.method === "ping") { + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: {}, + }); + } + if (body?.method === "tools/list") { return withCors( NextResponse.json({ jsonrpc: "2.0", id: body.id ?? null, result: { tools } }),