import { Client } from "@modelcontextprotocol/client"; // The protocol's own schemas for the reserved `_meta` members, so the client // validates against the SDK rather than a restatement of it that can drift. import { ProgressTokenSchema, RequestMetaSchema, } from "@modelcontextprotocol/core"; import type { MCPServerConfig, StderrLogEntry, ConnectionStatus, MessageEntry, MessageOrigin, FetchRequestEntry, FetchRequestEntryBase, FetchStreamState, InspectorServerSettings, ResourceReadInvocation, ResourceTemplateReadInvocation, PromptGetInvocation, ToolCallInvocation, AppRendererClient, InspectorClientOptions, PendingRequestOrigin, ResourceSubscriptionStreamState, ExcludedTool, RequestMetadata, } from "./types.js"; import { scanXMcpHeaderDeclarations, mcpParamHeadersForTool, } from "../json/xMcpHeader.js"; // Re-export so v1.5 tests that do `import { InspectorClientOptions } from // "@inspector/core/mcp/inspectorClient.js"` keep resolving. export type { InspectorClientOptions, InspectorClientEnvironment, CreateTransport, CreateTransportOptions, CreateTransportResult, AppRendererClient, } from "./types.js"; import { getServerType as getServerTypeFromConfig } from "./config.js"; import { INACTIVE_SUBSCRIPTION_STREAM_STATE, isTerminalStatus, resolveModernLogLevel, } from "./types.js"; import { cleanRoots } from "./serverList.js"; import { isNeverAcknowledgedSubscriptionClose, subscriptionFailureMessage, NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE, } from "./subscriptionAck.js"; // Fallback client identity, used ONLY when a caller doesn't pass // `clientIdentity`. Real clients supply their own: the Node clients (CLI, TUI) // read the single-source version from the root package.json via // `readInspectorVersion()`, and the web browser — which can't read the // filesystem — will pass a version sourced from `GET /api/config` (see #1639). // This stays a neutral placeholder rather than a hardcoded release number that // would silently drift out of sync with the root package.json version. const corePackageJson = { name: "mcp-inspector", version: "0.0.0", } as const; import type { CreateTransport, CreateTransportOptions, ServerType, } from "./types.js"; import { DEFAULT_CONNECTION_TIMEOUT_MS } from "./types.js"; import { MessageTrackingTransport, type MessageTrackingCallbacks, } from "./messageTrackingTransport.js"; import type { CallToolRequest, JSONRPCRequest, JSONRPCNotification, JSONRPCResultResponse, JSONRPCErrorResponse, JSONRPCMessage, ServerCapabilities, ClientCapabilities, Implementation, LoggingLevel, Tool, Resource, ResourceTemplateType as ResourceTemplate, Prompt, Root, CreateMessageRequest, CreateMessageResult, CreateTaskResult, ElicitRequest, ElicitResult, ElicitRequestURLParams, CallToolResult, Task, Progress, ProgressToken, ListToolsRequest, ListResourcesRequest, ListResourceTemplatesRequest, ListPromptsRequest, ReadResourceRequest, GetPromptRequest, CompleteRequest, ListRootsRequest, } from "@modelcontextprotocol/client"; import type { Transport } from "@modelcontextprotocol/client"; import type { RequestOptions, CacheableRequestOptions, CacheMode, ProgressCallback, VersionNegotiationOptions, ProtocolEra, DiscoverResult, InputRequests, InputRequiredOptions, StandardSchemaV1, McpSubscription, SubscriptionFilter, } from "@modelcontextprotocol/client"; import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; import { isInputRequiredResult, withInputRequired, LOG_LEVEL_META_KEY, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY, } from "@modelcontextprotocol/client"; import { TASKS_EXTENSION_KEY, MODERN_TASK_HANDLE_META, MODERN_PROTOCOL_VERSION, ModernGetTaskResultSchema, ModernUpdateTaskResultSchema, ModernCancelTaskResultSchema, normalizeModernTask, readInputRequests, isModernCreateTaskResult, type ModernDetailedTask, } from "./modernTaskSchemas.js"; import { buildClientExtensions } from "./extensions.js"; import { DirectoryReadResultSchema, GetSkillEnvelopeSchema, ListSkillsResultSchema, ModernListSkillsResultSchema, RESOURCES_DIRECTORY_READ_METHOD, SKILLS_EXTENSION_KEY, type DirectoryReadResult, type GetSkillEnvelope, SKILLS_GET_METHOD, SKILLS_LIST_METHOD, type SkillEntry, } from "./skillsSchemas.js"; import { getSkillsExtension, type SkillsExtensionSupport } from "./skills.js"; import { getElicitationUiResourceUri, isFormElicitation, supportsAppElicitation, validateAppElicitResult, type AppElicitationRenderer, } from "./appElicitation.js"; import { EmptyResultSchema, CallToolResultSchema, GetPromptResultSchema, ReadResourceResultSchema, // Task request schemas — used for `.shape.params` in the 3-arg custom // `setRequestHandler` form (tasks/* are excluded from v2's spec-method set). ListTasksRequestSchema, GetTaskRequestSchema, GetTaskPayloadRequestSchema, CancelTaskRequestSchema, TaskStatusNotificationSchema, // Task result schemas — explicit result schemas for the raw requestor-task // requests that replace the removed `client.experimental.tasks.*` helpers. CreateTaskResultSchema, GetTaskResultSchema, CancelTaskResultSchema, ListTasksResultSchema, // List result schemas — used by the single-page list methods below. SDK v2's // high-level `client.listTools()` etc. auto-aggregate ALL pages (returning // `nextCursor: undefined`), which defeats the Inspector's pagination-debugging // purpose. Drop to raw `client.request` with these explicit schemas so each // call fetches exactly one page and surfaces the server's `nextCursor`. ListToolsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, ListPromptsResultSchema, // Per-primitive item schemas — the salvage fallback validates a list's // entries one at a time with these when the whole-result parse rejects // (#1909, see `listAllSalvaging`). Tools go through `toolItemSchemaForEra` // instead, since the neutral schema is looser than the negotiated era's. ResourceSchema, ResourceTemplateSchema, PromptSchema, } from "@modelcontextprotocol/core"; import type { ClientResult } from "@modelcontextprotocol/client"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; import { z } from "zod/v4"; import { validateToolOutput } from "./toolOutputValidation.js"; import { LIST_MAX_PAGES, ModernResultEnvelopeSchema, isSalvageableRejection, isClientDecodeRejection, listPaginationExceeded, toolItemSchemaForEra, nextCursorOf, lenientListPageSchema, rawItemsOf, salvageListItems, summarizeMalformed, type MalformedListItem, } from "./listSalvage.js"; import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js"; import { type JsonValue, convertToolParameters, convertPromptArguments, } from "../json/jsonUtils.js"; import { expandUriTemplateStrict } from "./uriTemplate.js"; import { InspectorClientEventTarget, type TaskWithOptionalCreatedAt, } from "./inspectorClientEventTarget.js"; import { SamplingCreateMessage } from "./samplingCreateMessage.js"; import { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; import { getUrlElicitationsFromError, UrlElicitationLoopError, } from "./urlElicitation.js"; import { ToolCallCancelledError } from "./toolCallCancelledError.js"; import type { OAuthConnectionState, OAuthFlowState, OAuthStep, } from "../auth/types.js"; import { AuthRecoveryRequiredError, EMA_STEP_UP_PENDING_URL, findNestedAuthError, isAuthChallengeError, isConnectAuthRecoveryError, parseAuthChallengeFromError, type AuthChallenge, type AuthChallengeOutcome, type HandleAuthChallengeOptions, } from "../auth/challenge.js"; import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js"; import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js"; import { DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, exemptMcpEndpoint, withOAuthRequestTimeout, } from "../auth/requestTimeout.js"; import type { TokenRevocationOutcome } from "../auth/revocation.js"; import type { OAuthTokens } from "@modelcontextprotocol/client"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; import { createFetchTracker, findHeader, isLongLivedStreamResponse, } from "./fetchTracking.js"; import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { annotateRequestTimeout, isRequestTimeoutError, type ConnectionDiagnostics, type LastResponse, type NotificationStreamState, type OutstandingRequest, } from "./connectionDiagnostics.js"; import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; import { RemoteClientTransport } from "./remote/remoteClientTransport.js"; /** * The notification stream the diagnostics are following, plus the fetch entry * id that ties later `fetchRequestStreamUpdate`s back to it (#2318). */ interface TrackedNotificationStream extends NotificationStreamState { id: string; } /** Internal record for a receiver task (server polls us for status/result). */ interface ReceiverTaskRecord { task: Task; payloadPromise: Promise; resolvePayload: (payload: ClientResult) => void; rejectPayload: (reason?: unknown) => void; cleanupTimeoutId?: ReturnType; /** * Aborted when the task reaches a terminal state some way other than the * user answering — a `tasks/cancel`, or session teardown. Whatever is * collecting the answer (the native pending-request entry, or an app-rendered * elicitation and its bridge) is torn down from this, so a cancelled task * cannot leave a modal on screen waiting for an answer nothing will read. */ abort: AbortController; } /** * Cap on how many times a single `callTool` will surface URL elicitations and * retry after a `-32042` (UrlElicitationRequired) response. A spec-compliant * flow resolves in one round; the bound only guards against a server that keeps * returning the error. */ 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 * message is not surfaced directly — `callToolWithRetries` maps the abort to a * {@link ToolCallCancelledError} by inspecting the controller's reason — but a * concrete error is needed to reject the awaiting driver promise. */ function createPendingAbortError(): Error { return new Error("Pending request aborted"); } /** * The abort reason used by `cancelToolCall()`. It rides along on the * `notifications/cancelled` sent to the server and lets `callToolWithRetries` * tell a deliberate user cancel apart from other aborts of the same controller * (e.g. a disconnect, which aborts with a different reason and should surface as * an ordinary error, not a "Tool call cancelled" — #1458). */ const TOOL_CALL_CANCELLED_REASON = "Tool call cancelled by user"; /** * Fallback poll cadence (ms) for {@link InspectorClient.pollTaskToolCall} when a * task does not advertise its own `pollInterval`. Replaces the cadence the * removed SDK `experimental.tasks.callToolStream` helper managed internally. */ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; /** * Close a modern listen stream best-effort, absorbing both failure modes a * third-party `close()` can produce: a rejected promise and a synchronous * throw. All three stream closes go through here, and every one of them has * already dropped its reference to the stream — so in each case an escaping * failure abandons a stream that may still be open on the server. What it does * *besides* that differs per site, because the shape of the call does: * * - `resetSubscriptionStream` — fire-and-forget (`void`), and the last statement * of the method. Because this helper is `async`, even a synchronous throw * becomes a rejection of the promise it returns, which the `void` then drops: * the caller is unaffected, and the harm is an *unhandled rejection* — fatal * to a Node process by default, a console error in the browser. That, not * skipped teardown, is what the wrapping buys at this site. * - `refreshModernSubscription`'s re-listen close — awaited, with the * replacement `listen()` after it, so an escaping failure skips the re-listen * and leaves a non-empty subscription set with no stream. On the reconnect * caller it is instead absorbed into the backoff run and retried. * - the superseded-generation discard — awaited, but `return` follows, so * nothing local is skipped; the failure only propagates out of * `refreshModernSubscription` to whichever caller a newer refresh had already * superseded. Self-healing on the reconnect path; on the two user-initiated * paths it is a report of a failure that did not happen — for * `unsubscribeFromResource`, whose removal is kept regardless, a "Failed to * unsubscribe" for an unsubscribe that stuck. Both of those gate their * failure handling on still owning the re-listen, so a superseded call * reports its error without editing a filter or a badge that is no longer * its own (see `subscribeToResource`'s catch for the reasoning). * * Note what the generation bump proves in that last case is that a newer * refresh had *started*, not that it succeeded — it may yet fail at its own * `listen()`, making an escaping failure here a second one rather than a * redundant one. * * Wrapped identically all the same, so the rule is one rule (#1630, #1797). */ async function closeSubscriptionBestEffort( subscription: Pick, ): Promise { try { await subscription.close(); } catch { // Best-effort: there is nothing to do about a stream that won't close. } } /** * The descriptor for a single tools/call, threaded through the retry loop and * each attempt. Bundled into one object so `callToolWithRetries`/`attemptToolCall` * don't take a long, transposition-prone positional parameter list. */ interface ToolCallRequest { tool: Tool; args: Record; generalMetadata?: RequestMetadata; toolSpecificMetadata?: RequestMetadata; taskOptions?: { ttl?: number }; options?: { skipOutputValidation?: boolean }; } // Backoff for reconnect-by-re-listen on the modern `subscriptions/listen` stream // (#1630). A `"remote"` drop schedules a re-listen after a capped exponential // delay (based on the count of *consecutive failed* re-lists) so a flapping // server can't spin a tight zero-delay loop; a successful acknowledgement resets // the count, and past the cap we give up (mark the stream ended) rather than // retry a persistently-failing re-list forever. /** * A one-word name for a JSON value's type, for diagnostics that must not * disclose the value itself. */ function describeJsonType(value: unknown): string { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } /** * Whether a `_meta.progressToken` value is one the protocol allows. * * Delegates to the SDK's own `ProgressTokenSchema` rather than restating it. * A hand-rolled check drifts: this was `Number.isInteger` until review pointed * out that zod 4's `.int()` rejects anything past `Number.MAX_SAFE_INTEGER`, * so an unsafe integer passed here and was rejected by the server instead. */ function isProgressToken(value: unknown): value is ProgressToken { return ProgressTokenSchema.safeParse(value).success; } /** * Drop the reserved `_meta` members whose value the protocol will not accept. * * Most of `_meta` is arbitrary JSON (#1910), but a few keys are **reserved** * and carry their own schema — `progressToken` (a string or a safe integer; * zod's `.int()` rejects anything past `Number.MAX_SAFE_INTEGER`) and * `io.modelcontextprotocol/related-task` (`{ taskId: string }`) in the pinned * SDK. Before the widening every value was a string, so only `progressToken` * could even be wrong; now any of them can, and one bad reserved member makes a * conforming server reject the **whole request** rather than ignore the key. * * Validated with the SDK's `RequestMetaSchema` instead of a hand-maintained * list, which is the point: the schema is loose, so unknown keys pass through * untouched, and a member the SDK constrains in a later release is covered here * without anyone remembering to add it. Zod reports the offending top-level key * as `issue.path[0]`, so only that member is removed — siblings survive, and * the request stays well-formed because every reserved member is optional. */ function dropInvalidReservedMeta( meta: RequestMetadata, logger: InspectorLogger, ): RequestMetadata { const result = RequestMetaSchema.safeParse(meta); if (result.success) return meta; const cleaned = { ...meta }; for (const issue of result.error.issues) { const key = issue.path[0]; if (typeof key !== "string" || !(key in cleaned)) continue; // The key and the reason, never the value: `_meta` can carry credentials, // and this logger is persisted by real clients (the TUI writes it to // `~/.mcp-inspector/auth.log`). logger.warn( { key, received: describeJsonType(cleaned[key]) }, "Dropping reserved `_meta` member — it does not match the protocol schema.", ); delete cleaned[key]; } return cleaned; } const MODERN_RECONNECT_BASE_MS = 500; const MODERN_RECONNECT_MAX_MS = 15_000; const MODERN_RECONNECT_MAX_ATTEMPTS = 8; /** * InspectorClient wraps an MCP Client and provides: * - Message tracking and storage * - Stderr log tracking and storage (for stdio transports) * - EventTarget interface for React hooks (cross-platform: works in browser and Node.js) * - Access to client functionality (prompts, resources, tools) */ export class InspectorClient extends InspectorClientEventTarget { /** * Upper bound on MRTR (`input_required`) rounds for a single logical request * before {@link requestWithInputRequired} gives up. We drive the loop * ourselves (`inputRequired: { autoFulfill: false }`), so this is the manual * counterpart to the SDK auto-driver's default `maxRounds` (10) and guards * against a server that keeps returning `input_required` forever. */ private static readonly MRTR_MAX_ROUNDS = 10; private client: Client | null = null; // Lazily-built validator used only on the skipOutputValidation path to detect // (non-fatally) when a delivered result violates the tool's outputSchema. private outputValidator: AjvJsonSchemaValidator | null = null; private transport: Transport | MessageTrackingTransport | null = null; private baseTransport: Transport | null = null; // Every outbound request still awaiting a response, keyed by JSON-RPC id. // Serves two readers: the `markResponseRejected` correlation (#1953), which // needs the method, and the connection diagnostics (#2318), which need the // method and when it went out. Entries are dropped as responses arrive; a // request the SDK gave up on (a timeout, a cancel) stays until the session // resets, because it is still unanswered and that is what the diagnostics // report. private outstandingRequests = new Map(); // The id of the most recently answered request per method, for // `markResponseRejected` (#1953). At most one id per method, so it does not // grow with the session. private lastAnsweredRequestByMethod = new Map(); // Connection diagnostics (#2318): the most recent response to one of our // requests, and the most recent long-lived transport stream (the standalone // `GET` on Streamable HTTP). Both per-session, reset with the maps above. private lastResponse: LastResponse | undefined; private notificationStream: TrackedNotificationStream | undefined; /** True when the cached transport was built with an OAuth authProvider attached. */ private transportHasAuthProvider = false; /** Dedupes concurrent ambient auth challenges (reason + scopes). */ private ambientAuthChallengeInFlight = new Map>(); private pipeStderr: boolean; private initialLoggingLevel?: LoggingLevel; // Modern-era per-request log level (#1629). On 2026-07-28 servers // `logging/setLevel` is gone; the client opts into logs per request via the // `io.modelcontextprotocol/logLevel` `_meta` key, and the SDK does not attach // it automatically. When set, `mergeMeta` stamps this level on every outgoing // request so server logs arrive on each request's stream; `undefined` means // "don't opt in" (logs stay silently absent). Only honored on the modern era. private modernLogLevel?: LoggingLevel; private readonly sample: boolean; private readonly elicit: boolean | { form?: boolean; url?: boolean }; private progress: boolean; private resetTimeoutOnProgress: boolean; private requestTimeout: number | undefined; private defaultMetadata: RequestMetadata | undefined; private serverSettings: InspectorServerSettings | undefined; private versionNegotiation: VersionNegotiationOptions; private status: ConnectionStatus = "disconnected"; // True only while an explicit disconnect() owns the teardown. close() can // trigger the transport's onclose synchronously, so this lets onclose defer // the canonical status set + `disconnect` event to disconnect() and fire it // exactly once (see onclose / disconnect()). private disconnecting = false; // Server data (resources, resourceTemplates, prompts are in state managers) private capabilities?: ServerCapabilities; private serverInfo?: Implementation; private instructions?: string; private protocolVersion?: string; // Era model (SEP §7.8). Populated after connect from the SDK Client's // negotiation accessors. `protocolEra` is the negotiated era ("legacy" | // "modern"); `discoverResult` is the `server/discover` payload on a // probed/pinned connection (undefined on a plain legacy connect). private protocolEra?: ProtocolEra; private discoverResult?: DiscoverResult; // Tools the SDK excludes from `tools/list` for invalid `x-mcp-header` // annotations (SEP-2243), recomputed on every aggregate tools refresh and // surfaced so the Tools tab can show why a tool vanished (#1632). private excludedTools: ExcludedTool[] = []; // Entries the Inspector dropped from a list result because they failed the // spec schema for that primitive, keyed by list method. Populated only by the // salvage fallback in `listAllSalvaging` — empty against a conforming server // (#1909). private malformedListItems = new Map(); // The capabilities this Inspector client advertises to the server during the // initialize handshake. Built once in setupClient() and snapshotted here so // UI surfaces (Server Info modal) can display them without poking at the // SDK Client's private state. private clientCapabilities: ClientCapabilities = {}; // The client identity ({name, version}) passed to the SDK Client. Reused to // build the modern per-request envelope for raw tasks/* requests. private clientInfo: Implementation; // Sampling requests private pendingSamples: SamplingCreateMessage[] = []; // Elicitation requests private pendingElicitations: ElicitationCreateMessage[] = []; // Roots (undefined means roots capability not enabled, empty array means enabled but no roots) private roots: Root[] | undefined; /** * Whether `capabilities.roots` was advertised at `initialize`, read off the * capability object actually sent rather than re-derived from the constructor * option. Fixed for the client's lifetime, because the capability is * negotiated at construction and the SDK refuses `registerCapabilities` after * connect. * * The `roots/list` registration gates on *this*, not on `this.roots`, which * `setRoots()` can make defined later: the SDK throws "Client does not support * roots capability" from `setRequestHandler` when the capability was never * advertised, and that throw would land before the handshake on every * subsequent `connect()` — wedging the client permanently (#1797). */ private readonly rootsCapabilityAdvertised: boolean; /** * Whether `capabilities.elicitation` was advertised, on the same terms as * {@link rootsCapabilityAdvertised}. Not the same question as `this.elicit` * being truthy: `{}` / `{ form: false, url: false }` are valid options that * enable no mode, so nothing is advertised — and registering * `elicitation/create` anyway throws "Client does not support elicitation * capability" before the handshake, leaving the client unable to connect * at all (#1797). */ private readonly elicitationCapabilityAdvertised: boolean; /** As above, for `capabilities.sampling`. */ private readonly samplingCapabilityAdvertised: boolean; /** As above, for `capabilities.tasks` (the receiver-side `tasks/*` polls). */ private readonly tasksCapabilityAdvertised: boolean; /** As above, for `capabilities.elicitation.url` (the URL-mode completion). */ private readonly urlElicitationCapabilityAdvertised: boolean; /** * As above, for `capabilities.roots.listChanged` — the predicate the SDK * asserts before letting us *send* `roots/list_changed`, which is narrower * than the `capabilities.roots` presence it asserts before letting us * *register* `roots/list`. They coincide today (roots are only ever * advertised as `{ listChanged: true }`), but reading each assertion's own * predicate is what keeps a future conditional advertisement from silently * un-gating one of them. */ private readonly rootsListChangedCapabilityAdvertised: boolean; // Content cache // ListChanged notification configuration private listChangedNotifications: { tools: boolean; resources: boolean; prompts: boolean; }; // Resource subscriptions. The set of subscribed URIs is the era-agnostic // source of truth for the UI (the `resourceSubscriptionsChange` list). How a // subscription reaches the wire forks by era: legacy sends one // `resources/subscribe` per URI; modern (2026-07-28) has no such method — all // subscriptions are a filter over one long-lived `subscriptions/listen` stream // (#1630, SEP §7.4). private subscribedResources: Set = new Set(); // Modern-era listen stream backing the subscriptions above. A single // `McpSubscription` whose filter's `resourceSubscriptions` mirrors // `subscribedResources`; mutating the set re-lists (close old, open new), and // an unexpected `"remote"` close re-lists (reconnect-by-re-listen — the stream // is not resumable). `null` on the legacy era, and on the modern era whenever // the filter is empty — which is *not* the same as "no URI subscribed": the // stream also carries the list-change opt-ins, so a tools-only server opens it // with no subscriptions at all (#1920). private modernSubscription: McpSubscription | null = null; // Monotonic guard so a stale re-list/reconnect (whose `listen()` or `closed` // resolves after a newer refresh already started) can detect it lost the race // and bail without clobbering the current stream. private modernListenGeneration = 0; // Last dispatched modern stream state; `active: false` on the legacy era. private modernStreamState: ResourceSubscriptionStreamState = INACTIVE_SUBSCRIPTION_STREAM_STATE; // Reconnect-by-re-listen backoff state (#1630): the count of consecutive // *failed* re-lists (reset to 0 on any successful acknowledgement) and the // pending re-listen timer. private modernReconnectAttempts = 0; private modernReconnectTimer: ReturnType | undefined; // Set when the last `listen()` was rejected because the server answered it // with a bare graceful-closure result instead of acknowledging (#2097). That // condition is deterministic, so the reconnect machinery must not treat it as // a drop to retry; the flag is what carries the distinction from the rejection // site to the two failure handlers. Cleared by any user-initiated refresh (a // subscribe/unsubscribe is a fresh attempt, and the server may have changed). private modernNeverAcknowledged = false; // Task ids the user explicitly cancelled. A cancel makes the in-flight // `callToolStream` reject with a generic -32603 error, which the stream's // error path would otherwise report as a *failed* task — flashing "failed" // in the UI until a refresh fetches the server's true "cancelled" state. // Recording the id lets that path label the terminal task "cancelled" // instead, so it lands in the right state immediately (#1455). Cleared on // disconnect. private cancelledTaskIds: Set = new Set(); // Per-task abort controllers for a modern task paused at `input_required`. // While the poll loop blocks on the pending elicitation (the modal), the tool // call's own abort path isn't in play — so `cancelRequestorTask` aborts this // controller to reject the pending request, close the modal, and let the poll // observe the cancellation. Keyed by taskId; created/removed by the poll loops. private taskInputAbortControllers = new Map(); // Pending raw-wire requests (modern tasks/* — see rawWireRequest). Keyed by a // string JSON-RPC id we mint; the SDK Client only mints numeric ids, so ours // never collide with (or reach) it. Resolved by the transport's // consume-response hook and rejected on disconnect. private pendingRawWireRequests = new Map< string, { resolve: (result: unknown) => void; reject: (err: Error) => void; timer: ReturnType; } >(); private rawWireRequestCounter = 0; // Abort controller for the in-flight ordinary (non-task) tool call. Aborting // it hands the SDK the MCP cancellation flow for that request and rejects the // pending call, which `callTool` surfaces as a `ToolCallCancelledError`. Which // signal reaches the server is the transport's business, not ours: a // per-request-stream transport on a 2026-era connection has that request's // stream aborted, and everything else gets `notifications/cancelled` (#2140). // Undefined when no ordinary call is in flight. // Task-augmented calls have a server-side task and are cancelled via // `cancelRequestorTask` instead, so they don't use this (#1458). private activeToolCallAbortController?: AbortController; // Receiver tasks (server-initiated: server sends createMessage/elicit with params.task, server polls us) private readonly receiverTasks: boolean; // Per-extension advertise overrides (#1738); undefined key falls back to the // registry default in ADVERTISABLE_EXTENSIONS. private readonly advertisedExtensions?: Record; /** * Host-supplied renderer for app-rendered form elicitations (#1854), or * undefined on a client that cannot host MCP Apps. Its presence is what * advertises the nested MCP Apps `elicitation` capability, so this is the one * fact both the advertisement and the routing gate read. */ private readonly appElicitationRenderer?: AppElicitationRenderer; /** * Monotonic counter behind the request-scoped id handed to the renderer. The * association MUST be per request (a map keyed by this id owns the resource * URI, renderer instance and promise on the host side) so two concurrent * elicitations cannot resolve through each other's bridges. */ private appElicitationSeq = 0; /** * Per-instance prefix for {@link appElicitationSeq}. * * A counter alone is not enough: a host may hold ONE renderer across * replacement clients (the web client rebuilds its `InspectorClient` on a * settings change), and every fresh instance would otherwise mint * `app-elicitation-1` for its first request. Settling that id would then * resolve — or discard — whichever connection's request the host happened to * be keying. */ private readonly appElicitationPrefix = `app-elicitation-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`; /** * Abort controllers for app-rendered elicitations still awaiting an answer. * Aborted alongside the native pending queue on disconnect, so a rendered app * cannot outlive the connection that asked for it. */ private activeAppElicitations = new Set(); private receiverTaskTtlMs: number | (() => number); private receiverTaskRecords: Map = new Map(); // OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured") private oauthManager: OAuthManager | null = null; private logger: InspectorLogger; private transportClientFactory: CreateTransport; private fetchFn?: typeof fetch; private effectiveAuthFetch: typeof fetch; // Session ID (for OAuth state and saveSession event; persistence is in FetchRequestLogState) private sessionId?: string; private transportConfig: MCPServerConfig; /** null until first transport is built; then true for in-process OAuth runners. */ private directAuthRecoveryActive: boolean | null = null; /** * Nesting depth of in-flight silent auth recoveries. `withDirectAuthRecovery` * bounds retries per call (`attempt >= 1`), but a satisfied outcome recovers * by running a *nested* `connect()`, which starts its own recovery with a * fresh counter — so a server that keeps answering 401 with freshly refreshed * tokens would recurse without bound. Counted across the nesting boundary and * capped at {@link InspectorClient.MAX_NESTED_AUTH_RECOVERIES}. */ private authRecoveryDepth = 0; /** * Cap on nested silent recoveries. One is the normal case (challenge → * refresh → reconnect); a couple more absorb a legitimately re-challenged * reconnect. Beyond that the credentials are not fixing the challenge, so the * challenge is surfaced to the caller instead of recovered again. */ private static readonly MAX_NESTED_AUTH_RECOVERIES = 3; /** * Opt-in from {@link InspectorClientOptions.directAuthRecovery}: when true and * the live transport is direct (not {@link RemoteClientTransport}), RPCs use * fetch intercept + {@link withDirectAuthRecovery}. */ private readonly directAuthRecovery: boolean; constructor( transportConfig: MCPServerConfig, options: InspectorClientOptions, ) { super(); this.transportConfig = transportConfig; // Extract environment components this.transportClientFactory = options.environment.transport; this.fetchFn = options.environment.fetch; this.logger = options.environment.logger ?? silentLogger; // Initialize content cache this.pipeStderr = options.pipeStderr ?? false; this.initialLoggingLevel = options.initialLoggingLevel; this.sample = options.sample ?? true; this.elicit = options.elicit ?? true; this.receiverTasks = options.receiverTasks ?? false; this.advertisedExtensions = options.advertisedExtensions; this.appElicitationRenderer = options.appElicitation; this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000; this.progress = options.progress ?? true; this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true; this.requestTimeout = options.timeout; this.defaultMetadata = options.defaultMetadata && Object.keys(options.defaultMetadata).length > 0 ? options.defaultMetadata : undefined; this.serverSettings = options.serverSettings; // Seed the modern per-request log level from the server setting (#1629), so // a modern connection opts into logs by default without the user touching // the Logs-tab control. Absence means DEFAULT_MODERN_LOG_LEVEL; `"off"` // clears the opt-in. Only stamped on modern connections (see mergeMeta) — // legacy uses `logging/setLevel`. this.modernLogLevel = resolveModernLogLevel(options.serverSettings); // Default to the legacy 2025-11-25 era when the caller doesn't pin one, per // the SDK guidance that a debugging tool must not auto-probe (#1626). this.versionNegotiation = options.versionNegotiation ?? { mode: "legacy" }; this.directAuthRecovery = options.directAuthRecovery ?? false; // Only set roots if explicitly provided (even if empty array) - this enables // roots capability. Normalized here as well as in `setRoots`, so core owns // the invariant rather than trusting each client to clean at its call site // (they do, and `cleanRoots` is idempotent, so this costs nothing). The // ternary preserves the `undefined`-vs-`[]` distinction the capability // advertisement below gates on. this.roots = options.roots !== undefined ? cleanRoots(options.roots) : undefined; // Initialize listChangedNotifications config (default: all enabled) this.listChangedNotifications = { tools: options.listChangedNotifications?.tools ?? true, resources: options.listChangedNotifications?.resources ?? true, prompts: options.listChangedNotifications?.prompts ?? true, }; // Effective auth fetch: base fetch + tracking with category 'auth' // #1906: apply the per-server authorization/token endpoint overrides to the // authorization-server metadata document in flight. This wraps the *base* // fetch rather than `effectiveAuthFetch` so it also covers the discovery the // SDK runs from inside the transport (the 401/refresh path), which is handed // `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is // created a few lines below, and `setOAuthConfig` can change them later — so // the wrapper is inert until a server actually configures one. const withOverrides = withOAuthEndpointOverrides( this.fetchFn ?? fetch, () => this.oauthManager?.getEndpointOverrides(), ); // #2172: recover discovery when a plain OAuth 2.0 authorization server // publishes RFC 8414 metadata at `/.well-known/openid-configuration`, which // the SDK validates as an OpenID provider document and rejects. Wraps the // *base* fetch for the same reason the overrides above do: the SDK also // runs discovery from inside the transport, which is handed `this.fetchFn` // directly, so a reconnect with an existing auth provider would otherwise // still hit the upstream failure. The substituted response is stamped with // `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came // from rather than appearing to be a 200 from the RFC 8414 path. // The transport chain is bounded too, but only for the requests on it that // are *not* MCP traffic — the OAuth work the SDK runs from inside the // transport on the 401/refresh path, which otherwise waits out the SDK's // incidental per-request timeout and reports a bare `Request timed out` // naming no endpoint (Copilot). The MCP endpoint itself is exempt, and the // predicate fails open: a long-running tool call withholding its response // headers for minutes, or an SSE body that stays open, must never be timed // here. Deadline innermost, for the same reason as the auth chain below. this.fetchFn = withRfc8414OidcCompat( withOAuthRequestTimeout( withOverrides, DEFAULT_OAUTH_REQUEST_TIMEOUT_MS, exemptMcpEndpoint( () => this.getServerUrl(), // Legacy SSE negotiates its message endpoint inside the stream, so // its path is unknowable here and the exemption has to widen to the // origin. Streamable HTTP sends everything to the configured URL, so // the path rule is exact there and same-origin OAuth stays bounded. () => this.transportConfig?.type === "sse", ), ), ); // #2319: the auth chain is composed separately so the deadline sits // *inside* the compat wrapper. Reusing `this.fetchFn` as the base would put // it outside, and then a stalled OIDC probe would be reported under the // URL of the RFC 8414 request that preceded it — defeating the whole point // of naming the endpoint — while every probe in the loop shared that one // request's budget (Copilot). Innermost here matches the CLI's // `storedAuthFetch`. It also passes no `isExempt`, unlike the transport // chain above: every request on this chain is OAuth work, so there is // nothing here to exempt. this.effectiveAuthFetch = this.buildEffectiveAuthFetch( withRfc8414OidcCompat(withOAuthRequestTimeout(withOverrides)), ); this.sessionId = options.sessionId; // Merge OAuth config with environment components; create internal OAuth manager (owns config) if (options.oauth || options.environment.oauth) { const oauthConfig: OAuthManagerConfig = { // Environment components (storage, navigation, redirectUrlProvider) ...options.environment.oauth, // Config values (clientId, clientSecret, clientMetadataUrl, scope) ...options.oauth, }; this.oauthManager = new OAuthManager({ getServerUrl: () => this.getServerUrl(), effectiveAuthFetch: this.effectiveAuthFetch, getEventTarget: () => this, onBeforeOAuthRedirect: (sessionId: string) => { this.sessionId = sessionId; this.saveSession(); return Promise.resolve(); }, initialConfig: oauthConfig, logger: this.logger, enterpriseManagedAuth: options.enterpriseManagedAuth, installEnterpriseManagedAuth: options.installEnterpriseManagedAuth, dispatchOAuthComplete: (detail) => this.dispatchTypedEvent("oauthComplete", detail), dispatchOAuthAuthorizationRequired: (detail) => this.dispatchTypedEvent("oauthAuthorizationRequired", detail), dispatchOAuthError: (detail) => this.dispatchTypedEvent("oauthError", detail), }); } // Transport is created in connect() (single place for create / wrap / attach). // Build client capabilities const clientOptions: { capabilities?: ClientCapabilities; versionNegotiation?: VersionNegotiationOptions; inputRequired?: InputRequiredOptions; listMaxPages?: number; } = { // Bound the SDK's auto-aggregate with the SAME constant the #1909 salvage // re-walks use, so the strict path and its fallback cannot drift apart. // This is the SDK's own default today; setting it explicitly is what makes // the shared value a fact rather than a copied guess. listMaxPages: LIST_MAX_PAGES, // Per-server protocol era (SEP §7.8), threaded from config via // `eraToVersionNegotiation` and defaulted to `{ mode: "legacy" }` in the // constructor. "legacy" keeps the wire byte-identical to a 2025 client; // "auto"/"modern" opt into 2026-era negotiation (#1626). versionNegotiation: this.versionNegotiation, // Drive MRTR (SEP-2322) manually instead of letting the SDK auto-fulfil // and hide the retry loop (#1704). Unconditional and safe on every era: // legacy servers never return `input_required`, so this is a no-op there; // on modern connections the three multi-round-trip methods opt in via // `allowInputRequired` in `requestWithInputRequired`, and no other method // can receive an `input_required` result. The negotiated era is unknown // at construction time, so gating here is impossible anyway. inputRequired: { autoFulfill: false }, }; const capabilities: ClientCapabilities = {}; if (this.sample) { capabilities.sampling = {}; } // Handle elicitation capability with mode support if (this.elicit) { const elicitationCap: NonNullable = {}; if (this.elicit === true) { // Backward compatibility: `elicit: true` means form support only elicitationCap.form = {}; } else { // Explicit mode configuration if (this.elicit.form) { elicitationCap.form = {}; } if (this.elicit.url) { elicitationCap.url = {}; } } // Only add elicitation capability if at least one mode is enabled if (Object.keys(elicitationCap).length > 0) { capabilities.elicitation = elicitationCap; } } // Advertise roots capability if roots option was provided (even if empty array) if (this.roots !== undefined) { capabilities.roots = { listChanged: true }; } // Receiver tasks: advertise so server can send task-augmented createMessage/elicit and poll us if (this.receiverTasks) { // `requests` declares which server→client requests we accept as tasks, so // it must name only capabilities we actually advertised — both are decided // above. Advertising a channel we then answer `-32601` on is the shape // #1797 is about, and `{ receiverTasks: true, elicit: false }` would do // exactly that. const taskRequests: NonNullable< NonNullable["requests"] > = {}; if (capabilities.sampling) { taskRequests.sampling = { createMessage: {} }; } if (capabilities.elicitation) { taskRequests.elicitation = { create: {} }; } capabilities.tasks = { list: {}, cancel: {}, ...(Object.keys(taskRequests).length > 0 && { requests: taskRequests }), }; } // Assemble the advertised-extensions map from one builder (the single // source of truth), instead of ad-hoc per-extension spreads. It layers the // registry defaults (with any user overrides from `advertisedExtensions`) // and the auth-mode-driven EMA extension. The Tasks entry defaults to // advertised, so the map is non-empty and `capabilities.extensions` is // always attached — the modern Tasks extension (SEP-2663) must ride every // modern request envelope for a server to legally return a `CreateTaskResult` // (harmless on legacy, where extensions are ignored). (#1738) const advertisedExtensions = buildClientExtensions({ enterpriseManaged: options.oauth?.enterpriseManaged ?? false, advertised: this.advertisedExtensions, // Read off the built `capabilities.elicitation.form` rather than // re-deriving from `options.elicit`: the nested MCP Apps `elicitation` // setting must never be advertised without the core form capability it // extends, and two derivations of the same fact can drift. Disabling form // elicitation therefore drops both, as the contract requires. (#1854) appElicitation: this.appElicitationRenderer !== undefined && capabilities.elicitation?.form !== undefined, }); if (Object.keys(advertisedExtensions).length > 0) { capabilities.extensions = { ...capabilities.extensions, ...advertisedExtensions, }; } clientOptions.capabilities = capabilities; this.clientCapabilities = capabilities; // Read off the built capability object rather than re-deriving from // `options.roots`: the gate and the advertisement must agree, and two // independent derivations of the same fact can drift (a `readonly` field is // assignable anywhere in the constructor). this.rootsCapabilityAdvertised = capabilities.roots !== undefined; this.elicitationCapabilityAdvertised = capabilities.elicitation !== undefined; this.samplingCapabilityAdvertised = capabilities.sampling !== undefined; this.tasksCapabilityAdvertised = capabilities.tasks !== undefined; this.urlElicitationCapabilityAdvertised = capabilities.elicitation?.url !== undefined; this.rootsListChangedCapabilityAdvertised = capabilities.roots?.listChanged === true; this.clientInfo = options.clientIdentity ?? { name: corePackageJson.name.split("/")[1] ?? corePackageJson.name, version: corePackageJson.version, }; this.client = new Client( this.clientInfo, Object.keys(clientOptions).length > 0 ? clientOptions : undefined, ); this.annotateSdkRequestTimeouts(this.client); } /** * Decorate the SDK Client's `request` so a per-request timeout rejects with * the connection's state at that moment instead of a bare `Request timed * out` (#2318). * * `Protocol.request` is the one funnel every SDK verb goes through — * `listTools`, `callTool`, `readResource`, the handshake's `initialize`, * and this class's own raw `client.request` calls all reach the wire * through it — so decorating it here covers the whole surface without a * `catch` at each of the several dozen call sites, and covers the next * verb added without anyone remembering to. The SDK offers no hook on the * rejection path: the timeout error is constructed and thrown inside * `request()` itself. * * Replaced on the instance rather than through a subclass because * `request` is declared with two overloads (spec method with a keyed * result, custom method with an explicit schema), and an `override` would * have to restate both against types that move with every SDK release. One * implementation signature serves both at runtime; the single cast on the * way back is what the overloads cost. */ private annotateSdkRequestTimeouts(client: Client): void { type SdkRequest = Client["request"]; const sdkRequest: SdkRequest = client.request.bind(client); const decorated = async ( ...args: Parameters ): Promise => { try { return await sdkRequest(...args); } catch (err) { throw annotateRequestTimeout( err, args[0].method, this.getConnectionDiagnostics(), ); } }; client.request = decorated as SdkRequest; } /** * @param base - the composed OAuth-path fetch, deadline innermost (#2319). * * Passed in rather than read off `this.fetchFn` because the two chains are * bounded on different terms, not because one of them is unbounded. Both * carry a deadline; the transport chain additionally exempts the MCP endpoint * (`exemptMcpEndpoint`), since its bodies are streams meant to stay open and * a long-running tool call may withhold its headers for minutes. Reusing it * here would extend that exemption to the OAuth manager's own requests — and * an authorization server published at the MCP endpoint's path would then go * unbounded on the one chain that exists to bound it. * * The SDK's transport-internal discovery (the 401/refresh path) is covered by * the transport chain's own deadline, not by this one. */ private buildEffectiveAuthFetch(base: typeof fetch): typeof fetch { // Capture auth response bodies (OAuth discovery, DCR, token exchange) so // they're inspectable in the Network tab. Token-exchange responses carry // `access_token` / `refresh_token`; the Network UI masks those (and other // known secret fields) behind a click-to-reveal toggle so they aren't // surfaced at a glance during a screen-share. Masking is a display // concern, kept in the UI layer rather than mutating the captured entry, // so the raw body stays available for the user who explicitly reveals it. return createFetchTracker(base, { trackRequest: (entry) => this.dispatchFetchRequest({ ...entry, category: "auth" }), updateResponseBody: (id, body) => this.dispatchFetchRequestBodyUpdate(id, body), }); } private createMessageTrackingCallbacks(): MessageTrackingCallbacks { return { trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => { if (origin === "client") { this.outstandingRequests.set(message.id, { id: message.id, method: message.method, sentAt: Date.now(), }); this.dispatchConnectionDiagnosticsChange(); } const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), direction: "request", origin, message, }; this.dispatchTypedEvent("message", entry); }, trackSendFailure: (message: JSONRPCRequest, error: unknown) => { // A send that failed because the request timed out is not a send // that failed: the browser's remote transport awaits the response // inside `send`, so its relay timeout surfaces here — the request // reached the server and went unanswered, which is the state to // keep reporting. Every other failure means the frame never reached // the wire, and leaving it would report a request the server never // saw as unanswered, in every timeout message and Connection Info // row, until the session resets (#2318). if (isRequestTimeoutError(error)) return; if (this.outstandingRequests.delete(message.id)) { this.dispatchConnectionDiagnosticsChange(); } }, trackResponse: ( message: JSONRPCResultResponse | JSONRPCErrorResponse, origin: MessageOrigin, ) => { // A response to one of OUR requests closes that id's correlation entry // and becomes the method's most recent answer (#1953). The transport // only tracks responses that carry an id, but the JSON-RPC types leave // it optional for an error frame the server couldn't attribute — such a // frame answers no specific request, so it is skipped. const responseId = message.id; if (origin === "server" && responseId !== undefined) { const method = this.outstandingRequests.get(responseId)?.method; this.outstandingRequests.delete(responseId); if (method !== undefined) { this.lastAnsweredRequestByMethod.set(method, responseId); this.lastResponse = { method, receivedAt: Date.now() }; } this.dispatchConnectionDiagnosticsChange(); } const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), direction: "response", origin, message, }; this.dispatchTypedEvent("message", entry); }, trackNotification: ( message: JSONRPCNotification, origin: MessageOrigin, ) => { const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), direction: "notification", origin, message, }; this.dispatchTypedEvent("message", entry); }, }; } private attachTransportListeners(baseTransport: Transport): void { baseTransport.onclose = () => { // An explicit disconnect() owns the teardown and will set the canonical // status + fire `disconnect` itself. Defer to it so the event fires // exactly once whether or not the SDK calls onclose synchronously inside // close() — without this, an onclose that runs while status is held at // "error" would fire `disconnect`, then disconnect()'s own guard would // fire it again (#1490 re-review). if (this.disconnecting) return; // Already fully torn down — nothing to do (avoids a duplicate // `disconnect` event after an explicit disconnect()). if (this.status === "disconnected") return; // Do NOT let a trailing `onclose` downgrade a crash's "error" status to // "disconnected". On a real mid-session crash many SDK transports fire // BOTH `onclose` and `onerror` in a transport-dependent order; with the // old `!== "disconnected"` guard the final status differed by ordering // ("disconnected" when onerror landed first, "error" when onclose did). // Treating "error" as terminal here makes "error" the canonical resting // status in both orderings (#1490). We still emit the `disconnect` event // below so session-teardown consumers fire identically either way; only // the persistent status value is held at "error". if (this.status !== "error") { this.status = "disconnected"; this.dispatchTypedEvent("statusChange", this.status); } // A mid-session crash ends the connection without going through // `disconnect()`, so drop anything the server had queued with us — the // same reasoning as the `connect()` failure path. Cleared before the // `disconnect` event so a consumer reading the queue while handling it // sees it empty. `disconnect()` clears at the same point, but batches its // change events with the rest of its teardown dispatches — so on that // path the *events* land just after its `disconnect`, not before. this.clearAndAnnouncePendingPeerRequests(); // Same for what *we* asked the server. The SDK's chained `_onclose` // settles its own `_responseHandlers`, but the raw-wire map (the modern // `tasks/*` frames its era gate refuses to route) is ours and it doesn't // know about it — so a Tasks-tab poll in flight when the server dies // would otherwise wait out its own 30s timeout and blame the timeout for // a crash. Rejecting a settled promise is a no-op and the helper clears // the map, so this can't double-settle with `disconnect()`. this.rejectPendingRawWireRequests("Connection closed"); this.dispatchTypedEvent("disconnect"); }; baseTransport.onerror = (error: Error) => { // Suppress ONLY the handshake case. These listeners are attached before // the handshake runs (see connect()), so an SDK transport that reports a // connect-time error via `onerror` — in addition to rejecting connect() // — would otherwise dispatch the `error` event for a failure the awaited // connect() rejection already surfaces, double-reporting it. "connecting" // is precisely that state: the only one with a pending awaited connect() // that will reject. // // We deliberately do NOT guard on `!== "connected"`: on a real // mid-session crash many transports fire BOTH `onclose` and `onerror`, // and the order is transport-dependent. If `onclose` lands first it flips // status to "disconnected", so a "connected"-only guard would swallow the // reason that the trailing `onerror` carries (its sole surface). Firing // from any non-"connecting" state captures the reason regardless of // ordering. if (this.status === "connecting") return; this.status = "error"; this.dispatchTypedEvent("statusChange", this.status); this.dispatchTypedEvent("error", error); }; } /** * Build RequestOptions for SDK client calls (timeout, resetTimeoutOnProgress, onprogress). * When timeout is unset, SDK uses DEFAULT_REQUEST_TIMEOUT_MSEC (60s). * * When progress is enabled, we pass a per-request onprogress so the SDK routes progress and * runs timeout reset. The SDK injects progressToken: messageId; we do not expose the caller's * token to the server. We collect it from metadata and inject it into dispatched progressNotification * events only, so listeners can correlate progress with the request that triggered it. * * @param progressToken Optional token from request metadata; injected into progressNotification * events when provided (not sent to server). */ /** * Merge per-call metadata with this client's `defaultMetadata` (from * `InspectorClientOptions.defaultMetadata`, set from * `InspectorServerSettings.metadata`). Call-time keys override defaults. * Returns `undefined` when the combined map is empty so callers can skip * injecting an empty `_meta` field. */ private mergeMeta( callMetadata?: RequestMetadata, ): RequestMetadata | undefined { const defaults = this.defaultMetadata; // Modern-era per-request log level (#1629): stamp the opt-in `_meta` key on // every request so the server emits `notifications/message` on this // request's stream. Gated on the negotiated era — legacy servers use // `logging/setLevel` instead, so we never stamp it there. Placed before the // call-time keys so an explicit per-call `logLevel` (if ever passed) wins. const logMeta = this.protocolEra === "modern" && this.modernLogLevel ? { [LOG_LEVEL_META_KEY]: this.modernLogLevel } : undefined; const merged = { ...(defaults ?? {}), ...(logMeta ?? {}), ...(callMetadata ?? {}), }; // Reserved members carry their own schemas even though the rest of `_meta` // is arbitrary JSON, and one bad reserved value makes a conforming server // reject the whole request. See `dropInvalidReservedMeta`. const sanitized = dropInvalidReservedMeta(merged, this.logger); return Object.keys(sanitized).length > 0 ? sanitized : undefined; } /** * The `progressToken` a caller stamped on its request metadata, when it is * one the SDK will accept. * * This governs only **callback correlation** — whether the client wires up an * `onprogress` for the request. Keeping an invalid value off the **wire** is * a separate concern handled in {@link mergeMeta}, which deletes the member * from the outgoing `_meta` so a conforming server is never sent something * `ProgressTokenSchema` rejects. The two use the same {@link isProgressToken} * predicate, so they cannot disagree about what counts as valid. */ private progressTokenOf( metadata: RequestMetadata | undefined, ): ProgressToken | undefined { const token = metadata?.progressToken; return isProgressToken(token) ? token : undefined; } private getRequestOptions( progressToken?: ProgressToken, signal?: AbortSignal, ): RequestOptions { const opts: RequestOptions = { resetTimeoutOnProgress: this.resetTimeoutOnProgress, }; if (this.requestTimeout !== undefined) { opts.timeout = this.requestTimeout; } // When provided, aborting this signal cancels the request and rejects it // (#1458). The SDK picks the wire signal from the transport: an aborted // per-request SSE stream on a 2026-era Streamable HTTP connection, and // `notifications/cancelled` everywhere else (#2140). if (signal) { opts.signal = signal; } if (this.progress) { const token = progressToken; const onprogress: ProgressCallback = (progress: Progress) => { const payload: Progress & { progressToken?: ProgressToken } = { ...progress, ...(token != null && { progressToken: token }), }; this.dispatchTypedEvent("progressNotification", payload); }; opts.onprogress = onprogress; } return opts; } /** * {@link getRequestOptions} plus the per-call `cacheMode` for the SDK's * cacheable verbs (the high-level `client.listTools()` / `listPrompts()` / * `listResources()` / `listResourceTemplates()` used by the `listAll*` * aggregate methods below). `cacheMode` is only honored by those wrappers — * the single-page `client.request` path ignores it — so it lives here rather * than in `getRequestOptions`. Omitted when unset so the SDK default * (`'use'`) applies. */ private getCacheableRequestOptions( cacheMode?: CacheMode, ): CacheableRequestOptions { const opts: CacheableRequestOptions = this.getRequestOptions(); if (cacheMode !== undefined) { opts.cacheMode = cacheMode; } return opts; } /** * Build the `params` for the aggregate `listAll*` verbs: merge call metadata * with `defaultMetadata` and wrap as `{ _meta }`, or `undefined` when empty * (so the SDK skips an empty `_meta`). Shared by all four `listAll*` methods * so the merge/omit branch is defined once. */ private aggregateListParams( metadata?: RequestMetadata, ): { _meta: RequestMetadata } | undefined { const effectiveMeta = this.mergeMeta(metadata); return effectiveMeta ? { _meta: effectiveMeta } : undefined; } private isHttpOAuthConfig(): boolean { const serverType = getServerTypeFromConfig(this.transportConfig); return ( (serverType === "sse" || serverType === "streamable-http") && !!this.oauthManager ); } /** * True when task status is completed, failed, or cancelled. * We use this private helper instead of the SDK's experimental isTerminal() * to avoid depending on experimental API and to get a type predicate so * TypeScript narrows status to "completed" | "failed" | "cancelled" after the check. */ private static isTerminalTaskStatus( status: Task["status"], ): status is "completed" | "failed" | "cancelled" { return ( status === "completed" || status === "failed" || status === "cancelled" ); } /** * Route a receiver (server-initiated) task-augmented `sampling/createMessage` * or `elicitation/create` response around the v2 Client's result validation. * * SDK v2's `Client` wraps every spec request handler (`_wrapHandler`) to * validate the result it returns — for sampling/elicitation it checks the * value against `CreateMessageResult` / `ElicitResult` and rejects anything * else with a `-32602`. The 2025-11-25 task flow answers a task-augmented * request with a `CreateTaskResult` (`{ task }`), which that validation * rejects — breaking server-initiated tasks that worked on the legacy client. * * There is no public seam to opt a handler out of result validation, so we * swap the wrapped entry in the Protocol's private `_requestHandlers` map for * one that dispatches the task-augmented branch straight through the raw * handler (whose `{ task }` return then rides the legacy codec's pass-through * `encodeResult` to the wire), while ordinary (non-task) requests keep the * validating path. Mirrors the bypass a legacy server needs to emit `{ task }`. * Delete once the SDK models task-augmented results natively (see #1624 stack). */ private installReceiverTaskResponseBypass( method: "sampling/createMessage" | "elicitation/create", rawHandler: ( request: CreateMessageRequest & ElicitRequest, ) => Promise | Promise, ): void { if (!this.client) return; // SDK gap: `Client` exposes no public way to (a) read a registered request // handler or (b) opt one out of the result validation its `_wrapHandler` // installs, so we reach the private `_requestHandlers` map through a // narrowed cast. A public "register a raw/unvalidated handler" API — or a // handler-result type that includes `CreateTaskResult` — would remove both // this cast and the ones on the sampling/elicit returns above. const internal = this.client as unknown as { _requestHandlers: Map< string, (request: unknown, ctx: unknown) => unknown >; }; const validating = internal._requestHandlers.get(method); if (!validating) return; internal._requestHandlers.set(method, (request, ctx) => { const task = (request as { params?: { task?: unknown } })?.params?.task; // The advertisement check is redundant here — this wrapper only exists // when tasks are advertised — but it mirrors the handler branch below // deliberately: the two must agree, so they read one predicate. if (this.tasksCapabilityAdvertised && task != null) { return rawHandler(request as CreateMessageRequest & ElicitRequest); } return validating(request, ctx); }); } private createReceiverTask(opts: { ttl?: number; initialStatus: Task["status"]; statusMessage?: string; pollInterval?: number; }): ReceiverTaskRecord { const taskId = crypto.randomUUID(); const ttlMs = opts.ttl ?? (typeof this.receiverTaskTtlMs === "function" ? this.receiverTaskTtlMs() : this.receiverTaskTtlMs); const now = new Date().toISOString(); const task: Task = { taskId, status: opts.initialStatus, ttl: ttlMs, createdAt: now, lastUpdatedAt: now, ...(opts.pollInterval != null && { pollInterval: opts.pollInterval }), ...(opts.statusMessage != null && { statusMessage: opts.statusMessage }), }; let resolvePayload!: (payload: ClientResult) => void; let rejectPayload!: (reason?: unknown) => void; const payloadPromise = new Promise((resolve, reject) => { resolvePayload = resolve; rejectPayload = reject; }); // Mark it handled. The real consumer is the server polling `tasks/result` // (`getReceiverTaskPayload` returns this same promise, so a real awaiter // still sees the rejection), but nothing has attached a handler while the // task sits in `input_required` — and it can be rejected from there, by an // explicit `tasks/cancel` or by teardown settling a queued sample. Without // this, that reject surfaces as an unhandled rejection. void payloadPromise.catch(() => {}); const record: ReceiverTaskRecord = { task, payloadPromise, resolvePayload, rejectPayload, abort: new AbortController(), }; record.cleanupTimeoutId = setTimeout(() => { record.cleanupTimeoutId = undefined; this.receiverTaskRecords.delete(taskId); }, ttlMs); this.receiverTaskRecords.set(taskId, record); return record; } private emitReceiverTaskStatus(task: Task): void { if (!this.client) return; try { const notification = TaskStatusNotificationSchema.parse({ method: "notifications/tasks/status" as const, params: task, }); this.client.notification(notification).catch((err) => { this.logger.warn( { err, taskId: task.taskId }, "receiver task status notification failed", ); }); } catch (err) { this.logger.warn( { err, taskId: task.taskId }, "receiver task status notification failed", ); } } private upsertReceiverTask(updatedTask: Task): void { const record = this.receiverTaskRecords.get(updatedTask.taskId); if (record) { record.task = updatedTask; this.emitReceiverTaskStatus(updatedTask); } } private getReceiverTask(taskId: string): ReceiverTaskRecord | undefined { return this.receiverTaskRecords.get(taskId); } private listReceiverTasks(): Task[] { return Array.from(this.receiverTaskRecords.values()).map((r) => r.task); } private async getReceiverTaskPayload(taskId: string): Promise { const record = this.receiverTaskRecords.get(taskId); if (!record) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Unknown taskId: ${taskId}`, ); } return record.payloadPromise; } private cancelReceiverTask(taskId: string): Task { const record = this.receiverTaskRecords.get(taskId); if (!record) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Unknown taskId: ${taskId}`, ); } if (InspectorClient.isTerminalTaskStatus(record.task.status)) { return record.task; } const now = new Date().toISOString(); const updatedTask: Task = { ...record.task, status: "cancelled", lastUpdatedAt: now, }; record.task = updatedTask; record.rejectPayload(new Error("Task cancelled")); // Stop collecting an answer nobody will read: drops the native pending // entry and tears down an app-rendered elicitation's renderer. record.abort.abort(); if (record.cleanupTimeoutId != null) { clearTimeout(record.cleanupTimeoutId); record.cleanupTimeoutId = undefined; } this.emitReceiverTaskStatus(updatedTask); return updatedTask; } /** * Drop the cached MCP transport without a full disconnect() teardown. * Used when a pre-auth connect failed or tokens arrived after an unauthenticated * transport was created, so the next connect() can attach authProvider. */ private async dropCachedTransport(): Promise { if (!this.baseTransport && !this.transport) { this.transportHasAuthProvider = false; return; } try { await this.client?.close(); } catch { // Ignore errors on close } this.baseTransport = null; this.transport = null; this.transportHasAuthProvider = false; } /** * Register the handlers for requests the *server* makes of *us* — * `roots/list`, `sampling/createMessage`, `elicitation/create`, and the * receiver-side `tasks/*` polls. * * MUST be called before `client.connect()`. The matching capabilities are * advertised on the `Client` at construction time, so from the moment * `connect()` sends `notifications/initialized` the server is entitled to * issue any of these requests. Registering afterwards leaves a window in * which the SDK `Client` has no handler and answers `-32601 Method not * found` — which is exactly what a server that asks for roots the instant it * is initialized (e.g. `server-filesystem`, which learns its allowed * directories that way) hits, while a server that asks later does not (#1797). * * Nothing here depends on the server's capabilities — only on constructor-set * state — so there is nothing to wait for. Its sibling * {@link registerPeerNotificationHandlers} does the same for the one * notification handler in that position; the notification handlers that *do* * gate on `this.capabilities` stay in `connect()`, after the handshake. */ private registerPeerRequestHandlers(): void { // Gated on what was advertised, like the others — see // `rootsCapabilityAdvertised`. if (this.samplingCapabilityAdvertised && this.client) { const samplingHandler = ( request: CreateMessageRequest, ): Promise => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; if (this.tasksCapabilityAdvertised && paramsTask != null) { const record = this.createReceiverTask({ ttl: paramsTask.ttl, initialStatus: "input_required", statusMessage: "Awaiting user input", }); void (async () => { const samplingRequest = new SamplingCreateMessage( request, (result) => { record.resolvePayload(result); const now = new Date().toISOString(); const updated: Task = { ...record.task, status: "completed", lastUpdatedAt: now, }; record.task = updated; this.upsertReceiverTask(updated); }, (error) => { record.rejectPayload(error); const now = new Date().toISOString(); const updated: Task = { ...record.task, status: "failed", lastUpdatedAt: now, statusMessage: error instanceof Error ? error.message : String(error), }; record.task = updated; this.upsertReceiverTask(updated); }, (id) => this.removePendingSample(id), ); this.addPendingSample(samplingRequest); })(); // Task-augmented (2025-11-25) response: the server sent a // task-augmented `sampling/createMessage`, so we reply with a // `CreateTaskResult` (`{ task }`) rather than a `CreateMessageResult`. // The v2 Client validates a spec handler's result and would reject // `{ task }` with -32602; `installReceiverTaskResponseBypass` below // routes this task-augmented branch around that validation so the // legacy `{ task }` response reaches the wire. `taskResult` is typed // as `CreateTaskResult` so its shape IS checked; the unavoidable // `as unknown as CreateMessageResult` bridges the SDK gap — the 2-arg // `setRequestHandler` overload types a sampling handler's return as // `CreateMessageResult` only and doesn't model the (deprecated but // wire-valid) task-augmented `CreateTaskResult`. A handler-result // union `CreateMessageResult | CreateTaskResult` on the SDK side // would remove this cast. const taskResult: CreateTaskResult = { task: record.task }; return Promise.resolve(taskResult as unknown as CreateMessageResult); } return this.enqueuePendingSample(request, "server-request"); }; this.client.setRequestHandler("sampling/createMessage", samplingHandler); // Registration, like the `setRequestHandler` above it — and the whole // bypass mechanism (install, wrapper branch, handler branch) reads this // one predicate, so the install can't drift from the branch it controls. if (this.tasksCapabilityAdvertised) { this.installReceiverTaskResponseBypass( "sampling/createMessage", samplingHandler, ); } } // Gated on what was advertised, not on `this.elicit` — see the field's doc: // an elicit option that enables no mode advertises nothing, and registering // regardless throws before the handshake. if (this.elicitationCapabilityAdvertised && this.client) { const elicitHandler = ( request: ElicitRequest, // Structural, and only the one field this needs: the SDK's // `ClientContext` carries much more, and naming it here would tie the // handler to a type the bypass helper below does not thread through. ctx?: { mcpReq?: { signal?: AbortSignal } }, ): Promise => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; if (this.tasksCapabilityAdvertised && paramsTask != null) { const record = this.createReceiverTask({ ttl: paramsTask.ttl, initialStatus: "input_required", statusMessage: "Awaiting user input", }); // Settling the receiver task, shared by both answer routes below so // an app-rendered answer completes the task exactly as a native one // does. const completeTask = (result: ElicitResult) => { // A cancelled (or otherwise terminal) task must not be re-settled: // an answer that arrives after `tasks/cancel` would otherwise // overwrite `cancelled` with `completed`. if (InspectorClient.isTerminalTaskStatus(record.task.status)) return; record.resolvePayload(result); const updated: Task = { ...record.task, status: "completed", lastUpdatedAt: new Date().toISOString(), }; record.task = updated; this.upsertReceiverTask(updated); }; const failTask = (error: Error) => { if (InspectorClient.isTerminalTaskStatus(record.task.status)) return; record.rejectPayload(error); const updated: Task = { ...record.task, status: "failed", lastUpdatedAt: new Date().toISOString(), statusMessage: error.message, }; record.task = updated; this.upsertReceiverTask(updated); }; void (async () => { // A task-augmented request is still an `elicitation/create`, so the // app-rendering contract applies to it too (#1854). It cannot go // through `enqueuePendingElicitation` — the response frame has // already been sent as a `CreateTaskResult` and the answer settles // the TASK rather than the request — so the same attempt is made // here, falling back to the native queue exactly as that funnel // does. An abort (disconnect) fails the task rather than reopening // it natively. let appResult: ElicitResult | null; try { appResult = await this.tryAppElicitation( request, record.abort.signal, ); } catch (error) { failTask( error instanceof Error ? error : new Error(String(error)), ); return; } if (appResult) { completeTask(appResult); return; } const elicitationRequest = new ElicitationCreateMessage( request, completeTask, (id) => this.removePendingElicitation(id), failTask, ); this.addPendingElicitation(elicitationRequest); // A `tasks/cancel` (or teardown) drops the queued entry, so the // modal does not outlive the task it belongs to. this.wirePendingAbort(record.abort.signal, () => this.removePendingElicitation(elicitationRequest.id), ); })(); // Task-augmented (2025-11-25) response — see the sampling handler // above. Reply with a `CreateTaskResult` (`{ task }`), routed around // the v2 Client's result validation by // `installReceiverTaskResponseBypass` below. `taskResult` is typed so // its shape is checked; the `as unknown as ElicitResult` bridges the // same SDK gap as the sampling handler — the 2-arg `setRequestHandler` // overload types an elicitation handler's return as `ElicitResult` // only and doesn't model the task-augmented `CreateTaskResult`. const taskResult: CreateTaskResult = { task: record.task }; return Promise.resolve(taskResult as unknown as ElicitResult); } // `ctx.mcpReq.signal` aborts when the server cancels this request // (`notifications/cancelled`). Threading it through means both answer // surfaces — the native queue entry and an app-rendered elicitation's // renderer — are torn down with the request, instead of a modal // outliving work the server abandoned. The task-augmented branch above // deliberately does NOT use it: that request is answered immediately // with a `CreateTaskResult`, so its lifetime is the task's, which // carries its own abort (see `ReceiverTaskRecord.abort`). return this.enqueuePendingElicitation( request, "server-request", ctx?.mcpReq?.signal, ); }; this.client.setRequestHandler("elicitation/create", elicitHandler); // Registration, like the `setRequestHandler` above it — and the whole // bypass mechanism (install, wrapper branch, handler branch) reads this // one predicate, so the install can't drift from the branch it controls. if (this.tasksCapabilityAdvertised) { this.installReceiverTaskResponseBypass( "elicitation/create", elicitHandler, ); } } // Gated on what was advertised at construction, and it has to be: the SDK // asserts the matching client capability inside `setRequestHandler`, so // registering this on a client built without `roots` throws "Client does // not support roots capability". Since `capabilities.roots` is negotiated at // `initialize` (set in the constructor) and `registerCapabilities` refuses // to run after connect, a client that omits the option can never serve // `roots/list` — which is why every client that may call `setRoots()` later // must pass `roots` up front (web does; the CLI and TUI now do too — #1797). if (this.rootsCapabilityAdvertised && this.client) { this.client.setRequestHandler("roots/list", async () => { return { roots: this.roots ?? [] }; }); } // Set up receiver-task request handlers (server polls us for tasks/list, // tasks/get, tasks/result, tasks/cancel). SDK v2 removed tasks from the // spec-method set, so these register through the 3-arg custom form with an // explicit params schema (from the deprecated-but-importable task request // schemas). The `result` schema is intentionally omitted so the SDK does // not validate our responder return — matching v1, where only the // requester validated (our receiver `Task` may omit fields a strict result // schema would require). if (this.tasksCapabilityAdvertised && this.client) { this.client.setRequestHandler( "tasks/list", { params: ListTasksRequestSchema.shape.params }, async () => ({ tasks: this.listReceiverTasks() }), ); this.client.setRequestHandler( "tasks/get", { params: GetTaskRequestSchema.shape.params }, async (params) => { const record = this.getReceiverTask(params.taskId); if (!record) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Unknown taskId: ${params.taskId}`, ); } return record.task; }, ); this.client.setRequestHandler( "tasks/result", { params: GetTaskPayloadRequestSchema.shape.params }, async (params) => this.getReceiverTaskPayload(params.taskId), ); this.client.setRequestHandler( "tasks/cancel", { params: CancelTaskRequestSchema.shape.params }, async (params) => this.cancelReceiverTask(params.taskId), ); } } /** * Register the inbound *notification* handlers that depend on nothing but * constructor-set state, and so belong before the handshake for the same * reason as {@link registerPeerRequestHandlers} (#1797). * * `notifications/roots/list_changed` is the only one — and note it is a * **client**→server notification in the spec (`ClientNotification`): we send * it from {@link setRoots}, servers do not normally send it to us. This * inbound handler is defensive coverage for a non-conformant or experimental * server, and its body dispatches `rootsChange` with our own already-known * roots, i.e. a refresh signal carrying no new data. It sits here for * consistency with the request handlers — it gates on no server capability, * so there is nothing to wait for, and an unhandled notification is dropped * silently by the SDK (no wire error) rather than answered `-32601`. * * The remaining listChanged handlers gate on `this.capabilities`, which is * not populated until `fetchServerInfo()` runs, so they stay in `connect()`. */ private registerPeerNotificationHandlers(): void { /* v8 ignore next -- unreachable: the sole caller is connect(), past its `if (!this.client) throw`; the guard exists only to narrow the type. */ if (!this.client) return; this.client.setNotificationHandler( "notifications/roots/list_changed", async () => { // Re-dispatch our already-known roots as a refresh signal for the UI — // the payload carries no new data (see the note on ownership above). // Copied, as `setRoots` and `getRoots()` do: a listener must not be // able to push into the list we advertise. this.dispatchTypedEvent("rootsChange", [...(this.roots ?? [])]); }, ); } /** * Stop the receiver tasks' TTL timers and drop the records. * * These are tasks a *server* created with us, so they belong to the session * that created them: `listReceiverTasks()` is what the `tasks/list` handler * answers with, and a record surviving into the next session would report a * task the new server never created. `disconnect()` clears them, and so does * `connect()` — the auth-recovery retry reconnects the *same* client * instance, so ending the session isn't the only way a new one begins * (#1797). */ private clearReceiverTasks(): void { for (const record of this.receiverTaskRecords.values()) { if (record.cleanupTimeoutId != null) { clearTimeout(record.cleanupTimeoutId); } // Same reason as `cancelReceiverTask`: the session that owns whatever is // collecting the answer is ending. record.abort.abort(); } this.receiverTaskRecords.clear(); } /** * Reset the modern listen-stream cluster: the subscribed set, the stream * state derived from it, and the reconnect machinery that reports on it. * * One helper because these move together — the rest of the file derives the * stream's `active` from `subscribedResources.size > 0`, so clearing one * without the other leaves a combination those readers treat as impossible, * and a surviving `modernReconnectAttempts` makes a *new* session's first * drop back off as if it were the old session's nth. Both axes are announced: * every other mutation of the set dispatches, and so does the stream state. * * Closes the stream itself, best-effort. `disconnect()` is the obvious caller * with a live one, but not the only one: an `onerror` without an `onclose` * leaves the transport up, and `connect()` then reuses it — so the reference * dropped here can be the last one to a stream still open on the server. */ private resetSubscriptionStream(): void { const closing = this.modernSubscription; this.subscribedResources.clear(); this.modernListenGeneration++; this.clearModernReconnectTimer(); this.modernReconnectAttempts = 0; this.modernNeverAcknowledged = false; this.modernSubscription = null; // Announced only once both have moved: a listener that ran between them // would see an empty set with an `active` stream — the combination this // helper exists to prevent, so its own dispatches must not expose it. this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); this.dispatchSubscriptionsChange(); // After the dispatches, so the ordering above is unaffected, and // fire-and-forget because nothing downstream depends on it. That is also // why the wrapping matters here in a different way than at the awaited // sites: the `void` means a failure cannot reach the caller at all — so it // cannot cut `disconnect()`'s straight-line teardown short — and what it // would do instead is go unhandled, which ends a Node process by default. if (closing) void closeSubscriptionBestEffort(closing); } /** * Drop the session-scoped state a *new* session would misread — anything the * next server could be told about, or that would change how we treat its * traffic. State that only needs settling on the way out (the peer-request * queues, the raw-wire map, the in-flight tool call) is not reset here — the * in-flight tool call because `callTool`'s own `finally` releases it once the * SDK rejects it, the other two because they are settled *end*-clean, by * `disconnect()` and the crash path, so that a consumer handling the * `disconnect` event already sees them empty. That is a difference in when, * not in whether: the routes out do not cover every route *in* (an `onerror` * without an `onclose` runs neither), so `connect()` sweeps those two beside * this call as a backstop — see the comment there. * * `disconnect()` touches all of this on the way out too — two members through * the same helpers, three hand-rolled in both places — so a sixth member * added here has to be added there as well. One of the three is *paired* * rather than duplicated: `modernLogLevel` is re-derived here and blanked * there, deliberately (see the comment at that site). It is not the only * way a session ends — a crash, or a failed connect the caller retries on * this same instance (the auth-recovery path), both leave it behind. Called * start-clean from `connect()` so every route in is covered. * * Each member has a symptom, not just untidiness: a stale `subscribedResources` * entry makes the modern `subscribeToResource` early-return, so the user's * Subscribe click silently sends nothing to the new server; a stale * `cancelledTaskIds` entry mislabels a *new* task sharing the id as * `cancelled` rather than `failed`; a stale subscription stream state reads * `active` for a set that is now empty, which every reader of it treats as * impossible; a receiver-task record is reported to the new server by * `tasks/list`; and an un-aborted `taskInputAbortControllers` * entry delays a paused poll loop unwinding — both registration sites release * in a `finally`, so nothing leaks permanently; the abort just closes the * window between the crash and the unwind (#1797). */ private resetSessionState(): void { this.clearReceiverTasks(); this.resetSubscriptionStream(); this.cancelledTaskIds.clear(); // Correlation data is per-session: JSON-RPC ids don't survive it, and // MessageLogState drops its entries on disconnect, so anything left here // could only point at an entry that no longer exists. Clearing also // releases the ids of requests that never got a response (a timeout, a // dropped connection) — the only entries `trackResponse` can't remove, so // without this they accumulate across reconnects. Cleared here on the // start-clean path rather than in `disconnect()` for the reason documented // above: one route out (`onerror` with no `onclose`) tears down nothing // (#1953). this.outstandingRequests.clear(); this.lastAnsweredRequestByMethod.clear(); this.lastResponse = undefined; this.notificationStream = undefined; this.dispatchConnectionDiagnosticsChange(); // Per-session for the same reason: both name entries of the PREVIOUS // server's list. Cleared here as well as in `disconnect()` because the // route out that tears down nothing (`onerror` with no `onclose`) would // otherwise carry them into the next connection — indefinitely, if that // server never lists the method again. Safe to clear unconditionally at // connect: `listAllTools` recomputes the excluded set on every aggregate // and the salvage report on every list, so this can only drop a stale set, // never a live one (#1909; the `excludedTools` half is the same latent bug // from #1632, fixed here rather than left as a known sibling). if (this.malformedListItems.size > 0) { this.malformedListItems.clear(); this.dispatchTypedEvent("malformedListItemsChange", []); } if (this.excludedTools.length > 0) { this.excludedTools = []; this.dispatchTypedEvent("excludedToolsChange", []); } for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); } this.taskInputAbortControllers.clear(); // Restore the configured opt-in rather than carrying a mid-session // `setModernLogLevel` override into the next connection — and rather than // leaving it `undefined` after a `disconnect()` cleared it, which silently // dropped the user's configured level on reconnect (#1629, #1797). this.modernLogLevel = resolveModernLogLevel(this.serverSettings); } /** * Settle and drop the queued peer requests (sampling / elicitation). * * Every entry is settled before being dropped rather than discarded: an * elicitation so an error-path `awaitUrlElicitation` — which blocks * `callTool` — doesn't hang forever, and a sample so the *server* gets a * response frame for the request we accepted (the transport can outlive a * failed attempt; see the `connect()` catch). Callers dispatch the change * events themselves: * `disconnect()` batches them with its other teardown dispatches, and * {@link clearAndAnnouncePendingPeerRequests} emits them immediately * everywhere else. */ private clearPendingPeerRequests(): void { for (const sample of this.pendingSamples) { sample.cancel(); } this.pendingSamples = []; for (const elicitation of this.pendingElicitations) { elicitation.cancel(); } this.pendingElicitations = []; // App-rendered elicitations (#1854) are not in the queue above — they live // in the host's renderer — so abort them here on the same teardown paths. // `tryAppElicitation` removes each controller in its own `finally`. for (const controller of this.activeAppElicitations) { controller.abort(); } this.activeAppElicitations.clear(); } /** * {@link clearPendingPeerRequests} plus the change events, for every route * that drops a queue without going through `disconnect()`: the routes *out* * that end a connection some other way, plus the top of `connect()` as a * backstop for the one route in that settles nothing (an `onerror` without an * `onclose` — see the comment at that call). Named as a category rather than * counted, because the set has grown before. (One of the routes out doesn't * always end the connection: when a `connect()` failure leaves an auth * provider holding the transport open, the caller re-authenticates and * retries over it, and what's dropped is the queue left by the attempt that * failed.) * * The events are the load-bearing half: `usePendingClientRequests` tracks its * own state off them, so clearing the arrays without dispatching leaves the * web pending-request modal on screen for a connection that is gone. Guarded * on a non-empty queue so the paths that overlap (a `connect()` failure whose * `dropCachedTransport` also fires `onclose`) announce it once. */ private clearAndAnnouncePendingPeerRequests(): void { if ( this.pendingSamples.length === 0 && this.pendingElicitations.length === 0 && // App-rendered elicitations (#1854) are not in either array — they live in // the host's renderer — so an emptiness check that ignores them lets a // mid-session close (the `onclose` route, which reaches teardown only // through here) leave a modal open for a connection that is gone. this.activeAppElicitations.size === 0 ) { return; } this.clearPendingPeerRequests(); this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); this.dispatchTypedEvent( "pendingElicitationsChange", this.pendingElicitations, ); } /** * Connect to the MCP server */ async connect(): Promise { if (!this.client) { throw new Error("Client not initialized"); } if (this.status === "connected") { return; } // Start from a clean session — see `resetSessionState` for why this is // start-clean rather than relying on `disconnect()`. this.resetSessionState(); // The two collections `resetSessionState` excludes as "settled on the way // out", swept here as well — because one route out settles nothing. An // `onerror` without an `onclose` only flips status to `"error"`: it runs // neither teardown path, and it leaves `baseTransport` cached, so a // `connect()` on this same instance reuses a *live* transport. That is the // route the subscription-stream close exists for, and it strands these two // the same way. The peer queue is the sharper of them — the web // pending-request modal is derived from its length with no status gate, so // it outlives the session, and a user answering it later would write // *their* answer for the previous session's request id onto the new // connection, arbitrarily far past the re-handshake. Note what the sweep // does instead is emit a *cancel* for that same id, right here: still the // settle-don't-discard rule, and this is the earliest moment available: // the old connection is still the one on the wire here, and stays so at // least until the conditional `dropCachedTransport()` below — which on a // stdio server never runs at all, so the same transport carries straight // through the re-handshake. // // Both helpers are idempotent (one guards on a non-empty queue, the other // clears its map and re-rejecting a settled promise is a no-op), so these // are no-ops on the routes that already ran them; and anything still // pending here belongs to a session that is, by definition, no longer // connected. // // Must stay *after* `resetSessionState()`, which reads as independent of it // but is not: cancelling a task-augmented peer request settles it // synchronously into the record callback, which ends in // `upsertReceiverTask`. That is a no-op only because `clearReceiverTasks()` // just emptied the map — hoisted above the reset, it would instead emit a // `notifications/tasks/status` for the outgoing session's task, onto the // transport this connect is about to reuse, moments before the reset drops // the record anyway. this.clearAndAnnouncePendingPeerRequests(); this.rejectPendingRawWireRequests("Connection ended"); const oauthManager = this.oauthManager; if ( this.baseTransport && this.isHttpOAuthConfig() && oauthManager && !this.transportHasAuthProvider && !oauthManager.isEnterpriseManaged() && (await oauthManager.isOAuthAuthorized()) ) { await this.dropCachedTransport(); } // Create transport (single place for create / wrap / attach). if (!this.baseTransport) { const transportOptions: CreateTransportOptions = { fetchFn: this.fetchFn, pipeStderr: this.pipeStderr, onStderr: (entry: StderrLogEntry) => { this.dispatchStderrLog(entry); }, onFetchRequest: (entry: FetchRequestEntryBase) => { this.dispatchFetchRequest({ ...entry, category: "transport" }); this.noteTransportStream(entry); }, onFetchResponseBody: (id: string, body: string) => { this.dispatchFetchRequestBodyUpdate(id, body); }, onFetchStreamUpdate: (id: string, stream: FetchStreamState) => { this.dispatchFetchRequestStreamUpdate(id, stream); }, ...(this.serverSettings && { settings: this.serverSettings }), }; if (this.isHttpOAuthConfig() && oauthManager) { // Record every 401/403 the transport sees, whatever else happens to // it. The legacy first-authorization path deliberately runs with no // authProvider and no challenge interception (see below), so the SDK // raises a headerless `UnauthorizedError` and the client calls // `authenticate()` with nothing in hand — this is the only place the // challenge's RFC 9728 `resource_metadata` still exists (#2071). const manager = oauthManager; transportOptions.onAuthChallengeObserved = (challenge) => { manager.noteObservedAuthChallenge(challenge); }; if (oauthManager.isEnterpriseManaged()) { await oauthManager.trySilentEnterpriseManagedAuth(); const provider = await oauthManager.createOAuthProviderForTransport(); const tokens = await provider.tokens(); if (!tokens?.access_token) { const err = new Error( "Unauthorized: EMA resource access token unavailable", ) as Error & { status?: number; code?: number }; err.status = 401; err.code = 401; throw err; } transportOptions.authProvider = provider; } else if (await oauthManager.isOAuthAuthorized()) { // Without stored tokens, omit authProvider so connect() surfaces a plain // 401 instead of the SDK opening a browser before the app callback // server is listening (TUI/CLI run authenticate() explicitly). transportOptions.authProvider = await oauthManager.createOAuthProviderForTransport(); } } if ( this.directAuthRecovery && this.directAuthRecoveryActive !== false && this.isHttpOAuthConfig() && oauthManager && // No stored tokens means no authProvider (see above), and then a 401 on // the era-negotiation probe reaches the SDK as a raw `SdkHttpError`. // The probe's classifier ignores the HTTP status — it only looks for a // JSON-RPC error body — so it verdicts "not a modern server", and pin // ("modern") mode rethrows that as ERA_NEGOTIATION_FAILED with the 401 // discarded entirely: no status, not even a cause. Intercepting makes // the 401 a typed AuthChallengeError, which survives the probe as // `data.cause` for `findNestedAuthError` to recover (#1805). // // WORKAROUND (#1807, upstream modelcontextprotocol/typescript-sdk#2561): // remove this clause once the SDK classifies a probe 401/403 as // auth-required. `findNestedAuthError` is the permanent fix; the // `|| this.probesProtocolEra()` clause below exists only to compensate // for that upstream gap and should be deleted with it. // // Known, accepted side effect of turning intercept on with no stored // tokens: `parseAuthChallengeFromResponse` treats 403 as a challenge // too, so a probe answered 403 for a *non-auth* reason (a gateway // rejecting the unknown `server/discover` method, say) now starts OAuth // discovery instead of letting "auto" fall back to the legacy // `initialize`. The outcome is a surfaced `oauthError`, not a hang, and // it goes away with this clause. (transportOptions.authProvider || this.probesProtocolEra()) ) { transportOptions.interceptAuthChallenges = true; } this.transportHasAuthProvider = !!transportOptions.authProvider; const { transport: baseTransport } = this.transportClientFactory( this.transportConfig, transportOptions, ); this.baseTransport = baseTransport; if (this.directAuthRecovery) { this.directAuthRecoveryActive = !( baseTransport instanceof RemoteClientTransport ); } if ( baseTransport instanceof RemoteClientTransport && oauthManager && this.isHttpOAuthConfig() ) { baseTransport.setAuthRecovery({ handleAuthChallenge: (challenge, options) => oauthManager.handleAuthChallenge(challenge, options), pushAuthState: () => this.pushRemoteAuthState(), }); baseTransport.setOnAuthChallenge((challenge) => { void this.handleAmbientAuthChallenge(challenge); }); } const messageTracking = this.createMessageTrackingCallbacks(); this.transport = new MessageTrackingTransport( baseTransport, messageTracking, { rewriteIncomingResult: (message) => this.rewriteModernTaskResult(message), consumeIncomingResponse: (message) => this.consumeRawWireResponse(message), }, ); this.attachTransportListeners(this.baseTransport); } if (!this.transport) { throw new Error("Transport not initialized"); } try { this.status = "connecting"; this.dispatchTypedEvent("statusChange", this.status); // Register the handlers for server→client requests and the // capability-independent notifications before the handshake — see // `registerPeerRequestHandlers` for why the ordering is load-bearing. this.registerPeerRequestHandlers(); this.registerPeerNotificationHandlers(); // 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. // // 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 // `isAuthChallengeError` check is shallow), the outer catch's // `isConnectAuthRecoveryError` status guard, and every client's connect // error handling. See {@link findNestedAuthError} (#1805). const connectPromise = this.client .connect(this.transport) .catch((err: unknown) => { throw findNestedAuthError(err) ?? err; }); // Set when a satisfied auth recovery already completed a full connect() // underneath us — see the short-circuit in `runConnect`. let recoveredByNestedConnect = false; const runConnect = async (attempt: number): Promise => { if (attempt > 0) { // The *retry* leg of `withDirectAuthRecovery`: the challenge was // satisfied silently (e.g. a refresh token), so // `reconnectAfterAuthRecovery()` has already run a complete // `connect()` — handshake, server info, `connect` event and all. // `connectPromise` is created once, outside this closure, so // re-awaiting it here would rethrow the *original* rejection and // reject a `connect()` whose client is in fact connected. Short- // circuit instead, and let the caller skip the post-connect block the // nested connect already ran (dispatching a second `connect` event // would re-trigger every list-state manager's refresh). // // Keyed off the leg rather than live status: an `onclose` landing // between the nested connect and here would flip status off // "connected" and fall through to the rejected `connectPromise`, // reporting the stale handshake 401 as the reason the session died. recoveredByNestedConnect = true; if (this.status !== "connected") { throw new Error( "Connection closed during authorization recovery, after re-authorizing successfully", ); } return; } if (connectTimeoutMs > 0) { connectPromise.catch(() => {}); let timer: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { connectTimedOut = true; reject(new Error(connectionTimeoutMessage(connectTimeoutMs))); }, connectTimeoutMs); }); try { await Promise.race([connectPromise, timeoutPromise]); } finally { if (timer) clearTimeout(timer); } } else { await connectPromise; } }; try { await this.invokeMcpClient(runConnect); } catch (err) { if (connectTimedOut) { await this.disconnect().catch(() => {}); } throw err; } if (recoveredByNestedConnect) { // The nested connect() from the auth recovery did all of the below. return; } this.status = "connected"; this.dispatchTypedEvent("statusChange", this.status); // Always fetch server info (capabilities, serverInfo, instructions) - this is just cached data from initialize. // Must run BEFORE the "connect" event: the managed list-state managers // refresh on "connect" and gate their list RPC on getCapabilities() (see // #1395). If "connect" fired first, that gate would read undefined // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); // Set initial logging level if configured and server supports it. // // Era-gated (#1990): `logging/setLevel` is a legacy-era method, and the // modern wire era rejects it outright — so an unconditional call here // failed the *connect* of every modern server advertising `logging`, // taking down commands that have nothing to do with logging. Modern has // no session-scoped level at all: the equivalent is the per-request // `io.modelcontextprotocol/logLevel` `_meta` opt-in, which // `modernLogLevel` already drives from the server settings (see // {@link setModernLogLevel}). So the right behavior on modern is to skip // this call rather than translate it — `initialLoggingLevel` names a // mechanism that era does not have. if ( this.initialLoggingLevel && this.capabilities?.logging && this.protocolEra !== "modern" ) { await this.client.setLoggingLevel( this.initialLoggingLevel, this.getRequestOptions(), ); } // Set up listChanged notification handlers based on config if (this.client) { // Tools listChanged handler // Only register if both client config and server capability are enabled if ( this.listChangedNotifications.tools && this.capabilities?.tools?.listChanged ) { this.client.setNotificationHandler( "notifications/tools/list_changed", async () => { // Always fire notification event (for tracking) this.dispatchTypedEvent("toolsListChanged"); // Tools are managed by state managers; they can listen to toolsListChanged and refresh }, ); } // Note: If handler should not be registered, we don't set it // The SDK client will ignore notifications for which no handler is registered // Resources listChanged handler (state managers listen and refresh) if ( this.listChangedNotifications.resources && this.capabilities?.resources?.listChanged ) { this.client.setNotificationHandler( "notifications/resources/list_changed", async () => { this.dispatchTypedEvent("resourcesListChanged"); this.dispatchTypedEvent("resourceTemplatesListChanged"); }, ); } // Prompts listChanged handler (state managers listen and refresh) if ( this.listChangedNotifications.prompts && this.capabilities?.prompts?.listChanged ) { this.client.setNotificationHandler( "notifications/prompts/list_changed", async () => { this.dispatchTypedEvent("promptsListChanged"); }, ); } // Tasks list_changed and status handlers (when server advertises tasks // capability). Both are custom (2025-11-25) notification methods absent // from v2's spec-notification set, so they register through the 3-arg // custom form with an explicit params schema. if (this.capabilities?.tasks) { this.client.setNotificationHandler( "notifications/tasks/list_changed", { params: TasksListChangedNotificationSchema.shape.params }, async () => { this.dispatchTypedEvent("tasksListChanged"); }, ); this.client.setNotificationHandler( "notifications/tasks/status", { params: TaskStatusNotificationSchema.shape.params }, async (params) => { const task = params as Task; this.dispatchTypedEvent("taskStatusChange", { taskId: task.taskId, task, }); }, ); } // Resource updated notification handler (only if server supports subscriptions) if (this.capabilities?.resources?.subscribe === true) { this.client.setNotificationHandler( "notifications/resources/updated", async (notification) => { const uri = notification.params.uri; // Only process if we're subscribed to this resource if (this.subscribedResources.has(uri)) { this.dispatchTypedEvent("resourceUpdated", { uri }); } }, ); } // Elicitation complete notification (URL mode only): server notifies when out-of-band // elicitation completes; we resolve the corresponding pending elicitation if (this.urlElicitationCapabilityAdvertised) { this.client.setNotificationHandler( "notifications/elicitation/complete", async (notification) => { const { elicitationId } = notification.params; const pending = this.pendingElicitations.find( (e) => e.request.params?.mode === "url" && e.request.params?.elicitationId === elicitationId, ); if (pending) { // Resolve (not just remove): for the error-path retry loop this // unblocks `awaitUrlElicitation`, and for request-path it sends // the `accept` response the server is still awaiting. No-op once // the user already clicked "I've completed it". pending.completeIfPending(); } }, ); } // Progress: we use per-request onprogress (see getRequestOptions). We do not register // a progress notification handler so the Protocol's _onprogress stays; timeout reset // and routing work, and we inject the caller's progressToken into dispatched events. } // Modern era: the handlers registered above only fire for notifications // that reach us, and on this era every server→client notification rides // the `subscriptions/listen` stream. Open it now when the filter says // there is something to listen for — otherwise a list-change opt-in on a // server with no resources would have no way in, since a subscribe click // was the only thing that ever opened the stream (#1920). await this.openModernListenStreamOnConnect(); // Last, so the notification channel is established (or its retry armed) // before any consumer acts on the connection. The managed list states // start their initial `refresh()` from this event, so dispatching earlier // would let `tools/list` go out ahead of `subscriptions/listen` — and a // list the server changes in that window would notify nobody, leaving the // UI stale with no way to notice (#1920). The cost is one listen // round-trip added to connect on the modern era. // // …which is also why the announcement is conditional. Every await above // is a window for a `disconnect()` or a transport `onclose`/`onerror` to // overtake this connect, and the listen round-trip widened it. Announcing // then would restart every managed list refresh against a session being // torn down or already dead. `disconnecting` covers the teardown that has // claimed ownership but is still awaiting `client.close()` — the status is // whatever it was until that block finishes. if (this.status === "connected" && !this.disconnecting) { this.dispatchTypedEvent("connect"); } } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; this.dispatchTypedEvent("statusChange", this.status); } if (this.baseTransport && !this.transportHasAuthProvider) { await this.dropCachedTransport(); } // The peer handlers are registered before the handshake (#1797), so a // server can queue a sampling/elicitation request during it — and this is // where that connect attempt dies. Drop the queue: otherwise the UI keeps // a live pending-request modal for a connection that never came up, and // answering it would route to a transport that is either torn down or — // when an auth provider holds it open, so the caller can re-authenticate // and retry over it — carrying a queue from an attempt that already // failed. Note the retention is gated on `transportHasAuthProvider` // alone, independently of `isConnectAuthRecoveryError` above, which gates // only the status hold. `disconnect()` does the same clearing, but // `connect()` only reaches it on the connect-timeout path. this.clearAndAnnouncePendingPeerRequests(); // Deliberately do NOT dispatch the `error` event here: this is the // awaited `connect()` path, so re-throwing hands the reason straight to // the caller. The `error` event is reserved for non-awaited transitions // (the transport `onerror` above), where there is no promise to reject. // Dispatching here too would double-report a handshake failure. throw error; } } /** * Disconnect from the MCP server. * @param safeDisconnectTimeout If > 0, poll every 10ms until SDK _responseHandlers is empty or this many ms have elapsed, then close. Default 0 = close immediately. */ async disconnect(safeDisconnectTimeout = 0): Promise { // Claim ownership of the teardown so a synchronous onclose (fired from // within close() below) defers its status set + `disconnect` event to the // canonical block at the end of this method. Reset before that block so // its dispatch is unaffected; any later async onclose early-returns on the // "disconnected" status guard. Guarantees a single `disconnect` event even // when disconnecting from a held-"error" status (#1490 re-review). this.disconnecting = true; try { if (this.client) { if (safeDisconnectTimeout > 0) { // This is pretty creepy, but there are test cases where client calls return but there // are still response handlers pending. Usually a single macrotask delay is enough to // clear them, but not always (it's been >10ms in some cases). The pending handlers // themselves get the error (and in cases where those aren't awaited, the errors fly // out of the test). This workaround where we directly access the handlers (otherwise // private member of the SDK client) is creepy, but the least ugly working solution. // We will re-valuate this with the v2 SDK. Currenly only tests that do quick disconnects // use this setting. // const protocol = this.client as unknown as { _responseHandlers?: Map; }; const handlers = protocol._responseHandlers; const deadline = Date.now() + safeDisconnectTimeout; while ( handlers?.size !== undefined && handlers.size > 0 && Date.now() < deadline ) { await new Promise((r) => setTimeout(r, 10)); } } try { await this.client.close(); } catch { // Ignore errors on close } } } finally { // Release ownership before the canonical dispatch below so it runs // normally; the "disconnected" status it sets makes any later async // onclose early-return. this.disconnecting = false; } // Null out transport so next connect() creates a fresh one. this.baseTransport = null; this.transport = null; this.transportHasAuthProvider = false; // Drop anything the server had queued with us before announcing the // teardown, so a `disconnect` consumer sees an empty queue here as it does // on the crash path. The change events stay batched with the other teardown // dispatches below. this.clearPendingPeerRequests(); // Update status - any onclose fired during close() above deferred to us // (see `disconnecting`), so this is the single place the explicit-disconnect // path settles the status and emits `disconnect`. if (this.status !== "disconnected") { this.status = "disconnected"; this.dispatchTypedEvent("statusChange", this.status); this.dispatchTypedEvent("disconnect"); } // Clear the rest of the server state (list state is in state managers). // Clear resource subscriptions on disconnect. Tear down the modern listen // stream (best-effort — the transport is already going away) and bump the // generation so any in-flight re-listen/reconnect bails (#1630). this.resetSubscriptionStream(); this.cancelledTaskIds.clear(); // Settle any pending raw-wire (modern tasks/*) requests so their callers // don't hang past teardown. Rejected outright on every disconnect: the // drain above polls the SDK's own response-handler map, which never holds // raw-wire ids (those frames go straight through the transport), and it is // opt-in anyway — every production caller leaves `safeDisconnectTimeout` at // 0, so nothing is drained for anyone. this.rejectPendingRawWireRequests("Disconnected"); // Abort any task paused at input_required so its poll loop unwinds. for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Disconnected")); } this.taskInputAbortControllers.clear(); // Abort any in-flight ordinary tool call so its promise settles instead of // hanging past teardown; drop the controller reference either way. this.activeToolCallAbortController?.abort("Disconnected"); this.activeToolCallAbortController = undefined; this.clearReceiverTasks(); this.capabilities = undefined; this.serverInfo = undefined; this.instructions = undefined; this.protocolVersion = undefined; this.protocolEra = undefined; this.discoverResult = undefined; this.excludedTools = []; this.malformedListItems.clear(); // Read as "not opted in" while disconnected. This is no longer what stops // it leaking into the next connection — `resetSessionState()` re-derives it // at connect, so removing this would leak nothing (#1629). Note the web // Logs control deliberately shows the *configured* level in this window // (`resetSessionScopedUiState`), so the two disagree until the next // connect re-seeds both; harmless, since nothing is sent meanwhile. this.modernLogLevel = undefined; this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); this.dispatchTypedEvent( "pendingElicitationsChange", this.pendingElicitations, ); this.dispatchTypedEvent("capabilitiesChange", this.capabilities); this.dispatchTypedEvent("serverInfoChange", this.serverInfo); this.dispatchTypedEvent("instructionsChange", this.instructions); this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion); this.dispatchTypedEvent("protocolEraChange", this.protocolEra); this.dispatchTypedEvent("discoverResultChange", this.discoverResult); this.dispatchTypedEvent("excludedToolsChange", this.excludedTools); this.dispatchTypedEvent("malformedListItemsChange", []); } /** * The SDK client for the MCP Apps host bridge, or null when not connected. * * ext-apps' `AppBridge` (2.0.0+, an SDK v2 peer) takes the v2 `Client` * directly and registers its list-changed forwarding on it in the * method-string form, so this hands out the real client. The Proxy that * translated the 1.x peer's schema-first `setNotificationHandler(Schema, * handler)` into that form is gone with it (#1745). Kept as the seam rather * than exposing the field so the connected-status gate lives in one place * and hooks, fakes and tests depend on the protocol, not the class. */ getAppRendererClient(): AppRendererClient | null { if (!this.client || this.status !== "connected") return null; return this.client; } /** * Send a ping request to the server. Resolves when the server responds. */ async ping(): Promise { if (!this.client) { throw new Error("Client not initialized"); } await this.client.request( { method: "ping" }, EmptyResultSchema, this.getRequestOptions(), ); } /** * Get the current connection status */ getStatus(): ConnectionStatus { return this.status; } /** * Get the MCP server configuration used to create this client */ getTransportConfig(): MCPServerConfig { return this.transportConfig; } /** * Get the server type (stdio, sse, or streamable-http) */ getServerType(): ServerType { return getServerTypeFromConfig(this.transportConfig); } /** * Get task capabilities from server * @returns Task capabilities or undefined if not supported */ getTaskCapabilities(): { list: boolean; cancel: boolean } | undefined { if (!this.capabilities?.tasks) { return undefined; } return { list: !!this.capabilities.tasks.list, cancel: !!this.capabilities.tasks.cancel, }; } /** * True when the connection is modern (2026-07-28) AND the server advertised * the `io.modelcontextprotocol/tasks` extension (SEP-2663) in its * `server/discover` capabilities. Modern task methods (`tasks/get`, * `tasks/update`, `tasks/cancel`) and the "unsolicited task handle" behavior * are gated on this — legacy servers use `capabilities.tasks` and the * `tasks/list`-backed flow instead. Exposed so the Tasks tab and the modern * task store gate on the extension rather than the legacy capability. */ isTasksExtensionNegotiated(): boolean { return ( this.isModernEra() && this.capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined ); } /** * Build the full modern (2026-07-28) per-request envelope for a RAW tasks/* * request. The SDK's codec normally stamps this envelope, but raw requests * bypass the codec, and the modern server rejects a request whose * `MCP-Protocol-Version` header names 2026-07-28 but omits the required * envelope `_meta` keys (`protocolVersion`, `clientInfo`, plus * `clientCapabilities` carrying the tasks extension). We reproduce it here. */ private withModernTaskEnvelope( params: Record, ): Record { const clientCapabilities = { ...this.clientCapabilities, // Force-stamp the tasks extension regardless of what the client // advertised at construction: the raw `tasks/*` channel requires it, and // a user may disable general tasks advertisement via `advertisedExtensions` // (#1738). So this stamp is load-bearing, not a redundant re-add. extensions: { ...this.clientCapabilities.extensions, [TASKS_EXTENSION_KEY]: {}, }, }; return this.withModernEnvelope(params, clientCapabilities); } /** * Stamp the `_meta` envelope every raw-wire request needs on the modern leg: * the negotiated protocol version, the client identity, and the client * capabilities. * * Split out of {@link withModernTaskEnvelope} so the list-salvage re-walk * (#1909) can reuse it WITHOUT that method's force-stamped tasks extension — * a `tools/list` has no business claiming an extension the user may have * deliberately turned off (#1738). * * The NEGOTIATED protocol version is used so the envelope agrees with the * `MCP-Protocol-Version` header the transport stamps from the same source — a * future modern-family revision would negotiate a different string, and the * two must not disagree. The raw channel only runs on a connected modern * session, so this is always set; the constant is a defensive fallback. */ private withModernEnvelope( params: Record, clientCapabilities: ClientCapabilities = this.clientCapabilities, ): Record { const existingMeta = (params._meta as Record | undefined) ?? {}; /* v8 ignore start -- fallback only if getProtocolVersion() is unset, which can't happen on the connected modern session this runs on. Bracketed so the ignore is reflow-proof however prettier splits the statement. */ const protocolVersion = this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION; /* v8 ignore stop */ return { ...params, _meta: { ...existingMeta, [PROTOCOL_VERSION_META_KEY]: protocolVersion, [CLIENT_INFO_META_KEY]: this.clientInfo, [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities, }, }; } /** * Transport-level rewrite of a modern (SEP-2663) `CreateTaskResult` * (`resultType: "task"`) — the one task frame the SDK v2 codec rejects (tasks * were removed, so the codec knows only `complete`/`input_required`). The true * frame is already logged by `trackResponse`; here we hand the SDK a benign * `CallToolResult` that carries the real `DetailedTask` under * {@link MODERN_TASK_HANDLE_META}, where {@link pollTaskToolCall} reads it to * drive the poll. Any other message passes through untouched. */ private rewriteModernTaskResult( message: JSONRPCResultResponse, ): JSONRPCMessage { if (!isModernCreateTaskResult(message.result)) { return message; } const task = message.result as ModernDetailedTask; return { ...message, result: { resultType: "complete", content: [{ type: "text", text: `Modern task ${task.taskId} created` }], _meta: { [MODERN_TASK_HANDLE_META]: task }, }, }; } /** * Send an extension method the SDK v2 era gate refuses to route — the modern * `tasks/get` / `tasks/update` / `tasks/cancel`, which are spec-method names * absent from the 2026-07-28 era, so `client.request` throws * `MethodNotSupportedByProtocolVersion` before anything reaches the wire. * * We mint a string JSON-RPC id (the SDK only mints numeric ids, so ours never * collide), send the raw frame straight through the transport (which still * logs it via `trackRequest`, so the Protocol/Network tabs see it), and await * the matching response — captured and consumed by the transport's * consume-response hook so it never confuses the SDK Client. The response is * validated with the caller's explicit schema. */ private async rawWireRequest( method: string, params: Record, resultSchema: { parse: (value: unknown) => T }, ): Promise { const transport = this.transport; if (!transport) { throw new Error("Client is not connected"); } const id = `inspector-ext-${(this.rawWireRequestCounter += 1)}`; // `params` is an arbitrary caller-supplied record; the SDK types request // params with a specific optional `_meta` shape it can't satisfy, so widen // it with a single structural cast. Typing `message` as `JSONRPCRequest` // (a `JSONRPCMessage` member) then needs no further cast. const message: JSONRPCRequest = { jsonrpc: "2.0", id, method, params: params as JSONRPCRequest["params"], }; const timeoutMs = this.requestTimeout ?? 30_000; const raw = await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRawWireRequests.delete(id); // The same error, with the same annotation, as an SDK request that // times out: this path bypasses `Protocol.request`, so it builds the // SDK's own timeout shape and runs it through the decorator's // annotation by hand — a raw-wire caller sees one kind of timeout, // not two (#2318). reject( annotateRequestTimeout( new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { timeout: timeoutMs, }), method, this.getConnectionDiagnostics(), ), ); }, timeoutMs); this.pendingRawWireRequests.set(id, { resolve, reject, timer }); transport.send(message).catch((err: unknown) => { const pending = this.pendingRawWireRequests.get(id); if (pending) { clearTimeout(pending.timer); this.pendingRawWireRequests.delete(id); } // The browser's remote transport awaits the response inside `send`, // so its relay wait can expire here first, as the SDK's timeout // shape; annotate it exactly as the local timer above does (a // non-timeout error passes through untouched). reject( annotateRequestTimeout( err instanceof Error ? err : new Error(String(err)), method, this.getConnectionDiagnostics(), ), ); }); }); return resultSchema.parse(raw); } /** * Transport consume-response hook: resolve/reject a pending * {@link rawWireRequest} when its response arrives, and report it as consumed * (so the transport does not forward it to the SDK Client, which never sent * it). Returns false for any id we don't own, leaving normal SDK traffic * untouched. */ private consumeRawWireResponse( message: JSONRPCResultResponse | JSONRPCErrorResponse, ): boolean { const id = String((message as { id?: unknown }).id); const pending = this.pendingRawWireRequests.get(id); if (!pending) { return false; } this.pendingRawWireRequests.delete(id); clearTimeout(pending.timer); if ("error" in message) { const err = (message as JSONRPCErrorResponse).error; pending.reject(new Error(err?.message ?? `Request ${id} failed`)); } else { pending.resolve((message as JSONRPCResultResponse).result); } return true; } /** * Reject and clear all pending raw-wire requests — on every route out that * can hold one, and at the top of `connect()` for the route in that settles * nothing (see the comment there). */ private rejectPendingRawWireRequests(reason: string): void { for (const [, pending] of this.pendingRawWireRequests) { clearTimeout(pending.timer); pending.reject(new Error(reason)); } this.pendingRawWireRequests.clear(); } /** * Get requestor task status by taskId (tasks we created on the server) * @param taskId Task identifier * @returns Task status */ async getRequestorTask(taskId: string): Promise { if (!this.client) { throw new Error("Client is not connected"); } // Modern (SEP-2663): `tasks/get` returns a `DetailedTask` (ttlMs/pollIntervalMs, // inlined result/error/inputRequests) — a different wire shape than the // deprecated SDK schema. Parse with the explicit modern schema and normalize // onto the internal Task shape, stamping the extension client capability. if (this.isTasksExtensionNegotiated()) { const modern = await this.rawWireRequest( "tasks/get", this.withModernTaskEnvelope({ taskId }), ModernGetTaskResultSchema, ); const task = normalizeModernTask(modern); this.dispatchTypedEvent("requestorTaskUpdated", { taskId: task.taskId, task, }); return task; } // Legacy (2025-11-25): SDK v2 removed `client.experimental.tasks.*`; drive // the `tasks/get` wire method directly with its deprecated-but-importable // result schema. `GetTaskResult` is the flattened task object. const task = (await this.client.request( { method: "tasks/get", params: { taskId } }, GetTaskResultSchema, this.getRequestOptions(), )) as Task; // Dispatch client-origin event (taskStatusChange is server-only) this.dispatchTypedEvent("requestorTaskUpdated", { taskId: task.taskId, task: task, }); return task; } /** * Get requestor task result by taskId (tasks we created on the server) * @param taskId Task identifier * @returns Task result */ async getRequestorTaskResult(taskId: string): Promise { if (!this.client) { throw new Error("Client is not connected"); } // `tasks/result` returns the task's stored payload; for a task-augmented // tool call that payload is a CallToolResult, so validate with // CallToolResultSchema (replacing the removed experimental helper). return await this.client.request( { method: "tasks/result", params: { taskId } }, CallToolResultSchema, this.getRequestOptions(), ); } /** * Cancel a running requestor task (task we created on the server) * @param taskId Task identifier * @returns Cancel result */ async cancelRequestorTask(taskId: string): Promise { if (!this.client) { throw new Error("Client is not connected"); } // Mark before awaiting: cancelling unblocks the in-flight callToolStream, // whose error message may arrive before this resolves — the stream's error // path reads this set to label the task "cancelled" rather than "failed". this.cancelledTaskIds.add(taskId); // If the task is paused at `input_required` (its poll loop blocked on the // pending-request modal), abort it so the modal closes and the poll observes // the cancellation — otherwise the user is stuck answering a modal that a // non-advancing server would keep re-showing. const inputAbort = this.taskInputAbortControllers.get(taskId); if (inputAbort) { inputAbort.abort(new Error(`Task ${taskId} cancelled by user`)); } // Modern `tasks/cancel` is a raw-wire request (the SDK era gate blocks the // spec-method name on 2026-07-28); legacy uses the SDK path + deprecated // schema. if (this.isTasksExtensionNegotiated()) { await this.rawWireRequest( "tasks/cancel", this.withModernTaskEnvelope({ taskId }), ModernCancelTaskResultSchema, ); } else { await this.client.request( { method: "tasks/cancel", params: { taskId } }, CancelTaskResultSchema, this.getRequestOptions(), ); } // Dispatch event this.dispatchTypedEvent("taskCancelled", { taskId }); } /** * Fulfil the outstanding `inputRequests` of a modern (SEP-2663) * `input_required` task by sending `tasks/update` with the collected * `inputResponses`. The server acks with an empty result; the task's * observable status advances on a subsequent `tasks/get` poll (the update is * eventually consistent). Modern-only — legacy tasks surface input through the * server→client request channel, not `tasks/update`. * * @param taskId Task identifier * @param inputResponses Responses keyed by the server's `inputRequests` ids */ async updateRequestorTask( taskId: string, inputResponses: Record, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } await this.rawWireRequest( "tasks/update", this.withModernTaskEnvelope({ taskId, inputResponses }), ModernUpdateTaskResultSchema, ); } /** * Cancel the in-flight ordinary (non-task) tool call started by * {@link callTool}. Aborting its request runs the MCP cancellation flow and * rejects the pending call, which `callTool` surfaces as a * {@link ToolCallCancelledError}. The SDK chooses the wire signal from the * transport: on a 2026-era Streamable HTTP connection it aborts that * request's own SSE response stream — the spec's cancellation signal — * rather than sending `notifications/cancelled`, which is the stdio * mechanism and remains in use there (#2140). * * Task-augmented calls have a server-side task and are cancelled via * {@link cancelRequestorTask} instead — this is a no-op for them (and whenever * no ordinary call is in flight). * * @returns `true` if a call was in flight to cancel, `false` otherwise. */ cancelToolCall(): boolean { const controller = this.activeToolCallAbortController; if (!controller) { return false; } // Drop the reference up front so a rapid second cancel is a clean no-op and // can't re-abort a call that's already terminating. this.activeToolCallAbortController = undefined; // The reason string rides along on the `notifications/cancelled` the SDK // sends to the server (and lets the call's catch distinguish this deliberate // cancel from other aborts of the same controller, e.g. a disconnect). controller.abort(TOOL_CALL_CANCELLED_REASON); return true; } /** * List all requestor tasks with optional pagination (tasks we created on the server) * @param cursor Optional pagination cursor * @returns List of tasks with optional next cursor */ async listRequestorTasks( cursor?: string, ): Promise<{ tasks: Task[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); } const result = await this.client.request( { method: "tasks/list", // `!== undefined`, not truthiness: a cursor is opaque and `""` is a // legal value a server may hand back. Dropping it asks for page one // again, so a caller walking pages would loop on the first page. params: cursor !== undefined ? { cursor } : {}, }, ListTasksResultSchema, this.getRequestOptions(), ); return { tasks: result.tasks as Task[], nextCursor: result.nextCursor }; } /** * Surface a sampling request through the pending-request UI and resolve with * the user's answer. Shared by the inbound `sampling/createMessage` handler * (legacy server→client request) and the MRTR driver (a modern * `input_required` round embeds the request in a tool-call result). `origin` * tags which of the two so the UI can show era-accurate semantics. * * Sampling has no decline/cancel action (unlike elicitation): the panel * either sends a `CreateMessageResult` (resolve — echoed to the server) or * Rejects (reject — which fails the tool call). A `signal` abort likewise * rejects so the MRTR driver can abort the originating call. `signal` is only * passed by the MRTR driver (the tool call's abort signal) — while the driver * awaits an answer there is no in-flight SDK request to carry it. */ private enqueuePendingSample( request: CreateMessageRequest, origin: PendingRequestOrigin, signal?: AbortSignal, ): Promise { // A Promise's resolve/reject is idempotent (first settle wins, later ones // no-op), so the respond/reject/abort paths need no extra guard against a // double settle. return new Promise((resolvePromise, rejectPromise) => { const sample = new SamplingCreateMessage( request, resolvePromise, rejectPromise, (id) => this.removePendingSample(id), origin, ); this.addPendingSample(sample); this.wirePendingAbort(signal, () => { this.removePendingSample(sample.id); rejectPromise(createPendingAbortError()); }); }); } /** * Surface an elicitation request through the pending-request UI and resolve * with the user's answer. Shared by the inbound `elicitation/create` handler * and the MRTR driver — see {@link enqueuePendingSample} for the `origin` / * `signal` semantics. A declined/cancelled elicitation resolves with the * corresponding `ElicitResult` (echoed to the server on retry); only a * genuine failure or a `signal` abort rejects. */ private async enqueuePendingElicitation( request: ElicitRequest, origin: PendingRequestOrigin, signal?: AbortSignal, ): Promise { // App-rendered form elicitation (#1854) is offered first and falls back to // the native queue below on every failure. Both entry points — the inbound // `elicitation/create` handler and the MRTR driver's embedded requests — // funnel through here, so neither can miss the routing. const appResult = await this.tryAppElicitation(request, signal); if (appResult) return appResult; // See {@link enqueuePendingSample} — Promise settle is idempotent. return new Promise((resolvePromise, rejectPromise) => { const elicitation = new ElicitationCreateMessage( request, resolvePromise, (id) => this.removePendingElicitation(id), rejectPromise, origin, ); this.addPendingElicitation(elicitation); this.wirePendingAbort(signal, () => { this.removePendingElicitation(elicitation.id); rejectPromise(createPendingAbortError()); }); }); } /** * Attempt to resolve an `elicitation/create` request by rendering the MCP App * the server attached to it (#1854), returning the app's standard * `ElicitResult`. * * Returns `null` for "not app-rendered — use the native UI", which covers * every negotiation gate and every failure mode the contract lists: either * peer did not negotiate the capability, the metadata is absent or unusable, * the mode is not `form`, the renderer failed (resource read, sandbox/bridge * init, missing app capability, timeout), or the app returned something that * is not a valid result for this request. An explicit `decline` or `cancel` * is a *completed* elicitation and is returned, not fallen back on. * * The one case that does not fall back is an abort of the originating * request: the caller is cancelling the whole elicitation, so re-opening it * in the native queue would resurrect work the user just abandoned. */ private async tryAppElicitation( request: ElicitRequest, signal?: AbortSignal, ): Promise { const renderer = this.appElicitationRenderer; if (!renderer) return null; // Gate 1-3 (client) + 4 (server), from the negotiated capabilities of this // connection — `this.capabilities` is populated at initialize. if (!supportsAppElicitation(this.clientCapabilities, this.capabilities)) { return null; } // Only form mode is app-renderable; `url` keeps its existing path. if (!isFormElicitation(request.params)) return null; let resourceUri: string | undefined; try { resourceUri = getElicitationUiResourceUri(request.params); } catch (error) { this.logger.warn( { error }, "Elicitation carried unusable _meta.ui.resourceUri; using the native elicitation UI", ); return null; } if (!resourceUri) return null; // Request-scoped abort: forwards the caller's signal (MRTR cancellation) // and is aborted by `settleAndDropPendingPeerRequests` on disconnect, so a // rendered app cannot outlive the connection that asked for it. // Already cancelled before we got here (the caller aborted while an earlier // MRTR round was in flight): don't mount an app nobody is waiting on. if (signal?.aborted) throw createPendingAbortError(); const controller = new AbortController(); const forwardAbort = () => controller.abort(signal?.reason); signal?.addEventListener("abort", forwardAbort, { once: true }); this.activeAppElicitations.add(controller); try { const result = await renderer({ requestId: `${this.appElicitationPrefix}-${++this.appElicitationSeq}`, resourceUri, params: request.params, signal: controller.signal, }); this.outputValidator ??= new AjvJsonSchemaValidator(); const invalid = validateAppElicitResult( this.outputValidator, request.params, result, ); if (invalid) { this.logger.warn( { resourceUri, reason: invalid }, "App-rendered elicitation returned an invalid result; using the native elicitation UI", ); return null; } return result; } catch (error) { if (controller.signal.aborted) throw createPendingAbortError(); this.logger.warn( { error, resourceUri }, "App-rendered elicitation failed; using the native elicitation UI", ); return null; } finally { this.activeAppElicitations.delete(controller); signal?.removeEventListener("abort", forwardAbort); } } /** * Reject a still-pending request when `signal` aborts (e.g. the user cancels * the tool call while its MRTR round is awaiting an answer). No-op when * `signal` is absent — the legacy inbound-handler path passes none. */ private wirePendingAbort( signal: AbortSignal | undefined, onAbort: () => void, ): void { if (!signal) return; /* v8 ignore next 4 -- unreachable in the MRTR flow: an abort during the SDK request leg rejects `client.request` before we reach the pending enqueue, so the signal is never already-aborted here; kept as a defensive guard because addEventListener("abort") would not fire on a pre-aborted signal. */ if (signal.aborted) { onAbort(); return; } signal.addEventListener("abort", onAbort, { once: true }); } /** * Drive a multi-round-trip request (SEP-2322 "MRTR") for one of the modern * multi-round-trip methods (`tools/call`, `prompts/get`, `resources/read`). * * The client is constructed with `inputRequired: { autoFulfill: false }`, so * an `input_required` result is handed back here (via `allowInputRequired`) * instead of the SDK silently fulfilling and retrying. We surface each * embedded request through the SAME pending-request UI the legacy * server→client path uses (`fulfilInputRequests`), gather the bare results, * and retry the ORIGINAL request with `inputResponses` + the echoed * `requestState` on a fresh JSON-RPC id (`client.request` mints it). The loop * runs until the server returns a complete result, bounded by * {@link MRTR_MAX_ROUNDS}. * * On legacy connections a server never returns `input_required`, so the first * response is always complete and this is a single `client.request` call. */ private async requestWithInputRequired( method: "tools/call" | "prompts/get" | "resources/read", params: Record, resultSchema: TSchema, requestOptions: RequestOptions, ): Promise> { const client = this.client; /* v8 ignore next 3 -- defensive: every caller (callTool/getPrompt/ readResource) already verified this.client is non-null before reaching here, so this guard cannot trip in practice. */ if (!client) { throw new Error("Client is not connected"); } const wrapped = withInputRequired(resultSchema); const signal = requestOptions.signal; let round = 0; let nextParams = params; while (true) { const outcome = await client.request( { method, params: nextParams }, wrapped, { ...requestOptions, allowInputRequired: true, }, ); if (!isInputRequiredResult(outcome)) { return outcome; } round += 1; if (round > InspectorClient.MRTR_MAX_ROUNDS) { throw new Error( `Multi-round-trip "${method}" exceeded ${InspectorClient.MRTR_MAX_ROUNDS} input_required rounds without completing.`, ); } const inputResponses = await this.fulfilInputRequests( outcome.inputRequests, signal, ); // Retry re-issues the ORIGINAL params plus THIS round's answers and the // server's opaque state token; the SDK assigns a fresh JSON-RPC id. nextParams = { ...params, ...(inputResponses ? { inputResponses } : {}), ...(outcome.requestState !== undefined ? { requestState: outcome.requestState } : {}), }; } } /** * Fulfil the embedded requests of one MRTR `input_required` round, keyed by * the server-assigned identifiers echoed back in `inputResponses`. Sequential * (one modal at a time) to keep the single-slot pending UI coherent. Returns * `undefined` for a `requestState`-only round (no embedded requests); an empty * `inputRequests` map yields an empty `{}`, which the retry echoes harmlessly. */ private async fulfilInputRequests( inputRequests: InputRequests | undefined, signal?: AbortSignal, origin: PendingRequestOrigin = "input-required", ): Promise | undefined> { if (!inputRequests) return undefined; const responses: Record = {}; for (const [key, embedded] of Object.entries(inputRequests)) { responses[key] = await this.fulfilEmbeddedInputRequest( embedded, signal, origin, ); } return responses; } /** * Fulfil a single embedded input request. `roots/list` is auto-answered from * the configured roots (consistent with the legacy `roots/list` handler — no * pending UX); `elicitation/create` and `sampling/createMessage` surface * through the pending-request UI tagged with `origin`. `origin` distinguishes * an MRTR round (`"input-required"`, answer echoed as a retry) from a modern * task round (`"task-input-required"`, answer submitted via `tasks/update`). */ private async fulfilEmbeddedInputRequest( request: CreateMessageRequest | ElicitRequest | ListRootsRequest, signal?: AbortSignal, origin: PendingRequestOrigin = "input-required", ): Promise { switch (request.method) { case "roots/list": return { roots: this.roots ?? [] }; case "elicitation/create": return this.enqueuePendingElicitation(request, origin, signal); case "sampling/createMessage": return this.enqueuePendingSample(request, origin, signal); /* v8 ignore next 6 -- defensive: an SDK server rejects an unknown embedded method before it reaches the wire, so this only guards against a non-conformant hand-rolled server; not reproducible against the SDK-based test servers. */ default: throw new Error( `Unsupported embedded input_required request method: ${ (request as { method: string }).method }`, ); } } /** * Get all pending sampling requests */ getPendingSamples(): SamplingCreateMessage[] { return [...this.pendingSamples]; } /** * Add a pending sampling request */ private addPendingSample(sample: SamplingCreateMessage): void { this.pendingSamples.push(sample); this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); this.dispatchTypedEvent("newPendingSample", sample); } /** * Remove a pending sampling request by ID */ removePendingSample(id: string): void { const index = this.pendingSamples.findIndex((s) => s.id === id); if (index !== -1) { this.pendingSamples.splice(index, 1); this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); } } /** * Get all pending elicitation requests */ getPendingElicitations(): ElicitationCreateMessage[] { return [...this.pendingElicitations]; } /** * Add a pending elicitation request */ private addPendingElicitation(elicitation: ElicitationCreateMessage): void { this.pendingElicitations.push(elicitation); this.dispatchTypedEvent( "pendingElicitationsChange", this.pendingElicitations, ); this.dispatchTypedEvent("newPendingElicitation", elicitation); } /** * Remove a pending elicitation request by ID */ removePendingElicitation(id: string): void { const index = this.pendingElicitations.findIndex((e) => e.id === id); if (index !== -1) { this.pendingElicitations.splice(index, 1); this.dispatchTypedEvent( "pendingElicitationsChange", this.pendingElicitations, ); } } /** * Get server capabilities */ getCapabilities(): ServerCapabilities | undefined { return this.capabilities; } /** * Get the capabilities this client advertises to the server. Snapshotted * from the initialize-time build in setupClient(); does not reflect later * registerCapabilities() calls on the underlying SDK Client. */ getClientCapabilities(): ClientCapabilities { return this.clientCapabilities; } /** * Get server info (name, version) */ getServerInfo(): Implementation | undefined { return this.serverInfo; } /** * Get server instructions */ getInstructions(): string | undefined { return this.instructions; } /** * Get the MCP protocol version negotiated with the server. On a legacy * connect this is the version from the initialize handshake (e.g. * "2025-06-18"); on a modern connect it's the negotiated modern revision. * Undefined when not connected. */ getProtocolVersion(): string | undefined { return this.protocolVersion; } /** * The protocol era negotiated with the server (SEP §7.8): `"legacy"` for the * 2025-11-25 initialize handshake, `"modern"` for the 2026-era sessionless * model. Populated for every era once connected — including a plain legacy * (`mode: "legacy"`) connect, which the SDK reports as `"legacy"`. Undefined * only when not connected (before connect / after disconnect). (#1626) */ getProtocolEra(): ProtocolEra | undefined { return this.protocolEra; } /** * The `server/discover` result captured on a probed (`"auto"`) or pinned * (`"modern"`) connect — server identity, capabilities, and supported * versions learned up front without an initialize handshake. Undefined when * not connected or on a plain legacy connect. Persistable and feedable back * to the SDK as `connect(transport, { prior })` for a zero-round-trip * reconnect. (#1626) */ getDiscoverResult(): DiscoverResult | undefined { return this.discoverResult; } /** * The per-server settings this client was constructed with (headers, * timeouts, roots, OAuth, the auto-refresh-on-list-changed option, etc.). * Read by the managed list state to decide whether to auto-refresh on * `list_changed` notifications (#1402). */ getServerSettings(): InspectorServerSettings | undefined { return this.serverSettings; } /** * Replace the in-memory per-server settings on a live client. Lets a settings * edit (e.g. toggling auto-refresh-on-list-changed) take effect on the * current connection without a reconnect — the managed list state reads * `getServerSettings()` at notification time, so the next `list_changed` * notification honors the new value (#1444). Connection-time inputs * (transport, OAuth, timeouts) still only apply on the next connect. */ setServerSettings(settings: InspectorServerSettings): void { this.serverSettings = settings; } /** * Set the logging level for the MCP server (legacy era only). * * On legacy servers logging is session-scoped: one `logging/setLevel` request * sets the level for all subsequent `notifications/message`. Modern servers * removed this method — use {@link setModernLogLevel} there instead. * * @param level Logging level to set * @throws Error if client is not connected or server doesn't support logging */ async setLoggingLevel(level: LoggingLevel): Promise { if (!this.client) { throw new Error("Client is not connected"); } if (!this.capabilities?.logging) { throw new Error("Server does not support logging"); } await this.client.setLoggingLevel(level, this.getRequestOptions()); } /** * Set (or clear) the modern-era per-request log level (#1629). * * On 2026-07-28 servers `logging/setLevel` is gone and there is no * session-scoped level: the client opts into logs per request by stamping the * `io.modelcontextprotocol/logLevel` `_meta` key, and the server MUST NOT emit * `notifications/message` for requests that omit it. This stores the level so * {@link mergeMeta} stamps it on every subsequent request; pass `undefined` to * stop opting in (logs then stay silently absent). Takes effect immediately — * no request is sent, and it is a no-op on the wire until the next request. * * @param level Level to stamp on every request, or `undefined` to opt out. */ setModernLogLevel(level: LoggingLevel | undefined): void { this.modernLogLevel = level; } /** The modern-era per-request log level, or `undefined` when not opted in. */ getModernLogLevel(): LoggingLevel | undefined { return this.modernLogLevel; } /** * Fetch a single page of tools without updating the client's internal list. */ async listTools( cursor?: string, metadata?: RequestMetadata, ): Promise<{ tools: Tool[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: ListToolsRequest["params"] = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // `!== undefined`, not truthiness: "" is a valid cursor. Dropping it asks // for page one again — and `listToolsForScan` pairs this call with a // fallback that already preserves it, so the two legs of one scan page // would otherwise disagree about which page they fetched. ...(cursor !== undefined && { cursor }), }; const response = await this.invokeMcpClient(() => this.client!.request( { method: "tools/list", params }, ListToolsResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), ); const tools = [...(response.tools || [])]; return { tools, nextCursor: response.nextCursor }; } /** * Aggregate ALL pages of `tools/list` via the SDK's high-level * `client.listTools()` — the cache-aware verb. Unlike the single-page * {@link listTools} (raw `client.request`, for pagination debugging), this is * the path the managed tool list uses on refresh: the SDK walks every page, * applies the SEP-2243 `x-mcp-header` exclusion, and consults/writes its * response cache. `cacheMode` selects the disposition (`'use'` serves a * still-fresh cached list without a round trip; `'refresh'` always fetches * and re-stores; `'bypass'` fetches without touching the cache) — only * meaningful on modern servers that send `ttlMs` hints; a no-op on legacy * (nothing is cached, so every call hits the wire regardless). */ async listAllTools(options?: { cacheMode?: CacheMode; metadata?: RequestMetadata; }): Promise<{ tools: Tool[] }> { if (!this.client) { throw new Error("Client is not connected"); } const tools = await this.listAllSalvaging({ method: "tools/list", itemsKey: "tools", resultSchema: ListToolsResultSchema, // Era-aware: the neutral `ToolSchema` is more permissive than the // negotiated era's wire schema — see `toolItemSchemaForEra`. itemSchema: toolItemSchemaForEra(this.isModernEra()), metadata: options?.metadata, finalize: (salvaged) => this.excludeInvalidXMcpHeaderTools(salvaged), aggregate: async () => [ ...( await this.client!.listTools( this.aggregateListParams(options?.metadata), this.getCacheableRequestOptions(options?.cacheMode), ) ).tools, ], }); // Recompute the SEP-2243 excluded-tools set alongside the aggregate. The // SDK already filtered `response.tools`, so it can't tell us what it // dropped — {@link refreshExcludedTools} re-lists the RAW `tools/list` to // find out. This is a SECOND, deliberately un-cached walk: on a modern // non-stdio connection it roughly doubles the list round-trips per refresh // (and runs even when the aggregate above was served from cache), because // the raw per-page path has no response cache and the excluded set must // reflect the current wire truth. Accepted for a debugging tool where the // list is small and correctness of "why did this tool vanish" matters more // than the extra request; it's a no-op (no round trip) on legacy/stdio. // Kept best-effort: an error here must never fail the tools list itself — // but it is logged rather than dropped on the floor, so a failing // excluded-tools walk is diagnosable instead of silently leaving the // "Excluded (SEP-2243)" section empty and looking like a clean server // (#1953). await this.refreshExcludedTools(options?.metadata).catch((err: unknown) => { this.logger.warn( { err }, "Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete", ); }); return { tools }; } /** * Whether this connection excludes tools with invalid `x-mcp-header` * annotations from `tools/list`, matching the SDK's gate: only the modern * (2026-07-28) era on a non-stdio (Streamable HTTP / SSE) transport. Legacy * and stdio keep such tools in the list, so there is nothing to surface. */ private excludesInvalidXMcpHeaderTools(): boolean { return this.isModernEra() && this.getServerType() !== "stdio"; } /** * Mark the response that most recently answered `method` as rejected by the * client, so its Protocol entry shows why instead of rendering as a clean * success (#1953). * * The SDK gives no request id with a decode failure — `SdkError` carries the * method and nothing else — so the id is recovered by correlation: the last * response received for that method. That is exact rather than approximate, * because the SDK rejects synchronously while decoding the response (inside * the transport's `onmessage`) and the caller's `catch` runs in the very next * microtask. Delivering another response for the same method in that window * would take a macrotask (a socket read), which cannot interleave there. * * A no-op when nothing has answered `method` this session. */ markResponseRejected(method: string, reason: string): void { const id = this.lastAnsweredRequestByMethod.get(method); if (id === undefined) return; this.dispatchTypedEvent("responseRejected", { id, reason }); } /** * Put the method's response correlation back where it was before a salvage * re-walk, so a caller that goes on to mark the failure still marks the * ORIGINAL rejected response. * * The re-walk answers the same method, so on any rethrow path the map points * at a lenient page that succeeded. `ManagedListState.refresh()` catches the * rethrown error and calls `markResponseRejected(method, ...)`, which would * then stamp "Rejected by the Inspector" onto that page and leave the real * one rendering clean — the same misattribution the capture inside * `salvageList` exists to prevent, just one frame further out. Deleting when * nothing had answered before makes the outer mark a no-op rather than a lie. */ private restoreResponseCorrelation( method: string, id: string | number | undefined, ): void { if (id === undefined) this.lastAnsweredRequestByMethod.delete(method); else this.lastAnsweredRequestByMethod.set(method, id); } /** Every entry dropped from a list result as malformed, across methods (#1909). */ getMalformedListItems(): MalformedListItem[] { return [...this.malformedListItems.values()].flat(); } /** * Record (or clear) the malformed entries for one list method and notify. * * A no-op when the method had none and still has none, so the common case — * every conforming refresh, of which there are many — costs no event and no * re-render. */ private setMalformedListItems( method: string, malformed: MalformedListItem[], ): void { const previous = this.malformedListItems.get(method) ?? []; if (previous.length === 0 && malformed.length === 0) return; if (malformed.length === 0) this.malformedListItems.delete(method); else this.malformedListItems.set(method, malformed); this.dispatchTypedEvent( "malformedListItemsChange", this.getMalformedListItems(), ); } /** * Run a list aggregate, falling back to a per-item salvage when the SDK * rejects the result it received (#1909). * * The strict aggregate runs first and unchanged, so a conforming server pays * nothing: no extra request, no extra parse, and the SDK's response cache * still serves it. Only an `InvalidResult` rejection — a response in hand * that failed validation — reaches {@link salvageList}; every other failure * (transport drop, timeout, server-sent error) rethrows untouched, because * re-listing would not produce anything to salvage. */ private async listAllSalvaging({ method, itemsKey, itemSchema, resultSchema, aggregate, metadata, finalize, }: { method: string; itemsKey: string; itemSchema: z.ZodType; /** The method's real result schema — the salvage page schema is derived * from it so every field except the entries is still validated. */ resultSchema: z.ZodObject; aggregate: () => Promise; metadata?: RequestMetadata; /** * Filter applied to SALVAGED entries only, to reproduce a filter the SDK's * strict aggregate applies for us. Without it the fallback would return a * longer list than the strict path — see the `tools/list` caller. */ finalize?: (items: T[]) => T[]; }): Promise { try { const items = await this.invokeMcpClient(aggregate, { method }); this.setMalformedListItems(method, []); return items; } catch (err) { // Deliberately narrower than the Protocol-marking predicate — see // `isSalvageableRejection`: an unrecognized result KIND is not a list // with a bad entry in it. if (!isSalvageableRejection(err)) throw err; const salvaged = await this.salvageList({ method, itemsKey, itemSchema, resultSchema, metadata, err, }); return finalize ? finalize(salvaged) : salvaged; } } /** * Drop the tools the SDK would have excluded from `tools/list` for invalid * `x-mcp-header` annotations (SEP-2243). * * The strict aggregate is filtered by the SDK before we see it, so the * salvage path has to reapply the same rule itself: otherwise one ordinary * malformed tool anywhere in the list would trip the fallback and quietly * readmit every invalid-header tool alongside it — turning a rendering fix * into a spec violation. The dropped tools are still reported through * `refreshExcludedTools`, which lists them with their reason. */ private excludeInvalidXMcpHeaderTools(tools: Tool[]): Tool[] { if (!this.excludesInvalidXMcpHeaderTools()) return tools; return tools.filter( (tool) => scanXMcpHeaderDeclarations(tool.inputSchema).valid, ); } /** * Re-walk a list method with pagination validated but entries left raw, then * validate each entry on its own — keeping the good ones and reporting the * rest (#1909). * * Two deliberate properties: * * - **The original error wins when nothing is salvageable.** If every entry * validates here, the strict rejection was about something else (a * top-level field, an era codec decision), so there is no per-item story to * tell and swallowing it would hide a real failure. Rethrow the original. * - **A salvaged response is still a rejected response.** The Protocol entry * is marked (#1953) with what was dropped, so the wire truth survives even * though the list now renders. * * This walk is deliberately un-cached (it uses the raw per-page path), which * is fine: it only runs against a server that just returned a non-conforming * result. */ private async salvageList({ method, itemsKey, itemSchema, resultSchema, metadata, err, }: { method: string; itemsKey: string; itemSchema: z.ZodType; resultSchema: z.ZodObject; metadata?: RequestMetadata; err: unknown; }): Promise { const pageSchema = lenientListPageSchema(resultSchema, itemsKey); // Capture the correlation id BEFORE re-walking. `markResponseRejected` // resolves "the last response received for this method", and the re-walk // below answers that same method one or more times — so marking afterwards // would stamp the rejection onto a salvage page that succeeded and leave // the actually-invalid response rendering as a clean success, which is the // precise lie #1953 exists to remove. const rejectedResponseId = this.lastAnsweredRequestByMethod.get(method); const valid: T[] = []; const malformed: MalformedListItem[] = []; const seenCursors = new Set(); let cursor: string | undefined; let pages = 0; try { do { // Bounded like the SDK aggregate this stands in for: a server emitting // endlessly UNIQUE cursors never trips the repeated-cursor guard below, // so without this the walk would run forever. Aborting surfaces the // original validation error rather than a truncated list. if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded(method); pages += 1; const effectiveMeta = this.mergeMeta(metadata); const params = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // `!== undefined`, not truthiness: "" is a valid cursor, and dropping it // would re-request page one and duplicate its entries until the // repeated-cursor guard below stopped the walk. ...(cursor !== undefined && { cursor }), }; // The modern (2026-07-28) codec validates a result against the SPEC // schema for the method before the caller's schema is consulted, so a // lenient `client.request` is rejected exactly as the strict one was — // there is no salvage without going around it. The raw-wire channel that // exists for the modern `tasks/*` methods does precisely that: the frame // goes straight through the transport (still logged for the Protocol / // Network tabs) and only the caller's schema is applied. Legacy keeps the // ordinary SDK path, which honors request options and `_meta` for us. const page = await this.invokeMcpClient( () => this.isModernEra() ? this.rawWireRequest( method, this.withModernEnvelope(params), pageSchema, ) : this.client!.request( { method, params }, pageSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method }, ); // The raw-wire path skips the era codec, so the envelope it would have // enforced is checked here; a violation is not salvageable. if ( this.isModernEra() && !ModernResultEnvelopeSchema.safeParse(page).success ) { throw err; } const items = rawItemsOf(page, itemsKey); // The page's list member is missing or isn't a list: a top-level // violation, not a per-item one. Reading it as "no entries" would return // a silently truncated list and discard the strict error that was right // about it. if (items === undefined) throw err; const salvaged = salvageListItems({ method, items, schema: itemSchema, startIndex: valid.length + malformed.length, }); valid.push(...salvaged.valid); malformed.push(...salvaged.malformed); cursor = nextCursorOf(page); if (cursor !== undefined) { /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ if (seenCursors.has(cursor)) break; seenCursors.add(cursor); } } while (cursor !== undefined); } catch (walkErr) { // The re-walk itself failed — its own decode rejection (a malformed // `nextCursor`, an unsalvageable page), or the connection going away // mid-walk. Either way the ORIGINAL error is the one to surface: it is // what the caller's list actually failed on, and `rejectedResponseId` // above was captured for that response, not this one. The secondary // failure is logged so it stays diagnosable rather than vanishing. if (walkErr !== err) { this.logger.warn( { err: walkErr, method }, "Salvage re-walk failed; surfacing the original list error", ); } this.restoreResponseCorrelation(method, rejectedResponseId); throw err; } // Nothing per-item was wrong, so the strict rejection was about something // this fallback cannot explain — surface it rather than hide it. if (malformed.length === 0) { this.restoreResponseCorrelation(method, rejectedResponseId); throw err; } if (rejectedResponseId !== undefined) { this.dispatchTypedEvent("responseRejected", { id: rejectedResponseId, reason: summarizeMalformed(malformed), }); } this.setMalformedListItems(method, malformed); this.logger.warn( { method, malformed }, "Dropped malformed entries from a list result; the rest of the list was kept", ); return valid; } /** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */ getExcludedTools(): ExcludedTool[] { return this.excludedTools; } /** * Recompute the tools the SDK excludes from `tools/list` for invalid * `x-mcp-header` annotations (SEP-2243), and emit `excludedToolsChange`. * Returns `[]` without any round trip on connections that don't exclude * (legacy/stdio). Otherwise walks every page of the RAW `tools/list` (which, * unlike the SDK's high-level `listTools()`, is NOT filtered) and keeps the * tools whose annotation scan fails, each with its reason. A repeating cursor * stops the walk (non-converging-server guard, mirroring the SDK). */ async refreshExcludedTools( metadata?: RequestMetadata, ): Promise { const excluded: ExcludedTool[] = []; // Gated to connections that actually exclude; otherwise this is a pure // no-op (no round trip). The raw `listTools` below guards the connection. if (this.excludesInvalidXMcpHeaderTools()) { const seenCursors = new Set(); let cursor: string | undefined; let pages = 0; do { // Same bound as `salvageList`, for the same reason: this walk is also // outside the SDK aggregate's `listMaxPages` cap. Throwing (rather than // committing a partial set) keeps the caller's "the excluded list may // be incomplete" warning the honest description of what happened. if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded("tools/list"); pages += 1; const page = await this.listToolsForScan(cursor, metadata); for (const tool of page.tools) { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) excluded.push({ tool, reason: scan.reason }); } cursor = page.nextCursor; if (cursor !== undefined) { /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ if (seenCursors.has(cursor)) break; seenCursors.add(cursor); } } while (cursor !== undefined); } // The scan deliberately does NOT publish what it dropped into the // `tools/list` report. That report is rendered above the Tools list as // "entries dropped from this list", and the aggregate is what that list // is — so only the aggregate's salvage may write it. // // The reason is stronger than "these are two different requests". A // successful strict aggregate means every entry parsed, so the rendered // list has no malformed entries in it at all; and a FAILED aggregate runs // salvage, which writes the report itself. So by the time this walk finds // something, either the aggregate already reported it (with the indices // that match what is on screen), or the aggregate succeeded and whatever // this walk just found is provably not in the list being displayed — the // server changed, or the aggregate came from cache. Publishing here can // therefore only ever name entries the user cannot see. // // The finding is not lost: `listToolsForScan` marks the scan's own refused // response, so it surfaces on the Protocol entry for the exchange that // actually carried it, which is where it is true. this.excludedTools = excluded; this.dispatchTypedEvent("excludedToolsChange", excluded); return excluded; } /** * One page of the RAW `tools/list` for the SEP-2243 scan, tolerating a * malformed sibling entry (#1632 + #1909). * * The scan needs the unfiltered list, so it uses the strict single-page call * — but that call rejects the whole page when any entry is non-conforming, * which would blank the excluded set and leave a tool that vanished for an * invalid header with no explanation at all. That is the same failure #1909 * is about, one level down. So on a decode rejection the page is re-fetched * leniently and scanned entry by entry. * * The malformed ones are not "excluded tools" and are not returned: they * describe THIS exchange, not the aggregate the Tools panel renders, so they * surface as the rejection reason on this response's own Protocol entry and * go no further (see `refreshExcludedTools`). */ private async listToolsForScan( cursor: string | undefined, metadata: RequestMetadata | undefined, ): Promise<{ tools: Tool[]; nextCursor?: string; }> { try { return await this.listTools(cursor, metadata); } catch (err) { if (!isSalvageableRejection(err)) throw err; // Capture before the re-fetch, for the reason `salvageList` documents: // this refused response is a distinct exchange from the aggregate's, and // the re-fetch below moves the method's correlation off it. Without this // the scan's Protocol entry renders as a clean success even though the // client refused the result. const rejectedResponseId = this.lastAnsweredRequestByMethod.get("tools/list"); try { const effectiveMeta = this.mergeMeta(metadata); const params = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // See `salvageList`: "" is a valid cursor. ...(cursor !== undefined && { cursor }), }; const pageSchema = lenientListPageSchema( ListToolsResultSchema, "tools", ); // Through `invokeMcpClient`, like the strict `listTools` above and like // `salvageList`'s walk: this re-fetch can meet an auth challenge of its // own, and outside that wrapper the recovery never runs. Its failure is // then swallowed by `listAllTools`'s best-effort scan catch, leaving a // stale excluded-tools set and no sign of why. const page = await this.invokeMcpClient( () => this.isModernEra() ? this.rawWireRequest( "tools/list", this.withModernEnvelope(params), pageSchema, ) : this.client!.request( { method: "tools/list", params }, pageSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "tools/list" }, ); // Same era-envelope check as `salvageList` — the raw-wire path bypasses // the codec that would otherwise enforce it. if ( this.isModernEra() && !ModernResultEnvelopeSchema.safeParse(page).success ) { throw err; } const items = rawItemsOf(page, "tools"); // Not a list at all — a top-level violation the per-item pass can't // explain, so the strict error stands (mirrors `salvageList`). if (items === undefined) throw err; const { valid, malformed } = salvageListItems({ method: "tools/list", items, schema: toolItemSchemaForEra(this.isModernEra()), }); // Mark regardless of what the re-fetch found. The list can change // between the two requests, so a conforming re-fetch is possible — and // it says nothing about the response that was actually refused. Leaving // that one unmarked because the SECOND request came back clean would // render a rejected exchange as a clean success, which is the lie // #1953 removes. With nothing per-item to name, the original decode // error is the reason. if (rejectedResponseId !== undefined) { this.dispatchTypedEvent("responseRejected", { id: rejectedResponseId, reason: malformed.length > 0 ? summarizeMalformed(malformed) : err instanceof Error ? err.message : String(err), }); } const nextCursor = nextCursorOf(page); return { tools: valid, ...(nextCursor !== undefined && { nextCursor }), }; } catch (fallbackErr) { // The fallback could not explain the rejection — the re-fetch itself // failed, or the page carried a violation that is not per-item (a bad // modern envelope, a `tools` that is not a list). // // `salvageList` can simply rethrow here, because ITS caller // (`ManagedListState.refresh`) marks the Protocol entry from the error // it receives. This walk has no such backstop: `listAllTools` catches // and LOGS a failing scan so it can never fail the tools list, so an // unmarked rethrow leaves the response the client demonstrably refused // (`isSalvageableRejection` above proves it was an `InvalidResult`) // rendering as a clean exchange — the exact lie #1953 removes and the // one this method's own capture above exists to prevent. So mark it // here, with the ORIGINAL decode error as the reason: the fallback's // own failure is a different event, and the response being marked is // the one `err` was raised for. if (rejectedResponseId !== undefined) { this.dispatchTypedEvent("responseRejected", { id: rejectedResponseId, reason: err instanceof Error ? err.message : String(err), }); } if (fallbackErr !== err) { this.logger.warn( { err: fallbackErr }, "Excluded-tools salvage re-fetch failed; surfacing the original list error", ); } // Restore the correlation the re-fetch moved, so a later mark for this // method does not land on a lenient page that succeeded. this.restoreResponseCorrelation("tools/list", rejectedResponseId); throw err; } } } /** * Call a tool. Caller must provide the Tool (e.g. from a state manager). * @param tool The tool to call (use tool.name for the request) * @param args Tool arguments * @param generalMetadata Optional general metadata * @param toolSpecificMetadata Optional tool-specific metadata (takes precedence over general) * @param taskOptions Optional task options (e.g. ttl) for task-augmented requests * @returns Tool call response */ async callTool( tool: Tool, args: Record, generalMetadata?: RequestMetadata, toolSpecificMetadata?: RequestMetadata, taskOptions?: { ttl?: number }, options?: { skipOutputValidation?: boolean }, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } if (tool.execution?.taskSupport === "required") { throw new Error( `Tool "${tool.name}" requires task support. Use callToolStream() instead of callTool().`, ); } const request: ToolCallRequest = { tool, args, generalMetadata, toolSpecificMetadata, taskOptions, options, }; // Track this call so `cancelToolCall()` can abort it. Aborting makes the SDK // send a `notifications/cancelled` to the server (the MCP cancellation flow) // and reject the pending request, which we surface as a // `ToolCallCancelledError`. This single slot is shared by *every* `callTool` // caller (last-writer-wins), so `cancelToolCall()` targets the most recently // started ordinary call — fine today since the Cancel button only surfaces // for the single Tools-screen call. Cleared in `finally`, but only if still // ours so a later overlapping call's controller isn't clobbered (#1458). const abortController = new AbortController(); this.activeToolCallAbortController = abortController; try { return await this.callToolWithRetries(request, abortController); } finally { if (this.activeToolCallAbortController === abortController) { this.activeToolCallAbortController = undefined; } } } /** * The URL-elicitation retry loop for {@link callTool}, factored out so the * caller can wrap it in the abort-controller lifecycle (`try`/`finally`). On a * user cancellation (the call's abort signal fired with the cancel reason) the * SDK has already sent `notifications/cancelled`, so we throw a * {@link ToolCallCancelledError} without recording a failed call — the cancel * was intentional, not a failure. */ private async callToolWithRetries( request: ToolCallRequest, abortController: AbortController, ): Promise { const { tool, args, generalMetadata, toolSpecificMetadata } = request; // Retry-loop state for the URL-elicitation error path: a `-32042` // (UrlElicitationRequired) response means the server needs the user to // complete one or more URL elicitations before the call can succeed. We // surface them, wait for completion, then re-issue the same call. The // counter bounds a server that keeps returning `-32042` so we can't spin // forever (each accepted round is one attempt). `presentedUrls` guards the // loop: a retry that re-requests a URL we already handled can't progress, so // we abort with a UrlElicitationLoopError rather than re-prompt. let urlElicitationAttempt = 0; const presentedUrls = new Set(); while (true) { try { return await this.attemptToolCall(request, abortController.signal); } catch (error) { // The controller was aborted. A deliberate `cancelToolCall()` (matched // by reason) means the SDK already sent `notifications/cancelled` if the // abort landed during a `client.request` leg — so surface a clean // cancellation, not a generic failure, and don't record it in history. // If instead the abort lands while an MRTR round is awaiting an embedded // pending request (between `client.request` legs), there is no in-flight // SDK request, so nothing is sent on the wire — `wirePendingAbort` just // rejects the pending request and the driver abandons the retry; the // outcome here is identical. Any other abort (e.g. a disconnect, which // aborts with a different reason) falls through to the normal error path // (#1458). if ( abortController.signal.aborted && abortController.signal.reason === TOOL_CALL_CANCELLED_REASON ) { throw new ToolCallCancelledError(tool.name); } const urlElicitations = getUrlElicitationsFromError(error); if ( urlElicitations && urlElicitations.length > 0 && urlElicitationAttempt < MAX_URL_ELICITATION_RETRIES ) { // Loop guard: the server repeated a URL we already handled this call. const repeated = urlElicitations.find((e) => presentedUrls.has(e.url), ); if (repeated) { const loopError = new UrlElicitationLoopError(repeated.url); this.dispatchFailedToolCall( tool, args, generalMetadata, toolSpecificMetadata, loopError.message, ); throw loopError; } urlElicitationAttempt++; for (const e of urlElicitations) { presentedUrls.add(e.url); } const action = await this.runUrlElicitations(urlElicitations); if (action === "accept") { continue; } // The user declined/cancelled a required URL elicitation, so the // original call can't proceed. Surface it as a failed call with a // clear reason instead of the raw "-32042" message. const abortError = new Error( `Tool call cancelled: required URL elicitation was ${ action === "decline" ? "declined" : "cancelled" }.`, ); this.dispatchFailedToolCall( tool, args, generalMetadata, toolSpecificMetadata, abortError.message, ); throw abortError; } // Not a URL-elicitation error (or the non-spec no-list variant, or // retries exhausted): record + rethrow so the caller can surface it. // The App distinguishes the no-list `-32042` case (a dedicated toast) // via getUrlElicitationsFromError on the thrown error. if (urlElicitations && urlElicitations.length > 0) { // A non-empty list here means the retry cap was hit (the live path // returns or continues). Log the give-up so a server that keeps // demanding new URL elicitations is diagnosable rather than looking // like an ordinary failure. this.logger.warn( { tool: tool.name, attempts: urlElicitationAttempt }, `Tool "${tool.name}" still required URL elicitations after ${MAX_URL_ELICITATION_RETRIES} attempts; giving up.`, ); } this.dispatchFailedToolCall( tool, args, generalMetadata, toolSpecificMetadata, error instanceof Error ? error.message : String(error), ); throw error; } } } /** * Coerce the string-valued entries of a tool's arguments to the types its * `inputSchema` declares (the Tools form hands everything over as text). * Shared by the two `tools/call` entry points — {@link attemptToolCall} and * {@link callToolStream} — so both put the SAME arguments on the wire. */ private convertStringToolArgs( tool: Tool, args: Record, ): Record { const stringArgs: Record = {}; for (const [key, value] of Object.entries(args)) { if (typeof value === "string") { stringArgs[key] = value; } } if (Object.keys(stringArgs).length === 0) return args; return { ...args, ...convertToolParameters(tool, stringArgs) }; } /** * SEP-2243: mirror `x-mcp-header`-annotated arguments into `Mcp-Param-*` * headers on a modern connection. The SDK only does this inside * `client.callTool()` (and skips it in the browser), but we route * `tools/call` through `client.request()` for manual MRTR driving (#1704), so * we mirror ourselves. `Protocol.request` forwards `headers` (preserved * across MRTR retry legs) to the transport, and the remote transport relays * them to the backend's upstream send — issued server-side, where the browser * skip doesn't apply. No-op on legacy/stdio (no annotations). * * Applied by BOTH `tools/call` entry points: a plain call * ({@link attemptToolCall}) and a task-augmented one * ({@link callToolStream}) — a strict modern server rejects either with * `-32020` when the mirrored header is missing. */ private applyMirroredParamHeaders( tool: Tool, convertedArgs: Record, requestOptions: RequestOptions, ): void { if (this.protocolEra !== "modern") return; const paramHeaders = mcpParamHeadersForTool(tool, convertedArgs); if (Object.keys(paramHeaders).length === 0) return; requestOptions.headers = { ...requestOptions.headers, ...paramHeaders }; } /** * Run a single tools/call attempt: convert args, issue the request, validate, * and return a successful {@link ToolCallInvocation}. Throws on any error * (including a `-32042` UrlElicitationRequired response); {@link callTool}'s * retry loop owns the elicitation handling and failure bookkeeping. */ private async attemptToolCall( request: ToolCallRequest, signal?: AbortSignal, ): Promise { const { tool, args, generalMetadata, toolSpecificMetadata, taskOptions, options, } = request; const client = this.client; if (!client) { throw new Error("Client is not connected"); } const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; const timestamp = new Date(); // Fold in this client's defaultMetadata so server-wide _meta reaches // the wire even when the caller passed nothing. const metadata = this.mergeMeta(callMetadata); const callParams: { name: string; arguments: Record; _meta?: RequestMetadata; task?: { ttl: number }; } = { name: tool.name, arguments: convertedArgs, _meta: metadata, }; if (taskOptions?.ttl != null) { callParams.task = { ttl: taskOptions.ttl }; } const requestOptions = this.getRequestOptions( this.progressTokenOf(metadata), signal, ); this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); // Route through the MRTR driver (`requestWithInputRequired`) so a modern // `input_required` result pauses at the pending-request UI and retries with // the user's answer (#1704). Both eras use `client.request` with // `CallToolResultSchema`; on legacy this is a single round. We deliberately // do NOT use `client.callTool` (which would auto-fulfil / reject on an // `input_required` result) — its only extra behavior over `request` is // structuredContent output validation, which we already re-implement below // via `validateToolOutput`. MCP Apps passthrough (skipOutputValidation) // simply skips that check; both paths yield a CallToolResult once the // driver returns a complete (non-`input_required`) result. const rawResult = await this.invokeMcpClient( () => this.requestWithInputRequired( "tools/call", callParams, CallToolResultSchema, requestOptions, ), { method: "tools/call", toolName: tool.name }, ); // Unsolicited modern task handle (SEP-2663): on a modern connection the // server may answer ANY `tools/call` with a task rather than a result. The // transport rewrote that frame into a `CallToolResult` carrying the real // `DetailedTask` in `_meta`; poll it to completion here (the run-as-task // path does the same via `callToolStream`) so the ordinary call resolves to // the task's final result and the Tasks tab tracks it. const taskHandle = (rawResult as CallToolResult)._meta?.[ MODERN_TASK_HANDLE_META ] as ModernDetailedTask | undefined; const result = taskHandle ? await this.pollModernTaskToTermination(taskHandle) : rawResult; // Output-schema validation. SDK v2's `callTool` relaxed some checks (e.g. it // no longer rejects a structuredContent with undeclared properties against a // strict `additionalProperties: false` schema), so we run our own Ajv check // to preserve the Inspector's v1 behavior: // - default path: strict — a schema violation rejects the call (matching // what a strict host would do), so the caller sees the error. // - skipOutputValidation (MCP Apps passthrough): non-fatal — surface it as // an advisory so a schema-violating-but-real result still reaches the app. const outputValidationError = this.validateToolOutput(tool, result); if (outputValidationError && !options?.skipOutputValidation) { // Match the prior contract: on v1 a strict output-schema violation // surfaced as the SDK's typed `McpError`/`ProtocolError` (code // InvalidParams), not a bare Error — so downstream code that branches on // `instanceof ProtocolError` / `error.code` keeps working. throw new ProtocolError( ProtocolErrorCode.InvalidParams, outputValidationError, ); } const invocation: ToolCallInvocation = { toolName: tool.name, params: args, result, timestamp, success: true, metadata, outputValidationError, }; this.dispatchTypedEvent("toolCallResultChange", { toolName: tool.name, params: args, result: invocation.result, timestamp, success: true, metadata, outputValidationError, }); return invocation; } /** * Record a failed tools/call as a `toolCallResultChange` event (history + the * Tools panel) without throwing. {@link callTool} calls this before rethrowing * so a failure — whether a transport error, a declined URL elicitation, or a * non-spec `-32042` — lands in the request history exactly once. */ private dispatchFailedToolCall( tool: Tool, args: Record, generalMetadata: RequestMetadata | undefined, toolSpecificMetadata: RequestMetadata | undefined, errorMessage: string, ): void { const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; const metadata = this.mergeMeta(callMetadata); this.dispatchTypedEvent("toolCallResultChange", { toolName: tool.name, params: args, result: null, timestamp: new Date(), success: false, error: errorMessage, metadata, }); } /** * Surface the URL elicitations carried by a `-32042` error, one at a time and * in order (per the spec's "URL mode with elicitation required error" flow), * returning as soon as the user declines/cancels one. Returns `"accept"` only * when every elicitation was accepted, which is {@link callTool}'s signal to * retry the original call. */ private async runUrlElicitations( elicitations: ElicitRequestURLParams[], ): Promise { for (const params of elicitations) { const action = await this.awaitUrlElicitation(params); if (action !== "accept") { return action; } } return "accept"; } /** * Add one error-path URL elicitation to the pending queue (so it renders in * the same modal as request-path elicitations) and resolve with the user's * action. Unlike the request-path handler there is no server request to * answer — accepting it just unblocks the retry; the server's optional * `notifications/elicitation/complete` resolves it as accepted too (via * `completeIfPending`). */ private awaitUrlElicitation( params: ElicitRequestURLParams, ): Promise { return new Promise((resolve) => { const request = { method: "elicitation/create", params, } as ElicitRequest; const message = new ElicitationCreateMessage( request, (result) => resolve(result.action), (id) => this.removePendingElicitation(id), ); this.addPendingElicitation(message); }); } /** * Non-fatally validate a delivered tool result against the tool's outputSchema * (used by the skipOutputValidation path). Delegates to the pure * {@link validateToolOutput} helper with this client's lazily-built Ajv * validator. Returns an advisory message, or undefined when valid. */ private validateToolOutput( tool: Tool, result: CallToolResult, ): string | undefined { this.outputValidator ??= new AjvJsonSchemaValidator(); return validateToolOutput(this.outputValidator, tool, result); } /** * When a modern (SEP-2663) task is `input_required`, fulfil its embedded * `inputRequests` through the pending-request UI and submit them via * `tasks/update`. No-op for any other status. Shared by the streaming * ({@link pollTaskToolCall}) and ordinary ({@link pollModernTaskToTermination}) * poll loops so the input handling lives in one place. * * `priorRounds` is the count of `input_required` rounds already handled for * this task; the return value is the updated count. A non-conformant server * that keeps returning `input_required` without ever completing would * otherwise re-prompt the user on every poll forever, so we bound it with the * same {@link MRTR_MAX_ROUNDS} cap the MRTR driver uses. */ private async submitModernTaskInput( detailed: ModernDetailedTask, task: Task, priorRounds: number, signal?: AbortSignal, ): Promise { if (task.status !== "input_required") { return priorRounds; } const rounds = priorRounds + 1; if (rounds > InspectorClient.MRTR_MAX_ROUNDS) { throw new Error( `Modern task "${task.taskId}" exceeded ${InspectorClient.MRTR_MAX_ROUNDS} input_required rounds without completing.`, ); } const inputResponses = await this.fulfilInputRequests( this.tagInputRequestsWithTask(readInputRequests(detailed), task.taskId), signal, "task-input-required", ); /* v8 ignore next 3 -- a conformant `input_required` task always carries `inputRequests`, so `fulfilInputRequests` returns a (possibly empty) object here, never undefined; the guard is defensive. */ if (inputResponses) { await this.updateRequestorTask(task.taskId, inputResponses); } return rounds; } /** * Stamp `_meta[RELATED_TASK_META_KEY]` with the owning task id on each embedded * request of a modern task's `inputRequests`. The pending-request UI reads that * id (via `ElicitationCreateMessage.taskId`) so its Cancel control can cancel * the TASK — not just answer the request — when a task is paused at * `input_required`. */ private tagInputRequestsWithTask( inputRequests: InputRequests | undefined, taskId: string, ): InputRequests | undefined { /* v8 ignore next -- only called for an input_required task, which always carries inputRequests; the undefined passthrough is defensive. */ if (!inputRequests) return inputRequests; const tagged: Record = {}; for (const [key, req] of Object.entries(inputRequests)) { const request = req as { params?: { _meta?: Record } }; tagged[key] = { ...request, params: { ...request.params, _meta: { ...request.params?._meta, [RELATED_TASK_META_KEY]: { taskId }, }, }, }; } return tagged as InputRequests; } /** * Terminal outcome for a modern task: the inlined `CallToolResult` for a * `completed` task (SEP-2663 removed the blocking `tasks/result`), or a * `ProtocolError` for `failed` / `cancelled`. Shared so both poll loops agree * on the result/error shape. */ private modernTaskTerminalOutcome( task: Task, detailed: ModernDetailedTask, ): | { type: "result"; result: CallToolResult } | { type: "error"; error: ProtocolError } { if (task.status === "completed") { /* v8 ignore next -- a conformant `completed` task always inlines its `result`; the `{ content: [] }` fallback is defensive. */ return { type: "result", result: (detailed.result ?? { content: [] }) as CallToolResult, }; } return { type: "error", error: new ProtocolError( ProtocolErrorCode.InternalError, task.statusMessage ?? `Task ${task.status}`, ), }; } /** * Poll cadence for a task: the server-advertised `pollInterval` when * positive, else the default. Shared by every task poll loop (both eras). */ private taskPollInterval(task: Task): number { const advertised = task.pollInterval; if (typeof advertised !== "number") return DEFAULT_TASK_POLL_INTERVAL_MS; // A spec-conformant server never advertises a non-positive interval; the // `> 0` guard is defensive against a malformed value. /* v8 ignore next -- non-positive pollInterval is unreachable from a conformant server. */ return advertised > 0 ? advertised : DEFAULT_TASK_POLL_INTERVAL_MS; } /** * Register a per-task abort controller (keyed by taskId) whose signal gates * the task's `input_required` pending request, and return the signal plus a * `release` cleanup. {@link cancelRequestorTask} aborts it to unblock a task * paused at the pending-request modal. */ private registerTaskInputAbort(taskId: string): { signal: AbortSignal; release: () => void; } { const controller = new AbortController(); this.taskInputAbortControllers.set(taskId, controller); return { signal: controller.signal, release: () => { // Only delete our own entry — tool calls are serial, so a second task // never replaces this id's controller mid-poll; the guard is defensive. /* v8 ignore next */ if (this.taskInputAbortControllers.get(taskId) === controller) { this.taskInputAbortControllers.delete(taskId); } }, }; } /** * Drive a modern (SEP-2663) task to a terminal state from a seed * `DetailedTask`, dispatching task events so the Tasks tab and toasts track * it, and return the completed task's inlined `CallToolResult` (or throw on * `failed` / `cancelled`). Used by the ORDINARY `callTool` path when a server * returns an unsolicited task handle (the run-as-task streaming path drives * the equivalent loop inline in {@link pollTaskToolCall}). `input_required` * rounds are answered through the pending-request UI and submitted via * `tasks/update`. */ private async pollModernTaskToTermination( seed: ModernDetailedTask, ): Promise { let detailed = seed; let task = normalizeModernTask(detailed); const emit = (t: Task): void => { this.dispatchTypedEvent("toolCallTaskUpdated", { taskId: t.taskId, task: t, }); this.dispatchTypedEvent("requestorTaskUpdated", { taskId: t.taskId, task: t, }); }; emit(task); const { signal: inputSignal, release } = this.registerTaskInputAbort( task.taskId, ); try { let inputRounds = 0; while (!InspectorClient.isTerminalTaskStatus(task.status)) { inputRounds = await this.submitModernTaskInput( detailed, task, inputRounds, inputSignal, ); await new Promise((resolve) => setTimeout(resolve, this.taskPollInterval(task)), ); detailed = await this.rawWireRequest( "tasks/get", this.withModernTaskEnvelope({ taskId: task.taskId }), ModernGetTaskResultSchema, ); task = normalizeModernTask(detailed); emit(task); } } finally { release(); } const outcome = this.modernTaskTerminalOutcome(task, detailed); if (outcome.type === "error") { throw outcome.error; } return outcome.result; } /** * Poll a task-augmented tool call to completion. Replaces the removed * `client.experimental.tasks.callToolStream` helper: it sends the * task-augmented `tools/call` (the server responds with a task handle, i.e. a * `CreateTaskResult`), then polls `tasks/get` until the task reaches a * terminal status, yielding the same `taskCreated | taskStatus | result | * error` message shapes the caller's `for await` loop consumes — so all the * downstream event dispatch and terminal-state handling stays unchanged. */ private async *pollTaskToolCall( params: CallToolRequest["params"], requestOptions: RequestOptions, ): AsyncGenerator< | { type: "taskCreated"; task: Task } | { type: "taskStatus"; task: Task } | { type: "result"; result: CallToolResult } | { type: "error"; error: ProtocolError } > { if (!this.client) { throw new Error("Client is not connected"); } const client = this.client; // The server streams `notifications/progress` for a task AFTER the // task-augmented `tools/call` has already returned its `{ task }` handle. But // SDK v2 deletes a request's progress subscription the moment that request // resolves, so those later ticks would be dropped. Capture the subscription // id the SDK registers for this request (the only new key in the private // `_progressHandlers` map) so we can keep the caller's `onprogress` alive // through the poll and clean it up when the task terminates. // SDK gap: `Client` exposes no public API to keep a progress subscription // alive across a resolved request (or to subscribe to progress by token), so // we reach the private `_progressHandlers` map through a narrowed cast. A // public "durable progress subscription" hook would remove this cast. const progressHandlers = ( client as unknown as { _progressHandlers: Map; } )._progressHandlers; const keysBeforeRequest = new Set(progressHandlers.keys()); // Create the task-augmented tool call. A task-capable server returns a task // handle (`CreateTaskResult` = `{ task }`), but a server that completes // synchronously (or for which the tool forbids/ignores task augmentation) // may return an immediate `CallToolResult` instead — accept either with a // union schema and branch on the presence of `task`. // // NOTE: the LEGACY task path does NOT opt into `allowInputRequired` (MRTR // over legacy tasks is out of scope for #1704). The MODERN path (SEP-2663) // instead surfaces a task's `input_required` through `tasks/get`'s // `inputRequests` and answers via `tasks/update` (handled in the poll loop // below), reusing the same pending-request UI. const modernTasks = this.isTasksExtensionNegotiated(); const requestPromise = client.request( { // On modern the SDK codec stamps the tasks-extension client capability // into the request envelope (advertised at construction), so a server // may answer with a `CreateTaskResult` — no per-call `_meta` needed. method: "tools/call", params, }, // Modern: the SDK codec can't decode a `resultType: "task"` result, so the // transport rewrote it to a `CallToolResult` carrying the task handle in // `_meta` — parse as a CallToolResult and read the handle below. Legacy: // accept a `{ task }` handle or an immediate result. modernTasks ? CallToolResultSchema : CreateTaskResultSchema.or(CallToolResultSchema), requestOptions, ); // The SDK registers the progress handler synchronously while constructing // the request promise (before this await), so the new key is present now. // ASSUMES SERIAL CONSTRUCTION: `find` takes the first key not present in the // pre-request snapshot, which is unambiguous only because no OTHER request // registers a progress handler between the snapshot and this request's // synchronous registration. Tool calls are user-driven and serial, so that // holds today; if concurrent task-augmented calls are ever constructed in // the same microtask window, two subscription ids could cross-wire and this // must move to an SDK-supported correlation (see the delete-when-native note // on `installReceiverTaskResponseBypass`). const progressSubscriptionId = requestOptions.onprogress ? [...progressHandlers.keys()].find((k) => !keysBeforeRequest.has(k)) : undefined; const created = await requestPromise; if (modernTasks) { // Modern (SEP-2663): a task-creating `tools/call` came back as a // `resultType: "task"` frame the SDK can't decode, so the transport // rewrote it to a `CallToolResult` carrying the real `DetailedTask` under // MODERN_TASK_HANDLE_META. A synchronous completion has no such handle — // yield that `CallToolResult` directly. const handle = (created as CallToolResult)._meta?.[ MODERN_TASK_HANDLE_META ] as ModernDetailedTask | undefined; if (!handle) { yield { type: "result", result: created as CallToolResult }; return; } let detailed = handle; let task = normalizeModernTask(detailed); yield { type: "taskCreated", task }; if (progressSubscriptionId != null && requestOptions.onprogress) { progressHandlers.set(progressSubscriptionId, requestOptions.onprogress); } const { signal: inputSignal, release } = this.registerTaskInputAbort( task.taskId, ); let inputRounds = 0; try { while (!InspectorClient.isTerminalTaskStatus(task.status)) { // `input_required`: fulfil the embedded server→client requests through // the same pending-request UI the MRTR path uses, then submit them via // `tasks/update`. The update is eventually consistent — the task's // status advances on a following `tasks/get`, so keep polling // (bounded by MRTR_MAX_ROUNDS against a server that never advances). // `inputSignal` fires if the task is cancelled while paused here. inputRounds = await this.submitModernTaskInput( detailed, task, inputRounds, inputSignal, ); await new Promise((resolve) => setTimeout(resolve, this.taskPollInterval(task)), ); detailed = await this.rawWireRequest( "tasks/get", this.withModernTaskEnvelope({ taskId: task.taskId }), ModernGetTaskResultSchema, ); task = normalizeModernTask(detailed); yield { type: "taskStatus", task }; } } finally { release(); if (progressSubscriptionId != null) { progressHandlers.delete(progressSubscriptionId); } } // Modern removes the blocking `tasks/result`: a completed task inlines its // CallToolResult; failed/cancelled surface as an error. yield this.modernTaskTerminalOutcome(task, detailed); return; } if (!("task" in created) || created.task == null) { // Immediate result — no task was created; yield it directly. yield { type: "result", result: created as CallToolResult }; return; } let task = created.task as Task; yield { type: "taskCreated", task }; // Revive the (now-deleted) progress subscription for the poll so task- // execution progress ticks reach the caller's `onprogress`. if (progressSubscriptionId != null && requestOptions.onprogress) { progressHandlers.set(progressSubscriptionId, requestOptions.onprogress); } try { // Poll `tasks/get` until the task reaches a terminal status. Honour the // server-advertised `pollInterval` when present, else the default cadence. while (!InspectorClient.isTerminalTaskStatus(task.status)) { await new Promise((resolve) => setTimeout(resolve, this.taskPollInterval(task)), ); task = (await client.request( { method: "tasks/get", params: { taskId: task.taskId } }, GetTaskResultSchema, this.getRequestOptions(), )) as Task; yield { type: "taskStatus", task }; } } finally { if (progressSubscriptionId != null) { progressHandlers.delete(progressSubscriptionId); } } if (task.status === "completed") { const result = await client.request( { method: "tasks/result", params: { taskId: task.taskId } }, CallToolResultSchema, this.getRequestOptions(), ); yield { type: "result", result }; } else { // failed | cancelled — surface as an error the caller's loop labels as // "cancelled" (via cancelledTaskIds) or "failed". Carry a ProtocolError so // the `error` payload matches the event map's type (the SDK helper this // replaces also yielded a protocol-error-shaped value). yield { type: "error", error: new ProtocolError( ProtocolErrorCode.InternalError, task.statusMessage ?? `Task ${task.status}`, ), }; } } /** * Call a tool with task support (streaming). * Caller must provide the Tool (e.g. from a state manager). * @param tool The tool to call (use tool.name for the request) * @param args Tool arguments * @param generalMetadata Optional general metadata * @param toolSpecificMetadata Optional tool-specific metadata (takes precedence over general) * @param taskOptions Optional task options (e.g. ttl) for task-augmented requests * @returns Tool call response */ async callToolStream( tool: Tool, args: Record, generalMetadata?: RequestMetadata, toolSpecificMetadata?: RequestMetadata, taskOptions?: { ttl?: number }, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } try { const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; const timestamp = new Date(); const metadata = this.mergeMeta(callMetadata); // Call the streaming API const streamParams: Record = { name: tool.name, arguments: convertedArgs, }; if (metadata) { streamParams._meta = metadata; } if (taskOptions?.ttl != null) { streamParams.task = { ttl: taskOptions.ttl }; } let finalResult: CallToolResult | undefined; let taskId: string | undefined; let error: Error | undefined; // Correlate progress → task. getRequestOptions already wires onprogress to // dispatch the generic progressNotification (keyed by the caller's // progressToken). Wrap it so each tick that arrives after the task is // created also dispatches requestorTaskProgress tagged with the taskId // this stream owns — the only place that mapping is known. Ticks before // taskCreated (rare) just fall through to the generic event. // // Gate on `this.progress`, mirroring getRequestOptions: when progress is // globally disabled there's no inner handler to wrap, and we must not // attach one here either — doing so would request a progress token (and // emit requestorTaskProgress) for task calls only, bypassing the toggle // that governs every other call path. const requestOptions = this.getRequestOptions( this.progressTokenOf(metadata), ); // The task-augmented `tools/call` needs the same SEP-2243 mirroring as the // plain one — a strict modern server rejects it with -32020 otherwise. this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); if (this.progress) { const innerOnProgress = requestOptions.onprogress; requestOptions.onprogress = (progress: Progress) => { innerOnProgress?.(progress); if (taskId) { this.dispatchTypedEvent("requestorTaskProgress", { taskId, progress, }); } }; } const stream = this.pollTaskToolCall( streamParams as CallToolRequest["params"], requestOptions, ); // Iterate through the async generator for await (const message of stream) { switch (message.type) { case "taskCreated": taskId = message.task.taskId; this.dispatchTypedEvent("toolCallTaskUpdated", { taskId: message.task.taskId, task: message.task, }); this.dispatchTypedEvent("requestorTaskUpdated", { taskId: message.task.taskId, task: message.task, }); break; case "taskStatus": if (!taskId) { taskId = message.task.taskId; } this.dispatchTypedEvent("toolCallTaskUpdated", { taskId: message.task.taskId, task: message.task, }); this.dispatchTypedEvent("requestorTaskUpdated", { taskId: message.task.taskId, task: message.task, }); break; case "result": finalResult = message.result as CallToolResult; if (taskId) { const completedTask: TaskWithOptionalCreatedAt = { taskId, ttl: null, status: "completed", statusMessage: "Task completed" as string, lastUpdatedAt: new Date().toISOString(), }; this.dispatchTypedEvent("toolCallTaskUpdated", { taskId, task: completedTask, result: finalResult, }); this.dispatchTypedEvent("requestorTaskUpdated", { taskId, task: completedTask, result: finalResult, }); } break; case "error": { const errorMessage = message.error.message || "Task execution failed"; error = new Error(errorMessage); if (taskId) { // A user-cancelled task surfaces here as a generic error; report // it as "cancelled" (not "failed") so the UI lands on the true // terminal state immediately, matching what a refresh would show // (#1455). const cancelled = this.cancelledTaskIds.has(taskId); // Consume the marker — task ids are single-use, so this keeps the // set from growing across a long session of cancellations (the // disconnect-clear stays the backstop for cancels whose task // completed before the cancel landed and never hit this path). this.cancelledTaskIds.delete(taskId); const terminalTask: TaskWithOptionalCreatedAt = { taskId, ttl: null, status: cancelled ? "cancelled" : "failed", statusMessage: cancelled ? "Client cancelled task execution." : errorMessage, lastUpdatedAt: new Date().toISOString(), }; this.dispatchTypedEvent("toolCallTaskUpdated", { taskId, task: terminalTask, error: message.error, }); this.dispatchTypedEvent("requestorTaskUpdated", { taskId, task: terminalTask, error: message.error, }); } break; } } } // If we got an error, throw it if (error) { throw error; } // If we didn't get a result, something went wrong // This can happen if the task completed but result wasn't in the stream // Try to get it from the task result endpoint if (!finalResult && taskId) { try { finalResult = await this.client.request( { method: "tasks/result", params: { taskId } }, CallToolResultSchema, this.getRequestOptions(), // no metadata for fallback ); } catch (resultError) { throw new Error( `Tool call did not return a result: ${resultError instanceof Error ? resultError.message : String(resultError)}`, { cause: resultError }, ); } } if (!finalResult) { throw new Error("Tool call did not return a result"); } const invocation: ToolCallInvocation = { toolName: tool.name, params: args, result: finalResult, timestamp, success: true, metadata, }; this.dispatchTypedEvent("toolCallResultChange", { toolName: tool.name, params: args, result: invocation.result, timestamp, success: true, metadata, }); return invocation; } catch (error) { // Merge general metadata with tool-specific metadata for error case const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; const timestamp = new Date(); const metadata = this.mergeMeta(callMetadata); this.dispatchTypedEvent("toolCallResultChange", { toolName: tool.name, params: args, result: null, timestamp, success: false, error: error instanceof Error ? error.message : String(error), metadata, }); throw error; } } /** * List available resources with pagination support (stateless; state managers hold the list). * @param cursor Optional cursor for pagination * @param metadata Optional metadata to include in the request * @returns Object containing resources array and optional nextCursor */ async listResources( cursor?: string, metadata?: RequestMetadata, ): Promise<{ resources: Resource[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: ListResourcesRequest["params"] = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // `!== undefined`, not truthiness: a cursor is opaque and `""` is a // legal value a server may hand back. Dropping it asks for page one // again, so a caller walking pages would loop on the first page. ...(cursor !== undefined ? { cursor } : {}), }; const response = await this.invokeMcpClient(() => this.client!.request( { method: "resources/list", params }, ListResourcesResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), ); return { resources: response.resources || [], nextCursor: response.nextCursor, }; } /** * Aggregate ALL pages of `resources/list` via the SDK's high-level * cache-aware `client.listResources()`. See {@link listAllTools} for the * `cacheMode` semantics; this is the path the managed resource list uses on * refresh. */ async listAllResources(options?: { cacheMode?: CacheMode; metadata?: RequestMetadata; }): Promise<{ resources: Resource[] }> { if (!this.client) { throw new Error("Client is not connected"); } const resources = await this.listAllSalvaging({ method: "resources/list", itemsKey: "resources", resultSchema: ListResourcesResultSchema, itemSchema: ResourceSchema, metadata: options?.metadata, aggregate: async () => [ ...( await this.client!.listResources( this.aggregateListParams(options?.metadata), this.getCacheableRequestOptions(options?.cacheMode), ) ).resources, ], }); return { resources }; } /** * Read a resource by URI * @param uri Resource URI * @param metadata Optional metadata to include in the request * @returns Resource content */ async readResource( uri: string, metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: ReadResourceRequest["params"] = { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; // MRTR-driven (#1704): a modern `resources/read` can return `input_required` // (embedding an elicitation/sampling request); the driver pauses at the // pending-request UI and retries with the answer. Legacy is a single round. const result = await this.invokeMcpClient( () => this.requestWithInputRequired( "resources/read", params, ReadResourceResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "resources/read" }, ); const invocation: ResourceReadInvocation = { result, timestamp: new Date(), uri, metadata: effectiveMeta, }; this.dispatchTypedEvent("resourceContentChange", { uri, content: invocation, timestamp: invocation.timestamp, }); return invocation; } /** * Read a resource from a template by expanding the template URI with parameters * This encapsulates the business logic of template expansion and associates the * loaded resource with its template in InspectorClient state * @param templateName The name/ID of the resource template * @param params Parameters to fill in the template variables * @param metadata Optional metadata to include in the request * @returns The resource content along with expanded URI and template name * @throws Error if template is not found or URI expansion fails */ async readResourceFromTemplate( uriTemplate: string, params: Record, metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } const uriTemplateString = uriTemplate; // Expand through the shared helper in ./uriTemplate.js so this and the web // Resources form cannot disagree. It covers the expression shapes the SDK's // own expander gets wrong -- `{a,b}`, `{;id}`, and the `{id:3}` prefix // modifier (#1919). // // `params` is passed through AS GIVEN. Only an *absent* key omits its // expression; a key present with `""` is a defined RFC 6570 value and // expands (`{?q}` -> `?q=`, `{;q}` -> `;q`), which is what lets a caller // request those URIs deliberately. A caller whose values come from a form // -- where an untouched field is indistinguishable from a deliberately // empty one -- drops its blanks with `definedValues` first, as the TUI's // ResourceTestModal does. let expandedUri: string; try { expandedUri = expandUriTemplateStrict(uriTemplateString, params); } catch (error) { throw new Error( `Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } // Always fetch fresh content: Call readResource with expanded URI const readInvocation = await this.readResource(expandedUri, metadata); // Create the template invocation object. Use the merged metadata recorded // by readResource so the template-level history matches what was sent. const invocation: ResourceTemplateReadInvocation = { uriTemplate: uriTemplateString, expandedUri, result: readInvocation.result, timestamp: readInvocation.timestamp, params, metadata: readInvocation.metadata, }; this.dispatchTypedEvent("resourceTemplateContentChange", { uriTemplate: uriTemplateString, content: invocation, params, timestamp: invocation.timestamp, }); return invocation; } /** * List resource templates with pagination support (stateless; state managers hold the list). * @param cursor Optional cursor for pagination * @param metadata Optional metadata to include in the request * @returns Object containing resourceTemplates array and optional nextCursor */ async listResourceTemplates( cursor?: string, metadata?: RequestMetadata, ): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: ListResourceTemplatesRequest["params"] = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // `!== undefined`, not truthiness: a cursor is opaque and `""` is a // legal value a server may hand back. Dropping it asks for page one // again, so a caller walking pages would loop on the first page. ...(cursor !== undefined ? { cursor } : {}), }; const response = await this.invokeMcpClient( () => this.client!.request( { method: "resources/templates/list", params }, ListResourceTemplatesResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "resources/templates/list" }, ); return { resourceTemplates: response.resourceTemplates || [], nextCursor: response.nextCursor, }; } /** * Aggregate ALL pages of `resources/templates/list` via the SDK's high-level * cache-aware `client.listResourceTemplates()`. See {@link listAllTools} for * the `cacheMode` semantics; this is the path the managed resource-template * list uses on refresh. */ async listAllResourceTemplates(options?: { cacheMode?: CacheMode; metadata?: RequestMetadata; }): Promise<{ resourceTemplates: ResourceTemplate[] }> { if (!this.client) { throw new Error("Client is not connected"); } const resourceTemplates = await this.listAllSalvaging({ method: "resources/templates/list", itemsKey: "resourceTemplates", resultSchema: ListResourceTemplatesResultSchema, itemSchema: ResourceTemplateSchema, metadata: options?.metadata, aggregate: async () => [ ...( await this.client!.listResourceTemplates( this.aggregateListParams(options?.metadata), this.getCacheableRequestOptions(options?.cacheMode), ) ).resourceTemplates, ], }); return { resourceTemplates }; } /** * List available prompts with pagination support * @param cursor Optional cursor for pagination * @param metadata Optional metadata to include in the request * @returns Object containing prompts array and optional nextCursor */ async listPrompts( cursor?: string, metadata?: RequestMetadata, ): Promise<{ prompts: Prompt[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: ListPromptsRequest["params"] = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // `!== undefined`, not truthiness: a cursor is opaque and `""` is a // legal value a server may hand back. Dropping it asks for page one // again, so a caller walking pages would loop on the first page. ...(cursor !== undefined ? { cursor } : {}), }; const response = await this.invokeMcpClient(() => this.client!.request( { method: "prompts/list", params }, ListPromptsResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), ); return { prompts: response.prompts || [], nextCursor: response.nextCursor, }; } /** * Aggregate ALL pages of `prompts/list` via the SDK's high-level * cache-aware `client.listPrompts()`. See {@link listAllTools} for the * `cacheMode` semantics; this is the path the managed prompt list uses on * refresh. */ async listAllPrompts(options?: { cacheMode?: CacheMode; metadata?: RequestMetadata; }): Promise<{ prompts: Prompt[] }> { if (!this.client) { throw new Error("Client is not connected"); } const prompts = await this.listAllSalvaging({ method: "prompts/list", itemsKey: "prompts", resultSchema: ListPromptsResultSchema, itemSchema: PromptSchema, metadata: options?.metadata, aggregate: async () => [ ...( await this.client!.listPrompts( this.aggregateListParams(options?.metadata), this.getCacheableRequestOptions(options?.cacheMode), ) ).prompts, ], }); return { prompts }; } /** * The Skills extension (SEP-2640) the server declared, or `undefined` when it * declared none. Gates the Skills tab and the skills store the same way * {@link isTasksExtensionNegotiated} gates Tasks — but deliberately without an * era check: `skills/*` are not spec method names in either codec, so a * legacy-era server that declares the extension is serving it (#2234). */ getSkillsExtension(): SkillsExtensionSupport | undefined { return getSkillsExtension(this.capabilities); } /** * One page of `skills/list` (SEP-2640). * * An ordinary `client.request` with an explicit result schema — the SDK's era * gate skips methods neither codec defines, and `assertCapabilityForMethod` * falls through to a no-op for them, so this is all the extension needs. The * raw-wire path modern `tasks/*` uses would be wrong here: it exists for spec * names the 2026 codec deleted, and taking it would bypass the SDK's response * correlation for nothing (#2234). */ async listSkills( cursor?: string, metadata?: RequestMetadata, ): Promise<{ skills: SkillEntry[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: Record = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // `!== undefined`, not truthiness: a cursor is opaque and the empty // string is a legal value. Dropping `""` would silently re-request page // one, which the store then reports as a repeated-cursor failure — a // conforming server made to look broken. ...(cursor !== undefined ? { cursor } : {}), }; // Era-aware: a modern (2026-07-28+) `skills/list` result also carries the // base list envelope (`resultType` / `ttlMs` / `cacheScope`). The two halves // are checked in different places: the SDK codec checks `resultType` on // every modern result and removes it before this schema runs (#2373), but // `skills/*` is consumer-owned, so it checks neither caching attribute — // without picking the schema here a modern server could answer // `{ skills: [] }` and the conformance UI would show a clean list. Legacy // stays permissive: those are 2026-era attributes. const resultSchema = this.isModernEra() ? ModernListSkillsResultSchema : ListSkillsResultSchema; const response = await this.invokeMcpClient( () => this.client!.request( { method: SKILLS_LIST_METHOD, params }, resultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: SKILLS_LIST_METHOD }, ); return { skills: response.skills, nextCursor: response.nextCursor, }; } /** * One skill entry by URI (`skills/get`, SEP-2640). The result envelope is * required — `GetSkillEnvelopeSchema` requires `{ skill }` and rejects an entry * returned inline, so a non-conforming shape fails here rather than being * silently normalized past the conformance checks. */ async getSkill(uri: string, metadata?: RequestMetadata): Promise { return (await this.getSkillResult(uri, metadata)).skill; } /** * `skills/get` as the SDK decoded it — the `{ skill }` envelope **and any * other members the server sent**, such as the caching attributes. On a * modern connection `resultType` is not among them: the codec checks it and * removes it before the result gets here (#2373). * * Separate from {@link getSkill} because the callers differ: the UIs want the * entry, while the CLI prints the result and must not reshape it. SEP-2640 * explicitly leaves open whether this result carries `ttlMs` / `cacheScope`, * so a server may send them — and unwrapping to the entry discards exactly * those (Copilot). */ async getSkillResult( uri: string, metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: Record = { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; // One schema for both eras (#2373): on a modern connection the SDK codec // has already enforced `resultType` and lifted it off, and the caching // attributes are left open by SEP-2640, so there is nothing era-specific // left to require. The envelope is returned whole; `getSkill` unwraps. try { return await this.invokeMcpClient( () => this.client!.request( { method: SKILLS_GET_METHOD, params }, GetSkillEnvelopeSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: SKILLS_GET_METHOD }, ); } catch (err) { // Attribute a rejected envelope to the exchange it came from, so the // Protocol tab stops rendering it as a clean success while the Skills // screen shows an error — the same handling every managed list gets // (#1953). Done here rather than in a store because `skills/get` has // none: the screen calls it directly. Must happen in this catch, while // the correlation window is still current, and ONLY for a decode // rejection — a request that never produced a response would otherwise // stamp an earlier, successful exchange. if (isClientDecodeRejection(err)) { this.markResponseRejected( SKILLS_GET_METHOD, err instanceof Error ? err.message : String(err), ); } throw err; } } /** * One page of `resources/directory/read` (SEP-2640): the direct children of * a directory resource. * * **Gated on the server's own declaration, and the gate throws rather than * asks.** SEP-2640 is explicit that *"clients MUST NOT call * `resources/directory/read` against a server that has not declared * `directoryRead: true`"*, so this refuses locally instead of sending a call * the spec forbids and letting the server answer `-32601`. Refusing here also * keeps the Protocol tab honest: a request we were never allowed to make * should not appear in the exchange log as a server-side failure. * * Not recursive — the SEP says clients descend by calling the method again on * a child directory, so the walking is the caller's, not this method's. */ async readResourceDirectory( uri: string, cursor?: string, metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } const extension = this.getSkillsExtension(); if (!extension?.directoryRead) { throw new Error( `Server did not declare directoryRead in ${SKILLS_EXTENSION_KEY}; ${RESOURCES_DIRECTORY_READ_METHOD} must not be called.`, ); } const effectiveMeta = this.mergeMeta(metadata); const params: Record = { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), // `!== undefined` for the same reason `listSkills` uses it: a cursor is // opaque and `""` is a legal value, so truthiness would silently re-ask // for page one. ...(cursor !== undefined ? { cursor } : {}), }; // One schema for both eras (#2373): the SDK codec enforces and lifts // `resultType` on a modern connection, and SEP-2640 requires no caching // attributes of this method — see `skillsSchemas.ts` for why that differs // from `skills/list`. try { return await this.invokeMcpClient( () => this.client!.request( { method: RESOURCES_DIRECTORY_READ_METHOD, params }, DirectoryReadResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: RESOURCES_DIRECTORY_READ_METHOD }, ); } catch (err) { // Same attribution `getSkill` does, and for the same reason: there is no // managed store behind this method, so without this a rejected decode // would render in the Protocol tab as a clean success while the caller // showed an error. Only for a decode rejection — a request that never // produced a response would otherwise stamp an earlier exchange. if (isClientDecodeRejection(err)) { this.markResponseRejected( RESOURCES_DIRECTORY_READ_METHOD, err instanceof Error ? err.message : String(err), ); } throw err; } } /** * Get a prompt by name * @param name Prompt name * @param args Optional prompt arguments * @param metadata Optional metadata to include in the request * @returns Prompt content */ async getPrompt( name: string, args?: Record, metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } // Convert all arguments to strings for prompt arguments const stringArgs = args ? convertPromptArguments(args) : {}; const effectiveMeta = this.mergeMeta(metadata); const params: GetPromptRequest["params"] = { name, arguments: stringArgs, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; // MRTR-driven (#1704): a modern `prompts/get` can return `input_required`; // the driver pauses at the pending-request UI and retries with the answer. // Legacy is a single round. const result = await this.invokeMcpClient( () => this.requestWithInputRequired( "prompts/get", params, GetPromptResultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "prompts/get", toolName: name }, ); const invocation: PromptGetInvocation = { result, timestamp: new Date(), name, params: Object.keys(stringArgs).length > 0 ? stringArgs : undefined, metadata: effectiveMeta, }; this.dispatchTypedEvent("promptContentChange", { name, content: invocation, timestamp: invocation.timestamp, }); return invocation; } /** * Request completions for a resource template variable or prompt argument * @param ref Resource template reference or prompt reference * @param argumentName Name of the argument/variable to complete * @param argumentValue Current (partial) value of the argument * @param context Optional context with other argument values * @param metadata Optional metadata to include in the request * @returns Completion result with values array * @throws Error if client is not connected or request fails (except MethodNotFound) */ async getCompletions( ref: | { type: "ref/resource"; uri: string } | { type: "ref/prompt"; name: string }, argumentName: string, argumentValue: string, context?: Record, metadata?: RequestMetadata, ): Promise<{ values: string[]; total?: number; hasMore?: boolean }> { if (!this.client) { return { values: [] }; } try { const effectiveMeta = this.mergeMeta(metadata); const params: CompleteRequest["params"] = { ref, argument: { name: argumentName, value: argumentValue, }, ...(context ? { context: { arguments: context } } : {}), ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; const response = await this.invokeMcpClient( () => this.client!.complete( params, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "completion/complete", toolName: ref.type === "ref/prompt" ? ref.name : ref.uri, }, ); return { values: response.completion.values || [], total: response.completion.total, hasMore: response.completion.hasMore, }; } catch (error) { // Handle MethodNotFound gracefully (server doesn't support completions) if ( (error instanceof ProtocolError && error.code === ProtocolErrorCode.MethodNotFound) || (error instanceof Error && (error.message.includes("Method not found") || error.message.includes("does not support completions"))) ) { return { values: [] }; } // Re-throw other errors throw new Error( `Failed to get completions: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } } /** * Fetch server info (capabilities, serverInfo, instructions) from cached initialize response * This does not send any additional MCP requests - it just reads cached data * Always called on connect */ private async fetchServerInfo(): Promise { if (!this.client) { return; } try { // Get server capabilities (cached from initialize response) this.capabilities = this.client.getServerCapabilities(); this.dispatchTypedEvent("capabilitiesChange", this.capabilities); // Get server info (name, version) and instructions (cached from initialize response) this.serverInfo = this.client.getServerVersion(); this.instructions = this.client.getInstructions(); this.dispatchTypedEvent("serverInfoChange", this.serverInfo); if (this.instructions !== undefined) { this.dispatchTypedEvent("instructionsChange", this.instructions); } // Era model (SEP §7.8): the SDK Client owns negotiation and exposes the // outcome. `getProtocolEra()` is populated for every era once connected — // a plain legacy connect reports `"legacy"`. `getDiscoverResult()` is // populated only when "auto"/"modern" actually probed server/discover. this.protocolEra = this.client.getProtocolEra(); this.dispatchTypedEvent("protocolEraChange", this.protocolEra); this.discoverResult = this.client.getDiscoverResult(); this.dispatchTypedEvent("discoverResultChange", this.discoverResult); // The SDK's negotiated-version accessor works for both eras (the // initialize handshake on legacy, the discover/pin on modern), so it // supersedes the older MessageTrackingTransport capture. this.protocolVersion = this.client.getNegotiatedProtocolVersion(); this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion); } catch { // Ignore errors in fetching server info } } private dispatchStderrLog(entry: StderrLogEntry): void { this.dispatchTypedEvent("stderrLog", entry); } private dispatchFetchRequest(entry: FetchRequestEntry): void { this.logger.info( { component: "InspectorClient", category: entry.category, fetchRequest: { url: entry.url, method: entry.method, headers: entry.requestHeaders, body: entry.requestBody ?? "[no body]", }, fetchResponse: entry.error ? { error: entry.error } : { status: entry.responseStatus, statusText: entry.responseStatusText, headers: entry.responseHeaders, body: entry.responseBody, }, }, `${entry.category} fetch`, ); this.dispatchTypedEvent("fetchRequest", entry); } private dispatchFetchRequestBodyUpdate( id: string, responseBody: string, ): void { this.dispatchTypedEvent("fetchRequestBodyUpdate", { id, responseBody }); } private dispatchFetchRequestStreamUpdate( id: string, stream: FetchStreamState, ): void { this.dispatchTypedEvent("fetchRequestStreamUpdate", { id, stream }); // The diagnostics follow only the stream they are currently watching; // an update for an older one (a reconnected stream's predecessor // closing late) changes nothing they report. if (this.notificationStream?.id !== id) return; this.notificationStream = { ...this.notificationStream, eventCount: stream.eventCount, ...(stream.closedAt && { closedAt: stream.closedAt.getTime() }), }; this.dispatchConnectionDiagnosticsChange(); } /** * Adopt a transport fetch as the notification stream when it is one — a * `GET` answered with an unbounded event stream — so the diagnostics can * say how long it has been open and what it has delivered (#2318). The * newest such stream wins: the SDK reopens the standalone `GET` after a * drop, and the current one is the one a stalled request is queued behind. */ private noteTransportStream(entry: FetchRequestEntryBase): void { if ( !isLongLivedStreamResponse( entry.method, findHeader(entry.responseHeaders, "content-type"), ) ) { return; } this.notificationStream = { id: entry.id, url: entry.url, // The entry's `timestamp` is when the request went out; the stream is // open from when the response headers arrived, `duration` later. openedAt: entry.timestamp.getTime() + (entry.duration ?? 0), eventCount: 0, }; this.dispatchConnectionDiagnosticsChange(); } /** * What this client is still waiting on, when it last heard back, and the * state of the notification stream (#2318). A fresh snapshot per call — * the same one `annotateRequestTimeout` writes a timeout message from, and * the one `connectionDiagnosticsChange` carries. */ getConnectionDiagnostics(): ConnectionDiagnostics { return { capturedAt: Date.now(), // Copies, not the tracked records: the snapshot is handed to callers // and carried on errors, and a mutation there must not reach the // correlation state. outstandingRequests: [...this.outstandingRequests.values()].map( (request) => ({ ...request }), ), ...(this.lastResponse && { lastResponse: { ...this.lastResponse } }), ...(this.notificationStream && { notificationStream: this.publicStreamState(this.notificationStream), }), }; } /** The stream state minus the entry id, which is bookkeeping, not a fact. */ private publicStreamState( stream: TrackedNotificationStream, ): NotificationStreamState { const { id: _id, ...state } = stream; return state; } private dispatchConnectionDiagnosticsChange(): void { this.dispatchTypedEvent( "connectionDiagnosticsChange", this.getConnectionDiagnostics(), ); } /** * Get current session ID (from OAuth state authId) */ getSessionId(): string | undefined { return this.sessionId; } /** * Set session ID (typically extracted from OAuth state) */ setSessionId(sessionId: string): void { this.sessionId = sessionId; } /** * Dispatch saveSession so FetchRequestLogState (or other listeners) can persist. * Call before OAuth redirect; listeners use sessionStorage with this sessionId. */ saveSession(): void { if (!this.sessionId) return; this.dispatchTypedEvent("saveSession", { sessionId: this.sessionId }); } /** * Get current roots */ getRoots(): Root[] { return this.roots !== undefined ? [...this.roots] : []; } /** * Set roots and announce the change to the server. * * Note this does **not** enable the roots capability on a client that was * built without the constructor's `roots` option. `capabilities.roots` is * negotiated at `initialize` and the SDK refuses `registerCapabilities` * after connect, so such a client has no `roots/list` handler (see {@link * registerPeerRequestHandlers}) and would have to answer `-32601` if a * server asked. So on such a client the roots set here are stored and * readable via {@link getRoots}, but no server can ask for them and the * change is not announced — the SDK refuses `roots/list_changed` from a * client that never declared `roots.listChanged`, so the notification could * not have gone out anyway. Pass `roots` at construction — `[]` is enough — * in any client that may call this (#1797). * * The argument runs through `cleanRoots`, the same normalizer the * connect-time and settings-save paths use, so all three ways roots enter the * client agree and no caller can advertise a `Root` with no `uri` (the CLI's * `--roots-json` only checks that the JSON is an array). It is idempotent, so * a caller that already cleaned loses nothing. */ async setRoots(roots: Root[]): Promise { if (!this.client) { throw new Error("Client is not connected"); } this.roots = cleanRoots(roots); // Copy, as `getRoots()` does — a listener must not be able to push into the // list we advertise. this.dispatchTypedEvent("rootsChange", [...this.roots]); // The *server* needn't advertise support for this notification, but the // *client* must have declared `roots.listChanged` to send it. The SDK // enforces that itself — `notification()` rejects with "Client does not // support roots list changed notifications" — so nothing reaches the wire // on a client built without `roots`, and the server is never invited to // re-fetch something we'd answer `-32601`. Returning early only avoids // provoking that rejection and logging it as a *failure*: it isn't one, it // is a client that was never able to announce (#1797). if (!this.rootsListChangedCapabilityAdvertised) { this.logger.warn( "setRoots() on a client that did not advertise `roots.listChanged`; " + "roots are stored locally but the change is not announced", ); return; } try { await this.client.notification({ method: "notifications/roots/list_changed", }); } catch (error) { // Log but don't throw - roots were updated locally even if notification failed this.logger.error( { error }, "Failed to send roots/list_changed notification", ); } } /** * Get list of currently subscribed resource URIs */ getSubscribedResources(): string[] { return Array.from(this.subscribedResources); } /** * Check if a resource is currently subscribed */ isSubscribedToResource(uri: string): boolean { return this.subscribedResources.has(uri); } /** * Check if the server supports resource subscriptions */ supportsResourceSubscriptions(): boolean { return this.capabilities?.resources?.subscribe === true; } /** * The negotiated protocol era once connected (SEP §7.8). Modern (2026-07-28) * connections manage resource subscriptions through a `subscriptions/listen` * stream instead of `resources/subscribe`; every other era is legacy. */ private isModernEra(): boolean { return this.protocolEra === "modern"; } /** * Current state of the modern-era `subscriptions/listen` stream (#1630). * `active: false` on the legacy era (there is no persistent stream). */ getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState { return this.modernStreamState; } private setModernStreamState(next: ResourceSubscriptionStreamState): void { this.modernStreamState = next; this.dispatchTypedEvent("resourceSubscriptionStreamChange", next); } private dispatchSubscriptionsChange(): void { this.dispatchTypedEvent( "resourceSubscriptionsChange", Array.from(this.subscribedResources), ); } /** * The `subscriptions/listen` filter for the current modern subscriptions: * the subscribed URIs, plus the list-change opt-ins the Inspector already * tracks (config ∩ server capability) so the single stream also carries * list-change notifications — the spec models one listen stream for every * opted-in notification type (SEP §7.4). */ private buildSubscriptionFilter(): SubscriptionFilter { const filter: SubscriptionFilter = {}; // Omitted rather than sent empty: a listChanged-only stream (#1920) is not // subscribing to any resource, and `[]` would say it asked for none of a set // it is participating in. if (this.subscribedResources.size > 0) { filter.resourceSubscriptions = Array.from(this.subscribedResources); } if ( this.listChangedNotifications.tools && this.capabilities?.tools?.listChanged ) { filter.toolsListChanged = true; } if ( this.listChangedNotifications.resources && this.capabilities?.resources?.listChanged ) { filter.resourcesListChanged = true; } if ( this.listChangedNotifications.prompts && this.capabilities?.prompts?.listChanged ) { filter.promptsListChanged = true; } return filter; } /** * Whether the modern listen stream should be open: the built filter carries * something to listen for (#1920). Before #1920 this was "at least one URI is * subscribed", which made the stream unreachable on a server with no resources * — a tools-only server advertising `tools.listChanged` had no way to open it, * so `notifications/tools/list_changed` could never arrive. The filter already * modelled the list-change opt-ins; only the trigger was narrower than the * filter it built. This matches the SDK's own `ClientOptions.listChanged` * auto-open, which opens whenever the effective (config ∩ capability) * intersection is non-empty. * * Note this is deliberately *not* the same predicate as the stream state's * `active` — see `modernStreamActive()`. */ private wantsModernStream(): boolean { const filter = this.buildSubscriptionFilter(); return ( (filter.resourceSubscriptions?.length ?? 0) > 0 || filter.toolsListChanged === true || filter.resourcesListChanged === true || filter.promptsListChanged === true ); } /** * Whether the stream state reports `active` — i.e. whether the *Subscriptions* * UI has a stream to describe. That is a narrower question than * `wantsModernStream()`: `ResourceSubscriptionStreamState` drives the * Subscriptions section's badge, so a stream open purely for list-change * notifications (no subscribed URI) has nothing to report there and stays * `active: false` (#1920). Keeping the two apart also preserves the invariant * the rest of this file is written against — an empty subscribed set is never * announced alongside an `active` stream. */ private modernStreamActive(): boolean { return this.subscribedResources.size > 0; } /** * Open the modern listen stream at the end of a successful connect, when the * filter is non-empty (#1920). Only the list-change opt-ins can make it * non-empty here — the subscribed set is emptied by `resetSessionState()` on * the way in — so this is exactly the "server advertises a listChanged the * Inspector wants" case that had no trigger before. * * A failure is not allowed to fail the connect: the handshake succeeded and * every request-scoped feature works without this stream. It is reported the * way a lost stream is — hand it to the reconnect machinery, which retries * with backoff and settles on `"ended"` past the cap. * * Gated on the same generation test as `subscribeToResource` — see the long * comment there. The `connect` event has deliberately *not* been dispatched * yet (that is the point of running here), so a list-state consumer is not the * risk; what is, is anything else that bumps the generation while this * `listen()` is in flight. `statusChange` has already fired, and any * concurrent call on this instance qualifies: a `subscribeToResource` from a * caller restoring subscriptions, or a `disconnect()` — whose * `resetSubscriptionStream` bumps the generation too, making a reconcile here * arm a reconnect for a session that is already gone. */ private async openModernListenStreamOnConnect(): Promise { if (!this.isModernEra() || !this.wantsModernStream()) return; const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { this.logger.error( { error }, "Failed to open the modern subscriptions/listen stream on connect", ); if (this.modernListenGeneration === generationBefore + 1) { this.reconcileModernStreamStateAfterFailedRefresh(); } } } /** Cancel a pending reconnect re-listen, if any (#1630). */ private clearModernReconnectTimer(): void { if (this.modernReconnectTimer !== undefined) { clearTimeout(this.modernReconnectTimer); this.modernReconnectTimer = undefined; } } /** * (Re-)establish the modern `subscriptions/listen` stream to match the current * filter (#1630). Because the stream is not resumable, every filter change * re-lists: the existing stream is closed and a fresh `listen()` opened. With * an empty filter — no subscribed URIs *and* no enabled list-change opt-in the * server advertises — the stream is left closed (#1920). * * `modernListenGeneration` guards against races — if a newer refresh starts * while this one awaits its acknowledgement, the just-opened stream is * discarded rather than overwriting the newer one. */ private async refreshModernSubscription( fromReconnect = false, ): Promise { if (!this.client) return; // A user-initiated (subscribe/unsubscribe) refresh is a clean slate: clear // any pending reconnect and reset the backoff run so the next drop starts // from the base delay. if (!fromReconnect) { this.clearModernReconnectTimer(); this.modernReconnectAttempts = 0; this.modernNeverAcknowledged = false; } const generation = ++this.modernListenGeneration; // Tear down the current stream before opening a replacement (re-listen). const previous = this.modernSubscription; this.modernSubscription = null; if (previous) { await closeSubscriptionBestEffort(previous); } // Nothing to listen for → keep the stream closed. if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } let subscription: McpSubscription; try { subscription = await this.client.listen( this.buildSubscriptionFilter(), this.getRequestOptions(), ); } catch (error) { // Record the one rejection that must not be retried, so the failure // handlers can tell it from a drop (#2097). Recorded rather than acted on // here because *which* state to write depends on whether this caller still // owns the stream, which only they know — see // `reconcileModernStreamStateAfterFailedRefresh`. // // Gated on the same generation test the callers use: a newer refresh has // already cleared the flag for its own attempt, and a stale caller writing // to it afterwards would make that attempt's *unrelated* failure look // deterministic and end a stream that deserved a retry. if ( generation === this.modernListenGeneration && isNeverAcknowledgedSubscriptionClose(error) ) { this.modernNeverAcknowledged = true; } throw error; } // A newer refresh superseded us while awaiting the ack — discard this one. if (generation !== this.modernListenGeneration) { await closeSubscriptionBestEffort(subscription); return; } this.modernSubscription = subscription; // A successful acknowledgement ends any reconnect run: the backoff counts // only *consecutive* failed re-lists, so a stream that recovers and holds // starts fresh next time (#1630). this.modernReconnectAttempts = 0; this.setModernStreamState({ active: this.modernStreamActive(), status: "acknowledged", honoredUris: subscription.honoredFilter.resourceSubscriptions ?? [], }); // Observe termination; an unexpected drop reconnects by re-listing. void subscription.closed.then( (reason) => this.onModernSubscriptionClosed(subscription, reason, generation), // The rejection arm exists because a `closed` that rejects carries no // reason to act on, and an unhandled rejection ends a Node process by // default. It is scoped to the rejection rather than chained after the // handler because the handler cannot throw today — it only assigns state, // dispatches on a native `EventTarget` (which reports listener throws // rather than propagating them) and arms a timer — and chaining a // `.catch` after it would silently abandon a re-listen if that ever // changed. (Closing a stream *resolves* `closed`; what the connect-path // close newly reaches is the handler running at all, where the reference // used to be dropped with `closed` pending forever.) () => {}, ); } /** * Handle termination of a modern listen stream (#1630). `"remote"` is an * unexpected drop — reconnect by re-listing (no resumability, so the re-listen * re-establishes the full filter). `"local"` (we closed it) and `"graceful"` * (server shutdown) are expected and leave the stream ended. */ private onModernSubscriptionClosed( subscription: McpSubscription, reason: "local" | "graceful" | "remote", generation: number, ): void { // Ignore a superseded stream (a newer refresh already replaced it). if ( generation !== this.modernListenGeneration || this.modernSubscription !== subscription ) { return; } this.modernSubscription = null; const shouldReconnect = reason === "remote" && !isTerminalStatus(this.status) && this.wantsModernStream(); if (!shouldReconnect) { // "stream gone but subscriptions remain" renders the same whether we gave // up after failed reconnects or the server closed it gracefully: keep the // ended badge while URIs are still subscribed. (A `disconnect()` clears // the set and forces the inactive state; a *crash* leaves both in place // until the next `connect()` calls `resetSubscriptionStream`, which moves // them together — so "active with an empty set" is never observable.) this.setModernStreamState({ active: this.modernStreamActive(), status: "ended", honoredUris: [], }); return; } // A drop of an established stream is not itself a failure — schedule a // re-listen at the current backoff (0 after a healthy stream, so the base // delay). The counter only advances when a re-listen actually fails. this.scheduleModernReconnect(); } /** * Reconcile the stream state after a re-listen *this* call owned and lost * (#1797). Only correct for a caller that has not been superseded — a newer * refresh owns the state as well as the filter — so both call sites gate on * the generation first. * * The two branches are the empty and non-empty *filter* (#1920) — which is * "nothing subscribed" only when no list-change opt-in is live. The empty case * is the ordinary one: nothing to listen for, no stream, inactive. The * non-empty one exists because a failed re-listen leaves `modernSubscription` * null with the filter still wanting a stream, and nothing else will notice: the reconnect machinery is reachable only from a stream that closed * or a reconnect that failed, and neither happened here. Left alone, the state * keeps whatever the last success (or the optimistic `"connecting"`) wrote — a * badge that will never change over subscriptions the server may never have * honored, recoverable only by an Unsubscribe/Subscribe toggle (a fresh * Subscribe early-returns on the URI already being in the set). * * So it reconnects rather than settling for an honest-but-dead `"ended"`: * every other route to "stream gone, filter live" either expects the close or * has exhausted the retry cap, and this is the one that has made no attempt * at all. `scheduleModernReconnect` fits as-is — a user-initiated refresh * already reset `modernReconnectAttempts`, so it starts at the base delay; the * timer bails on a terminal status or an emptied filter; and past the cap * `onModernReconnectFailed` lands on the same `"ended"` badge. The state * therefore becomes true or ends after a real attempt. The caller still sees * its error either way — the retry is about the subscriptions, not the call. */ private reconcileModernStreamStateAfterFailedRefresh(): void { if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } // The one failure a retry cannot fix (#2097) — end the stream here rather // than spending the whole backoff run on a server that will answer the same // way every time. if (this.modernNeverAcknowledged) { this.endModernStreamNeverAcknowledged(); return; } this.scheduleModernReconnect(); } /** * Settle the stream on the never-acknowledged close (#2097): a status of its * own so the UI can say what happened, no reconnect, and a log line carrying * the same sentence the UI shows. * * The state is *not* `"ended"` — that badge covers the two expected closes (a * server shutting an established stream down, and reconnection abandoned after * repeated failures), and reading this case as either of them is exactly the * silence #2063 reported. */ private endModernStreamNeverAcknowledged(): void { this.clearModernReconnectTimer(); this.logger.warn( { message: NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE }, "subscriptions/listen closed without an acknowledgement", ); this.setModernStreamState({ active: this.modernStreamActive(), status: "never-acknowledged", honoredUris: [], }); } /** * Schedule a reconnect re-listen after the current backoff delay (#1630). * `modernReconnectAttempts` reflects the number of *consecutive failed* * re-lists (reset to 0 on any successful acknowledgement), so the delay grows * only while re-listing keeps failing. */ private scheduleModernReconnect(): void { this.setModernStreamState({ active: this.modernStreamActive(), status: "reconnecting", honoredUris: [], }); const delay = Math.min( MODERN_RECONNECT_BASE_MS * 2 ** this.modernReconnectAttempts, MODERN_RECONNECT_MAX_MS, ); this.clearModernReconnectTimer(); this.modernReconnectTimer = setTimeout(() => { this.modernReconnectTimer = undefined; // Disconnect/unsubscribe may have raced the timer — bail if the reconnect // is no longer wanted. if (isTerminalStatus(this.status) || !this.wantsModernStream()) { return; } this.refreshModernSubscription(true).catch(() => this.onModernReconnectFailed(), ); }, delay); } /** * A reconnect re-listen failed (#1630). Count it and either retry with a * longer backoff or, past the consecutive-failure cap, give up and mark the * stream ended (re-subscribing resets the run and tries again). */ private onModernReconnectFailed(): void { // A reconnect that lost to the never-acknowledged close (#2097) ends the run // immediately: the remaining attempts would each buy the same answer. This // is reachable when a stream that *had* been acknowledged dropped and the // server has since started refusing to acknowledge. if ( this.modernNeverAcknowledged && !isTerminalStatus(this.status) && this.wantsModernStream() ) { this.endModernStreamNeverAcknowledged(); return; } this.modernReconnectAttempts += 1; if ( this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS || isTerminalStatus(this.status) || !this.wantsModernStream() ) { this.setModernStreamState({ active: this.modernStreamActive(), status: "ended", honoredUris: [], }); return; } this.scheduleModernReconnect(); } /** * Subscribe to a resource to receive update notifications. * * Legacy era: sends `resources/subscribe`. Modern era (2026-07-28): adds the * URI to the `subscriptions/listen` filter and re-lists (#1630). In both eras * `notifications/resources/updated` is delivered through the same handler. * * @param uri - The URI of the resource to subscribe to * @throws Error if client is not connected or server doesn't support subscriptions */ async subscribeToResource(uri: string): Promise { if (!this.client) { throw new Error("Client is not connected"); } if (!this.supportsResourceSubscriptions()) { throw new Error("Server does not support resource subscriptions"); } try { if (this.isModernEra()) { // Already subscribed → the filter is unchanged, so skip the re-listen // (which would needlessly tear down and reopen the server stream). if (this.subscribedResources.has(uri)) return; this.subscribedResources.add(uri); // Reflect the subscription optimistically so the UI responds to the // click immediately, and show the stream as "connecting" until the // `listen()` acknowledgement lands (which can be a visible round-trip on // the modern era, unlike the single-shot legacy `resources/subscribe`). this.dispatchSubscriptionsChange(); this.setModernStreamState({ active: true, status: "connecting", honoredUris: this.modernStreamState.honoredUris, }); // Read before the call, to tell "our refresh failed" from "someone else // took over" in the catch — see there. const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { // Roll back the optimistic add + stream state so both stay consistent // with the server filter — but only while this call still owns that // filter. A refresh bumps the generation exactly once, synchronously, // so anything past that bump means something else advanced it, and // both bumpers make the rollback wrong: // // - a newer `refreshModernSubscription` built its filter from the set // *including* this URI, so if it succeeded the server is honoring // the subscription; deleting it here would leave the set missing a // URI the live stream carries, the UI showing it unsubscribed while // its `resources/updated` keep arriving. // - `resetSubscriptionStream` (a `disconnect()`, or the start-clean // reset in `connect()`) already cleared the set and set the state, // so there is nothing to roll back — and if the caller has since // re-subscribed on the new session, this stale catch would delete a // URI that session legitimately holds. // // Either way the state is no longer ours to correct. The error is // still the caller's to see. if (this.modernListenGeneration === generationBefore + 1) { // State before the announce, for `resetSubscriptionStream`'s // reason: on a last-URI rollback, dispatching first would expose an // empty set with a stream still reading `"connecting"` — the pair // this file treats as impossible. The reverse intermediate is the // benign one (and the optimistic add above already exposes it). this.subscribedResources.delete(uri); this.reconcileModernStreamStateAfterFailedRefresh(); this.dispatchSubscriptionsChange(); } throw error; } return; } await this.client.subscribeResource({ uri }, this.getRequestOptions()); this.subscribedResources.add(uri); this.dispatchSubscriptionsChange(); } catch (error) { throw new Error( `Failed to subscribe to resource: ${subscriptionFailureMessage(error)}`, { cause: error }, ); } } /** * Unsubscribe from a resource. * * Legacy era: sends `resources/unsubscribe`. Modern era: drops the URI from * the `subscriptions/listen` filter and re-lists (closing the stream once the * last URI is removed) (#1630). * * @param uri - The URI of the resource to unsubscribe from * @throws Error if client is not connected */ async unsubscribeFromResource(uri: string): Promise { if (!this.client) { throw new Error("Client is not connected"); } try { if (this.isModernEra()) { // Not subscribed → the filter is unchanged, so skip the re-listen. if (!this.subscribedResources.delete(uri)) return; // The removal is the user's intent; keep it even if the re-listen fails // (the stale URI simply lingers in the server's honored filter). // // Removing the last URI moves the stream to inactive before announcing // the set, rather than letting the re-listen below do it a round-trip // later: dispatching first would expose an empty set with an `active` // stream, which is the pair `resetSubscriptionStream` orders its own // writes to prevent. The re-listen sets the same state on arrival, and // sets it on the failure path too (see the catch). if (this.subscribedResources.size === 0) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); } this.dispatchSubscriptionsChange(); // Same ownership test as `subscribeToResource` — see the long comment // there. Nothing to undo on this path (the removal is kept // deliberately), but the *state* still needs reconciling when this call // owned the re-listen that failed: it leaves no stream behind, and the // badge would otherwise keep reporting the last success. const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { if (this.modernListenGeneration === generationBefore + 1) { this.reconcileModernStreamStateAfterFailedRefresh(); } throw error; } } else { await this.client.unsubscribeResource( { uri }, this.getRequestOptions(), ); this.subscribedResources.delete(uri); this.dispatchSubscriptionsChange(); } } catch (error) { throw new Error( `Failed to unsubscribe from resource: ${subscriptionFailureMessage(error)}`, { cause: error }, ); } } // ============================================================================ // OAuth Support (delegated to oauthManager) // ============================================================================ private ensureOAuthManager(): OAuthManager { if (!this.oauthManager) { throw new Error("OAuth not configured. Call setOAuthConfig() first."); } return this.oauthManager; } /** * Get server URL from transport config (full URL including path, for OAuth discovery) */ private getServerUrl(): string { if ( this.transportConfig.type === "sse" || this.transportConfig.type === "streamable-http" ) { return this.transportConfig.url; } // Stdio transports don't have a URL - OAuth not applicable throw new Error( "OAuth is only supported for HTTP-based transports (SSE, streamable-http)", ); } /** * Set OAuth configuration */ setOAuthConfig(config: { clientId?: string; clientSecret?: string; clientMetadataUrl?: string; scope?: string; /** Custom authorization-request parameters (#2018). Authorize URL only. */ authorizationParams?: Record; /** Endpoint overrides applied to the discovered AS metadata (#1906). */ authorizationUrl?: string; tokenUrl?: string; /** Declare the `refresh_token` grant in client metadata (#2068). */ requestRefreshToken?: boolean; }): void { if (!this.oauthManager) { throw new Error( "OAuth config must be set at creation. Pass oauth in constructor.", ); } this.oauthManager.setOAuthConfig(config); } /** * Initiates OAuth flow. Can be called directly by user or automatically * triggered by 401 errors. */ async authenticate(): Promise { return this.ensureOAuthManager().authenticate(); } /** * Satisfy a mid-session auth challenge (token refresh, step-up, or interactive re-auth). */ async handleAuthChallenge( challenge: AuthChallenge, options?: HandleAuthChallengeOptions, ): Promise { return this.ensureOAuthManager().handleAuthChallenge(challenge, options); } /** * Re-read OAuth storage and test whether a challenge is already satisfied. * See {@link OAuthManager.checkAuthChallengeSatisfied}. */ async checkAuthChallengeSatisfied( challenge: AuthChallenge, ): Promise { return this.ensureOAuthManager().checkAuthChallengeSatisfied(challenge); } /** * Push recovered OAuth auth state to the remote backend (same MCP session). */ async pushRemoteAuthState(): Promise { if (!(this.baseTransport instanceof RemoteClientTransport)) { return; } await this.baseTransport.pushAuthState(); } /** * Handle an ambient (SSE) auth challenge when no command-scoped send is active. * Recovers session tokens on the remote backend; does not retry RPCs. */ async handleAmbientAuthChallenge(challenge: AuthChallenge): Promise { const key = this.ambientAuthChallengeKey(challenge); const existing = this.ambientAuthChallengeInFlight.get(key); if (existing) { return existing; } const promise = this.runAmbientAuthChallenge(challenge); this.ambientAuthChallengeInFlight.set(key, promise); try { await promise; } finally { if (this.ambientAuthChallengeInFlight.get(key) === promise) { this.ambientAuthChallengeInFlight.delete(key); } } } private async runAmbientAuthChallenge( challenge: AuthChallenge, ): Promise { try { this.dispatchTypedEvent("authChallengeAmbient", { challenge }); const oauthManager = this.oauthManager; if (!oauthManager) { return; } const outcome = await oauthManager.handleAuthChallenge(challenge); if (outcome.kind === "satisfied") { if (this.baseTransport instanceof RemoteClientTransport) { await this.pushRemoteAuthState(); } else { await this.reconnectAfterAuthRecovery(); } this.dispatchTypedEvent("authChallengeRecovered", { challenge }); } else if (outcome.kind === "step_up_confirm") { this.dispatchTypedEvent("authChallengeInteractive", { challenge: outcome.challenge, authorizationUrl: EMA_STEP_UP_PENDING_URL, }); } else if (outcome.kind === "interactive") { this.dispatchTypedEvent("authChallengeInteractive", { challenge: outcome.challenge, authorizationUrl: outcome.authorizationUrl, }); } else { this.dispatchTypedEvent("oauthError", { error: outcome.error }); } } catch (error) { this.dispatchTypedEvent("oauthError", { error: error instanceof Error ? error : new Error(String(error)), }); } } private ambientAuthChallengeKey(challenge: AuthChallenge): string { const requiredScopes = [...(challenge.requiredScopes ?? [])] .sort() .join(" "); const authorizationScopes = [...(challenge.authorizationScopes ?? [])] .sort() .join(" "); return `${challenge.reason}:${requiredScopes}:${authorizationScopes}`; } /** * Full disconnect + reconnect after ambient auth recovery on direct transports. */ private async reconnectAfterAuthRecovery(): Promise { await this.disconnect().catch(() => {}); await this.dropCachedTransport(); await this.connect(); } /** Direct (non-remote) OAuth transports recover via fetch intercept + handleAuthChallenge. */ private usesDirectAuthRecovery(): boolean { return this.directAuthRecovery && this.directAuthRecoveryActive === true; } /** * True when connect() sends the SDK's `server/discover` negotiation probe — * i.e. `protocolEra` is "auto", or "modern" (`{ pin: MODERN_PROTOCOL_VERSION }`). * Legacy is the default for an absent `mode`, matching the SDK. * * `versionNegotiation` is a public option, so a caller can pin a revision the * repo's own {@link eraToVersionNegotiation} never produces (it only ever * pins {@link MODERN_PROTOCOL_VERSION}). That is still "probing" and * deliberately reports true: per the SDK, *any* `{ pin }` sends the * connect-time `server/discover` — "the connect-time `server/discover` must * offer it. No fallback" — so the pinned revision doesn't change whether the * probe (and hence the buried-401 problem) happens. Only "legacy" skips it. */ private probesProtocolEra(): boolean { const mode = this.versionNegotiation.mode; return mode !== undefined && mode !== "legacy"; } private async withDirectAuthRecovery( operation: (attempt: number) => Promise, context?: { method?: string; toolName?: string }, attempt = 0, ): Promise { try { // `attempt` is passed through so an operation can tell the first leg from // the post-recovery retry leg — `connect()` needs that distinction, and // live connection status is a racy proxy for it. return await operation(attempt); } catch (err) { if (attempt >= 1 || !this.usesDirectAuthRecovery()) { throw err; } if (!isAuthChallengeError(err)) { throw err; } if ( this.authRecoveryDepth >= InspectorClient.MAX_NESTED_AUTH_RECOVERIES ) { // Refreshed credentials are not satisfying the server: recovering again // would just re-enter the nested connect() below. Surface the challenge. throw err; } const challenge = parseAuthChallengeFromError(err, context); /* v8 ignore next 3 -- defensive: parseAuthChallengeFromError shares isAuthChallengeError's checks, so it always returns a truthy challenge once that guard passes */ if (!challenge) { throw err; } if (context?.method || context?.toolName) { this.dispatchTypedEvent("authChallengeCommand", { challenge }); } else { this.dispatchTypedEvent("authChallengeAmbient", { challenge }); } const outcome = await this.handleAuthChallenge(challenge); if (outcome.kind === "satisfied") { // Reconnect aborts activeToolCallAbortController; clear it so callTool // retries are not immediately rejected with "Disconnected". if (this.activeToolCallAbortController) { this.activeToolCallAbortController = undefined; } this.authRecoveryDepth += 1; try { await this.reconnectAfterAuthRecovery(); } finally { this.authRecoveryDepth -= 1; } this.dispatchTypedEvent("authChallengeRecovered", { challenge }); return this.withDirectAuthRecovery(operation, context, attempt + 1); } if (outcome.kind === "step_up_confirm") { throw new AuthRecoveryRequiredError( EMA_STEP_UP_PENDING_URL, outcome.challenge, { emaStepUpConfirm: true }, ); } if (outcome.kind === "interactive") { throw new AuthRecoveryRequiredError( outcome.authorizationUrl, outcome.challenge, ); } this.dispatchTypedEvent("oauthError", { error: outcome.error }); throw outcome.error; } } private async invokeMcpClient( operation: (attempt: number) => Promise, context?: { method?: string; toolName?: string }, ): Promise { if (!this.usesDirectAuthRecovery()) { return operation(0); } return this.withDirectAuthRecovery(operation, context); } /** * Completes OAuth flow with authorization code from the redirect callback. * Direct transports reconnect after token exchange so the live MCP session * picks up the new Bearer token (mirrors silent recovery reconnect). */ async completeOAuthFlow( authorizationCode: string, iss?: string, ): Promise { await this.ensureOAuthManager().completeOAuthFlow(authorizationCode, iss); if (this.usesDirectAuthRecovery()) { await this.reconnectAfterAuthRecovery(); } } /** * Navigate to the authorization server for interactive recovery. */ async beginInteractiveAuthorization(authorizationUrl: URL): Promise { return this.ensureOAuthManager().beginInteractiveAuthorization( authorizationUrl, ); } /** Remote Hono session id when using {@link RemoteClientTransport}. */ getRemoteBackendSessionId(): string | undefined { if (this.baseTransport instanceof RemoteClientTransport) { return this.baseTransport.getRemoteBackendSessionId(); } return undefined; } /** * Finish OAuth after a full-page redirect and reconnect (or reattach) the MCP session. */ async resumeAfterOAuth( authorizationCode: string, options?: { remoteSessionId?: string; iss?: string }, ): Promise { await this.completeOAuthFlow(authorizationCode, options?.iss); const remoteSessionId = options?.remoteSessionId; const transport = this.baseTransport; if (remoteSessionId && transport instanceof RemoteClientTransport) { try { await transport.attachToSession(remoteSessionId); await transport.pushAuthState(); if (this.status !== "connected") { await this.connect(); } return; } catch { // Session expired during OAuth round trip — fall back to fresh connect. } } if (this.status !== "connected") { await this.connect(); } } /** * Gets current OAuth tokens (if authorized) */ async getOAuthTokens(): Promise { if (!this.oauthManager) { return undefined; } return this.oauthManager.getOAuthTokens(); } /** * Drop this server's stored OAuth state and revoke the grant at the * authorization server (RFC 7009, #2144). * * The request is planned from the stored state, the state is cleared, and * only then is the request sent — so the clear never waits on the network. * * The revocation is best-effort — an authorization server that advertises no * `revocation_endpoint` is left behaving exactly as before, and a network * error, a non-2xx or a timeout is reported in the returned outcome rather * than thrown — so the local clear always completes. Pass * `{ revoke: false }` to skip it. */ async clearOAuthTokens(options?: { revoke?: boolean; }): Promise { return ( (await this.oauthManager?.clearOAuthTokens(options)) ?? { status: "skipped", reason: "no_tokens", } ); } /** * Checks if client is currently OAuth authorized */ async isOAuthAuthorized(): Promise { if (!this.oauthManager) { return false; } return this.oauthManager.isOAuthAuthorized(); } /** * In-memory OAuth flow snapshot. Undefined when no flow has run on this * client instance; use {@link getOAuthState} for persisted authorization state. */ getOAuthFlowState(): OAuthFlowState | undefined { return this.oauthManager?.getOAuthFlowState(); } /** Current step when an OAuth flow is active. */ getOAuthFlowStep(): OAuthStep | undefined { return this.oauthManager?.getOAuthFlowStep(); } /** * Persisted OAuth authorization snapshot for this HTTP server (storage + * config). Undefined for stdio transports or when OAuth is not configured. */ async getOAuthState(): Promise { if (!this.isHttpOAuthConfig() || !this.oauthManager) { return undefined; } return this.oauthManager.getOAuthState(); } }