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