inspector/core/mcp/inspectorClientEventTarget.ts
cliffhall d57801d7b3 feat(core): report connection state on a request timeout (#2318)
A request timeout used to surface as a bare `Request timed out`. Every
fact needed to explain it was already in-process: what the client was
still waiting on, when it last heard back, and the state of the
standalone GET notification stream. The SDK's timeout now rejects with
all three, and the same snapshot is shown live in Connection Info and
on the Network entry for the stream.

- core/mcp/connectionDiagnostics.ts: the snapshot type, its one-line
  description, and `annotateRequestTimeout`, which rebuilds the SDK's
  per-request timeout with the state at that moment.
- InspectorClient decorates the SDK Client's `request` (the one funnel
  every verb goes through) so the annotation covers the whole surface;
  tracks outstanding requests with their send time, the last response,
  and the most recent long-lived transport stream; exposes
  `getConnectionDiagnostics()` and `connectionDiagnosticsChange`.
- fetchTracking watches a long-lived stream through a tee'd clone,
  counting SSE events and reporting the close, via a new
  `updateStream` callback wired through the node transport, the remote
  session (`fetch_stream_update`), and the browser transport.
- FetchRequestLogState patches `entry.stream`; the Network entry's
  placeholder reports the event count and open/closed state.
- Connection Info gains a Connection Activity section, ticking while
  open (`useTickingClock`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 21:25:31 -04:00

228 lines
9 KiB
TypeScript

/**
* Type-safe EventTarget for InspectorClient events.
* Extends the generic TypedEventTarget with InspectorClientEventMap; TypedEvent
* is provided as a type alias for use in listener signatures.
*
* v2 note: the v1.5 event map referenced SamplingCreateMessage / ElicitationCreateMessage
* classes (with embedded promise resolvers) and OAuth events from the auth subsystem.
* The auth subsystem and the runtime promise-based pending classes are not yet ported to
* v2 — see #1243 scope. Pending requests are typed with the v2 wrapper types
* (InspectorPendingSampling / InspectorPendingElicitation); OAuth events are deliberately
* omitted here and will be added when the auth subsystem is ported.
*/
import {
TypedEventTarget,
type TypedEventGeneric,
} from "./typedEventTarget.js";
import type {
ConnectionStatus,
MessageEntry,
StderrLogEntry,
FetchRequestEntry,
FetchStreamState,
PromptGetInvocation,
ResourceReadInvocation,
ResourceTemplateReadInvocation,
ResourceSubscriptionStreamState,
ExcludedTool,
RequestMetadata,
} from "./types.js";
import type { MalformedListItem } from "./listSalvage.js";
import type { ConnectionDiagnostics } from "./connectionDiagnostics.js";
import type {
Tool,
ServerCapabilities,
Implementation,
Root,
Progress,
ProgressToken,
Task,
CallToolResult,
ProtocolError,
ProtocolEra,
DiscoverResult,
} from "@modelcontextprotocol/client";
import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import type { AuthChallenge } from "../auth/challenge.js";
/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */
export type TaskWithOptionalCreatedAt = Omit<Task, "createdAt"> & {
createdAt?: string;
};
/**
* Maps event names to their detail types for CustomEvents
*/
export interface InspectorClientEventMap {
statusChange: ConnectionStatus;
toolsChange: Tool[];
/**
* Tools the SDK excluded from `tools/list` for invalid `x-mcp-header`
* annotations (SEP-2243), each with its reason. Empty on legacy/stdio
* connections (which don't exclude) and before connect (#1632).
*/
excludedToolsChange: ExcludedTool[];
/**
* Entries dropped from a list result because they failed the spec schema for
* their primitive, across every list method. Empty against a conforming
* server; a non-empty set means the list rendered without them (#1909).
*/
malformedListItemsChange: MalformedListItem[];
capabilitiesChange: ServerCapabilities | undefined;
serverInfoChange: Implementation | undefined;
instructionsChange: string | undefined;
protocolVersionChange: string | undefined;
/** Protocol era negotiated after connect (SEP §7.8); `"legacy"` on a legacy connect, undefined when not connected. */
protocolEraChange: ProtocolEra | undefined;
/** `server/discover` result on a probed/pinned connect; undefined on legacy. */
discoverResultChange: DiscoverResult | undefined;
message: MessageEntry;
/**
* A response the client REJECTED after it was logged — the server answered,
* but the SDK's era codec refused the result (e.g. a 2026-07-28 `tools/list`
* missing `ttlMs`/`cacheScope`). The wire frame is a valid JSON-RPC result,
* so the Protocol entry would otherwise render as a clean success; this
* carries the reason so it can be marked instead (#1953). `id` is the
* JSON-RPC id of the rejected response.
*/
responseRejected: { id: string | number; reason: string };
stderrLog: StderrLogEntry;
fetchRequest: FetchRequestEntry;
/** Fired when an in-flight fetch's response body is read asynchronously. */
fetchRequestBodyUpdate: { id: string; responseBody: string };
/**
* Fired as a tracked long-lived stream (the standalone `GET` on Streamable
* HTTP, the legacy SSE event stream) delivers events and when it ends. The
* body of such a stream is never captured, so this is the log's only view
* of its lifetime (#2318).
*/
fetchRequestStreamUpdate: { id: string; stream: FetchStreamState };
/**
* Fired whenever the connection diagnostics snapshot changes — a request
* goes out, a response comes back, the notification stream opens, delivers
* an event or closes, or a new session resets it. Carries the fresh
* snapshot; `getConnectionDiagnostics()` returns the same one (#2318).
*/
connectionDiagnosticsChange: ConnectionDiagnostics;
/**
* Fired whenever the client transitions `status` to `"error"` from a path
* that is NOT an awaited promise — i.e. a mid-session transport failure
* (stdio subprocess crash, SSE stream drop, HTTP 5xx) surfaced via the
* transport's `onerror`. The `Error` carries the reason (`.message`,
* optional `.cause`). Handshake failures are NOT dispatched here: they
* reject the awaited `connect()` promise, so the caller already has the
* error and a second surface would double-report it. Consumers that don't
* subscribe directly can read the last error via `useInspectorClient`'s
* `lastError`.
*/
error: Error;
resourceUpdated: { uri: string };
progressNotification: Progress & { progressToken?: ProgressToken };
toolCallResultChange: {
toolName: string;
params: Record<string, JsonValue>;
result: CallToolResult | null;
timestamp: Date;
success: boolean;
error?: string;
metadata?: RequestMetadata;
/** Non-fatal outputSchema mismatch detected on the skipOutputValidation path. */
outputValidationError?: string;
};
resourceContentChange: {
uri: string;
content: ResourceReadInvocation;
timestamp: Date;
};
resourceTemplateContentChange: {
uriTemplate: string;
content: ResourceTemplateReadInvocation;
params: Record<string, string>;
timestamp: Date;
};
promptContentChange: {
name: string;
content: PromptGetInvocation;
timestamp: Date;
};
pendingSamplesChange: SamplingCreateMessage[];
newPendingSample: SamplingCreateMessage;
pendingElicitationsChange: ElicitationCreateMessage[];
newPendingElicitation: ElicitationCreateMessage;
rootsChange: Root[];
resourceSubscriptionsChange: string[];
/**
* Fired when the modern-era `subscriptions/listen` stream that backs resource
* subscriptions changes lifecycle state (#1630): opened+acknowledged, dropped
* and reconnecting, or ended. Legacy connections never dispatch an `active`
* state (they have no persistent stream).
*/
resourceSubscriptionStreamChange: ResourceSubscriptionStreamState;
// Task events
/** Fired only from server notification notifications/tasks/status. */
taskStatusChange: { taskId: string; task: Task };
/** Fired from callToolStream for each task update. */
toolCallTaskUpdated: {
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: CallToolResult;
error?: ProtocolError;
};
/** Fired from getRequestorTask() and callToolStream (client-origin task updates). */
requestorTaskUpdated: {
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: CallToolResult;
error?: ProtocolError;
};
taskCancelled: { taskId: string };
/**
* Fired from callToolStream when a progress notification arrives for a
* task-augmented tool call, tagged with the taskId callToolStream owns so
* consumers can correlate progress → task (the generic progressNotification
* event carries only the caller's progressToken, not the taskId).
*/
requestorTaskProgress: { taskId: string; progress: Progress };
tasksChange: Task[];
// Signal events (no payload)
connect: void;
disconnect: void;
// List changed notification events
toolsListChanged: void;
resourcesListChanged: void;
resourceTemplatesListChanged: void;
promptsListChanged: void;
tasksListChanged: void;
// Session persistence (dispatched by client; FetchRequestLogState listens and saves)
saveSession: { sessionId: string };
// OAuth events (#1302 — fired by the ported oauthManager / InspectorClient)
oauthComplete: { tokens: OAuthTokens };
oauthAuthorizationRequired: { url: URL };
oauthError: { error: Error };
/** Ambient (SSE) auth challenge while no command-scoped send is active. */
authChallengeAmbient: { challenge: AuthChallenge };
/** Command-scoped direct-transport auth challenge (no ambient toast). */
authChallengeCommand: { challenge: AuthChallenge };
/** Ambient auth recovery completed (remote auth state pushed). */
authChallengeRecovered: { challenge: AuthChallenge };
/** Interactive auth required; App orchestrates redirect (step-up modal or reauth). */
authChallengeInteractive: {
challenge: AuthChallenge;
authorizationUrl: URL;
};
}
/**
* Type alias for InspectorClient typed events (for listener signatures).
*/
export type TypedEvent<K extends keyof InspectorClientEventMap> =
TypedEventGeneric<InspectorClientEventMap, K>;
/**
* Type-safe EventTarget for InspectorClient events.
*/
export class InspectorClientEventTarget extends TypedEventTarget<InspectorClientEventMap> {}