inspector/core/mcp/remote/node/remote-session.ts
cliffhall 7a74b5eba1 fix: forward the negotiated protocol version to the remote backend transport
The web client's SDK Client owns the `initialize` handshake, so the
`setProtocolVersion()` call it makes after initialize lands on
RemoteClientTransport — a pass-through to the Node backend, not the real
HTTP transport. The backend's StreamableHTTPClientTransport therefore never
learned the negotiated version and stamped no `Mcp-Protocol-Version` on any
request after initialize, starting with `notifications/initialized`. A
stateful server that requires the header rejects that notification with
HTTP 400.

RemoteClientTransport now records the version and rides it on every
`/api/mcp/send`; the backend applies it to its transport before the send, so
that request and everything the transport issues afterwards (the standalone
SSE GET, the session DELETE) carry the header. The version is validated
against a token pattern on the backend so a client can't push arbitrary
bytes into an upstream header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84
2026-08-10 20:44:37 -04:00

297 lines
8.5 KiB
TypeScript

/**
* Remote session - holds a transport and event queue for a remote client.
*/
import type { Transport } from "@modelcontextprotocol/client";
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
import type { FetchRequestEntryBase } from "../../types.js";
import type { RemoteEvent } from "../types.js";
import type { AuthChallenge } from "../../../auth/challenge.js";
import { AuthChallengeError } from "../../../auth/challenge.js";
import type { RemoteAuthProviderHandle } from "./tokenAuthProvider.js";
import type { RemoteAuthState } from "../types.js";
export interface SessionEvent {
type: RemoteEvent["type"];
data: unknown;
}
type RequestWait = {
resolve: () => void;
reject: (error: Error) => void;
};
export class RemoteSession {
public readonly sessionId: string;
public transport!: Transport;
private eventQueue: SessionEvent[] = [];
private eventConsumer: ((event: SessionEvent) => void) | null = null;
private transportDead: boolean = false;
private transportError: string | null = null;
private activeSendCount = 0;
/**
* Suppress duplicate ambient SSE auth echoes after HTTP command-scoped delivery.
* Stale transport `onerror` may arrive after the send completes.
*/
private authHttpEchoSuppressUntilMs = 0;
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;
}
setAuthProviderHandle(handle: RemoteAuthProviderHandle | null): void {
this.authProviderHandle = handle;
}
setAuthState(authState: RemoteAuthState): void {
if (!this.authProviderHandle) {
throw new Error("Session has no OAuth auth provider");
}
this.authProviderHandle.setAuthState(authState);
}
setTransport(transport: Transport): void {
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`).
*/
applyProtocolVersion(version: string | undefined): void {
if (
version === undefined ||
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
while (this.eventQueue.length > 0) {
const ev = this.eventQueue.shift()!;
consumer(ev);
}
}
clearEventConsumer(): boolean {
this.eventConsumer = null;
// If transport is dead and no client connected, signal to cleanup
return this.transportDead;
}
markTransportDead(error: string): void {
this.transportDead = true;
this.transportError = error;
// Always push the transport_error event — pushEvent queues it if no
// consumer is attached yet, so a process that crashes during startup
// (between POST /api/mcp/connect returning 200 and the browser opening
// /api/mcp/events) still surfaces its error to the eventual consumer
// instead of vanishing.
this.pushEvent({
type: "transport_error",
data: {
error,
code: -32000, // MCP error code for connection closed
},
});
}
isTransportDead(): boolean {
return this.transportDead;
}
getTransportError(): string | null {
return this.transportError;
}
hasEventConsumer(): boolean {
return this.eventConsumer !== null;
}
beginSend(): void {
this.activeSendCount++;
}
endSend(): void {
if (this.activeSendCount > 0) {
this.activeSendCount--;
}
}
/** Command-scoped auth was returned on HTTP; suppress async onerror echo on SSE. */
noteAuthChallengeDeliveredViaHttp(): void {
this.extendAuthHttpEchoSuppression();
}
private extendAuthHttpEchoSuppression(): void {
this.authHttpEchoSuppressUntilMs =
Date.now() + RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS;
}
private shouldSuppressAuthEcho(): boolean {
return Date.now() < this.authHttpEchoSuppressUntilMs;
}
hasActiveSend(): boolean {
return this.activeSendCount > 0;
}
pushAuthChallenge(challenge: AuthChallenge): void {
this.pushEvent({ type: "auth_challenge", data: challenge });
}
/**
* Streamable HTTP `transport.send()` can return before the JSON-RPC response
* arrives. The remote send handler awaits this so `/api/mcp/send` returns
* `auth_challenge` or `ok: true` only after the MCP round-trip (or auth error).
*/
waitForRequestResponse(
requestId: string | number,
timeoutMs = 60_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer =
timeoutMs > 0
? setTimeout(() => {
this.requestWaits.delete(requestId);
reject(
new Error(
`MCP request ${String(requestId)} timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs)
: undefined;
this.requestWaits.set(requestId, {
resolve: () => {
if (timer !== undefined) {
clearTimeout(timer);
}
resolve();
},
reject: (error) => {
if (timer !== undefined) {
clearTimeout(timer);
}
reject(error);
},
});
});
}
cancelRequestWait(requestId: string | number): void {
const wait = this.requestWaits.get(requestId);
if (!wait) {
return;
}
this.requestWaits.delete(requestId);
wait.reject(new Error("MCP request wait cancelled"));
}
rejectActiveRequestWaits(error: Error): void {
for (const wait of this.requestWaits.values()) {
wait.reject(error);
}
this.requestWaits.clear();
}
/**
* Auth errors from the MCP transport.
* - During an active send: command path owns delivery (HTTP); never SSE.
* - After HTTP already returned auth_challenge: swallow async onerror echo.
* - Otherwise: ambient SSE (idle session).
*/
handleTransportAuthError(error: unknown): error is AuthChallengeError {
if (!(error instanceof AuthChallengeError)) {
return false;
}
this.rejectActiveRequestWaits(error);
if (this.hasActiveSend()) {
this.extendAuthHttpEchoSuppression();
return true;
}
if (this.shouldSuppressAuthEcho()) {
return true;
}
this.pushAuthChallenge(error.authChallenge);
return true;
}
private settleRequestWait(requestId: string | number): void {
const wait = this.requestWaits.get(requestId);
if (!wait) {
return;
}
this.requestWaits.delete(requestId);
wait.resolve();
}
pushEvent(event: SessionEvent): void {
if (this.eventConsumer) {
this.eventConsumer(event);
} else {
this.eventQueue.push(event);
}
}
onMessage(message: JSONRPCMessage): void {
if (
"id" in message &&
message.id !== null &&
message.id !== undefined &&
("result" in message || "error" in message)
) {
this.settleRequestWait(message.id);
}
this.pushEvent({ type: "message", data: message });
}
onFetchRequest(entry: FetchRequestEntryBase): void {
this.pushEvent({
type: "fetch_request",
data: {
...entry,
timestamp:
entry.timestamp instanceof Date
? entry.timestamp.toISOString()
: entry.timestamp,
},
});
}
onFetchResponseBody(id: string, responseBody: string): void {
this.pushEvent({
type: "fetch_request_body_update",
data: { id, responseBody },
});
}
onStderr(entry: { timestamp: Date; message: string }): void {
this.pushEvent({
type: "stdio_log",
data: {
timestamp: entry.timestamp.toISOString(),
message: entry.message,
},
});
}
}