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:
cliffhall 2026-09-13 22:48:14 -04:00
commit 1b46b9564b
7 changed files with 796 additions and 111 deletions

View file

@ -68,6 +68,7 @@ import type {
CreateTransportOptions,
ServerType,
} from "./types.js";
import { DEFAULT_CONNECTION_TIMEOUT_MS } from "./types.js";
import {
MessageTrackingTransport,
type MessageTrackingCallbacks,
@ -315,6 +316,20 @@ interface ReceiverTaskRecord {
*/
const MAX_URL_ELICITATION_RETRIES = 5;
/**
* The message `connect()` rejects with when the connect-time timeout fires.
* Names the bound that fired and, since the default is now a real value that
* a slow server on a slow link can legitimately hit, says where to raise it
* (#2320). All three clients surface this text as-is: the web client's
* "Failed to connect" toast, the TUI status line, and the CLI error envelope.
*/
export function connectionTimeoutMessage(timeoutMs: number): string {
return (
`Connection timed out after ${timeoutMs} ms. To accommodate a slower ` +
`server, you may increase the timeout value in Server Settings.`
);
}
/**
* Error used to reject a pending sampling/elicitation request when the tool
* call driving its MRTR round is aborted (e.g. the user hits Cancel). Its
@ -2321,11 +2336,28 @@ export class InspectorClient extends InspectorClientEventTarget {
this.registerPeerRequestHandlers();
this.registerPeerNotificationHandlers();
// Optional connect-time timeout from per-server settings. The MCP SDK
// has no connect-time timeout option, so we wrap the handshake in a
// Connect-time timeout from per-server settings, defaulting to
// `DEFAULT_CONNECTION_TIMEOUT_MS` when the settings carry none. The MCP
// SDK has no connect-time timeout option, so we wrap the handshake in a
// Promise.race. On timeout, tear the transport down so the next
// connect() starts clean and the upstream socket isn't left hanging.
const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0;
//
// The default is ours on purpose (#2320): without it the only bound on
// a connect attempt was the SDK's per-request timeout on `initialize`,
// which covers nothing outside that request (`transport.start()` for
// one), fires after 60 s, and reports `Request timed out` — a sentence
// about a JSON-RPC request, not a connection. An explicit `0` is still
// "no timeout": that is what the CLI's `--connect-timeout 0` documents
// and what a user who cleared the field asked for.
const connectTimeoutMs =
this.serverSettings?.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS;
// Set only by the timer below, so the teardown in the catch runs for a
// timeout and nothing else. Gating it on `connectTimeoutMs > 0` instead
// would tear down *every* failed connect once the default is non-zero —
// including a recoverable 401, whose transport the auth recovery needs
// held open and whose status must stay at "connecting" (see the outer
// catch and `transportHasAuthProvider`).
let connectTimedOut = false;
// Unwrap here — the earliest point — so an auth error the SDK's
// era-negotiation probe buried in its cause chain is surfaced before
// anything downstream inspects it: `withDirectAuthRecovery` (whose
@ -2369,15 +2401,10 @@ export class InspectorClient extends InspectorClientEventTarget {
connectPromise.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Connection timed out after ${connectTimeoutMs} ms`,
),
),
connectTimeoutMs,
);
timer = setTimeout(() => {
connectTimedOut = true;
reject(new Error(connectionTimeoutMessage(connectTimeoutMs)));
}, connectTimeoutMs);
});
try {
await Promise.race([connectPromise, timeoutPromise]);
@ -2392,7 +2419,7 @@ export class InspectorClient extends InspectorClientEventTarget {
try {
await this.invokeMcpClient(runConnect);
} catch (err) {
if (connectTimeoutMs > 0) {
if (connectTimedOut) {
await this.disconnect().catch(() => {});
}
throw err;

View file

@ -1,7 +1,11 @@
import type { SecretStore } from "../../auth/node/secret-store.js";
import { defaultSecretStore } from "../../auth/node/secret-store-selection.js";
import type { InspectorServerSettings, MCPServerConfig } from "../types.js";
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js";
import {
DEFAULT_CONNECTION_TIMEOUT_MS,
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
} from "../types.js";
import { mcpConfigToServerEntries } from "../serverList.js";
import {
applyOverrides,
@ -50,7 +54,7 @@ export function headersToServerSettings(
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
env: [],
metadata: {},
connectionTimeout: 0,
connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,

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

@ -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,

View file

@ -6,6 +6,7 @@
*/
import {
DEFAULT_CONNECTION_TIMEOUT_MS,
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_MODERN_LOG_LEVEL,
DEFAULT_PROTOCOL_ERA,
@ -490,8 +491,11 @@ export function oauthEndpointOverridesFromSettings(
*
* `headers` becomes a pair-array preserving the object's key insertion order;
* `oauth.*` becomes the flat `oauthClientId` / `oauthClientSecret` /
* `oauthScopes` fields. Numeric timeouts default to 0 when absent — the form
* needs concrete values to render and 0 is the SDK's "no timeout" signal.
* `oauthScopes` fields. The numeric timeouts get concrete values when absent
* because the form needs them to render: `connectionTimeout` reads back as
* `DEFAULT_CONNECTION_TIMEOUT_MS` (30 s), while `requestTimeout` reads back as
* 0, the SDK's "use your own default" signal. An explicit `connectionTimeout`
* of 0 is preserved — it is the user's "no timeout" opt-out (#2320).
*
* `env` / `cwd` are SDK config fields (not Inspector-extension keys), but they
* are mirrored into the settings here so the Server Settings modal can edit
@ -533,9 +537,13 @@ export function storedFieldsToInspectorSettings(
headers: headersPairs,
env: envRecordToPairs(stored.env),
metadata: normalizeStoredMetadata(stored.metadata),
connectionTimeout: stored.connectionTimeout ?? 0,
// Concrete product default, like taskTtl below: an absent field reads back
// as 30 s so the form shows the bound that will actually apply. An explicit
// 0 is preserved as-is — it is the user's opt-out, not a sentinel (#2320).
connectionTimeout:
stored.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS,
requestTimeout: stored.requestTimeout ?? 0,
// Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product
// Unlike requestTimeout (0 = "SDK default"), task TTL has a concrete product
// default so the form shows it and "Run as task" has a value to send.
taskTtl: stored.taskTtl ?? DEFAULT_TASK_TTL_MS,
autoRefreshOnListChanged: stored.autoRefreshOnListChanged ?? false,
@ -632,8 +640,10 @@ export function storedFieldsToInspectorSettings(
* Splat the form-shape `InspectorServerSettings` back into the on-disk
* Inspector-extension fields (object-form `headers`, nested `oauth`, etc.).
* Empty-key rows are dropped — the form lets users leave new rows blank
* mid-edit and those shouldn't reach disk. Numeric timeouts at 0 are omitted
* so the file diff stays minimal for entries that never touched them.
* mid-edit and those shouldn't reach disk. Timeouts at their default are
* omitted so the file diff stays minimal for entries that never touched them:
* that is `DEFAULT_CONNECTION_TIMEOUT_MS` for `connectionTimeout` (an explicit
* 0 is a chosen opt-out and is written) and 0 for `requestTimeout`.
*
* Returns the field deltas to merge onto a `StoredMCPServer`; callers can
* spread the result.
@ -659,7 +669,11 @@ export function inspectorSettingsToStoredFields(
out.metadata = settings.metadata;
}
if (settings.connectionTimeout > 0) {
// The product default is the omit-sentinel (as for taskTtl below), so a file
// that never named a timeout stays byte-stable. Anything else persists —
// including 0, which is now a non-default the user chose (no timeout) and
// would otherwise silently read back as 30 s on the next load (#2320).
if (settings.connectionTimeout !== DEFAULT_CONNECTION_TIMEOUT_MS) {
out.connectionTimeout = settings.connectionTimeout;
}
if (settings.requestTimeout > 0) {
@ -714,10 +728,11 @@ export function inspectorSettingsToStoredFields(
out.modernLogLevel = settings.modernLogLevel;
}
// Persist only when it differs from the default. Unlike the timeouts, 0 is a
// meaningful value here (unlimited), so the omit-sentinel is the default
// itself rather than 0 — writing the default would inject the field into
// hand-edited files that never had it and break byte-stable round-trips.
// Persist only when it differs from the default. Like connectionTimeout and
// unlike requestTimeout, 0 is a meaningful value here (unlimited), so the
// omit-sentinel is the default itself rather than 0 — writing the default
// would inject the field into hand-edited files that never had it and break
// byte-stable round-trips.
if (settings.maxFetchRequests !== DEFAULT_MAX_FETCH_REQUESTS) {
out.maxFetchRequests = settings.maxFetchRequests;
}

View file

@ -132,7 +132,11 @@ export type StoredMCPServer = MCPServerConfig & {
* (#1629)
*/
modernLogLevel?: ModernLogLevel;
/** Inspector-specific connect-time timeout (ms). */
/**
* Inspector-specific connect-time timeout (ms). Absent reads back as
* `DEFAULT_CONNECTION_TIMEOUT_MS`; an explicit `0` disables the timeout and
* is persisted so it round-trips (#2320).
*/
connectionTimeout?: number;
/** Inspector-specific request timeout (ms). */
requestTimeout?: number;
@ -665,6 +669,18 @@ export type OnInsufficientScopePolicy = "reauthorize" | "throw";
*/
export const DEFAULT_TASK_TTL_MS = 60000;
/**
* Default connect-time timeout (ms) applied when a server has no explicit
* `connectionTimeout` — on disk, in the form, or on the client. The SDK has no
* connect-time timeout of its own; without this, the only thing bounding a
* connect attempt was the SDK's per-request timeout on `initialize`, which
* fires after 60 s with a message that describes a JSON-RPC request rather
* than a connection (#2320). `0` remains the explicit opt-out ("no timeout"),
* so a stored `connectionTimeout: 0` and the CLI's `--connect-timeout 0` keep
* their documented meaning; only an *absent* value resolves to this.
*/
export const DEFAULT_CONNECTION_TIMEOUT_MS = 30000;
/**
* Default maximum number of HTTP fetch requests retained in the Network log
* (per server). When exceeded, the oldest entries rotate out. A larger value
@ -803,6 +819,10 @@ export interface InspectorServerSettings {
* empty/unset means "inherit". Only meaningful for stdio transports.
*/
cwd?: string;
/**
* Connect-time timeout (ms). Defaults to `DEFAULT_CONNECTION_TIMEOUT_MS`
* (30000) when the server has no explicit value; `0` means no timeout.
*/
connectionTimeout: number;
requestTimeout: number;
/** TTL (ms) for tasks created via "Run as task". Defaults to 60000. */