diff --git a/docs/bugs/2026-08-04-mcp-initialize-handshake-missing.md b/docs/bugs/2026-08-04-mcp-initialize-handshake-missing.md new file mode 100644 index 0000000..cb32886 --- /dev/null +++ b/docs/bugs/2026-08-04-mcp-initialize-handshake-missing.md @@ -0,0 +1,146 @@ +# 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: + +```yaml +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: + +```ts +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: + +```ts +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: + +```bash +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): + +```json +{ + "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. \ No newline at end of file