feat: bound connect attempts with a 30 s default connectionTimeout (#2320)
connectionTimeout defaulted to 0, which InspectorClient.connect() read as "no bound at all" — so out of the box nothing the Inspector owns bounded a connect attempt, and the only thing ending a hung handshake was the SDK's 60 s per-request timeout on initialize, reporting "Request timed out". - Add DEFAULT_CONNECTION_TIMEOUT_MS (30000) to core/mcp/types.ts. An absent value resolves to it on the client, on disk read, in the web form draft, and in the CLI's header-only settings shell. An explicit 0 keeps its documented meaning (no timeout; --connect-timeout 0) and is now persisted so the opt-out round-trips; the default is the omit-sentinel, as for taskTtl. - Key the post-timeout transport teardown to the timer actually firing, not to connectionTimeout > 0 — otherwise a non-zero default would tear down every failed connect, including a recoverable 401 whose transport the auth recovery holds open. - The timeout message names the bound and where to raise it: "Connection timed out after N ms. To accommodate a slower server, you may increase the timeout value in Server Settings." - The CLI's UNREACHABLE classifier now matches that message. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4h8Xa6Ed9zwtv5ojX35MT Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
153ee7dd15
commit
e641edeeab
4 changed files with 343 additions and 38 deletions
|
|
@ -67,6 +67,7 @@ import type {
|
|||
CreateTransportOptions,
|
||||
ServerType,
|
||||
} from "./types.js";
|
||||
import { DEFAULT_CONNECTION_TIMEOUT_MS } from "./types.js";
|
||||
import {
|
||||
MessageTrackingTransport,
|
||||
type MessageTrackingCallbacks,
|
||||
|
|
@ -293,6 +294,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
|
||||
|
|
@ -2220,11 +2235,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
|
||||
|
|
@ -2268,15 +2300,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]);
|
||||
|
|
@ -2291,7 +2318,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
try {
|
||||
await this.invokeMcpClient(runConnect);
|
||||
} catch (err) {
|
||||
if (connectTimeoutMs > 0) {
|
||||
if (connectTimedOut) {
|
||||
await this.disconnect().catch(() => {});
|
||||
}
|
||||
throw err;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_CONNECTION_TIMEOUT_MS,
|
||||
DEFAULT_MAX_FETCH_REQUESTS,
|
||||
DEFAULT_MODERN_LOG_LEVEL,
|
||||
DEFAULT_PROTOCOL_ERA,
|
||||
|
|
@ -533,9 +534,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,
|
||||
|
|
@ -659,7 +664,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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -643,6 +647,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
|
||||
|
|
@ -781,6 +797,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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue