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",
|
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", () => {
|
describe("RemoteSession", () => {
|
||||||
it("queues events before a consumer attaches and drains them on attach", () => {
|
it("queues events before a consumer attaches and drains them on attach", () => {
|
||||||
const session = new RemoteSession("s1");
|
const session = new RemoteSession("s1");
|
||||||
|
|
@ -120,6 +130,45 @@ describe("RemoteSession", () => {
|
||||||
expect(session.transport).toBe(transport);
|
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", () => {
|
it("hasEventConsumer reflects whether a consumer is attached", () => {
|
||||||
const session = new RemoteSession("s9");
|
const session = new RemoteSession("s9");
|
||||||
expect(session.hasEventConsumer()).toBe(false);
|
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 () => {
|
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`
|
// A modern (2026-07-28) server whose `get_weather` tool annotates `city`
|
||||||
// with `x-mcp-header: "City"`. The SDK's modern handler validates the
|
// 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;
|
static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000;
|
||||||
private readonly requestWaits = new Map<string | number, RequestWait>();
|
private readonly requestWaits = new Map<string | number, RequestWait>();
|
||||||
private authProviderHandle: RemoteAuthProviderHandle | null = null;
|
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) {
|
constructor(sessionId: string) {
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
|
|
@ -57,6 +60,32 @@ export class RemoteSession {
|
||||||
this.transport = transport;
|
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 {
|
setEventConsumer(consumer: (event: SessionEvent) => void): void {
|
||||||
this.eventConsumer = consumer;
|
this.eventConsumer = consumer;
|
||||||
// Flush queued events
|
// Flush queued events
|
||||||
|
|
|
||||||
|
|
@ -728,7 +728,8 @@ export function createRemoteApp(
|
||||||
return c.json({ error: "Invalid JSON body" }, 400);
|
return c.json({ error: "Invalid JSON body" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, message, relatedRequestId, headers } = body;
|
const { sessionId, message, relatedRequestId, headers, protocolVersion } =
|
||||||
|
body;
|
||||||
if (!sessionId || !message) {
|
if (!sessionId || !message) {
|
||||||
return c.json({ error: "Missing sessionId or message" }, 400);
|
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 });
|
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();
|
session.beginSend();
|
||||||
const requestId = requestIdForSendWait(message);
|
const requestId = requestIdForSendWait(message);
|
||||||
let responseWait: Promise<void> | undefined;
|
let responseWait: Promise<void> | undefined;
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ async function* parseSSE(
|
||||||
*/
|
*/
|
||||||
export class RemoteClientTransport implements Transport {
|
export class RemoteClientTransport implements Transport {
|
||||||
private _sessionId: string | undefined = undefined;
|
private _sessionId: string | undefined = undefined;
|
||||||
|
private _protocolVersion: string | undefined = undefined;
|
||||||
private eventStreamReader: ReadableStreamDefaultReader<Uint8Array> | null =
|
private eventStreamReader: ReadableStreamDefaultReader<Uint8Array> | null =
|
||||||
null;
|
null;
|
||||||
private eventStreamAbort: AbortController | null = null;
|
private eventStreamAbort: AbortController | null = null;
|
||||||
|
|
@ -250,6 +251,22 @@ export class RemoteClientTransport implements Transport {
|
||||||
return this._sessionId;
|
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
|
* Reattach to an existing remote backend session after a full-page OAuth
|
||||||
* redirect. Opens the SSE event stream without POST /connect.
|
* 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
|
// 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.
|
// apply to the upstream send; the browser can't set them cross-origin.
|
||||||
...(options?.headers != null && { headers: options.headers }),
|
...(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`, {
|
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.
|
* them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix.
|
||||||
*/
|
*/
|
||||||
headers?: Record<string, string>;
|
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 =
|
export type RemoteEventType =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue