Merge pull request #2363 from modelcontextprotocol/v2/fix/2028-stdio-delayed-responses

fix: reset relay request-response waits on progress notifications (#2028)
This commit is contained in:
Cliff Hall 2026-09-13 21:50:36 -04:00 • committed by GitHub
commit 3e728a3b8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 450 additions and 27 deletions

View file

@ -10,6 +10,7 @@ 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";
import { progressTokenOf } from "../progressToken.js";
export interface SessionEvent {
type: RemoteEvent["type"];
@ -19,6 +20,14 @@ export interface SessionEvent {
type RequestWait = {
resolve: () => void;
reject: (error: Error) => void;
/**
* Re-arm this wait's timeout for another full window. Called when a
* `notifications/progress` for this request arrives, mirroring the SDK
* client's `resetTimeoutOnProgress` so the relay's own deadline never fires
* before the client's progress-aware one (#2028). A no-op when the wait was
* created without a timeout.
*/
resetTimeout: () => void;
};
export class RemoteSession {
@ -172,35 +181,57 @@ export class RemoteSession {
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;
let timer: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
if (timeoutMs <= 0) return;
timer = setTimeout(() => {
this.requestWaits.delete(requestId);
reject(
new Error(
`MCP request ${String(requestId)} timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
};
const clear = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
arm();
this.requestWaits.set(requestId, {
resolve: () => {
if (timer !== undefined) {
clearTimeout(timer);
}
clear();
resolve();
},
reject: (error) => {
if (timer !== undefined) {
clearTimeout(timer);
}
clear();
reject(error);
},
// A progress notification for this request re-arms the full window, so
// a tool that keeps reporting progress never trips the relay's own
// deadline — the same guarantee the SDK client's `resetTimeoutOnProgress`
// gives on a direct transport (#2028).
resetTimeout: () => {
if (timer === undefined) return;
clear();
arm();
},
});
});
}
/**
* Re-arm a pending request's wait timeout. Invoked from {@link onMessage} when
* a `notifications/progress` carrying this request's `progressToken` arrives.
* A no-op when no wait is pending for the id.
*/
noteRequestProgress(requestId: string | number): void {
this.requestWaits.get(requestId)?.resetTimeout();
}
cancelRequestWait(requestId: string | number): void {
const wait = this.requestWaits.get(requestId);
if (!wait) {
@ -265,6 +296,15 @@ export class RemoteSession {
) {
this.settleRequestWait(message.id);
}
// A `notifications/progress` carries the originating request's id in
// `params.progressToken` (the SDK stamps `progressToken: messageId`). Re-arm
// that request's wait so the relay's deadline tracks the client's
// progress-aware one instead of firing at a flat 60s (#2028). Log messages
// (`notifications/message`) deliberately do NOT reset it, matching the SDK.
const progressToken = progressTokenOf(message);
if (progressToken !== undefined) {
this.noteRequestProgress(progressToken);
}
this.pushEvent({ type: "message", data: message });
}

View file

@ -0,0 +1,40 @@
/**
* Shared helper for the remote relay's progress-aware request-response waits
* (#2028). Pure and browser-safe, so both the browser `RemoteClientTransport`
* and the Node `RemoteSession` import it rather than each carrying a copy.
*/
import { ProgressTokenSchema } from "@modelcontextprotocol/core";
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
/**
* The token identifying the request a `notifications/progress` belongs to, or
* `undefined` for any other message. The SDK stamps `progressToken: messageId`
* on the outbound request, so a progress notification's `progressToken` is the
* id of the request whose wait should be re-armed.
*
* Validation is delegated to the SDK's own `ProgressTokenSchema` rather than a
* hand-rolled `typeof` check — the protocol allows only a string or a *safe*
* integer, and zod's `.int()` rejects a fractional or past-`MAX_SAFE_INTEGER`
* value that a bare `typeof token === "number"` would wave through (the same
* drift `isProgressToken` in `core/mcp/inspectorClient.ts` exists to avoid).
*/
export function progressTokenOf(
message: JSONRPCMessage,
): string | number | undefined {
if (
"method" in message &&
message.method === "notifications/progress" &&
"params" in message &&
typeof message.params === "object" &&
message.params !== null
) {
const parsed = ProgressTokenSchema.safeParse(
(message.params as { progressToken?: unknown }).progressToken,
);
if (parsed.success) {
return parsed.data;
}
}
return undefined;
}

View file

@ -31,6 +31,7 @@ import type {
RemoteSendResponse,
} from "./types.js";
import { oauthTokensToRemoteAuthState } from "./types.js";
import { progressTokenOf } from "./progressToken.js";
export interface AuthRecoveryHandlers {
handleAuthChallenge(
@ -85,6 +86,12 @@ const DEFAULT_SSE_RESPONSE_TIMEOUT_MS = 60_000;
type SseResponseWait = {
resolve: () => void;
reject: (error: Error) => void;
/**
* Re-arm this wait's timeout for another full window when a
* `notifications/progress` for the request arrives, mirroring the SDK
* client's `resetTimeoutOnProgress` (#2028).
*/
resetTimeout: () => void;
};
function requestIdForMessage(
@ -524,6 +531,13 @@ export class RemoteClientTransport implements Transport {
if (parsed.type === "message") {
const msg = parsed.data as JSONRPCMessage;
this.settleSseResponseWait(msg);
// Keep the SSE wait alive while progress flows for a long call
// (#2028) — the settle above never matches a notification, so this
// is the only place a progress note touches the wait.
const progressToken = progressTokenOf(msg);
if (progressToken !== undefined) {
this.resetSseResponseWait(progressToken);
}
this.onmessage?.(msg, undefined);
} else if (
parsed.type === "fetch_request" &&
@ -675,14 +689,18 @@ export class RemoteClientTransport implements Transport {
private waitForSseResponse(requestId: string | number): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.sseResponseWaits.delete(requestId);
reject(
new Error(
`Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`,
),
);
}, this.sseResponseTimeoutMs);
let timer: ReturnType<typeof setTimeout>;
const arm = () => {
timer = setTimeout(() => {
this.sseResponseWaits.delete(requestId);
reject(
new Error(
`Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`,
),
);
}, this.sseResponseTimeoutMs);
};
arm();
this.sseResponseWaits.set(requestId, {
resolve: () => {
clearTimeout(timer);
@ -692,10 +710,22 @@ export class RemoteClientTransport implements Transport {
clearTimeout(timer);
reject(error);
},
// A progress notification for this request re-arms the full window so
// the browser-side deadline tracks the SDK client's progress-aware one
// rather than firing at a flat 60s (#2028).
resetTimeout: () => {
clearTimeout(timer);
arm();
},
});
});
}
/** Re-arm a pending request's SSE wait timeout on a matching progress note. */
private resetSseResponseWait(requestId: string | number): void {
this.sseResponseWaits.get(requestId)?.resetTimeout();
}
private async postSend(
message: JSONRPCMessage,
options: TransportSendOptions | undefined,