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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user