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:
parent
0455ddd346
commit
fd07b514ab
2 changed files with 105 additions and 0 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 } }),
|
||||
|
|
|
|||
Loading…
Reference in a new issue