fix: reset the relay's request-response waits on progress notifications (#2028)

The web client drives its MCP Client through a two-hop relay
(RemoteClientTransport -> Node backend -> upstream transport). The relay
added two request-response waits that the direct transports don't have —
the backend's RemoteSession.waitForRequestResponse and the browser's
RemoteClientTransport.waitForSseResponse — and both were hardcoded to 60s
and blind to notifications/progress. The SDK Client's own request timeout
resets on progress (resetTimeoutOnProgress, which the Inspector enables),
but the relay's progress-blind waits fire first and shadow it, so a
long-running tool that reports progress is cancelled at 60s on the web
client alone while it completes over the CLI/TUI and other MCP clients.

Reset both relay waits on a notifications/progress whose progressToken
matches the pending request id, mirroring the SDK's resetTimeoutOnProgress.
A shared browser-safe progressTokenOf helper extracts the token. Log
messages (notifications/message, i.e. ctx.info) deliberately do not reset
the deadline, matching the SDK and every other client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-13 21:13:30 -04:00
commit cd31d76371

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,