* fix(web): don't show a permanent PENDING badge on history notifications (#1439) `extractStatus` derived the history status badge purely from whether a `response` was attached, so a fire-and-forget notification (no id, no response ever) — and any unmatched standalone response — was labelled "Pending" forever. The pending → OK/Error lifecycle only applies to request entries (messageLogState attaches the response by JSON-RPC id), so scope the status to `direction === "request"` and render no request-style badge for notifications/responses. The method badge already labels them. Part of the History-screen work on #1438. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): track outgoing responses so server→client requests resolve in history (#1440) MessageTrackingTransport.send only tracked outgoing requests, so the client's response to a server→client request (roots/list, sampling, elicitation) was never recorded. The inbound request entry never got its response folded in, leaving it stuck "Pending" with no response body. Make send symmetric with onmessage: track outgoing responses (by id + result/error) as well as outgoing requests. The response now correlates to its request entry, which resolves to OK/Error with the body. Outgoing notifications remain untracked (separate gap). Part of the History-screen work on #1438. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): wire up History Pin and Replay (#1438) Both HistoryEntry actions were rendered but stubbed to todoNoop in App. - Pin: App now owns a session-scoped Set<string> of pinned entry ids, toggled by onTogglePinHistory and passed down as pinnedHistoryIds. HistoryListPanel already sorts pinned entries to the top. The set resets with the rest of the per-screen state on disconnect/server-switch. - Replay: onReplayHistory re-issues the entry's original request by method (tools/call → callTool, prompts/get → getPrompt, resources/read → readResource). The call flows through InspectorClient → tracked transport → message log, so the fresh request+response surface as a new History entry (history-local) without touching the Tools/Prompts/ Resources panels. Unsupported methods / a removed tool surface a toast. Tests: pin toggle propagation, replay dispatch for each supported method, unsupported-method and missing-tool toasts. Closes #1438. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): make history Replay support list/discovery requests Replaying a tools/list entry reported "Replay isn't supported". Extend replayHistoryRequest beyond the call-style requests to the read/discovery methods, re-issuing each via its InspectorClient method (preserving the pagination cursor): - tools/list → listTools, prompts/list → listPrompts, resources/list → listResources, resources/templates/list → listResourceTemplates, ping → ping. Tests: tools/list replay (cursor preserved) and an unsupported-method toast (logging/setLevel). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): replay tasks/list; reorder + conditionally show History buttons - Add tasks/list to the replayable set (re-issued via listRequestorTasks), completing the client-issued */list methods. - Reverse the HistoryEntry actions to Pin then Replay, and hide Replay for entries whose method can't be replayed. - Introduce a single source of truth — REPLAYABLE_HISTORY_METHODS / isReplayableHistoryMethod in historyUtils — used by HistoryEntry to show/hide the button and by App's replayHistoryRequest to gate dispatch, so the two can't drift. Tests: tasks/list replay, Replay hidden for a non-replayable method, and Pin-before-Replay ordering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): add client↔server direction badge to History and Network (#1437) A dual-state badge at the top-left of each entry shows which way it traveled: "client → server" (green) for inspector-originated, "client ← server" (yellow) for server-originated. - New shared element MessageDirectionBadge (Elements). - History: MessageEntry gains an `origin` ("client" | "server") set at tracking time — MessageTrackingTransport tags `send` as client and `onmessage` as server, threaded through the track callbacks. HistoryEntry maps origin → badge. (direction stays the request/response/notification message-type used for response correlation; origin is the flow.) - Network: fetches are always inspector-originated, so NetworkEntry renders the badge as outgoing. Tests + stories for the badge; HistoryEntry direction tests; transport test asserts the origin argument. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(web): use purple (not yellow) for the client ← server direction badge Yellow + green read as caution/ok status; purple vs green reads as direction. Incoming (server → client) is now violet; outgoing stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): make History Pinned/History sections collapsible toggles The "Pinned Requests (N)" and "History (N)" section headers are now clickable toggle buttons that expand/collapse their entries — styled like the LogControls level toggles (UnstyledButton listItem variant with the active background) and wrapping the entries in a Collapse. Both default open and collapse independently. Tests assert the per-section aria-expanded toggling and independence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): replace Expand/Collapse text button with an icon toggle History, Network, and Task cards now use a shared ExpandToggle ActionIcon instead of the "Expand"/"Collapse" text button: LuArrowDownToLine when collapsed (expand), LuArrowUpFromLine when expanded (collapse). The aria-label stays "Expand"/"Collapse" so the control reads the same. - New shared element ExpandToggle (+ test + stories). - HistoryEntry: actions split into a left group (Pin/Replay) with the toggle pushed right via space-between. - NetworkEntry / TaskCard: drop their now-unused SubtleButton const. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): pin icon toggle on HistoryEntry, moved to the right Replace the Pin/Unpin text button with a shared PinToggle ActionIcon (TiPinOutline when unpinned, TiPin when pinned; aria-label stays Pin/Unpin). Move it to the right action group, just left of the expand/collapse toggle — Replay stays on the left. - New shared element PinToggle (+ test + stories). - HistoryEntry action row: Replay left; Pin + expand toggle right. - Drop the now-unused formatPinLabel helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): per-section Clear/Export on the Pinned and History headers Each section header now shows Clear and Export links on the right, but only when both sections are present (a single section is already covered by the panel-level Clear/Export). They act on just that section's entries: - Clear: removes that section's entries by pin membership (clearing the Pinned section also drops the now-stale pinned id set). - Export: downloads just that section's entries (inspector-history-pinned/-unpinned-...json). Threaded onClearSection/onExportSection App → InspectorView → HistoryScreen → HistoryListPanel; App computes each section from messages + pinned ids. Extracted a CollapsibleSection subcomponent and a SectionActions links group. Extended ExportKind with history-pinned/history-unpinned. Tests cover both-sections visibility gating and per-section dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): Filter by Message Direction on the History controls Add a "Filter by Message Direction" section to HistoryControls, modeled on LogControls' "Filter by Level": a Select/Deselect All control plus two listItem toggles — "client → server" and "client ← server" — that show or hide entries by origin. - HistoryUiState gains visibleDirections (Record<MessageOrigin, boolean>), defaulting to both visible; persists with the other per-screen state. - HistoryScreen wires the toggles (single + all) through onUiChange and passes the filter to HistoryListPanel, where matchesFilters drops entries whose direction is off (entries without a recorded origin are never filtered out). Tests cover the controls (render, single toggle, select/deselect all), the HistoryScreen onUiChange wiring, and the list-level direction filtering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(web): section Clear/Export as subtle buttons, not link anchors Match the Select/Deselect All control in HistoryControls: the per-section Clear/Export on the History/Pinned headers now use a subtle Button (variant subtle, size xs) instead of an Anchor link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(web): color the direction filter labels to match the badge The "client → server" / "client ← server" toggles in HistoryControls now color their labels green / violet, matching the MessageDirectionBadge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): track outgoing client notifications in history too Review follow-up (#1441): MessageTrackingTransport.send now also tracks the client's own notifications (initialized, progress, roots/list_changed) as origin "client", making send fully symmetric with onmessage. Previously only id-bearing outgoing messages (requests, responses) were tracked, so client notifications never reached History while incoming server notifications did. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): Filter by Message Direction on the Network screen Add the same "Filter by Message Direction" section to NetworkControls as on HistoryControls. Extracted the shared section into a MessageDirectionFilter group component (used by both HistoryControls and NetworkControls) so they stay identical — a Select/Deselect All control plus green/violet client → server / client ← server toggles. - NetworkUiState gains visibleDirections (default both visible), persisted with the rest of the per-screen state; NetworkScreen wires the toggles and NetworkStreamPanel filters on them. - Network fetches are always inspector-originated, so all entries are client → server: the client toggle gates them all and the server toggle matches nothing (documented in NetworkStreamPanel). The section mirrors History for parity. Tests cover the shared filter, both controls' direction sections, the Network list filtering, and the NetworkScreen wiring; ambiguous "Deselect All" selectors (now two sections) disambiguated by order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert(web): drop message direction from the Network tab Network traffic is always client → server — even SSE is a client-initiated stream tagged outgoing — so a direction badge/filter there is meaningless. Remove the MessageDirectionBadge from NetworkEntry and the Filter by Message Direction section (and its NetworkUiState.visibleDirections wiring) from the Network screen. History keeps its direction badge + filter; the shared MessageDirectionBadge and MessageDirectionFilter components stay (still used by History). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): a lone History section isn't a collapsible accordion When only one of Pinned / History is present, its header is now a plain title and its entries always show — instead of a `listItem` accordion toggle that could hide them based on a stale collapsed state from when both sections were on screen. The accordion (and per-section Clear/Export) only applies when both sections are present. Example fixed: collapse History while pins exist, then unpin everything — History becomes the sole section and shows its entries under a plain header rather than staying collapsed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(web): put section Clear/Export on the header pleat itself The per-section Clear/Export sat to the right of the collapsible header bar, on the panel background. Make the whole header row the "pleat": a new Group `sectionHeader` theme variant (rounded + hover, active bg passed per instance) wraps a flex-1 toggle button (the title) on the left and the Clear/Export buttons on the right — so the actions live on the pleat. The toggle is a separate button since the action buttons can't nest inside it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(web): entry expand toggle uses the same icons as ListToggle Swap ExpandToggle (History / Network / Task cards) from the LuArrow icons to RiExpandVerticalLine / RiCollapseVerticalLine — the same expand/collapse icons the list-level ListToggle uses. Same ActionIcon size, so it matches the other small buttons on the cards; aria-labels unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): panel Clear keeps pinned entries (history-only) Review follow-up (#1441): the panel Clear button is gated on the unpinned count (implying history-only), but onClearHistory wiped everything (pinned included) and left pinnedHistoryIds stale. Make Clear history-only — clearMessages((m) => !pinnedHistoryIds.has(m.id)) — so pinning protects an entry, matching the gating and the per-section model; the pin set stays valid since its entries survive. Also drop the stale "and Network" mention from the MessageDirectionFilter docstring (Network direction filter was reverted). Test: panel Clear's predicate keeps pinned ids and removes unpinned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2513 lines
86 KiB
TypeScript
2513 lines
86 KiB
TypeScript
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 type { AuthGuidedState, OAuthStep } from "../auth/types.js";
|
|
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
import type pino from "pino";
|
|
import { silentLogger } 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<ClientResult>;
|
|
resolvePayload: (payload: ClientResult) => void;
|
|
rejectPayload: (reason?: unknown) => void;
|
|
cleanupTimeoutId?: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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;
|
|
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<string, string> | undefined;
|
|
private serverSettings: InspectorServerSettings | undefined;
|
|
private status: ConnectionStatus = "disconnected";
|
|
// Server data (resources, resourceTemplates, prompts are in state managers)
|
|
private capabilities?: ServerCapabilities;
|
|
private serverInfo?: Implementation;
|
|
private instructions?: 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<string> = new Set();
|
|
// Receiver tasks (server-initiated: server sends createMessage/elicit with params.task, server polls us)
|
|
private receiverTasks: boolean;
|
|
private receiverTaskTtlMs: number | (() => number);
|
|
private receiverTaskRecords: Map<string, ReceiverTaskRecord> = new Map();
|
|
// OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured")
|
|
private oauthManager: OAuthManager | null = null;
|
|
private logger: pino.Logger;
|
|
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,
|
|
dispatchOAuthStepChange: (detail) =>
|
|
this.dispatchTypedEvent("oauthStepChange", detail),
|
|
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<ClientCapabilities["elicitation"]> = {};
|
|
|
|
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 (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 = () => {
|
|
if (this.status !== "disconnected") {
|
|
this.status = "disconnected";
|
|
this.dispatchTypedEvent("statusChange", this.status);
|
|
this.dispatchTypedEvent("disconnect");
|
|
}
|
|
};
|
|
baseTransport.onerror = (error: Error) => {
|
|
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<string, string>,
|
|
): Record<string, string> | 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): RequestOptions {
|
|
const opts: RequestOptions = {
|
|
resetTimeoutOnProgress: this.resetTimeoutOnProgress,
|
|
};
|
|
if (this.requestTimeout !== undefined) {
|
|
opts.timeout = this.requestTimeout;
|
|
}
|
|
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<ClientResult>((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<ClientResult> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Connect to the MCP server
|
|
*/
|
|
async connect(): Promise<void> {
|
|
if (!this.client) {
|
|
throw new Error("Client not initialized");
|
|
}
|
|
if (this.status === "connected") {
|
|
return;
|
|
}
|
|
|
|
// 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 }),
|
|
};
|
|
const oauthManager = this.oauthManager;
|
|
if (this.isHttpOAuthConfig() && oauthManager) {
|
|
const provider = await oauthManager.createOAuthProviderForTransport();
|
|
transportOptions.authProvider = provider;
|
|
}
|
|
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<typeof setTimeout> | undefined;
|
|
const timeoutPromise = new Promise<never>((_, 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<CreateMessageResult>((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<ElicitResult>((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);
|
|
this.dispatchTypedEvent(
|
|
"error",
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
);
|
|
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<void> {
|
|
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<unknown, unknown>;
|
|
};
|
|
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
|
|
}
|
|
}
|
|
// Null out transport so next connect() creates a fresh one.
|
|
this.baseTransport = null;
|
|
this.transport = null;
|
|
// Update status - transport onclose handler will also fire and clear state
|
|
// But we also do it here in case disconnect() is called directly
|
|
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();
|
|
// 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.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
|
|
this.dispatchTypedEvent("pendingElicitationsChange", this.pendingElicitations);
|
|
this.dispatchTypedEvent("capabilitiesChange", this.capabilities);
|
|
this.dispatchTypedEvent("serverInfoChange", this.serverInfo);
|
|
this.dispatchTypedEvent("instructionsChange", this.instructions);
|
|
}
|
|
|
|
/**
|
|
* 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<Client["setNotificationHandler"]>) => {
|
|
// 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<void> {
|
|
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<Task> {
|
|
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<CallToolResult> {
|
|
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<void> {
|
|
if (!this.client) {
|
|
throw new Error("Client is not connected");
|
|
}
|
|
await this.client.experimental.tasks.cancelTask(
|
|
taskId,
|
|
this.getRequestOptions(),
|
|
);
|
|
|
|
// Dispatch event
|
|
this.dispatchTypedEvent("taskCancelled", { taskId });
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<string, string>,
|
|
): 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<string, JsonValue>,
|
|
generalMetadata?: Record<string, string>,
|
|
toolSpecificMetadata?: Record<string, string>,
|
|
taskOptions?: { ttl?: number },
|
|
options?: { skipOutputValidation?: boolean },
|
|
): Promise<ToolCallInvocation> {
|
|
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().`,
|
|
);
|
|
}
|
|
|
|
// Retry loop 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).
|
|
let urlElicitationAttempt = 0;
|
|
// URLs already presented in this call. If a retry's error re-requests one,
|
|
// completing it again can't make progress (the server isn't advancing), so
|
|
// we abort with a UrlElicitationLoopError rather than re-prompt the user.
|
|
const presentedUrls = new Set<string>();
|
|
while (true) {
|
|
try {
|
|
return await this.attemptToolCall(
|
|
tool,
|
|
args,
|
|
generalMetadata,
|
|
toolSpecificMetadata,
|
|
taskOptions,
|
|
options,
|
|
);
|
|
} catch (error) {
|
|
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(
|
|
tool: Tool,
|
|
args: Record<string, JsonValue>,
|
|
generalMetadata?: Record<string, string>,
|
|
toolSpecificMetadata?: Record<string, string>,
|
|
taskOptions?: { ttl?: number },
|
|
options?: { skipOutputValidation?: boolean },
|
|
): Promise<ToolCallInvocation> {
|
|
const client = this.client;
|
|
if (!client) {
|
|
throw new Error("Client is not connected");
|
|
}
|
|
let convertedArgs: Record<string, JsonValue> = args;
|
|
const stringArgs: Record<string, string> = {};
|
|
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<string, string> | 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<string, JsonValue>;
|
|
_meta?: Record<string, string>;
|
|
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);
|
|
// 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<string, JsonValue>,
|
|
generalMetadata: Record<string, string> | undefined,
|
|
toolSpecificMetadata: Record<string, string> | undefined,
|
|
errorMessage: string,
|
|
): void {
|
|
const callMetadata: Record<string, string> | 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<ElicitResult["action"]> {
|
|
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<ElicitResult["action"]> {
|
|
return new Promise<ElicitResult["action"]>((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<string, JsonValue>,
|
|
generalMetadata?: Record<string, string>,
|
|
toolSpecificMetadata?: Record<string, string>,
|
|
taskOptions?: { ttl?: number },
|
|
): Promise<ToolCallInvocation> {
|
|
if (!this.client) {
|
|
throw new Error("Client is not connected");
|
|
}
|
|
try {
|
|
let convertedArgs: Record<string, JsonValue> = args;
|
|
const stringArgs: Record<string, string> = {};
|
|
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<string, string> | undefined =
|
|
generalMetadata || toolSpecificMetadata
|
|
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
|
|
: undefined;
|
|
|
|
const timestamp = new Date();
|
|
const metadata = this.mergeMeta(callMetadata);
|
|
|
|
// Call the streaming API
|
|
const streamParams: Record<string, unknown> = {
|
|
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) {
|
|
const failedTask: TaskWithOptionalCreatedAt = {
|
|
taskId,
|
|
ttl: null,
|
|
status: "failed",
|
|
statusMessage: errorMessage,
|
|
lastUpdatedAt: new Date().toISOString(),
|
|
};
|
|
this.dispatchTypedEvent("toolCallTaskUpdated", {
|
|
taskId,
|
|
task: failedTask,
|
|
error: message.error,
|
|
});
|
|
this.dispatchTypedEvent("requestorTaskUpdated", {
|
|
taskId,
|
|
task: failedTask,
|
|
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<string, string> | 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<string, string>,
|
|
): 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<string, string>,
|
|
): Promise<ResourceReadInvocation> {
|
|
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<string, string>,
|
|
metadata?: Record<string, string>,
|
|
): Promise<ResourceTemplateReadInvocation> {
|
|
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<string, string>,
|
|
): 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<string, string>,
|
|
): 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<string, JsonValue>,
|
|
metadata?: Record<string, string>,
|
|
): Promise<PromptGetInvocation> {
|
|
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<string, string>,
|
|
metadata?: Record<string, string>,
|
|
): 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<void> {
|
|
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);
|
|
}
|
|
} 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 using SDK's auth() function (normal mode)
|
|
* Can be called directly by user or automatically triggered by 401 errors
|
|
*/
|
|
async authenticate(): Promise<URL> {
|
|
return this.ensureOAuthManager().authenticate();
|
|
}
|
|
|
|
/**
|
|
* Starts guided OAuth flow (step-by-step). Runs only the first step.
|
|
* Use proceedOAuthStep() to advance. When oauthStep is "authorization_code",
|
|
* set authorizationCode and call proceedOAuthStep() to complete.
|
|
*/
|
|
async beginGuidedAuth(): Promise<void> {
|
|
return this.ensureOAuthManager().beginGuidedAuth();
|
|
}
|
|
|
|
/**
|
|
* Runs guided OAuth flow to completion. If already started (via beginGuidedAuth),
|
|
* continues from current step. Otherwise initializes and runs from the start.
|
|
* Returns the authorization URL when user must authorize, or undefined if already complete.
|
|
*/
|
|
async runGuidedAuth(): Promise<URL | undefined> {
|
|
return this.ensureOAuthManager().runGuidedAuth();
|
|
}
|
|
|
|
/**
|
|
* Set authorization code for guided OAuth flow.
|
|
* Validates that the client is in guided OAuth mode (has active state machine).
|
|
* @param authorizationCode The authorization code from the OAuth callback
|
|
* @param completeFlow If true, automatically proceed through all remaining steps to completion.
|
|
* If false, only set the code and wait for manual progression via proceedOAuthStep().
|
|
* Defaults to false for manual step-by-step control.
|
|
* @throws Error if not in guided OAuth flow or not at authorization_code step
|
|
*/
|
|
async setGuidedAuthorizationCode(
|
|
authorizationCode: string,
|
|
completeFlow: boolean = false,
|
|
): Promise<void> {
|
|
return this.ensureOAuthManager().setGuidedAuthorizationCode(
|
|
authorizationCode,
|
|
completeFlow,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Completes OAuth flow with authorization code.
|
|
* For guided mode, this calls setGuidedAuthorizationCode(code, true) internally.
|
|
* For normal mode, uses SDK auth() directly.
|
|
*/
|
|
async completeOAuthFlow(authorizationCode: string): Promise<void> {
|
|
return this.ensureOAuthManager().completeOAuthFlow(authorizationCode);
|
|
}
|
|
|
|
/**
|
|
* Gets current OAuth tokens (if authorized)
|
|
*/
|
|
async getOAuthTokens(): Promise<OAuthTokens | undefined> {
|
|
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<boolean> {
|
|
if (!this.oauthManager) {
|
|
return false;
|
|
}
|
|
return this.oauthManager.isOAuthAuthorized();
|
|
}
|
|
|
|
/**
|
|
* Get current OAuth state machine state (for guided mode)
|
|
*/
|
|
getOAuthState(): AuthGuidedState | undefined {
|
|
return this.oauthManager?.getOAuthState();
|
|
}
|
|
|
|
/**
|
|
* Get current OAuth step (for guided mode)
|
|
*/
|
|
getOAuthStep(): OAuthStep | undefined {
|
|
return this.oauthManager?.getOAuthStep();
|
|
}
|
|
|
|
/**
|
|
* Manually progress to next step in guided OAuth flow
|
|
*/
|
|
async proceedOAuthStep(): Promise<void> {
|
|
return this.ensureOAuthManager().proceedOAuthStep();
|
|
}
|
|
}
|