fix(server): stop rejecting agent MCP requests over an unadvertised protocol version header - #4375
Conversation
…rotocol version header Codex CLI >= 0.148 (and Claude Code >= 2.1.257, which recovers on retry) sends its *preferred* MCP protocol version in the MCP-Protocol-Version header of post-initialize requests instead of the negotiated one. The bundled @modelcontextprotocol/sdk only advertises versions up to its own LATEST_PROTOCOL_VERSION, so the transport answers every tools/list and tools/call with 400 "Unsupported protocol version" and the client never sees the injected Paseo tools. Codex does not retry, so its agents silently lose the whole tool surface (getpaseo#3599). The header is a post-handshake sanity echo, not a negotiation point: initialize already negotiated a version this server supports, and the stateless agent MCP transport serves every request independently. Drop unadvertised header values (both from req.headers and req.rawHeaders, which the SDK's node-to-web-standard adapter actually reads) so misbehaving clients keep working; advertised values pass through untouched and a warn is logged for the drop.
|
| Filename | Overview |
|---|---|
| packages/server/src/server/bootstrap.ts | Correctly normalizes unsupported protocol-version headers before transport handling, but the compatibility shim lacks the repository-required cleanup tag. |
| packages/server/src/server/agent/agent-mcp.e2e.test.ts | Covers the client-visible regression through the real endpoint, but uses unchecked response assertions and embeds protocol framing in the test body. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[MCP client initializes with preferred version] --> B[SDK negotiates a supported version]
B --> C[Client sends post-initialize request]
C --> D{Header advertised by SDK?}
D -- Yes --> E[Preserve header]
D -- No --> F[Warn and remove parsed and raw header]
E --> G[SDK transport handles request]
F --> G
G --> H[Paseo tools returned]
Reviews (1): Last reviewed commit: "fix(server): stop rejecting agent MCP re..." | Re-trigger Greptile
| // Some MCP clients (Codex CLI >= 0.148, Claude Code >= 2.1.257) send | ||
| // their *preferred* protocol version in the MCP-Protocol-Version | ||
| // header on post-initialize requests instead of the *negotiated* one | ||
| // (#3599). The bundled SDK only advertises versions up to its own | ||
| // LATEST_PROTOCOL_VERSION, so every tools/list and tools/call is then | ||
| // rejected with 400 "Unsupported protocol version". Claude Code | ||
| // recovers by retrying with an accepted value; Codex does not, and | ||
| // its agents silently lose the injected Paseo tools. | ||
| // | ||
| // The header is a post-handshake sanity echo, not a negotiation | ||
| // point: initialize already negotiated a version this server | ||
| // supports, and this stateless transport serves every request | ||
| // independently. Drop unadvertised header values so misbehaving | ||
| // clients keep working; advertised values pass through untouched. |
There was a problem hiding this comment.
This client-specific compatibility shim is documented with issue prose but has no // COMPAT(name): added in vX, remove after <date> tag. The repository requires every compatibility shim to use this tag so it appears in the cleanup backlog; this requirement must be satisfied before merging.
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| const payload = JSON.parse(dataLine ? dataLine.slice("data:".length).trim() : body); | ||
| if (payload?.error) { | ||
| throw new Error(`MCP response carried an error: ${JSON.stringify(payload.error)}`); | ||
| } | ||
| if (payload?.result === undefined || typeof payload.result !== "object") { | ||
| throw new Error(`MCP response had no result object: ${body.slice(0, 200)}`); | ||
| } | ||
| return payload.result as Record<string, unknown>; |
There was a problem hiding this comment.
parseJsonRpcResult parses a network response with JSON.parse and then converts payload.result to Record<string, unknown> through a type assertion. The test later makes another unchecked assertion before reading the tools array. This violates the repository requirement to validate data at network boundaries and must be addressed before merging.
Rule Used: # Code Review Pattern Reference: Slop, Tests, Feat... (source)
| const initialize = await fetch(mcpUrl, { | ||
| method: "POST", | ||
| headers: { | ||
| "content-type": "application/json", | ||
| accept: "application/json, text/event-stream", | ||
| }, | ||
| body: JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| id: 1, | ||
| method: "initialize", | ||
| params: { | ||
| protocolVersion: futureVersion, | ||
| capabilities: {}, | ||
| clientInfo: { name: "future-client", version: "1.0.0" }, | ||
| }, | ||
| }), | ||
| }); |
There was a problem hiding this comment.
The regression test constructs raw HTTP and JSON-RPC payloads directly in the test body for both initialization here and tools/list at lines 314–326. Repository test guidance requires protocol mechanics to live in helpers so the test reads as setup, domain-level actions, and assertions; this requirement must be satisfied before merging.
Rule Used: # Code Review Pattern Reference: Slop, Tests, Feat... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Fixes #3599 (the protocol-rejection half).
Problem
Clients that send their preferred MCP protocol version in the
MCP-Protocol-Versionheader of post-initialize requests — instead of the negotiated one — get everytools/list/tools/callrejected with400 "Unsupported protocol version"and never see the injected Paseo tools.create_agent,notifyOnFinish, everything).@modelcontextprotocol/sdk1.29.0 (and the current latest 1.30.0) advertises versions only up to2025-11-25, while these clients speak2026-07-28. No SDK release accepts that header today, so the daemon cannot negotiate or tolerate it without help.Fix
The header is a post-handshake sanity echo, not a negotiation point:
initializealready negotiates down to a version this server supports, and the stateless agent MCP transport serves every request independently. So inrunAgentMcpRequest, when the header value is not one the SDK advertises, drop it (log awarn) and let the request proceed. Advertised values pass through untouched.Two implementation details worth flagging:
SUPPORTED_PROTOCOL_VERSIONSimport, so when the SDK is eventually bumped to know2026-07-28, this path simply stops firing for it.req.headersandreq.rawHeaders— the SDK's node-to-web-standard adapter (@hono/node-servergetRequestListener) builds the fetchRequestfromrawHeaders, so deleting only the parsed headers has no effect.QA evidence
Against a production daemon (Linux x86_64, daemon 0.7.1,
daemon.mcp.injectIntoAgents: true, Codex CLI 0.153.3), probing/mcp/agentsdirectly with raw JSON-RPC:initializewithprotocolVersion: 2026-07-282025-11-252025-11-25(unchanged)tools/listwithMCP-Protocol-Version: 2026-07-28Unsupported protocol versiontools/listwithMCP-Protocol-Version: 2025-11-25tools/listwith no headerAfter applying the same change on the live daemon, a Paseo-managed Codex orchestrator session regained the injected
mcp__paseo__*tools and subagent finish notifications started arriving; the daemon log shows no furtherUnsupported protocol versionerrors.Tests
agent-mcp.e2e.test.ts:initializewith a never-advertised version (2999-01-01) must still negotiate down to an advertised version, and a followingtools/listcarrying that version inMCP-Protocol-Versionmust return the tool list instead of the 400. Reverting the fix makes this test fail with exactlyexpected 400 to be 200.agent-mcp.e2e.test.ts: the new test passes; the two pre-existing failures (create_agent auto-injects paseo MCP by default and can be disabled,create_agent injects a loopback MCP URL...) fail identically on unmodifiedmain— not affected by this change.bootstrap.smoke.test.ts: 21/21 pass.tsc -p tsconfig.server.typecheck.json --noEmit: clean.Tested on Linux x86_64 only (headless daemon); I did not test macOS or the desktop app.