fix(mcp): reserve MCP-Protocol-Version from configured headers

The header is transport-owned. Strip any user-configured MCP-Protocol-Version so it cannot leak onto the initialize request before negotiation, nor override the negotiated value afterwards.

Fixes #8264
This commit is contained in:
roboomp
2026-08-11 18:10:46 +00:00
parent 004821342c
commit b65ac59653
3 changed files with 68 additions and 6 deletions
@@ -48,6 +48,34 @@ export function setGeneratedHeader(headers: Record<string, string>, 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<string, string> | undefined,
name: string,
): Record<string, string> | 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<string, string> = {};
for (const key in headers) {
if (key.toLowerCase() !== lower) result[key] = headers[key];
}
return result;
}
const REDIRECT_STATUSES: Record<number, true> = { 301: true, 302: true, 303: true, 307: true, 308: true };
const MAX_REDIRECT_HOPS = 5;
@@ -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<string, string>): Promise<Response> {
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",
);
}
@@ -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");
});
});