Merge pull request #1963 from modelcontextprotocol/v2/fix/proxy-protocol-version-header
fix: forward the negotiated Mcp-Protocol-Version through the remote proxy
This commit is contained in:
commit
f404d50af7
7 changed files with 219 additions and 1 deletions
|
|
@ -515,4 +515,58 @@ describe("RemoteClientTransport", () => {
|
|||
reason: "token_expired",
|
||||
});
|
||||
});
|
||||
|
||||
// #1935: the SDK Client calls setProtocolVersion() on this transport, but the
|
||||
// real HTTP transport lives on the backend — so the version has to ride the
|
||||
// send envelope or the backend drops `Mcp-Protocol-Version` upstream.
|
||||
it("forwards the negotiated protocol version on every send after setProtocolVersion", async () => {
|
||||
const seenBodies: string[] = [];
|
||||
let sse = createPushableSseStream();
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/mcp/connect")) {
|
||||
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
sse = createPushableSseStream();
|
||||
return sse.response;
|
||||
}
|
||||
if (url.endsWith("/api/mcp/send")) {
|
||||
seenBodies.push(String(init?.body));
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||
await transport.start();
|
||||
|
||||
// Pre-initialize sends carry no version (none negotiated yet).
|
||||
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
|
||||
expect(JSON.parse(seenBodies[0]!)).not.toHaveProperty("protocolVersion");
|
||||
|
||||
transport.setProtocolVersion("2025-11-25");
|
||||
|
||||
// The first post-initialize send is `notifications/initialized` — the one
|
||||
// the reporting server rejected for a missing header.
|
||||
await transport.send({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
});
|
||||
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
|
||||
|
||||
expect(JSON.parse(seenBodies[1]!)).toMatchObject({
|
||||
protocolVersion: "2025-11-25",
|
||||
message: { method: "notifications/initialized" },
|
||||
});
|
||||
expect(JSON.parse(seenBodies[2]!)).toMatchObject({
|
||||
protocolVersion: "2025-11-25",
|
||||
});
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ function makeFetchEntry(
|
|||
};
|
||||
}
|
||||
|
||||
/** A structurally valid `Transport`, so a fixture needs no cast. */
|
||||
function makeTransport(overrides: Partial<Transport> = {}): Transport {
|
||||
return {
|
||||
start: async () => {},
|
||||
send: async () => {},
|
||||
close: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoteSession", () => {
|
||||
it("queues events before a consumer attaches and drains them on attach", () => {
|
||||
const session = new RemoteSession("s1");
|
||||
|
|
@ -120,6 +130,45 @@ describe("RemoteSession", () => {
|
|||
expect(session.transport).toBe(transport);
|
||||
});
|
||||
|
||||
// #1935: the browser's Client negotiates the version, so the backend has to
|
||||
// be told before it can stamp `Mcp-Protocol-Version` on upstream requests.
|
||||
it("applyProtocolVersion forwards a new version to the transport once", () => {
|
||||
const session = new RemoteSession("s8a");
|
||||
const setProtocolVersion = vi.fn();
|
||||
session.setTransport(makeTransport({ setProtocolVersion }));
|
||||
|
||||
session.applyProtocolVersion("2025-11-25");
|
||||
session.applyProtocolVersion("2025-11-25");
|
||||
expect(setProtocolVersion.mock.calls).toEqual([["2025-11-25"]]);
|
||||
|
||||
// A renegotiated version (e.g. after a reconnect) is re-applied.
|
||||
session.applyProtocolVersion("2026-07-28");
|
||||
expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28");
|
||||
});
|
||||
|
||||
it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => {
|
||||
const session = new RemoteSession("s8b");
|
||||
const setProtocolVersion = vi.fn();
|
||||
session.setTransport(makeTransport({ setProtocolVersion }));
|
||||
|
||||
session.applyProtocolVersion(undefined);
|
||||
// Header injection attempt — must never reach the upstream transport.
|
||||
session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1");
|
||||
session.applyProtocolVersion("");
|
||||
// The value arrives from an unvalidated JSON body, so a non-string must be
|
||||
// rejected on its type — `RegExp.test` would coerce these into a match.
|
||||
session.applyProtocolVersion(123);
|
||||
session.applyProtocolVersion(true);
|
||||
session.applyProtocolVersion(null);
|
||||
expect(setProtocolVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => {
|
||||
const session = new RemoteSession("s8c");
|
||||
session.setTransport(makeTransport());
|
||||
expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow();
|
||||
});
|
||||
|
||||
it("hasEventConsumer reflects whether a consumer is attached", () => {
|
||||
const session = new RemoteSession("s9");
|
||||
expect(session.hasEventConsumer()).toBe(false);
|
||||
|
|
|
|||
|
|
@ -452,6 +452,58 @@ describe("Remote transport e2e", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("end-to-end: the negotiated Mcp-Protocol-Version reaches the upstream server after initialize (#1935)", async () => {
|
||||
// The browser's SDK Client owns the initialize handshake, so its
|
||||
// setProtocolVersion() lands on the *remote* transport. Without the
|
||||
// forward to the backend, the backend's real streamable-HTTP transport
|
||||
// never learns the version and every post-initialize request — starting
|
||||
// with `notifications/initialized` — goes out without the header, which
|
||||
// a stateful server may reject outright.
|
||||
mcpHttpServer = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
serverType: "streamable-http",
|
||||
});
|
||||
await mcpHttpServer.start();
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
type: "streamable-http",
|
||||
url: mcpHttpServer.url,
|
||||
};
|
||||
|
||||
const { client, fetchRequestLogState } =
|
||||
await setupRemoteAndConnect(config);
|
||||
|
||||
try {
|
||||
await client.listTools();
|
||||
|
||||
const protocolVersion = client.getProtocolVersion();
|
||||
expect(protocolVersion).toBeDefined();
|
||||
|
||||
const posts = fetchRequestLogState
|
||||
.getFetchRequests()
|
||||
.filter((r) => r.method === "POST");
|
||||
const headerFor = (body: string): string | undefined => {
|
||||
const entry = posts.find((r) => (r.requestBody ?? "").includes(body));
|
||||
expect(entry).toBeDefined();
|
||||
for (const [k, v] of Object.entries(entry?.requestHeaders ?? {})) {
|
||||
if (k.toLowerCase() === "mcp-protocol-version") return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// The initialize POST itself predates negotiation and carries none.
|
||||
expect(headerFor('"method":"initialize"')).toBeUndefined();
|
||||
// Everything after it does.
|
||||
expect(headerFor('"method":"notifications/initialized"')).toBe(
|
||||
protocolVersion,
|
||||
);
|
||||
expect(headerFor('"method":"tools/list"')).toBe(protocolVersion);
|
||||
} finally {
|
||||
await client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => {
|
||||
// A modern (2026-07-28) server whose `get_weather` tool annotates `city`
|
||||
// with `x-mcp-header: "City"`. The SDK's modern handler validates the
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ export class RemoteSession {
|
|||
static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000;
|
||||
private readonly requestWaits = new Map<string | number, RequestWait>();
|
||||
private authProviderHandle: RemoteAuthProviderHandle | null = null;
|
||||
private appliedProtocolVersion: string | undefined;
|
||||
/** A protocol version is a dated token (`2025-11-25`, `2026-07-28`, …). */
|
||||
private static readonly PROTOCOL_VERSION_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
|
||||
constructor(sessionId: string) {
|
||||
this.sessionId = sessionId;
|
||||
|
|
@ -57,6 +60,32 @@ export class RemoteSession {
|
|||
this.transport = transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the client's negotiated protocol version to the upstream transport
|
||||
* (#1935). The browser's SDK Client owns the `initialize` handshake, so the
|
||||
* `setProtocolVersion` call it makes lands on the *remote* transport; without
|
||||
* this the backend's real HTTP transport never learns the version and drops
|
||||
* `Mcp-Protocol-Version` from every subsequent request.
|
||||
*
|
||||
* Ignores anything that isn't a plausible version token so a client can't
|
||||
* push arbitrary bytes into an upstream header, and re-applies only on
|
||||
* change (the value rides every `/api/mcp/send`). The value arrives from an
|
||||
* unvalidated JSON body, so the type is checked rather than assumed —
|
||||
* `RegExp.test` would otherwise coerce a `123` or `true` into a "matching"
|
||||
* string and hand the wrong type to the transport.
|
||||
*/
|
||||
applyProtocolVersion(version: unknown): void {
|
||||
if (
|
||||
typeof version !== "string" ||
|
||||
version === this.appliedProtocolVersion ||
|
||||
!RemoteSession.PROTOCOL_VERSION_PATTERN.test(version)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.appliedProtocolVersion = version;
|
||||
this.transport.setProtocolVersion?.(version);
|
||||
}
|
||||
|
||||
setEventConsumer(consumer: (event: SessionEvent) => void): void {
|
||||
this.eventConsumer = consumer;
|
||||
// Flush queued events
|
||||
|
|
|
|||
|
|
@ -728,7 +728,8 @@ export function createRemoteApp(
|
|||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const { sessionId, message, relatedRequestId, headers } = body;
|
||||
const { sessionId, message, relatedRequestId, headers, protocolVersion } =
|
||||
body;
|
||||
if (!sessionId || !message) {
|
||||
return c.json({ error: "Missing sessionId or message" }, 400);
|
||||
}
|
||||
|
|
@ -744,6 +745,11 @@ export function createRemoteApp(
|
|||
return c.json({ ok: false, kind: "transport_error", error: errorMsg });
|
||||
}
|
||||
|
||||
// The browser's SDK Client negotiated the version; hand it to the real
|
||||
// upstream transport before the send so `Mcp-Protocol-Version` is stamped
|
||||
// on this request and everything after it (#1935).
|
||||
session.applyProtocolVersion(protocolVersion);
|
||||
|
||||
session.beginSend();
|
||||
const requestId = requestIdForSendWait(message);
|
||||
let responseWait: Promise<void> | undefined;
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ async function* parseSSE(
|
|||
*/
|
||||
export class RemoteClientTransport implements Transport {
|
||||
private _sessionId: string | undefined = undefined;
|
||||
private _protocolVersion: string | undefined = undefined;
|
||||
private eventStreamReader: ReadableStreamDefaultReader<Uint8Array> | null =
|
||||
null;
|
||||
private eventStreamAbort: AbortController | null = null;
|
||||
|
|
@ -250,6 +251,22 @@ export class RemoteClientTransport implements Transport {
|
|||
return this._sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK Client calls this with the negotiated protocol version as soon as
|
||||
* `initialize` resolves — before it sends `notifications/initialized` — so an
|
||||
* HTTP transport can stamp `Mcp-Protocol-Version` on every later request.
|
||||
*
|
||||
* Here the real upstream transport lives on the backend, so we can only
|
||||
* record the version and forward it on the next `/api/mcp/send`, which
|
||||
* applies it to the upstream transport *before* sending. Because
|
||||
* `notifications/initialized` is that next send, it and everything after it
|
||||
* (including the standalone SSE GET and the session DELETE, both issued by
|
||||
* the upstream transport itself) carry the header (#1935).
|
||||
*/
|
||||
setProtocolVersion(version: string): void {
|
||||
this._protocolVersion = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattach to an existing remote backend session after a full-page OAuth
|
||||
* redirect. Opens the SSE event stream without POST /connect.
|
||||
|
|
@ -707,6 +724,11 @@ export class RemoteClientTransport implements Transport {
|
|||
// Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to
|
||||
// apply to the upstream send; the browser can't set them cross-origin.
|
||||
...(options?.headers != null && { headers: options.headers }),
|
||||
// Forward the negotiated protocol version so the backend's transport can
|
||||
// stamp `Mcp-Protocol-Version` on this and every later request (#1935).
|
||||
...(this._protocolVersion !== undefined && {
|
||||
protocolVersion: this._protocolVersion,
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
||||
|
|
|
|||
|
|
@ -125,6 +125,12 @@ export interface RemoteSendRequest {
|
|||
* them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* MCP protocol version negotiated by the client's `initialize`. The backend
|
||||
* applies it to the upstream transport before the send so HTTP transports
|
||||
* stamp `Mcp-Protocol-Version` on this and every later request (#1935).
|
||||
*/
|
||||
protocolVersion?: string;
|
||||
}
|
||||
|
||||
export type RemoteEventType =
|
||||
|
|
|
|||
Loading…
Reference in a new issue