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>
This commit is contained in:
parent
153ee7dd15
commit
d57801d7b3
15 changed files with 2315 additions and 32 deletions
|
|
@ -40,6 +40,7 @@ import type {
|
|||
} from "../types.js";
|
||||
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
|
||||
import type { MalformedListItem } from "../listSalvage.js";
|
||||
import type { ConnectionDiagnostics } from "../connectionDiagnostics.js";
|
||||
import type { SkillEntry, SkillResource } from "../skillsSchemas.js";
|
||||
import type { SkillsExtensionSupport } from "../skills.js";
|
||||
import type { JsonValue } from "../../json/jsonUtils.js";
|
||||
|
|
@ -321,6 +322,21 @@ export class FakeInspectorClient
|
|||
return this.discoverResult;
|
||||
}
|
||||
|
||||
private connectionDiagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: 0,
|
||||
outstandingRequests: [],
|
||||
};
|
||||
|
||||
getConnectionDiagnostics(): ConnectionDiagnostics {
|
||||
return this.connectionDiagnostics;
|
||||
}
|
||||
|
||||
/** Test helper: set the diagnostics snapshot and emit the change (#2318). */
|
||||
setConnectionDiagnostics(diagnostics: ConnectionDiagnostics): void {
|
||||
this.connectionDiagnostics = diagnostics;
|
||||
this.dispatchTypedEvent("connectionDiagnosticsChange", diagnostics);
|
||||
}
|
||||
|
||||
resourceSubscriptionStreamState: ResourceSubscriptionStreamState =
|
||||
INACTIVE_SUBSCRIPTION_STREAM_STATE;
|
||||
|
||||
|
|
|
|||
252
core/mcp/connectionDiagnostics.ts
Normal file
252
core/mcp/connectionDiagnostics.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* Connection diagnostics: what the client is still waiting on, when it last
|
||||
* heard back, and the state of the server-to-client notification stream — and
|
||||
* the one place that turns the SDK's bare `Request timed out` into a message
|
||||
* that reports those facts (#2318).
|
||||
*
|
||||
* The motivating case is #2187: a server that serves one request at a time.
|
||||
* The standalone `GET /mcp` stream the client opens after `initialize`
|
||||
* occupies that single slot, so `initialize` succeeds and every request after
|
||||
* it hangs — and the Inspector's only word on it was `Request timed out`,
|
||||
* which names the symptom the user can already see. Every fact needed to
|
||||
* suspect the cause was already in-process at that moment: the stream had been
|
||||
* open for the whole session and delivered nothing, `tools/list` had been
|
||||
* outstanding for 60s, and nothing had been answered since `initialize`.
|
||||
*
|
||||
* Everything here is pure. `InspectorClient` owns the bookkeeping (it sees
|
||||
* every outbound request, every inbound response and every tracked fetch) and
|
||||
* hands a snapshot to {@link annotateRequestTimeout}; the UI clients render the
|
||||
* same snapshot through `getConnectionDiagnostics()`.
|
||||
*
|
||||
* Deliberately factual: the summary reports what the connection's state *is*
|
||||
* and lets the reader draw the conclusion. It does not assert a diagnosis the
|
||||
* Inspector cannot verify from its own side.
|
||||
*/
|
||||
|
||||
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
|
||||
|
||||
/** A request this client sent that no response has arrived for. */
|
||||
export interface OutstandingRequest {
|
||||
id: string | number;
|
||||
method: string;
|
||||
/** Epoch ms at which the request went out. */
|
||||
sentAt: number;
|
||||
}
|
||||
|
||||
/** The most recent response received to one of this client's requests. */
|
||||
export interface LastResponse {
|
||||
/** The method of the request it answered. */
|
||||
method: string;
|
||||
/** Epoch ms at which it arrived. */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent long-lived server-to-client stream the transport opened —
|
||||
* the standalone `GET` on Streamable HTTP, or the primary event stream on
|
||||
* legacy SSE. Absent on stdio, and until the first such stream is opened.
|
||||
*/
|
||||
export interface NotificationStreamState {
|
||||
/** The request URL, as recorded (query already redacted). */
|
||||
url: string;
|
||||
/** Epoch ms at which the response headers arrived and the stream opened. */
|
||||
openedAt: number;
|
||||
/** SSE events delivered so far — messages, not keepalive comments. */
|
||||
eventCount: number;
|
||||
/** Epoch ms at which the stream ended, once it has. */
|
||||
closedAt?: number;
|
||||
}
|
||||
|
||||
export interface ConnectionDiagnostics {
|
||||
/**
|
||||
* Epoch ms at which this snapshot was taken. Every duration derived from
|
||||
* the snapshot ("sent 60s ago", "open for 4m12s") is measured against this
|
||||
* clock rather than against the moment of rendering, so the snapshot is
|
||||
* self-contained: a message written from it, a component rendering it and
|
||||
* a test asserting on it all read the same numbers.
|
||||
*/
|
||||
capturedAt: number;
|
||||
/**
|
||||
* Every request sent this session that has received no response, oldest
|
||||
* first. A request the SDK gave up on (a timeout, a cancel) stays here: it
|
||||
* is still unanswered, which is the fact being reported.
|
||||
*/
|
||||
outstandingRequests: OutstandingRequest[];
|
||||
lastResponse?: LastResponse;
|
||||
notificationStream?: NotificationStreamState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data the SDK attaches to a per-request timeout: `{ timeout }` in ms.
|
||||
* Narrowed here rather than imported because the SDK types it `unknown`.
|
||||
*/
|
||||
interface RequestTimeoutData {
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
function isRequestTimeoutData(value: unknown): value is RequestTimeoutData {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as { timeout?: unknown }).timeout === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `err` is the SDK's per-request timeout: an `SdkError` with
|
||||
* `RequestTimeout` and a numeric `timeout` in its data.
|
||||
*
|
||||
* The data check is not decoration. `Protocol.request` reuses the
|
||||
* `RequestTimeout` code for two other rejections that are not timeouts at
|
||||
* all — an abort through `options.signal` whose reason is not an `SdkError`
|
||||
* (the Cancel button), and "Maximum total timeout exceeded" — and neither
|
||||
* carries `{ timeout }`. Only the genuine per-request timeout does.
|
||||
*/
|
||||
export function isRequestTimeoutError(
|
||||
err: unknown,
|
||||
): err is SdkError & { data: RequestTimeoutData } {
|
||||
return (
|
||||
SdkError.isInstance(err) &&
|
||||
err.code === SdkErrorCode.RequestTimeout &&
|
||||
isRequestTimeoutData(err.data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact human duration: `850ms`, `12s`, `4m12s`, `1h02m`. Sub-second
|
||||
* values are shown in ms so a very short timeout still reads as a number
|
||||
* rather than as `0s`.
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const clamped = Math.max(0, ms);
|
||||
if (clamped < 1000) return `${Math.round(clamped)}ms`;
|
||||
const totalSeconds = Math.round(clamped / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m`;
|
||||
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many outstanding requests the summary names individually before
|
||||
* collapsing the rest into "and N more". Enough to show the shape of a stall
|
||||
* (the one everything is queued behind, plus what queued behind it) without
|
||||
* turning a toast into a page.
|
||||
*/
|
||||
export const MAX_LISTED_OUTSTANDING_REQUESTS = 5;
|
||||
|
||||
/**
|
||||
* `GET /mcp` for a stream URL: method plus path only. The origin is the
|
||||
* server the user is already connected to, and the query string (already
|
||||
* redacted at tracking time) adds nothing to a one-line summary. Falls back to
|
||||
* the raw string for an unparseable URL rather than dropping the clause.
|
||||
*/
|
||||
export function describeStreamEndpoint(url: string): string {
|
||||
try {
|
||||
return `GET ${new URL(url).pathname}`;
|
||||
} catch {
|
||||
return `GET ${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
function plural(count: number, noun: string): string {
|
||||
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function describeOutstanding(
|
||||
requests: OutstandingRequest[],
|
||||
now: number,
|
||||
): string {
|
||||
if (requests.length === 0) return "No requests are unanswered.";
|
||||
const listed = requests
|
||||
.slice(0, MAX_LISTED_OUTSTANDING_REQUESTS)
|
||||
.map((r) => `${r.method} (sent ${formatDuration(now - r.sentAt)} ago)`);
|
||||
const rest = requests.length - listed.length;
|
||||
const tail = rest > 0 ? `, and ${rest} more` : "";
|
||||
const verb = requests.length === 1 ? "is" : "are";
|
||||
return `${plural(requests.length, "request")} ${verb} unanswered: ${listed.join(", ")}${tail}.`;
|
||||
}
|
||||
|
||||
function describeLastResponse(
|
||||
lastResponse: LastResponse | undefined,
|
||||
now: number,
|
||||
): string {
|
||||
if (!lastResponse) return "No response has been received this session.";
|
||||
return `Last response received ${formatDuration(now - lastResponse.receivedAt)} ago (${lastResponse.method}).`;
|
||||
}
|
||||
|
||||
function describeStream(
|
||||
stream: NotificationStreamState | undefined,
|
||||
now: number,
|
||||
): string | undefined {
|
||||
if (!stream) return undefined;
|
||||
const endpoint = describeStreamEndpoint(stream.url);
|
||||
const events = plural(stream.eventCount, "event");
|
||||
if (stream.closedAt !== undefined) {
|
||||
return `Notification stream (${endpoint}) closed ${formatDuration(now - stream.closedAt)} ago after ${formatDuration(stream.closedAt - stream.openedAt)}, ${events} delivered.`;
|
||||
}
|
||||
return `Notification stream (${endpoint}) open for ${formatDuration(now - stream.openedAt)}, ${events} delivered.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One paragraph describing the connection's state as of the snapshot's own
|
||||
* clock: outstanding requests, the last response, and the notification
|
||||
* stream when there is one. Each clause is a sentence, so a consumer that
|
||||
* wants only one of them can split on the sentence boundary rather than
|
||||
* re-deriving it.
|
||||
*/
|
||||
export function describeConnectionDiagnostics(
|
||||
diagnostics: ConnectionDiagnostics,
|
||||
): string {
|
||||
const now = diagnostics.capturedAt;
|
||||
const parts = [
|
||||
describeOutstanding(diagnostics.outstandingRequests, now),
|
||||
describeLastResponse(diagnostics.lastResponse, now),
|
||||
describeStream(diagnostics.notificationStream, now),
|
||||
];
|
||||
return parts.filter((p): p is string => p !== undefined).join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The data carried by an annotated timeout: the SDK's own `{ timeout }`, plus
|
||||
* the method that timed out and the snapshot the message was written from.
|
||||
* Exposed as a type so a consumer that wants the structured form (a details
|
||||
* modal, a CLI's JSON output) can read it off `error.data` instead of parsing
|
||||
* the message.
|
||||
*/
|
||||
export interface AnnotatedRequestTimeoutData extends RequestTimeoutData {
|
||||
method: string;
|
||||
diagnostics: ConnectionDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the SDK's per-request timeout with a message that says which
|
||||
* request timed out and what the connection looked like at that moment.
|
||||
*
|
||||
* Anything that is not that exact error is returned untouched — the SDK's
|
||||
* other `RequestTimeout` rejections (see {@link isRequestTimeoutError}), and
|
||||
* every other failure — so this is safe to apply blindly on the rejection
|
||||
* path of every request.
|
||||
*
|
||||
* The result is a fresh `SdkError` with the same code, so an `isInstance` or
|
||||
* code check downstream sees exactly what it saw before; only the message and
|
||||
* the data change. The original is kept as `cause`.
|
||||
*/
|
||||
export function annotateRequestTimeout(
|
||||
err: unknown,
|
||||
method: string,
|
||||
diagnostics: ConnectionDiagnostics,
|
||||
): unknown {
|
||||
if (!isRequestTimeoutError(err)) return err;
|
||||
const data: AnnotatedRequestTimeoutData = {
|
||||
...err.data,
|
||||
method,
|
||||
diagnostics,
|
||||
};
|
||||
const message = `Request timed out after ${formatDuration(err.data.timeout)} (${method}). ${describeConnectionDiagnostics(diagnostics)}`;
|
||||
const annotated = new SdkError(SdkErrorCode.RequestTimeout, message, data);
|
||||
annotated.cause = err;
|
||||
return annotated;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { FetchRequestEntryBase } from "./types.js";
|
||||
import type { FetchRequestEntryBase, FetchStreamState } from "./types.js";
|
||||
|
||||
/**
|
||||
* Header names whose values are replaced with `REDACTED_HEADER_VALUE` before a
|
||||
|
|
@ -190,11 +190,18 @@ export function redactBody(
|
|||
return body;
|
||||
}
|
||||
|
||||
/** Case-insensitive lookup of a header value from a plain header record. */
|
||||
function findHeader(
|
||||
headers: Record<string, string>,
|
||||
/**
|
||||
* Case-insensitive lookup of a header value from a plain header record.
|
||||
* Exported because a recorded entry's header casing is host-dependent (Node
|
||||
* lowercases, happy-dom preserves the wire casing), so any consumer reading
|
||||
* one back — the Network UI, the connection diagnostics — needs the same
|
||||
* tolerance the tracker itself has.
|
||||
*/
|
||||
export function findHeader(
|
||||
headers: Record<string, string> | undefined,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
const target = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === target) return value;
|
||||
|
|
@ -233,6 +240,98 @@ export interface FetchTrackingCallbacks {
|
|||
* never invoked and the entry's responseBody stays undefined.
|
||||
*/
|
||||
updateResponseBody?: (id: string, responseBody: string) => void;
|
||||
/**
|
||||
* Called for a long-lived stream response — the one case `updateResponseBody`
|
||||
* never covers — each time an SSE event is delivered on it, and once more
|
||||
* when it ends (with `closedAt` set). Lets the consumer show that the stream
|
||||
* is open and what it has carried, which is the whole diagnostic when a
|
||||
* server serializes requests behind it (#2318).
|
||||
*/
|
||||
updateStream?: (id: string, stream: FetchStreamState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the SSE events on a long-lived stream without buffering it, and
|
||||
* report when it ends.
|
||||
*
|
||||
* Reads a `clone()` of the response through a reader and discards the bytes,
|
||||
* so the transport keeps consuming the original at its own pace (the same
|
||||
* tee the bounded-body path relies on). Counting follows the SSE framing the
|
||||
* transport's own parser applies: a blank line dispatches the block before
|
||||
* it, and only a block carrying a `data` field is an event — keepalive
|
||||
* comments (`: ping`) and bare `event:`/`id:` lines are not.
|
||||
*
|
||||
* Reports once as soon as watching starts (zero events, open), once per event,
|
||||
* and once more on every exit from the read loop — a clean end-of-stream, a
|
||||
* network error, or the transport aborting the fetch on disconnect (which
|
||||
* errors both tee branches) — with `closedAt` set. The reader is never cancelled from here:
|
||||
* cancelling one branch of a tee does not release the source, and
|
||||
* `ReadableStream.cancel()` adopts the source's promise, which on a teardown
|
||||
* path may never settle (the same hazard `core/mcp/remote/node/server.ts`
|
||||
* documents at its own `cancel()` call).
|
||||
*/
|
||||
function watchLongLivedStream(
|
||||
response: Response,
|
||||
id: string,
|
||||
callbacks: FetchTrackingCallbacks,
|
||||
): void {
|
||||
const update = callbacks.updateStream;
|
||||
if (!update) return;
|
||||
let body: ReadableStream<Uint8Array> | null;
|
||||
try {
|
||||
body = response.clone().body;
|
||||
} catch {
|
||||
// Clone failed (consumed body, transport quirks) — there is nothing to
|
||||
// watch; the entry stays as tracked with no stream state.
|
||||
return;
|
||||
}
|
||||
if (!body) return;
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let eventCount = 0;
|
||||
let pending = "";
|
||||
let blockHasData = false;
|
||||
// Announce the stream as open before a byte arrives. A stream that never
|
||||
// delivers anything is the #2187 tell, and it would otherwise be the one
|
||||
// stream with no state to show — the count only moves on an event.
|
||||
update(id, { eventCount });
|
||||
|
||||
const consumeLine = (rawLine: string): void => {
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line === "") {
|
||||
if (blockHasData) {
|
||||
eventCount += 1;
|
||||
blockHasData = false;
|
||||
update(id, { eventCount });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A field line is `name[:value]`; `data` with no colon is still a data
|
||||
// field per the spec, so match the name rather than the `data:` prefix.
|
||||
if (line === "data" || line.startsWith("data:")) blockHasData = true;
|
||||
};
|
||||
|
||||
const pump = async (): Promise<void> => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
pending += decoder.decode(value, { stream: true });
|
||||
let newline = pending.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
consumeLine(pending.slice(0, newline));
|
||||
pending = pending.slice(newline + 1);
|
||||
newline = pending.indexOf("\n");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An errored or aborted stream has still ended — fall through to report
|
||||
// the close; the entry's `error` field is for a fetch that never got a
|
||||
// response, which this one did.
|
||||
}
|
||||
update(id, { eventCount, closedAt: new Date() });
|
||||
};
|
||||
void pump();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -378,6 +477,13 @@ export function createFetchTracker(
|
|||
|
||||
callbacks.trackRequest?.(entry);
|
||||
|
||||
// A long-lived stream is watched instead of read: its body never ends,
|
||||
// but its event count and its eventual close are exactly the state a
|
||||
// stalled connection is diagnosed from (#2318).
|
||||
if (isLongLivedStream && response.body && !response.bodyUsed) {
|
||||
watchLongLivedStream(response, id, callbacks);
|
||||
}
|
||||
|
||||
// Kick off a fire-and-forget read of the cloned body. The clone is an
|
||||
// independent tee'd stream so the transport keeps consuming the
|
||||
// original at its own pace. When the read resolves we patch the entry
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
MessageOrigin,
|
||||
FetchRequestEntry,
|
||||
FetchRequestEntryBase,
|
||||
FetchStreamState,
|
||||
InspectorServerSettings,
|
||||
ResourceReadInvocation,
|
||||
ResourceTemplateReadInvocation,
|
||||
|
|
@ -264,10 +265,29 @@ import {
|
|||
import type { TokenRevocationOutcome } from "../auth/revocation.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
||||
import { createFetchTracker } from "./fetchTracking.js";
|
||||
import {
|
||||
createFetchTracker,
|
||||
findHeader,
|
||||
isLongLivedStreamResponse,
|
||||
} from "./fetchTracking.js";
|
||||
import {
|
||||
annotateRequestTimeout,
|
||||
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;
|
||||
|
|
@ -475,13 +495,23 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private outputValidator: AjvJsonSchemaValidator | null = null;
|
||||
private transport: Transport | MessageTrackingTransport | null = null;
|
||||
private baseTransport: Transport | null = null;
|
||||
// Correlation for `markResponseRejected` (#1953): the method of each
|
||||
// outbound request still awaiting a response, and — once one is answered —
|
||||
// the id of the most recently answered request per method. Entries are
|
||||
// dropped as responses arrive, so this holds at most one id per method
|
||||
// rather than growing with the session.
|
||||
private outboundRequestMethods = new Map<string | number, string>();
|
||||
// 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). */
|
||||
|
|
@ -1006,6 +1036,47 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1043,7 +1114,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return {
|
||||
trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => {
|
||||
if (origin === "client") {
|
||||
this.outboundRequestMethods.set(message.id, message.method);
|
||||
this.outstandingRequests.set(message.id, {
|
||||
id: message.id,
|
||||
method: message.method,
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
this.dispatchConnectionDiagnosticsChange();
|
||||
}
|
||||
const entry: MessageEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -1065,11 +1141,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// frame answers no specific request, so it is skipped.
|
||||
const responseId = message.id;
|
||||
if (origin === "server" && responseId !== undefined) {
|
||||
const method = this.outboundRequestMethods.get(responseId);
|
||||
this.outboundRequestMethods.delete(responseId);
|
||||
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(),
|
||||
|
|
@ -1928,8 +2006,11 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// start-clean path rather than in `disconnect()` for the reason documented
|
||||
// above: one route out (`onerror` with no `onclose`) tears down nothing
|
||||
// (#1953).
|
||||
this.outboundRequestMethods.clear();
|
||||
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
|
||||
|
|
@ -2098,10 +2179,14 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
},
|
||||
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) {
|
||||
|
|
@ -5962,6 +6047,80 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
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,
|
||||
openedAt: entry.timestamp.getTime(),
|
||||
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(),
|
||||
outstandingRequests: [...this.outstandingRequests.values()],
|
||||
...(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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
MessageEntry,
|
||||
StderrLogEntry,
|
||||
FetchRequestEntry,
|
||||
FetchStreamState,
|
||||
PromptGetInvocation,
|
||||
ResourceReadInvocation,
|
||||
ResourceTemplateReadInvocation,
|
||||
|
|
@ -28,6 +29,7 @@ import type {
|
|||
RequestMetadata,
|
||||
} from "./types.js";
|
||||
import type { MalformedListItem } from "./listSalvage.js";
|
||||
import type { ConnectionDiagnostics } from "./connectionDiagnostics.js";
|
||||
import type {
|
||||
Tool,
|
||||
ServerCapabilities,
|
||||
|
|
@ -92,6 +94,20 @@ export interface InspectorClientEventMap {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import type { JsonValue } from "../json/jsonUtils.js";
|
||||
import type { MalformedListItem } from "./listSalvage.js";
|
||||
import type { ConnectionDiagnostics } from "./connectionDiagnostics.js";
|
||||
import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js";
|
||||
import type { SkillEntry } from "./skillsSchemas.js";
|
||||
import type { SkillsExtensionSupport } from "./skills.js";
|
||||
|
|
@ -78,6 +79,14 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
getDiscoverResult(): DiscoverResult | undefined;
|
||||
getServerSettings(): InspectorServerSettings | undefined;
|
||||
setServerSettings(settings: InspectorServerSettings): void;
|
||||
/**
|
||||
* What the client is still waiting on, when it last heard back, and the
|
||||
* state of the notification stream — the snapshot a request timeout is
|
||||
* annotated with (#2318). Optional so existing test doubles satisfy the
|
||||
* interface without implementing it; `connectionDiagnosticsChange` fires
|
||||
* whenever it changes.
|
||||
*/
|
||||
getConnectionDiagnostics?(): ConnectionDiagnostics;
|
||||
getAppRendererClient(): AppRendererClient | null;
|
||||
|
||||
// Connection control
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export function createTransportNode(
|
|||
pipeStderr = false,
|
||||
onFetchRequest,
|
||||
onFetchResponseBody,
|
||||
onFetchStreamUpdate,
|
||||
authProvider,
|
||||
settings,
|
||||
interceptAuthChallenges = false,
|
||||
|
|
@ -118,6 +119,7 @@ export function createTransportNode(
|
|||
? createFetchTracker(sseFetch, {
|
||||
trackRequest: onFetchRequest,
|
||||
updateResponseBody: onFetchResponseBody,
|
||||
updateStream: onFetchStreamUpdate,
|
||||
})
|
||||
: sseFetch;
|
||||
|
||||
|
|
@ -138,6 +140,7 @@ export function createTransportNode(
|
|||
? createFetchTracker(fetchWithOptionalAuthIntercept, {
|
||||
trackRequest: onFetchRequest,
|
||||
updateResponseBody: onFetchResponseBody,
|
||||
updateStream: onFetchStreamUpdate,
|
||||
})
|
||||
: fetchWithOptionalAuthIntercept;
|
||||
|
||||
|
|
@ -165,6 +168,7 @@ export function createTransportNode(
|
|||
? createFetchTracker(fetchWithOptionalAuthIntercept, {
|
||||
trackRequest: onFetchRequest,
|
||||
updateResponseBody: onFetchResponseBody,
|
||||
updateStream: onFetchStreamUpdate,
|
||||
})
|
||||
: fetchWithOptionalAuthIntercept;
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function createRemoteTransport(
|
|||
onStderr: transportOptions.onStderr,
|
||||
onFetchRequest: transportOptions.onFetchRequest,
|
||||
onFetchResponseBody: transportOptions.onFetchResponseBody,
|
||||
onFetchStreamUpdate: transportOptions.onFetchStreamUpdate,
|
||||
authProvider: transportOptions.authProvider,
|
||||
settings: transportOptions.settings,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import type { Transport } from "@modelcontextprotocol/client";
|
||||
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||
import type { FetchRequestEntryBase } from "../../types.js";
|
||||
import type { FetchRequestEntryBase, FetchStreamState } from "../../types.js";
|
||||
import type { RemoteEvent } from "../types.js";
|
||||
import type { AuthChallenge } from "../../../auth/challenge.js";
|
||||
import { AuthChallengeError } from "../../../auth/challenge.js";
|
||||
|
|
@ -288,6 +288,17 @@ export class RemoteSession {
|
|||
});
|
||||
}
|
||||
|
||||
onFetchStreamUpdate(id: string, stream: FetchStreamState): void {
|
||||
this.pushEvent({
|
||||
type: "fetch_stream_update",
|
||||
data: {
|
||||
id,
|
||||
eventCount: stream.eventCount,
|
||||
...(stream.closedAt && { closedAt: stream.closedAt.toISOString() }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onStderr(entry: { timestamp: Date; message: string }): void {
|
||||
this.pushEvent({
|
||||
type: "stdio_log",
|
||||
|
|
|
|||
|
|
@ -868,6 +868,8 @@ export function createRemoteApp(
|
|||
onFetchRequest: (entry) => session.onFetchRequest(entry),
|
||||
onFetchResponseBody: (id, body) =>
|
||||
session.onFetchResponseBody(id, body),
|
||||
onFetchStreamUpdate: (id, stream) =>
|
||||
session.onFetchStreamUpdate(id, stream),
|
||||
authProvider: authHandle?.provider,
|
||||
settings: body.settings,
|
||||
// Always intercept 401/403 on the node MCP transport. Without this, the
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
MessageExtraInfo,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { InspectorServerSettings, StderrLogEntry } from "../types.js";
|
||||
import type { FetchRequestEntryBase } from "../types.js";
|
||||
import type { FetchRequestEntryBase, FetchStreamState } from "../types.js";
|
||||
import type {
|
||||
AuthChallenge,
|
||||
AuthChallengeOutcome,
|
||||
|
|
@ -60,6 +60,9 @@ export interface RemoteTransportOptions {
|
|||
/** Callback for async response-body updates to a previously tracked fetch. */
|
||||
onFetchResponseBody?: (id: string, responseBody: string) => void;
|
||||
|
||||
/** Callback for a previously tracked long-lived stream's events and close (#2318). */
|
||||
onFetchStreamUpdate?: (id: string, stream: FetchStreamState) => void;
|
||||
|
||||
/** Optional OAuth client provider for Bearer authentication */
|
||||
authProvider?: import("@modelcontextprotocol/client").OAuthClientProvider;
|
||||
|
||||
|
|
@ -545,6 +548,15 @@ export class RemoteClientTransport implements Transport {
|
|||
parsed.data.id,
|
||||
parsed.data.responseBody,
|
||||
);
|
||||
} else if (
|
||||
parsed.type === "fetch_stream_update" &&
|
||||
this.options.onFetchStreamUpdate
|
||||
) {
|
||||
const { id, eventCount, closedAt } = parsed.data;
|
||||
this.options.onFetchStreamUpdate(id, {
|
||||
eventCount,
|
||||
...(closedAt !== undefined && { closedAt: new Date(closedAt) }),
|
||||
});
|
||||
} else if (parsed.type === "stdio_log" && this.options.onStderr) {
|
||||
this.options.onStderr({
|
||||
timestamp: new Date(parsed.data.timestamp),
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export type RemoteEventType =
|
|||
| "message"
|
||||
| "fetch_request"
|
||||
| "fetch_request_body_update"
|
||||
| "fetch_stream_update"
|
||||
| "stdio_log"
|
||||
| "transport_error"
|
||||
| "auth_challenge";
|
||||
|
|
@ -156,6 +157,16 @@ export interface RemoteEventFetchRequestBodyUpdate {
|
|||
data: { id: string; responseBody: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* A long-lived stream's lifecycle, forwarded from the backend's fetch tracker
|
||||
* (#2318). `closedAt` is an ISO string on the wire; the browser transport
|
||||
* rebuilds the `Date` the way it does for `fetch_request`'s timestamp.
|
||||
*/
|
||||
export interface RemoteEventFetchStreamUpdate {
|
||||
type: "fetch_stream_update";
|
||||
data: { id: string; eventCount: number; closedAt?: string };
|
||||
}
|
||||
|
||||
export interface RemoteEventStdioLog {
|
||||
type: "stdio_log";
|
||||
data: { timestamp: string; message: string };
|
||||
|
|
@ -178,6 +189,7 @@ export type RemoteEvent =
|
|||
| RemoteEventMessage
|
||||
| RemoteEventFetchRequest
|
||||
| RemoteEventFetchRequestBodyUpdate
|
||||
| RemoteEventFetchStreamUpdate
|
||||
| RemoteEventStdioLog
|
||||
| RemoteEventTransportError
|
||||
| RemoteEventAuthChallenge;
|
||||
|
|
|
|||
|
|
@ -151,6 +151,28 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
|
|||
onFetchRequestBodyUpdate,
|
||||
);
|
||||
|
||||
// A long-lived stream's lifecycle arrives the same way its sibling's
|
||||
// body does: asynchronously, patched onto the entry in place (#2318). A
|
||||
// missing entry is left silent here, unlike the body path — a stream
|
||||
// reports on every event, so after a clear the stragglers are many and
|
||||
// none of them is data the user could have wanted retained.
|
||||
const onFetchRequestStreamUpdate = (
|
||||
event: TypedEventGeneric<
|
||||
InspectorClientEventMap,
|
||||
"fetchRequestStreamUpdate"
|
||||
>,
|
||||
): void => {
|
||||
const { id, stream } = event.detail;
|
||||
const idx = this.fetchRequests.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return;
|
||||
this.fetchRequests[idx] = { ...this.fetchRequests[idx]!, stream };
|
||||
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
|
||||
};
|
||||
this.client.addEventListener(
|
||||
"fetchRequestStreamUpdate",
|
||||
onFetchRequestStreamUpdate,
|
||||
);
|
||||
|
||||
const sessionStorage = options.sessionStorage;
|
||||
const sessionId = options.sessionId;
|
||||
|
||||
|
|
@ -194,6 +216,10 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
|
|||
"fetchRequestBodyUpdate",
|
||||
onFetchRequestBodyUpdate,
|
||||
);
|
||||
this.client.removeEventListener(
|
||||
"fetchRequestStreamUpdate",
|
||||
onFetchRequestStreamUpdate,
|
||||
);
|
||||
this.client.removeEventListener("saveSession", onSaveSession);
|
||||
}
|
||||
this.client = null;
|
||||
|
|
@ -220,6 +246,10 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
|
|||
"fetchRequestBodyUpdate",
|
||||
onFetchRequestBodyUpdate,
|
||||
);
|
||||
this.client.removeEventListener(
|
||||
"fetchRequestStreamUpdate",
|
||||
onFetchRequestStreamUpdate,
|
||||
);
|
||||
}
|
||||
this.client = null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -376,10 +376,32 @@ export interface FetchRequestEntry {
|
|||
responseBody?: string;
|
||||
duration?: number; // Time between request and response in ms
|
||||
error?: string;
|
||||
/**
|
||||
* Lifecycle of a long-lived stream response (the standalone `GET` on
|
||||
* Streamable HTTP, the legacy SSE event stream), whose body is never
|
||||
* captured. Filled in asynchronously via `fetchRequestStreamUpdate` as
|
||||
* events arrive and when the stream ends; absent on every bounded response
|
||||
* (#2318).
|
||||
*/
|
||||
stream?: FetchStreamState;
|
||||
/** Distinguishes OAuth/auth fetches from MCP transport fetches */
|
||||
category: FetchRequestCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the fetch tracker knows about a long-lived stream it declined to
|
||||
* buffer: how many SSE events have been delivered on it so far, and when it
|
||||
* ended. `closedAt` is absent while the stream is still open. The Network
|
||||
* tab renders it in place of the body, and `InspectorClient` folds the most
|
||||
* recent transport stream into its connection diagnostics (#2318).
|
||||
*/
|
||||
export interface FetchStreamState {
|
||||
/** SSE events delivered so far — dispatched events, not keepalive comments. */
|
||||
eventCount: number;
|
||||
/** When the stream ended (server close, network error, or abort), once it has. */
|
||||
closedAt?: Date;
|
||||
}
|
||||
|
||||
/** Entry shape from createFetchTracker before category is added by the caller */
|
||||
export type FetchRequestEntryBase = Omit<FetchRequestEntry, "category">;
|
||||
|
||||
|
|
@ -966,6 +988,14 @@ export interface CreateTransportOptions {
|
|||
*/
|
||||
onFetchResponseBody?: (id: string, responseBody: string) => void;
|
||||
|
||||
/**
|
||||
* Optional callback fired asynchronously as a previously tracked long-lived
|
||||
* stream (GET + `text/event-stream`) delivers events and when it ends. The
|
||||
* tracker never buffers such a body, so this is the only view the consumer
|
||||
* gets of the stream's lifetime (#2318).
|
||||
*/
|
||||
onFetchStreamUpdate?: (id: string, stream: FetchStreamState) => void;
|
||||
|
||||
/**
|
||||
* Optional OAuth client provider for Bearer authentication (SSE, streamable-http).
|
||||
* When set, the SDK injects tokens and handles 401 via the provider.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
} 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`
|
||||
|
|
@ -27,6 +28,13 @@ const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
|
|||
*/
|
||||
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();
|
||||
|
|
@ -53,6 +61,10 @@ const readExcludedTools = (client: InspectorClientProtocol): ExcludedTool[] =>
|
|||
const readMalformedListItems = (
|
||||
client: InspectorClientProtocol,
|
||||
): MalformedListItem[] => client.getMalformedListItems();
|
||||
const readConnectionDiagnostics = (
|
||||
client: InspectorClientProtocol,
|
||||
): ConnectionDiagnostics =>
|
||||
client.getConnectionDiagnostics?.() ?? NO_CONNECTION_DIAGNOSTICS;
|
||||
|
||||
export interface UseInspectorClientResult {
|
||||
status: ConnectionStatus;
|
||||
|
|
@ -86,6 +98,13 @@ export interface UseInspectorClientResult {
|
|||
* 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
|
||||
|
|
@ -174,6 +193,12 @@ export function useInspectorClient(
|
|||
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
|
||||
|
|
@ -243,6 +268,7 @@ export function useInspectorClient(
|
|||
discoverResult,
|
||||
excludedTools,
|
||||
malformedListItems,
|
||||
connectionDiagnostics,
|
||||
lastError,
|
||||
appRendererClient: inspectorClient?.getAppRendererClient() ?? null,
|
||||
connect,
|
||||
|
|
|
|||
Loading…
Reference in a new issue