WFDASH-WEBAPP/docs/bugs/2026-08-04-mcp-initialize-handshake-missing.md
SJCoetz 0013ff5267 docs(bugs): add post-mortem for MCP initialize handshake bug
Captures the full timeline of the 2026-08-04 incident where WFDASH
/api/mcp silently failed every spec-compliant MCP client's handshake
because the route didn't handle the mandatory 'initialize' method.

Includes:
  - Symptom (Hermes log lines + WebUI '0 tools' display)
  - Root cause (HTTP 404 on initialize → client terminates session)
  - Fix summary (the three new handlers, with the critical 'don't use
    the content envelope for initialize' gotcha)
  - Verification recipe (curl against the live endpoint)
  - Lessons to apply to other JSON-RPC endpoints we might add later
    (initialize is mandatory; don't reuse the tools/call envelope;
    HTTP 404 is a signal the endpoint isn't MCP at all)

Co-authored-by: Orca <help@stably.ai>
2026-08-05 01:53:02 +02:00

6.1 KiB

Bug: WFDASH MCP endpoint silently failed the initialize handshake

Status: ✅ Fixed 2026-08-04 in commit fixing src/app/api/mcp/route.ts. Severity: High — every spec-compliant MCP client silently failed to connect. Discovery: Live server logs from a Hermes agent deployment after the wfdash MCP server was added to its config. Reporter: Giellie (the WFDASH agent) at the operator's request.


Symptom

A spec-compliant MCP client (Hermes in this case, but the same would apply to Claude Desktop, Cursor, mcp-cli, or any other) configured with:

mcp_servers:
  wfdash:
    type: http
    url: https://wfdash.apphub.co.za/api/mcp
    headers:
      Authorization: "Bearer 50e15d09…b1"
    enabled: true

…would log:

WARNING tools.mcp_tool: MCP server 'wfdash' initial connection failed (attempt 1/3),
retrying in 1s: unhandled errors in a TaskGroup (1 sub-exception)
WARNING tools.mcp_tool: MCP server 'wfdash' initial connection failed (attempt 2/3),
retrying in 2s: unhandled errors in a TaskGroup (1 sub-exception)
WARNING tools.mcp_tool: MCP server 'wfdash' initial connection failed (attempt 3/3),
retrying in 4s: unhandled errors in a TaskGroup (1 sub-exception)
WARNING tools.mcp_tool: MCP server 'wfdash' failed initial connection after 3 attempts,
giving up: unhandled errors in a TaskGroup (1 sub-exception)
WARNING tools.mcp_tool: Failed to connect to MCP server 'wfdash': Session terminated
INFO tools.mcp_tool: MCP: registered 0 tool(s) from 0 server(s) (1 failed)

The Hermes WebUI would show:

MCP Servers → wfdash → HTTP → CONFIGURED → 0 tools

…and the agent runtime would have no wfdash__* tools available.

Root cause

/api/mcp only handled two JSON-RPC methods:

  • tools/list
  • tools/call

The MCP protocol requires a three-step handshake before either of those:

  1. Client → Server: {"method": "initialize", "params": {protocolVersion, capabilities, clientInfo}}
  2. Server → Client: {"result": {protocolVersion, capabilities, serverInfo}}
  3. Client → Server: {"method": "notifications/initialized"} (notification, no id)

When Hermes sent step 1, our route fell through to the catch-all:

return NextResponse.json(
  { jsonrpc: "2.0", id: body?.id ?? null, error: { code: -32601, message: "Method not found" } },
  { status: 404 }   // ← this is the killer
);

A spec-compliant MCP client interprets HTTP 404 as "this URL is not an MCP server" and terminates the session immediately — never reaching tools/list. Hence 0 tools registered.

This was masked during earlier testing because we (and previous operators) hit tools/list directly via curl or the Giellie skill — the loose JSON-RPC clients (or skill-based LLM context) didn't care about the missing handshake, but real MCP clients do.

Fix

Three new handlers in src/app/api/mcp/route.ts, added before the tools/list dispatch:

if (body?.method === "initialize") {
  // Plain object — NOT wrapped in the tools/call content envelope
  // (jsonRpcOk wraps in {content: [{type:"text", text: JSON.stringify(...)}]},
  //  which would still break clients).
  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" },
    },
  });
}
if (body?.method === "notifications/initialized") {
  return new NextResponse(null, { status: 204 });
}
if (body?.method === "ping") {
  return NextResponse.json({
    jsonrpc: "2.0",
    id: body.id ?? null,
    result: {},
  });
}

Key detail: the initialize response must use a plain object for result, not the content: [{type:"text", text: JSON.stringify(...)}] envelope that our jsonRpcOk helper wraps around tools/list / tools/call results. Using jsonRpcOk here would have left the handshake broken in a different way (the protocolVersion would have ended up at result.content[0].text, not result.protocolVersion).

Tests

Four new Vitest cases in src/app/api/mcp/route.test.ts:

  • handles MCP initialize handshake and returns server info (regression: 2026-08-04) — confirms HTTP 200, correct result shape, echo of client-provided protocolVersion
  • initialize echoes back an unrecognised protocolVersion so older clients keep working — confirms we don't hard-pin a version
  • acknowledges notifications/initialized with HTTP 204 and no body — confirms the notification returns empty
  • answers ping with an empty result (liveness probe) — confirms ping returns { result: {} }

Total: 46 tests, all passing.

How to verify after redeploy

From any terminal with internet access:

curl -sS -X POST https://wfdash.apphub.co.za/api/mcp \
  -H "Authorization: Bearer 50e15d097fe85023fed7bb29340347befa224afda83cb270d89cdfa3c8299cb1" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}'

Expected (HTTP 200):

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": { "tools": {} },
    "serverInfo": { "name": "wfdash-mcp", "version": "1.0.0" }
  }
}

Then in your Hermes WebUI (or any MCP client configured against this URL), the wfdash server's tool count should go from 0 to 69.

Lessons / things to look for in other endpoints

  • Check every JSON-RPC server we expose for the standard method list. initialize and notifications/initialized are mandatory. Other useful ones to add if we ever extend scope: prompts/list, prompts/get, resources/list, resources/read, logging/setLevel.
  • Don't reuse the tools/call content envelope for non-tools/call methods. initialize and ping want a plain object result. Consider extracting a separate jsonRpcResultOk(id, result) helper to make the right pattern the default.
  • HTTP 404 ≠ "method not implemented" in MCP-land. Clients use 404 as a signal that the endpoint isn't an MCP server at all. The catch-all for unknown methods should return HTTP 200 with error.code = -32601, not HTTP 404.