fix(mcp): handle initialize/notifications/initialized/ping handshake

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 <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 01:53:02 +02:00
parent 0455ddd346
commit fd07b514ab
2 changed files with 105 additions and 0 deletions

View file

@ -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({

View file

@ -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 } }),