diff --git a/packages/coding-agent/src/mcp/transports/header-policy.ts b/packages/coding-agent/src/mcp/transports/header-policy.ts index ee278e22e..590f71443 100644 --- a/packages/coding-agent/src/mcp/transports/header-policy.ts +++ b/packages/coding-agent/src/mcp/transports/header-policy.ts @@ -48,6 +48,34 @@ export function setGeneratedHeader(headers: Record, name: string headers[name] = value; } +/** + * Return `headers` without any entry whose name case-insensitively matches + * `name`. Used to keep transport-reserved protocol headers (e.g. + * `MCP-Protocol-Version`) out of user-configured headers so config can never + * inject them. Returns the original reference when there is nothing to strip, + * so the common (no-match) path allocates nothing. + */ +export function withoutHeader( + headers: Record | undefined, + name: string, +): Record | undefined { + if (!headers) return headers; + const lower = name.toLowerCase(); + let hasMatch = false; + for (const key in headers) { + if (key.toLowerCase() === lower) { + hasMatch = true; + break; + } + } + if (!hasMatch) return headers; + const result: Record = {}; + for (const key in headers) { + if (key.toLowerCase() !== lower) result[key] = headers[key]; + } + return result; +} + const REDIRECT_STATUSES: Record = { 301: true, 302: true, 303: true, 307: true, 308: true }; const MAX_REDIRECT_HOPS = 5; diff --git a/packages/coding-agent/src/mcp/transports/http.ts b/packages/coding-agent/src/mcp/transports/http.ts index 6a1acac12..86b22909a 100644 --- a/packages/coding-agent/src/mcp/transports/http.ts +++ b/packages/coding-agent/src/mcp/transports/http.ts @@ -20,7 +20,7 @@ import type { import { toJsonRpcError } from "../../mcp/types"; import { RequestIdAllocator } from "../request-id"; import { createMCPTimeout, getNeverAbortSignal, isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout"; -import { type MCPFetchInit, mcpFetch } from "./header-policy"; +import { type MCPFetchInit, mcpFetch, withoutHeader } from "./header-policy"; const HTTP_SSE_CONNECT_TIMEOUT_MS = 1_000; /** @@ -67,18 +67,20 @@ export class HttpTransport implements MCPTransport { /** * Fetch the configured endpoint with header precedence and origin policy. * - * Once a version is negotiated, every request carries `MCP-Protocol-Version` - * (required by the MCP Streamable HTTP spec after `initialize`). It rides - * under `generated` so it wins over a same-named configured header. Before - * negotiation (the `initialize` request itself) the header is omitted. + * The transport fully owns `MCP-Protocol-Version`: it is stripped from + * configured headers so a user's `mcp.json` can never inject it, and added + * only once a version is negotiated (required by the MCP Streamable HTTP spec + * after `initialize`). Before negotiation — the `initialize` request itself — + * no protocol-version header is sent from either source. */ #fetch(init: MCPFetchInit, generated: Record): Promise { + const configured = withoutHeader(this.config.headers, "MCP-Protocol-Version"); const withVersion = this.#protocolVersion === null ? generated : { "MCP-Protocol-Version": this.#protocolVersion, ...generated }; return mcpFetch( this.config.url, init, - { generated: withVersion, configured: this.config.headers }, + { generated: withVersion, configured }, this.config.headerPolicy === "origin-locked", ); } diff --git a/packages/coding-agent/test/mcp-http-transport.test.ts b/packages/coding-agent/test/mcp-http-transport.test.ts index 812fe4a7d..e1f2244df 100644 --- a/packages/coding-agent/test/mcp-http-transport.test.ts +++ b/packages/coding-agent/test/mcp-http-transport.test.ts @@ -137,4 +137,36 @@ describe("MCP Streamable HTTP protocol version header", () => { await withPendingGuard(transport.request("tools/list"), "request"); expect(seen.version).toBe("2025-06-18"); }); + + it("never lets a configured MCP-Protocol-Version reach the server", async () => { + const seen: { pre: string | null; post: string | null } = { pre: null, post: null }; + server = Bun.serve({ + port: 0, + fetch(req) { + const body = req.headers.get("MCP-Protocol-Version"); + return Response.json({ jsonrpc: "2.0", id: 1, result: { seen: body } }); + }, + }); + if (!server) throw new Error("Test server was not started"); + const transport = new HttpTransport({ + type: "http", + url: `http://127.0.0.1:${server.port}/mcp`, + timeout: REQUEST_TIMEOUT_MS, + headers: { "MCP-Protocol-Version": "1999-01-01" }, + }); + await transport.connect(); + + // Pre-negotiation: configured header must be stripped, not leaked. + seen.pre = await withPendingGuard(transport.request<{ seen: string | null }>("initialize"), "request").then( + r => r.seen, + ); + // Post-negotiation: the negotiated version wins over the configured one. + transport.setProtocolVersion("2025-11-25"); + seen.post = await withPendingGuard(transport.request<{ seen: string | null }>("tools/list"), "request").then( + r => r.seen, + ); + + expect(seen.pre).toBeNull(); + expect(seen.post).toBe("2025-11-25"); + }); });