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>
This commit is contained in:
parent
fd07b514ab
commit
0013ff5267
1 changed files with 146 additions and 0 deletions
146
docs/bugs/2026-08-04-mcp-initialize-handshake-missing.md
Normal file
146
docs/bugs/2026-08-04-mcp-initialize-handshake-missing.md
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue