import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { MCPServerConfig, StderrLogEntry, ConnectionStatus, MessageEntry, MessageOrigin, FetchRequestEntry, FetchRequestEntryBase, InspectorServerSettings, ResourceReadInvocation, ResourceTemplateReadInvocation, PromptGetInvocation, ToolCallInvocation, AppRendererClient, InspectorClientOptions, } from "./types.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"; // v2 doesn't have a core/package.json (the package isn't published independently), // so we hardcode the client identity that v1.5 read from corePackageJson. const corePackageJson = { name: "@modelcontextprotocol/inspector-core", version: "0.20.0", } as const; import type { CreateTransport, CreateTransportOptions, ServerType, } from "./types.js"; import { MessageTrackingTransport, type MessageTrackingCallbacks, } from "./messageTrackingTransport.js"; import type { CallToolRequest, JSONRPCRequest, JSONRPCNotification, JSONRPCResultResponse, JSONRPCErrorResponse, ServerCapabilities, ClientCapabilities, Implementation, LoggingLevel, Tool, Resource, ResourceTemplate, Prompt, Root, CreateMessageResult, ElicitRequest, ElicitResult, ElicitRequestURLParams, CallToolResult, Task, Progress, ProgressToken, ListToolsRequest, ListResourcesRequest, ListResourceTemplatesRequest, ListPromptsRequest, ReadResourceRequest, GetPromptRequest, CompleteRequest, } from "@modelcontextprotocol/sdk/types.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { RequestOptions, ProgressCallback, } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { CreateMessageRequestSchema, ElicitRequestSchema, EmptyResultSchema, ListRootsRequestSchema, ElicitationCompleteNotificationSchema, RootsListChangedNotificationSchema, ToolListChangedNotificationSchema, ResourceListChangedNotificationSchema, PromptListChangedNotificationSchema, ResourceUpdatedNotificationSchema, CallToolResultSchema, McpError, ErrorCode, ListTasksRequestSchema, GetTaskRequestSchema, GetTaskPayloadRequestSchema, CancelTaskRequestSchema, TaskStatusNotificationSchema, } from "@modelcontextprotocol/sdk/types.js"; import type { ClientResult } from "@modelcontextprotocol/sdk/types.js"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; import { validateToolOutput } from "./toolOutputValidation.js"; import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js"; import { type JsonValue, convertToolParameters, convertPromptArguments, } from "../json/jsonUtils.js"; import { UriTemplate } from "@modelcontextprotocol/sdk/shared/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 type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; import { createFetchTracker } from "./fetchTracking.js"; import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; /** 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; } /** * 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 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"; /** * 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?: Record; toolSpecificMetadata?: Record; taskOptions?: { ttl?: number }; options?: { skipOutputValidation?: boolean }; } /** * 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 { private client: Client | null = null; private appRendererClientProxy: AppRendererClient | 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; /** True when the cached transport was built with an OAuth authProvider attached. */ private transportHasAuthProvider = false; private pipeStderr: boolean; private initialLoggingLevel?: LoggingLevel; private sample: boolean; private elicit: boolean | { form?: boolean; url?: boolean }; private progress: boolean; private resetTimeoutOnProgress: boolean; private requestTimeout: number | undefined; private defaultMetadata: Record | undefined; private serverSettings: InspectorServerSettings | undefined; 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; // 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 = {}; // 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; // Content cache // ListChanged notification configuration private listChangedNotifications: { tools: boolean; resources: boolean; prompts: boolean; }; // Resource subscriptions private subscribedResources: Set = new Set(); // 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(); // Abort controller for the in-flight ordinary (non-task) tool call. Aborting // it makes the SDK send a `notifications/cancelled` for that request (the MCP // cancellation flow) and reject the pending call, which `callTool` surfaces as // a `ToolCallCancelledError`. 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 receiverTasks: boolean; 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; 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.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; // Only set roots if explicitly provided (even if empty array) - this enables roots capability this.roots = options.roots; // 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' this.effectiveAuthFetch = this.buildEffectiveAuthFetch(); 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, 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 } = {}; 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) { capabilities.tasks = { list: {}, cancel: {}, requests: { sampling: { createMessage: {} }, elicitation: { create: {} }, }, }; } if (options.oauth?.enterpriseManaged) { capabilities.extensions = { "io.modelcontextprotocol/enterprise-managed-authorization": {}, }; } if (Object.keys(capabilities).length > 0) { clientOptions.capabilities = capabilities; } this.clientCapabilities = capabilities; this.appRendererClientProxy = null; this.client = new Client( options.clientIdentity ?? { name: corePackageJson.name.split("/")[1] ?? corePackageJson.name, version: corePackageJson.version, }, Object.keys(clientOptions).length > 0 ? clientOptions : undefined, ); } private buildEffectiveAuthFetch(): typeof fetch { const base = this.fetchFn ?? 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) => { const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), direction: "request", origin, message, }; this.dispatchTypedEvent("message", entry); }, trackResponse: ( message: JSONRPCResultResponse | JSONRPCErrorResponse, origin: MessageOrigin, ) => { 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); } 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?: Record, ): Record | undefined { const defaults = this.defaultMetadata; const hasDefaults = defaults && Object.keys(defaults).length > 0; const hasCall = callMetadata && Object.keys(callMetadata).length > 0; if (!hasDefaults && !hasCall) return undefined; return { ...(defaults ?? {}), ...(callMetadata ?? {}) }; } 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 makes the SDK send a // `notifications/cancelled` for the request and reject it (#1458). 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; } 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" ); } 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; }); const record: ReceiverTaskRecord = { task, payloadPromise, resolvePayload, rejectPayload, }; 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 McpError(ErrorCode.InvalidParams, `Unknown taskId: ${taskId}`); } return record.payloadPromise; } private cancelReceiverTask(taskId: string): Task { const record = this.receiverTaskRecords.get(taskId); if (!record) { throw new McpError(ErrorCode.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")); 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; } /** * Connect to the MCP server */ async connect(): Promise { if (!this.client) { throw new Error("Client not initialized"); } if (this.status === "connected") { return; } 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" }); }, onFetchResponseBody: (id: string, body: string) => { this.dispatchFetchRequestBodyUpdate(id, body); }, ...(this.serverSettings && { settings: this.serverSettings }), }; if (this.isHttpOAuthConfig() && oauthManager) { 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(); } } this.transportHasAuthProvider = !!transportOptions.authProvider; const { transport: baseTransport } = this.transportClientFactory( this.transportConfig, transportOptions, ); this.baseTransport = baseTransport; const messageTracking = this.createMessageTrackingCallbacks(); this.transport = new MessageTrackingTransport( baseTransport, messageTracking, ); this.attachTransportListeners(this.baseTransport); } if (!this.transport) { throw new Error("Transport not initialized"); } try { this.status = "connecting"; this.dispatchTypedEvent("statusChange", this.status); // Optional connect-time timeout from per-server settings. The MCP SDK // has no connect-time timeout option, so we wrap the handshake in a // Promise.race. On timeout, tear the transport down so the next // connect() starts clean and the upstream socket isn't left hanging. const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0; const connectPromise = this.client.connect(this.transport); if (connectTimeoutMs > 0) { // Absorb any late rejection from `connectPromise` — when the timeout // wins the race and `disconnect()` tears the transport down, real // transports (SSE / streamable-http) reject the in-flight handshake // *after* Promise.race has already settled. Without a handler here // Node emits an unhandledRejection warning (and in test runs vitest // can surface it as a suite failure). Only needed on the race path; // the await-only branch below propagates rejection cleanly through // the outer try/catch. connectPromise.catch(() => {}); let timer: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout( () => reject( new Error( `Connection timed out after ${connectTimeoutMs} ms`, ), ), connectTimeoutMs, ); }); try { await Promise.race([connectPromise, timeoutPromise]); } catch (err) { await this.disconnect().catch(() => {}); throw err; } finally { if (timer) clearTimeout(timer); } } else { await connectPromise; } 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(); this.dispatchTypedEvent("connect"); // Set initial logging level if configured and server supports it if (this.initialLoggingLevel && this.capabilities?.logging) { await this.client.setLoggingLevel( this.initialLoggingLevel, this.getRequestOptions(), ); } // Set up sampling request handler if sampling capability is enabled if (this.sample && this.client) { this.client.setRequestHandler(CreateMessageRequestSchema, (request) => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; if (this.receiverTasks && 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); })(); return Promise.resolve({ task: record.task }); } return new Promise((resolve, reject) => { const samplingRequest = new SamplingCreateMessage( request, (result) => { resolve(result); }, (error) => { reject(error); }, (id) => this.removePendingSample(id), ); this.addPendingSample(samplingRequest); }); }); } // Set up elicitation request handler if elicitation capability is enabled if (this.elicit && this.client) { this.client.setRequestHandler(ElicitRequestSchema, (request) => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; if (this.receiverTasks && paramsTask != null) { const record = this.createReceiverTask({ ttl: paramsTask.ttl, initialStatus: "input_required", statusMessage: "Awaiting user input", }); void (async () => { const elicitationRequest = new ElicitationCreateMessage( 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); }, (id) => this.removePendingElicitation(id), (error) => { record.rejectPayload(error); const now = new Date().toISOString(); const updated: Task = { ...record.task, status: "failed", lastUpdatedAt: now, statusMessage: error.message, }; record.task = updated; this.upsertReceiverTask(updated); }, ); this.addPendingElicitation(elicitationRequest); })(); return Promise.resolve({ task: record.task }); } return new Promise((resolve) => { const elicitationRequest = new ElicitationCreateMessage( request, (result) => { resolve(result); }, (id) => this.removePendingElicitation(id), ); this.addPendingElicitation(elicitationRequest); }); }); } // Set up roots/list request handler if roots capability is enabled if (this.roots !== undefined && this.client) { this.client.setRequestHandler(ListRootsRequestSchema, async () => { return { roots: this.roots ?? [] }; }); } // Set up receiver-task request handlers (server polls us for tasks/list, tasks/get, tasks/result, tasks/cancel) if (this.receiverTasks && this.client) { this.client.setRequestHandler(ListTasksRequestSchema, async () => ({ tasks: this.listReceiverTasks(), })); this.client.setRequestHandler(GetTaskRequestSchema, async (req) => { const record = this.getReceiverTask(req.params.taskId); if (!record) { throw new McpError( ErrorCode.InvalidParams, `Unknown taskId: ${req.params.taskId}`, ); } return record.task; }); this.client.setRequestHandler( GetTaskPayloadRequestSchema, async (req) => this.getReceiverTaskPayload(req.params.taskId), ); this.client.setRequestHandler(CancelTaskRequestSchema, async (req) => this.cancelReceiverTask(req.params.taskId), ); } // Set up notification handler for roots/list_changed from server if (this.client) { this.client.setNotificationHandler( RootsListChangedNotificationSchema, async () => { // Dispatch event to notify UI that server's roots may have changed // Note: rootsChange is a CustomEvent with Root[] payload, not a signal event // We'll reload roots when the UI requests them, so we don't need to pass data here // For now, we'll just dispatch an empty array as a signal to reload this.dispatchTypedEvent("rootsChange", this.roots || []); }, ); } // 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( ToolListChangedNotificationSchema, 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( ResourceListChangedNotificationSchema, 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( PromptListChangedNotificationSchema, async () => { this.dispatchTypedEvent("promptsListChanged"); }, ); } // Tasks list_changed and status handlers (when server advertises tasks capability) if (this.capabilities?.tasks) { this.client.setNotificationHandler( TasksListChangedNotificationSchema, async () => { this.dispatchTypedEvent("tasksListChanged"); }, ); this.client.setNotificationHandler( TaskStatusNotificationSchema, async (notification) => { const task = notification.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( ResourceUpdatedNotificationSchema, 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 const urlElicitEnabled = this.elicit && typeof this.elicit === "object" && this.elicit.url === true; if (urlElicitEnabled) { this.client.setNotificationHandler( ElicitationCompleteNotificationSchema, 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. } } catch (error) { this.status = "error"; this.dispatchTypedEvent("statusChange", this.status); if (this.baseTransport && !this.transportHasAuthProvider) { await this.dropCachedTransport(); } // 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; // 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 server state on disconnect (list state is in state managers). // Settle any outstanding elicitations as cancelled before dropping them, so // an error-path `awaitUrlElicitation` (which blocks `callTool`) doesn't hang // forever when the queue is cleared on teardown. this.pendingSamples = []; for (const elicitation of this.pendingElicitations) { elicitation.cancel(); } this.pendingElicitations = []; // Clear resource subscriptions on disconnect this.subscribedResources.clear(); this.cancelledTaskIds.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; // Clear receiver tasks: stop TTL timers and drop records for (const record of this.receiverTaskRecords.values()) { if (record.cleanupTimeoutId != null) { clearTimeout(record.cleanupTimeoutId); } } this.receiverTaskRecords.clear(); this.appRendererClientProxy = null; this.capabilities = undefined; this.serverInfo = undefined; this.instructions = undefined; this.protocolVersion = 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); } /** * Returns a client proxy for use by AppRenderer / @mcp-ui. Delegates to the * internal MCP Client. Returns null when not connected. Use this instead of * accessing the raw client so behavior can be adapted here later if needed. */ getAppRendererClient(): AppRendererClient | null { if (!this.client || this.status !== "connected") return null; if (this.appRendererClientProxy !== null) return this.appRendererClientProxy; const target = this.client; this.appRendererClientProxy = new Proxy(this.client, { get(proxyTarget, prop, receiver) { const value = Reflect.get(proxyTarget, prop, receiver); if (prop === "setNotificationHandler" && typeof value === "function") { return (...args: Parameters) => { // Add behavior here (e.g. wrap handler, log, filter) return value.apply(target, args); }; } return value; }, }) as AppRendererClient; return this.appRendererClientProxy; } /** * 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, }; } /** * 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"); } const task = await this.client.experimental.tasks.getTask( taskId, this.getRequestOptions(), ); // 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"); } // Use CallToolResultSchema for validation return await this.client.experimental.tasks.getTaskResult( 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); await this.client.experimental.tasks.cancelTask( taskId, this.getRequestOptions(), ); // Dispatch event this.dispatchTypedEvent("taskCancelled", { taskId }); } /** * Cancel the in-flight ordinary (non-task) tool call started by * {@link callTool}. Aborting its request makes the SDK send a * `notifications/cancelled` to the server (the MCP cancellation flow) and * reject the pending call, which `callTool` surfaces as a * {@link ToolCallCancelledError}. * * 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"); } return await this.client.experimental.tasks.listTasks( cursor, this.getRequestOptions(), ); } /** * 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 during the * initialize handshake (e.g. "2025-06-18"). Undefined when not connected. */ getProtocolVersion(): string | undefined { return this.protocolVersion; } /** * 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 * @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()); } /** * Fetch a single page of tools without updating the client's internal list. */ async listTools( cursor?: string, metadata?: Record, ): 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 } : {}), ...(cursor ? { cursor } : {}), }; const response = await this.client.listTools( params, this.getRequestOptions(metadata?.progressToken), ); const tools = [...(response.tools || [])]; return { tools, nextCursor: response.nextCursor }; } /** * 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?: Record, toolSpecificMetadata?: Record, 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` — so // surface a clean cancellation, not a generic failure, and don't record // it in history. 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; } } } /** * 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"); } let convertedArgs: Record = args; const stringArgs: Record = {}; for (const [key, value] of Object.entries(args)) { if (typeof value === "string") { stringArgs[key] = value; } } if (Object.keys(stringArgs).length > 0) { const convertedStringArgs = convertToolParameters(tool, stringArgs); convertedArgs = { ...args, ...convertedStringArgs }; } // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: Record | 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?: Record; task?: { ttl: number }; } = { name: tool.name, arguments: convertedArgs, _meta: metadata, }; if (taskOptions?.ttl != null) { callParams.task = { ttl: taskOptions.ttl }; } // MCP Apps forward the server's CallToolResult straight to the running // view, which is the real consumer. The SDK's callTool() validates // structuredContent against the tool's outputSchema and THROWS on a // mismatch — which would deny the app a result the server actually // returned (and that legacy hosts render fine). For those passthrough // calls go through request() directly, which skips that host-side // validation. Regular Tools-screen calls keep validating. const requestOptions = this.getRequestOptions( metadata?.progressToken, signal, ); // Both branches yield a CallToolResult: request() parsed it with // CallToolResultSchema above, callTool() returns the same shape — so the // `as CallToolResult` casts below are safe. const result = options?.skipOutputValidation ? await client.request( { method: "tools/call", params: callParams }, CallToolResultSchema, requestOptions, ) : await client.callTool(callParams, undefined, requestOptions); // On the bypass path the result was delivered without the SDK's strict // output validation. Run that check ourselves, non-fatally, so callers can // warn that strict clients would reject this payload (the app still // renders, but it may not in other hosts). const outputValidationError = options?.skipOutputValidation ? this.validateToolOutput(tool, result as CallToolResult) : undefined; const invocation: ToolCallInvocation = { toolName: tool.name, params: args, result: result as CallToolResult, 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: Record | undefined, toolSpecificMetadata: Record | undefined, errorMessage: string, ): void { const callMetadata: Record | 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); } /** * 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?: Record, toolSpecificMetadata?: Record, taskOptions?: { ttl?: number }, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } try { let convertedArgs: Record = args; const stringArgs: Record = {}; for (const [key, value] of Object.entries(args)) { if (typeof value === "string") { stringArgs[key] = value; } } if (Object.keys(stringArgs).length > 0) { const convertedStringArgs = convertToolParameters(tool, stringArgs); convertedArgs = { ...args, ...convertedStringArgs }; } // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: Record | 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(metadata?.progressToken); if (this.progress) { const innerOnProgress = requestOptions.onprogress; requestOptions.onprogress = (progress: Progress) => { innerOnProgress?.(progress); if (taskId) { this.dispatchTypedEvent("requestorTaskProgress", { taskId, progress, }); } }; } const stream = this.client.experimental.tasks.callToolStream( streamParams as CallToolRequest["params"], undefined, // Use default CallToolResultSchema 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.experimental.tasks.getTaskResult( taskId, undefined, 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)}`, ); } } 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: Record | 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?: Record, ): 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 } : {}), ...(cursor ? { cursor } : {}), }; const response = await this.client.listResources( params, this.getRequestOptions(metadata?.progressToken), ); return { resources: response.resources || [], nextCursor: response.nextCursor, }; } /** * 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?: Record, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } const effectiveMeta = this.mergeMeta(metadata); const params: ReadResourceRequest["params"] = { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; const result = await this.client.readResource( params, this.getRequestOptions(metadata?.progressToken), ); 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?: Record, ): Promise { if (!this.client) { throw new Error("Client is not connected"); } const uriTemplateString = uriTemplate; // Expand the template's uriTemplate using the provided params let expandedUri: string; try { const uriTemplate = new UriTemplate(uriTemplateString); expandedUri = uriTemplate.expand(params); } catch (error) { throw new Error( `Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(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?: Record, ): 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 } : {}), ...(cursor ? { cursor } : {}), }; const response = await this.client.listResourceTemplates( params, this.getRequestOptions(metadata?.progressToken), ); return { resourceTemplates: response.resourceTemplates || [], nextCursor: response.nextCursor, }; } /** * 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?: Record, ): 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 } : {}), ...(cursor ? { cursor } : {}), }; const response = await this.client.listPrompts( params, this.getRequestOptions(metadata?.progressToken), ); return { prompts: response.prompts || [], nextCursor: response.nextCursor, }; } /** * 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?: Record, ): 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 } : {}), }; const result = await this.client.getPrompt( params, this.getRequestOptions(metadata?.progressToken), ); 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?: Record, ): 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.client.complete( params, this.getRequestOptions(metadata?.progressToken), ); 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 McpError && error.code === ErrorCode.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)}`, ); } } /** * 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); } // The negotiated protocol version isn't exposed by the SDK Client; it's // stamped onto the transport during initialize. MessageTrackingTransport // captures it (see its setProtocolVersion) so we can surface it here. if (this.transport instanceof MessageTrackingTransport) { this.protocolVersion = this.transport.protocolVersion; 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 }); } /** * 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 notify server if it supports roots/listChanged * Note: This will enable roots capability if it wasn't already enabled */ async setRoots(roots: Root[]): Promise { if (!this.client) { throw new Error("Client is not connected"); } // Enable roots capability if not already enabled if (this.roots === undefined) { this.roots = []; } this.roots = [...roots]; this.dispatchTypedEvent("rootsChange", this.roots); // Send notification to server - clients can send this notification to any server // The server doesn't need to advertise support for it 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; } /** * Subscribe to a resource to receive update notifications * @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 { await this.client.subscribeResource({ uri }, this.getRequestOptions()); this.subscribedResources.add(uri); this.dispatchTypedEvent( "resourceSubscriptionsChange", Array.from(this.subscribedResources), ); } catch (error) { throw new Error( `Failed to subscribe to resource: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Unsubscribe from a resource * @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 { await this.client.unsubscribeResource({ uri }, this.getRequestOptions()); this.subscribedResources.delete(uri); this.dispatchTypedEvent( "resourceSubscriptionsChange", Array.from(this.subscribedResources), ); } catch (error) { throw new Error( `Failed to unsubscribe from resource: ${error instanceof Error ? error.message : String(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; }): 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(); } /** * Completes OAuth flow with authorization code from the redirect callback. */ async completeOAuthFlow(authorizationCode: string): Promise { return this.ensureOAuthManager().completeOAuthFlow(authorizationCode); } /** * Gets current OAuth tokens (if authorized) */ async getOAuthTokens(): Promise { if (!this.oauthManager) { return undefined; } return this.oauthManager.getOAuthTokens(); } /** * Clears OAuth tokens and client information */ clearOAuthTokens(): void { this.oauthManager?.clearOAuthTokens(); } /** * 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(); } }