Merge origin/v2/main into v2/feat/2318-timeout-diagnostics
Resolves the relay-wait conflict with #2028: the wait keeps its progress-driven re-arm and expires with the SDK's timeout shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
commit
1b46b9564b
3 changed files with 796 additions and 111 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
40
core/mcp/remote/progressToken.ts
Normal file
40
core/mcp/remote/progressToken.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
RemoteSendResponse,
|
||||
} from "./types.js";
|
||||
import { oauthTokensToRemoteAuthState } from "./types.js";
|
||||
import { progressTokenOf } from "./progressToken.js";
|
||||
|
||||
export interface AuthRecoveryHandlers {
|
||||
handleAuthChallenge(
|
||||
|
|
@ -89,6 +90,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(
|
||||
|
|
@ -528,6 +535,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" &&
|
||||
|
|
@ -688,21 +702,25 @@ export class RemoteClientTransport implements Transport {
|
|||
|
||||
private waitForSseResponse(requestId: string | number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.sseResponseWaits.delete(requestId);
|
||||
// The SDK's own per-request timeout shape, not a plain `Error`: this
|
||||
// wait can expire before the SDK's timer does (its budget is the
|
||||
// relay's, not the request's), and the rejection travels up through
|
||||
// `Client.request`, where `InspectorClient` annotates a timeout of
|
||||
// exactly this shape with the connection's state (#2318). A plain
|
||||
// error would pass that decorator untouched and reach the user as a
|
||||
// bare timeout on the web client alone.
|
||||
reject(
|
||||
new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
|
||||
timeout: this.sseResponseTimeoutMs,
|
||||
}),
|
||||
);
|
||||
}, this.sseResponseTimeoutMs);
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const arm = () => {
|
||||
timer = setTimeout(() => {
|
||||
this.sseResponseWaits.delete(requestId);
|
||||
// The SDK's own per-request timeout shape, not a plain `Error`:
|
||||
// this wait can expire before the SDK's timer does (its budget is
|
||||
// the relay's, not the request's), and the rejection travels up
|
||||
// through `Client.request`, where `InspectorClient` annotates a
|
||||
// timeout of exactly this shape with the connection's state
|
||||
// (#2318). A plain error would pass that decorator untouched and
|
||||
// reach the user as a bare timeout on the web client alone.
|
||||
reject(
|
||||
new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
|
||||
timeout: this.sseResponseTimeoutMs,
|
||||
}),
|
||||
);
|
||||
}, this.sseResponseTimeoutMs);
|
||||
};
|
||||
arm();
|
||||
this.sseResponseWaits.set(requestId, {
|
||||
resolve: () => {
|
||||
clearTimeout(timer);
|
||||
|
|
@ -712,10 +730,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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue