inspector/core/react/useInspectorClient.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

277 lines
10 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { AppRendererClient } from "../mcp/inspectorClientProtocol.js";
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
import type { ConnectionStatus } from "../mcp/types.js";
import type {
ClientCapabilities,
ServerCapabilities,
Implementation,
ProtocolEra,
DiscoverResult,
} from "@modelcontextprotocol/client";
import type { ExcludedTool } from "../mcp/types.js";
import type { MalformedListItem } from "../mcp/listSalvage.js";
import type { ConnectionDiagnostics } from "../mcp/connectionDiagnostics.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
// fallback below doesn't return a fresh literal on every render —
// downstream `useMemo`/`useEffect` deps that key on `clientCapabilities`
// would otherwise invalidate every tick when no client is attached.
const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
/**
* Stable fallbacks for the no-client case, module scope for the same reason as
* the constant above — a `useStoreSnapshot` fallback built in the component
* body would look like a new value on every read.
*/
const NO_EXCLUDED_TOOLS: ExcludedTool[] = [];
const NO_MALFORMED_LIST_ITEMS: MalformedListItem[] = [];
// Also the fallback for a client that does not implement the optional
// accessor (a test double), so the field is always a snapshot and never
// `undefined` — one shape for consumers, whatever client is attached.
const NO_CONNECTION_DIAGNOSTICS: ConnectionDiagnostics = Object.freeze({
capturedAt: 0,
outstandingRequests: [],
});
const readStatus = (client: InspectorClientProtocol): ConnectionStatus =>
client.getStatus();
const readCapabilities = (
client: InspectorClientProtocol,
): ServerCapabilities | undefined => client.getCapabilities();
const readServerInfo = (
client: InspectorClientProtocol,
): Implementation | undefined => client.getServerInfo();
const readInstructions = (
client: InspectorClientProtocol,
): string | undefined => client.getInstructions();
const readProtocolVersion = (
client: InspectorClientProtocol,
): string | undefined => client.getProtocolVersion();
const readProtocolEra = (
client: InspectorClientProtocol,
): ProtocolEra | undefined => client.getProtocolEra();
const readDiscoverResult = (
client: InspectorClientProtocol,
): DiscoverResult | undefined => client.getDiscoverResult();
const readExcludedTools = (client: InspectorClientProtocol): ExcludedTool[] =>
client.getExcludedTools();
const readMalformedListItems = (
client: InspectorClientProtocol,
): MalformedListItem[] => client.getMalformedListItems();
const readConnectionDiagnostics = (
client: InspectorClientProtocol,
): ConnectionDiagnostics =>
client.getConnectionDiagnostics?.() ?? NO_CONNECTION_DIAGNOSTICS;
export interface UseInspectorClientResult {
status: ConnectionStatus;
capabilities?: ServerCapabilities;
clientCapabilities: ClientCapabilities;
serverInfo?: Implementation;
instructions?: string;
protocolVersion?: string;
/**
* 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 (a plain legacy connect
* reports `"legacy"`); undefined only when not connected. (#1626)
*/
protocolEra?: ProtocolEra;
/**
* The `server/discover` result on a probed/pinned connect — server identity,
* capabilities, and supported versions learned without an initialize
* handshake. Undefined on a legacy connect. (#1626)
*/
discoverResult?: DiscoverResult;
/**
* Tools the SDK excluded from `tools/list` for invalid `x-mcp-header`
* annotations (SEP-2243), each with its reason. Empty on legacy/stdio
* connections and before connect (#1632).
*/
excludedTools: ExcludedTool[];
/**
* Entries the Inspector dropped from a list result because they failed the
* spec schema for their primitive. Empty against a conforming server; a
* non-empty set means that list rendered without them (#1909).
*/
malformedListItems: MalformedListItem[];
/**
* What the client is still waiting on, when it last heard back, and the
* state of the notification stream — re-read on every request, response
* and stream event. The Connection Info modal renders it; a request
* timeout's message is written from the same snapshot (#2318).
*/
connectionDiagnostics: ConnectionDiagnostics;
/**
* Message from the most recent mid-session transport failure (the client's
* `error` event — stdio crash, SSE drop, HTTP 5xx). Stays set until the next
* connection attempt (`status` → `"connecting"`) clears it, so consumers can
* render it without subscribing to the event directly. Handshake failures do
* NOT populate this — they reject the `connect()` promise instead.
*/
lastError?: string;
appRendererClient: AppRendererClient | null;
connect: () => Promise<void>;
disconnect: () => Promise<void>;
}
/**
* React hook that subscribes to InspectorClient events and provides reactive
* connection state. Log lists (message / stderr / fetch) live in dedicated
* state managers consumed via useMessageLog / useStderrLog / useFetchRequestLog.
*
* Every value the client can be *asked* for is read through `useStoreSnapshot`
* rather than mirrored into local state by an effect (#1955): the effect ran
* after the commit, so swapping servers painted one frame of the previous
* client's status and capabilities before correcting itself.
*
* Note: `appRendererClient` is read lazily from the client on every render
* and is NOT subscribed. It changes once at connect time and is not expected
* to change again during a session, so callers will see the current value
* on any rerender triggered by status / capabilities / serverInfo / instructions.
* If a future use case requires autonomous updates when the renderer attaches,
* add an `appRendererClientChange` event to `InspectorClientEventMap` and
* subscribe here.
*/
export function useInspectorClient(
inspectorClient: InspectorClientProtocol | null,
): UseInspectorClientResult {
const status = useStoreSnapshot(
inspectorClient,
"statusChange",
readStatus,
"disconnected" as ConnectionStatus,
);
const capabilities = useStoreSnapshot(
inspectorClient,
"capabilitiesChange",
readCapabilities,
undefined,
);
const serverInfo = useStoreSnapshot(
inspectorClient,
"serverInfoChange",
readServerInfo,
undefined,
);
const instructions = useStoreSnapshot(
inspectorClient,
"instructionsChange",
readInstructions,
undefined,
);
const protocolVersion = useStoreSnapshot(
inspectorClient,
"protocolVersionChange",
readProtocolVersion,
undefined,
);
const protocolEra = useStoreSnapshot(
inspectorClient,
"protocolEraChange",
readProtocolEra,
undefined,
);
const discoverResult = useStoreSnapshot(
inspectorClient,
"discoverResultChange",
readDiscoverResult,
undefined,
);
const excludedTools = useStoreSnapshot(
inspectorClient,
"excludedToolsChange",
readExcludedTools,
NO_EXCLUDED_TOOLS,
);
const malformedListItems = useStoreSnapshot(
inspectorClient,
"malformedListItemsChange",
readMalformedListItems,
NO_MALFORMED_LIST_ITEMS,
);
const connectionDiagnostics = useStoreSnapshot(
inspectorClient,
"connectionDiagnosticsChange",
readConnectionDiagnostics,
NO_CONNECTION_DIAGNOSTICS,
);
// `lastError` is the one value here that is NOT a snapshot of something the
// client stores: there is no `getLastError()`, because the client emits the
// failure and moves on. It is accumulated from the event stream instead, so
// it stays local state — but it is still tied to one client, and must reset
// when a different one is passed in.
//
// That reset is done during render (React's documented "adjusting state
// when a prop changes" pattern, the same one `useValueChange` implements for
// the web client) rather than in an effect. An effect would paint one frame
// carrying the previous client's error, which is precisely the defect this
// hook was converted to fix.
const [errorState, setErrorState] = useState<{
client: InspectorClientProtocol | null;
message?: string;
}>({ client: inspectorClient });
if (errorState.client !== inspectorClient) {
setErrorState({ client: inspectorClient });
}
const lastError =
errorState.client === inspectorClient ? errorState.message : undefined;
useEffect(() => {
if (!inspectorClient) return;
const onStatusChange = (event: TypedEvent<"statusChange">) => {
// A fresh connection attempt clears any stale error from the prior
// session so the UI doesn't keep showing why the last transport died.
if (event.detail === "connecting") {
setErrorState({ client: inspectorClient });
}
};
const onError = (event: TypedEvent<"error">) => {
setErrorState({ client: inspectorClient, message: event.detail.message });
};
inspectorClient.addEventListener("statusChange", onStatusChange);
inspectorClient.addEventListener("error", onError);
return () => {
inspectorClient.removeEventListener("statusChange", onStatusChange);
inspectorClient.removeEventListener("error", onError);
};
}, [inspectorClient]);
const connect = useCallback(async () => {
if (!inspectorClient) return;
await inspectorClient.connect();
}, [inspectorClient]);
const disconnect = useCallback(async () => {
if (!inspectorClient) return;
await inspectorClient.disconnect();
}, [inspectorClient]);
return {
status,
capabilities,
// Read lazily on every render rather than subscribed: client capabilities
// are built once in InspectorClient's constructor (from `sample`, `elicit`,
// `roots`, `receiverTasks`) and never mutate during a session, so there's
// no event to subscribe to. The module-scope frozen empty object is the
// stable fallback when no client is attached.
clientCapabilities:
inspectorClient?.getClientCapabilities() ?? EMPTY_CLIENT_CAPABILITIES,
serverInfo,
instructions,
protocolVersion,
protocolEra,
discoverResult,
excludedTools,
malformedListItems,
connectionDiagnostics,
lastError,
appRendererClient: inspectorClient?.getAppRendererClient() ?? null,
connect,
disconnect,
};
}