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:
cliffhall 2026-09-13 21:25:31 -04:00
commit d57801d7b3
38 changed files with 2315 additions and 32 deletions

View file

@ -714,6 +714,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
instructions: undefined, instructions: undefined,
excludedTools: [], excludedTools: [],
malformedListItems: [], malformedListItems: [],
connectionDiagnostics: { capturedAt: 0, outstandingRequests: [] },
appRendererClient: null, appRendererClient: null,
connect: vi.fn().mockResolvedValue(undefined), connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined),

View file

@ -401,6 +401,7 @@ function App() {
discoverResult, discoverResult,
excludedTools, excludedTools,
malformedListItems, malformedListItems,
connectionDiagnostics,
lastError, lastError,
} = useInspectorClient(inspectorClient); } = useInspectorClient(inspectorClient);
// What every settings write that landed on disk actually wrote, so a later // What every settings write that landed on disk actually wrote, so a later
@ -2028,6 +2029,7 @@ function App() {
transport={connectionInfoTransport} transport={connectionInfoTransport}
protocolEra={protocolEra} protocolEra={protocolEra}
discoverResult={discoverResult} discoverResult={discoverResult}
diagnostics={connectionDiagnostics}
oauth={connectionInfoOAuth} oauth={connectionInfoOAuth}
onClearOAuth={ onClearOAuth={
connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined

View file

@ -46,6 +46,51 @@ export const FullCapabilities: Story = {
}, },
}; };
// The snapshot clock, read once when the stories load. The rows start from it
// and then tick against the wall clock, so the fixture has to be "now" — a
// fixed historical date would read as months ago a second after mounting.
const STORY_NOW = Date.now();
/**
* The Connection Activity section, in the #2187 shape: the notification
* stream has been open the whole session and delivered nothing, `tools/list`
* has been outstanding for a minute, and nothing has been answered since
* `initialize`. The same facts a request timeout's message reports, shown
* while the request is still in flight (#2318).
*/
export const WithConnectionActivity: Story = {
args: {
initializeResult: fullResult,
clientCapabilities: fullClientCaps,
transport: "streamable-http",
diagnostics: {
capturedAt: STORY_NOW,
outstandingRequests: [
{ id: 2, method: "tools/list", sentAt: STORY_NOW - 60_000 },
{ id: 3, method: "ping", sentAt: STORY_NOW - 12_000 },
],
lastResponse: { method: "initialize", receivedAt: STORY_NOW - 61_000 },
notificationStream: {
url: "http://127.0.0.1:9779/mcp",
openedAt: STORY_NOW - 252_000,
eventCount: 0,
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Connection Activity")).toBeInTheDocument();
// Ticking: the rows may have advanced a second or two since the fixture
// was stamped, so match the shape rather than the exact second.
await expect(
canvas.getByText(/^tools\/list — sent 1m0\ds ago$/),
).toBeInTheDocument();
await expect(
canvas.getByText(/^GET \/mcp — open for 4m1\ds, 0 events delivered$/),
).toBeInTheDocument();
},
};
export const ModernEra: Story = { export const ModernEra: Story = {
args: { args: {
initializeResult: { initializeResult: {

View file

@ -5,6 +5,13 @@ import type {
InitializeResult, InitializeResult,
} from "@modelcontextprotocol/client"; } from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
import {
NO_NOTIFICATION_STREAM_LABEL,
NO_OUTSTANDING_REQUESTS_LABEL,
NO_RESPONSE_YET_LABEL,
NO_STREAM_ON_STDIO_LABEL,
} from "../../../utils/connectionActivity";
import { import {
CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
ConnectionInfoContent, ConnectionInfoContent,
@ -934,3 +941,81 @@ describe("ConnectionInfoContent", () => {
expect(onClearOAuth).toHaveBeenCalledTimes(1); expect(onClearOAuth).toHaveBeenCalledTimes(1);
}); });
}); });
describe("ConnectionInfoContent connection activity (#2318)", () => {
const NOW = 1_000_000;
const seconds = (n: number) => n * 1000;
it("omits the section when no diagnostics are supplied", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
/>,
);
expect(screen.queryByText("Connection Activity")).not.toBeInTheDocument();
});
it("renders the idle state: nothing unanswered, nothing received, stream not opened", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
diagnostics={{ capturedAt: NOW, outstandingRequests: [] }}
/>,
);
expect(screen.getByText("Connection Activity")).toBeInTheDocument();
expect(screen.getByText(NO_OUTSTANDING_REQUESTS_LABEL)).toBeInTheDocument();
expect(screen.getByText(NO_RESPONSE_YET_LABEL)).toBeInTheDocument();
expect(screen.getByText(NO_NOTIFICATION_STREAM_LABEL)).toBeInTheDocument();
});
it("lists each unanswered request on its own line, with the last response and the open stream", () => {
const diagnostics: ConnectionDiagnostics = {
capturedAt: NOW,
outstandingRequests: [
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
{ id: 3, method: "ping", sentAt: NOW - seconds(12) },
],
lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
notificationStream: {
url: "http://127.0.0.1:9779/mcp",
openedAt: NOW - seconds(252),
eventCount: 0,
},
};
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
diagnostics={diagnostics}
/>,
);
const outstanding = screen.getByTestId("connection-activity-outstanding");
expect(outstanding.children).toHaveLength(2);
expect(outstanding.children[0]?.textContent).toBe(
"tools/list — sent 1m00s ago",
);
expect(outstanding.children[1]?.textContent).toBe("ping — sent 12s ago");
expect(screen.getByText("initialize — 1m01s ago")).toBeInTheDocument();
expect(
screen.getByText("GET /mcp — open for 4m12s, 0 events delivered"),
).toBeInTheDocument();
});
it("reports the stream row as not applicable on stdio", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
diagnostics={{ capturedAt: NOW, outstandingRequests: [] }}
/>,
);
// Both the Session row and the Notification stream row read N/A (stdio).
expect(screen.getAllByText(NO_STREAM_ON_STDIO_LABEL)).toHaveLength(2);
});
});

View file

@ -18,6 +18,14 @@ import type {
ServerCapabilities, ServerCapabilities,
} from "@modelcontextprotocol/client"; } from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js"; import type { ServerType } from "@inspector/core/mcp/types.js";
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
import {
formatLastResponse,
formatNotificationStream,
formatOutstandingRequests,
NO_OUTSTANDING_REQUESTS_LABEL,
} from "../../../utils/connectionActivity";
import { useTickingClock } from "../../../hooks/useTickingClock";
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { getSkillsExtension } from "@inspector/core/mcp/skills.js"; import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js"; import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
@ -75,6 +83,16 @@ export interface ConnectionInfoContentProps {
* capabilities, and extensions learned up front. Undefined on legacy. (#1626) * capabilities, and extensions learned up front. Undefined on legacy. (#1626)
*/ */
discoverResult?: DiscoverResult; discoverResult?: DiscoverResult;
/**
* What the client is still waiting on, when it last heard back, and the
* state of the notification stream (#2318). Renders the Connection Activity
* section when present — the same facts a request timeout reports, shown
* here while the request is still in flight. Durations first read against
* the snapshot's own `capturedAt` and then tick once a second while the
* panel is open (`useTickingClock`), so an in-flight request's age keeps
* moving rather than freezing at the moment it went out.
*/
diagnostics?: ConnectionDiagnostics;
oauth?: OAuthDetails; oauth?: OAuthDetails;
onClearOAuth?: () => void; onClearOAuth?: () => void;
} }
@ -291,6 +309,7 @@ export function ConnectionInfoContent({
transport, transport,
protocolEra, protocolEra,
discoverResult, discoverResult,
diagnostics,
oauth, oauth,
onClearOAuth, onClearOAuth,
}: ConnectionInfoContentProps) { }: ConnectionInfoContentProps) {
@ -317,6 +336,10 @@ export function ConnectionInfoContent({
? serverInfo.version?.trim() || "—" ? serverInfo.version?.trim() || "—"
: SERVER_INFO_NOT_REPORTED_LABEL; : SERVER_INFO_NOT_REPORTED_LABEL;
// The activity rows' clock: the snapshot's own on first paint (pure), then
// the wall clock once a second so "sent 5s ago" keeps counting.
const now = useTickingClock(diagnostics?.capturedAt ?? 0);
const serverCaps = getServerCapabilityEntries(capabilities, protocolEra); const serverCaps = getServerCapabilityEntries(capabilities, protocolEra);
const clientCaps = getCapabilityEntries( const clientCaps = getCapabilityEntries(
clientCapabilities, clientCapabilities,
@ -348,6 +371,42 @@ export function ConnectionInfoContent({
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
{diagnostics && (
<Stack gap="xs">
<SectionHeading>Connection Activity</SectionHeading>
<SimpleGrid cols={2}>
<FieldLabel>Unanswered requests</FieldLabel>
{/* A `Stack` so several in-flight requests read as a list, the
way the extension sections do; one line per request. */}
<Stack gap={2} data-testid="connection-activity-outstanding">
{diagnostics.outstandingRequests.length === 0 ? (
<ValueText>{NO_OUTSTANDING_REQUESTS_LABEL}</ValueText>
) : (
formatOutstandingRequests(diagnostics, now).map(
(line, index) => (
<ValueText key={diagnostics.outstandingRequests[index]!.id}>
{line}
</ValueText>
),
)
)}
</Stack>
<FieldLabel>Last response</FieldLabel>
<ValueText>{formatLastResponse(diagnostics, now)}</ValueText>
<FieldLabel>Notification stream</FieldLabel>
<ValueText>
{formatNotificationStream(
diagnostics.notificationStream,
transport,
now,
)}
</ValueText>
</SimpleGrid>
</Stack>
)}
{discoverResult && ( {discoverResult && (
<Stack gap="xs"> <Stack gap="xs">
<SectionHeading>Discovery</SectionHeading> <SectionHeading>Discovery</SectionHeading>

View file

@ -56,6 +56,27 @@ describe("ConnectionInfoModal", () => {
expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument(); expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument();
}); });
it("forwards connection diagnostics to ConnectionInfoContent (#2318)", () => {
renderWithMantine(
<ConnectionInfoModal
opened
onClose={vi.fn()}
initializeResult={initializeResult}
serverInfoReported
clientCapabilities={clientCapabilities}
transport="streamable-http"
diagnostics={{
capturedAt: 1_000_000,
outstandingRequests: [
{ id: 2, method: "tools/list", sentAt: 1_000_000 - 60_000 },
],
}}
/>,
);
expect(screen.getByText("Connection Activity")).toBeInTheDocument();
expect(screen.getByText("tools/list — sent 1m00s ago")).toBeInTheDocument();
});
it("forwards oauth details to ConnectionInfoContent", () => { it("forwards oauth details to ConnectionInfoContent", () => {
renderWithMantine( renderWithMantine(
<ConnectionInfoModal <ConnectionInfoModal

View file

@ -6,6 +6,7 @@ import type {
ProtocolEra, ProtocolEra,
} from "@modelcontextprotocol/client"; } from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js"; import type { ServerType } from "@inspector/core/mcp/types.js";
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
import { import {
ConnectionInfoContent, ConnectionInfoContent,
type OAuthDetails, type OAuthDetails,
@ -38,6 +39,8 @@ export interface ConnectionInfoModalProps {
transport: ServerType; transport: ServerType;
protocolEra?: ProtocolEra; protocolEra?: ProtocolEra;
discoverResult?: DiscoverResult; discoverResult?: DiscoverResult;
/** Live connection activity for the Connection Activity section (#2318). */
diagnostics?: ConnectionDiagnostics;
oauth?: OAuthDetails; oauth?: OAuthDetails;
onClearOAuth?: () => void; onClearOAuth?: () => void;
} }
@ -51,6 +54,7 @@ export function ConnectionInfoModal({
transport, transport,
protocolEra, protocolEra,
discoverResult, discoverResult,
diagnostics,
oauth, oauth,
onClearOAuth, onClearOAuth,
}: ConnectionInfoModalProps) { }: ConnectionInfoModalProps) {
@ -79,6 +83,7 @@ export function ConnectionInfoModal({
transport={transport} transport={transport}
protocolEra={protocolEra} protocolEra={protocolEra}
discoverResult={discoverResult} discoverResult={discoverResult}
diagnostics={diagnostics}
oauth={oauth} oauth={oauth}
onClearOAuth={onClearOAuth} onClearOAuth={onClearOAuth}
/> />

View file

@ -86,6 +86,31 @@ const streamingEntry: FetchRequestEntry = {
category: "transport", category: "transport",
}; };
/**
* The standalone `GET /mcp` notification stream, still open, as the tracker
* watches it: the body is never captured, so the placeholder reports what the
* stream has delivered instead (#2318).
*/
const notificationStreamEntry: FetchRequestEntry = {
id: "n-notify",
timestamp: new Date("2026-03-17T10:30:01Z"),
method: "GET",
url: "http://localhost:3000/mcp",
requestHeaders: {
accept: "text/event-stream",
"mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb",
},
responseStatus: 200,
responseStatusText: "OK",
responseHeaders: {
"cache-control": "no-cache",
"content-type": "text/event-stream",
},
duration: 12,
stream: { eventCount: 0 },
category: "transport",
};
const transportError: FetchRequestEntry = { const transportError: FetchRequestEntry = {
id: "n-4", id: "n-4",
timestamp: new Date("2026-03-17T10:30:15Z"), timestamp: new Date("2026-03-17T10:30:15Z"),
@ -166,6 +191,18 @@ export const StreamingResponse: Story = {
args: { entry: streamingEntry, isListExpanded: true }, args: { entry: streamingEntry, isListExpanded: true },
}; };
export const NotificationStreamOpen: Story = {
args: { entry: notificationStreamEntry, isListExpanded: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText(
"Long-lived stream — 0 events delivered, still open; body not captured",
),
).toBeInTheDocument();
},
};
export const FetchError: Story = { export const FetchError: Story = {
args: { entry: transportError, isListExpanded: true }, args: { entry: transportError, isListExpanded: true },
}; };

View file

@ -150,6 +150,24 @@ describe("NetworkEntry", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("shows the watched stream's event count and open state in place of the body (#2318)", async () => {
const user = userEvent.setup();
const open: FetchRequestEntry = {
...baseEntry,
method: "GET",
responseHeaders: { "content-type": "text/event-stream" },
responseBody: undefined,
stream: { eventCount: 3 },
};
renderWithMantine(<NetworkEntry entry={open} isListExpanded={false} />);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(
screen.getByText(
"Long-lived stream — 3 events delivered, still open; body not captured",
),
).toBeInTheDocument();
});
it("shows an SSE badge on long-lived GET event-stream entries", () => { it("shows an SSE badge on long-lived GET event-stream entries", () => {
const sse: FetchRequestEntry = { const sse: FetchRequestEntry = {
...baseEntry, ...baseEntry,

View file

@ -15,7 +15,10 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { RiErrorWarningLine } from "react-icons/ri"; import { RiErrorWarningLine } from "react-icons/ri";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; import {
isLongLivedStreamEntry,
uncapturedBodyNote,
} from "../../../utils/uncapturedBodyNote";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils"; import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { CopyButton } from "../../elements/CopyButton/CopyButton";
@ -158,13 +161,6 @@ function statusLabel(entry: FetchRequestEntry): string {
: `${entry.responseStatus}`; : `${entry.responseStatus}`;
} }
function isLongLivedStream(entry: FetchRequestEntry): boolean {
return isLongLivedStreamResponse(
entry.method,
entry.responseHeaders?.["content-type"],
);
}
// Header-table cell text. A modern MCP-mirrored header name gets a violet accent // Header-table cell text. A modern MCP-mirrored header name gets a violet accent
// so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version) // so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version)
// stand out from ordinary ones; a value that disagrees with the request body is // stand out from ordinary ones; a value that disagrees with the request body is
@ -542,7 +538,7 @@ export function NetworkEntry({
{entry.duration != null && ( {entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText> <DurationText>{formatDuration(entry.duration)}</DurationText>
)} )}
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>} {isLongLivedStreamEntry(entry) && <Badge color="orange">SSE</Badge>}
<Badge color={statusColor(entry)} variant="status"> <Badge color={statusColor(entry)} variant="status">
{statusLabel(entry)} {statusLabel(entry)}
</Badge> </Badge>
@ -647,11 +643,7 @@ export function NetworkEntry({
label="Response body" label="Response body"
/> />
) : ( ) : (
<DimmedNote> <DimmedNote>{uncapturedBodyNote(entry)}</DimmedNote>
{isLongLivedStream(entry)
? "Long-lived stream — body not captured"
: "(empty)"}
</DimmedNote>
)} )}
</Stack> </Stack>
)} )}

View file

@ -0,0 +1,58 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useTickingClock } from "./useTickingClock";
describe("useTickingClock", () => {
afterEach(() => {
vi.useRealTimers();
});
it("reads the seed on the first render, before any tick", () => {
vi.useFakeTimers();
vi.setSystemTime(5_000_000);
const { result } = renderHook(() => useTickingClock(1_000_000));
expect(result.current).toBe(1_000_000);
});
it("advances to the wall clock on each interval", () => {
vi.useFakeTimers();
vi.setSystemTime(5_000_000);
const { result } = renderHook(() => useTickingClock(1_000_000, 1000));
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current).toBe(5_001_000);
act(() => {
vi.setSystemTime(5_010_000);
// Advancing the fake timers also advances the fake clock.
vi.advanceTimersByTime(1000);
});
expect(result.current).toBe(5_011_000);
});
it("never reads behind a seed newer than the last tick", () => {
vi.useFakeTimers();
vi.setSystemTime(5_000_000);
const { result, rerender } = renderHook(
({ seed }) => useTickingClock(seed, 1000),
{ initialProps: { seed: 1_000_000 } },
);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current).toBe(5_001_000);
// A fresher snapshot arrives before the next tick: it is what the caller
// renders from, so it must not be measured against an older clock.
rerender({ seed: 5_002_500 });
expect(result.current).toBe(5_002_500);
});
it("stops ticking on unmount", () => {
vi.useFakeTimers();
const clearSpy = vi.spyOn(globalThis, "clearInterval");
const { unmount } = renderHook(() => useTickingClock(0, 250));
unmount();
expect(clearSpy).toHaveBeenCalled();
clearSpy.mockRestore();
});
});

View file

@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
/**
* A clock that starts at `seed` and advances to the wall clock once per
* `intervalMs` while the component is mounted (#2318).
*
* For rendering "N seconds ago" from a snapshot stamped with its own
* `capturedAt`: the first render reads the snapshot's clock (pure — no
* `Date.now()` in render, which `react-hooks/purity` reports), and the
* interval then keeps the displayed durations moving while the panel stays
* open. The seed also floors the result, so a snapshot newer than the last
* tick is never rendered against a clock that predates it — a duration
* cannot go negative, and a freshly captured snapshot reads as fresh until the
* next tick catches up.
*
* A timer is exactly the "synchronize with an external system" case an effect
* exists for; the state is set only from the interval callback, never in the
* effect body, so the first paint is not followed by a corrective re-render.
*/
export function useTickingClock(seed: number, intervalMs = 1000): number {
const [now, setNow] = useState(seed);
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return Math.max(now, seed);
}

View file

@ -0,0 +1,279 @@
import { describe, it, expect } from "vitest";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import {
annotateRequestTimeout,
describeConnectionDiagnostics,
describeStreamEndpoint,
formatDuration,
isRequestTimeoutError,
MAX_LISTED_OUTSTANDING_REQUESTS,
type AnnotatedRequestTimeoutData,
type ConnectionDiagnostics,
} from "@inspector/core/mcp/connectionDiagnostics.js";
// A fixed clock so every duration in these assertions is exact.
const NOW = 1_000_000;
const seconds = (n: number) => n * 1000;
/** The SDK's own per-request timeout, exactly as `Protocol.request` throws it. */
function sdkTimeout(timeout = 60_000): SdkError {
return new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
timeout,
});
}
describe("formatDuration", () => {
it("renders sub-second values in milliseconds", () => {
expect(formatDuration(0)).toBe("0ms");
expect(formatDuration(850)).toBe("850ms");
expect(formatDuration(999.6)).toBe("1000ms");
});
it("renders seconds, minutes and hours at increasing granularity", () => {
expect(formatDuration(seconds(1))).toBe("1s");
expect(formatDuration(seconds(59))).toBe("59s");
expect(formatDuration(seconds(60))).toBe("1m00s");
expect(formatDuration(seconds(252))).toBe("4m12s");
expect(formatDuration(seconds(3600))).toBe("1h00m");
expect(formatDuration(seconds(3600 + 120 + 5))).toBe("1h02m");
});
it("clamps a negative value to zero rather than rendering nonsense", () => {
// A clock skew between two timestamps must not print `-3s`.
expect(formatDuration(-3000)).toBe("0ms");
});
});
describe("describeStreamEndpoint", () => {
it("reduces a stream URL to its method and path", () => {
expect(describeStreamEndpoint("https://host.example:8443/mcp?x=1")).toBe(
"GET /mcp",
);
});
it("falls back to the raw string for an unparseable URL", () => {
expect(describeStreamEndpoint("not a url")).toBe("GET not a url");
});
});
describe("describeConnectionDiagnostics", () => {
it("says so when nothing is outstanding and nothing has been received", () => {
expect(
describeConnectionDiagnostics({
capturedAt: NOW,
outstandingRequests: [],
}),
).toBe(
"No requests are unanswered. No response has been received this session.",
);
});
it("names each unanswered request with how long ago it went out", () => {
const diagnostics: ConnectionDiagnostics = {
capturedAt: NOW,
outstandingRequests: [
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
{ id: 3, method: "ping", sentAt: NOW - seconds(12) },
],
lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
};
expect(describeConnectionDiagnostics(diagnostics)).toBe(
"2 requests are unanswered: tools/list (sent 1m00s ago), ping (sent 12s ago). " +
"Last response received 1m01s ago (initialize).",
);
});
it("uses the singular for exactly one unanswered request", () => {
const diagnostics: ConnectionDiagnostics = {
capturedAt: NOW,
outstandingRequests: [
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
],
};
expect(describeConnectionDiagnostics(diagnostics)).toContain(
"1 request is unanswered: tools/list (sent 1m00s ago).",
);
});
it("collapses the tail of a long outstanding list into a count", () => {
const outstandingRequests = Array.from({ length: 8 }, (_, i) => ({
id: i,
method: `m${i}`,
sentAt: NOW - seconds(i + 1),
}));
const text = describeConnectionDiagnostics({
capturedAt: NOW,
outstandingRequests,
});
expect(text).toContain("8 requests are unanswered: ");
for (let i = 0; i < MAX_LISTED_OUTSTANDING_REQUESTS; i++) {
expect(text).toContain(`m${i} (sent`);
}
expect(text).not.toContain("m5 (sent");
expect(text).toContain(
`, and ${8 - MAX_LISTED_OUTSTANDING_REQUESTS} more.`,
);
});
it("describes an open notification stream by its endpoint, age and event count", () => {
const diagnostics: ConnectionDiagnostics = {
capturedAt: NOW,
outstandingRequests: [],
notificationStream: {
url: "http://127.0.0.1:9779/mcp",
openedAt: NOW - seconds(252),
eventCount: 0,
},
};
expect(describeConnectionDiagnostics(diagnostics)).toContain(
"Notification stream (GET /mcp) open for 4m12s, 0 events delivered.",
);
});
it("describes a closed notification stream by when it closed and how long it lasted", () => {
const diagnostics: ConnectionDiagnostics = {
capturedAt: NOW,
outstandingRequests: [],
notificationStream: {
url: "http://127.0.0.1:9779/mcp",
openedAt: NOW - seconds(185),
eventCount: 1,
closedAt: NOW - seconds(5),
},
};
expect(describeConnectionDiagnostics(diagnostics)).toContain(
"Notification stream (GET /mcp) closed 5s ago after 3m00s, 1 event delivered.",
);
});
it("measures every duration against the snapshot's own clock, not the wall clock", () => {
// A snapshot taken long ago still reads the way it read when it was
// taken — that is what makes a timeout message and a later render of the
// same snapshot agree.
const diagnostics: ConnectionDiagnostics = {
capturedAt: NOW,
outstandingRequests: [
{ id: 1, method: "tools/list", sentAt: NOW - seconds(5) },
],
};
expect(describeConnectionDiagnostics(diagnostics)).toBe(
"1 request is unanswered: tools/list (sent 5s ago). " +
"No response has been received this session.",
);
});
});
describe("isRequestTimeoutError", () => {
it("recognises the SDK's per-request timeout", () => {
expect(isRequestTimeoutError(sdkTimeout())).toBe(true);
});
it("rejects the other RequestTimeout-coded rejections, which are not timeouts", () => {
// An abort through `options.signal` with a non-SdkError reason: the SDK
// wraps it in the same code with no `{ timeout }` data.
expect(
isRequestTimeoutError(
new SdkError(SdkErrorCode.RequestTimeout, "AbortError: cancelled"),
),
).toBe(false);
// The max-total-timeout variant carries different data.
expect(
isRequestTimeoutError(
new SdkError(
SdkErrorCode.RequestTimeout,
"Maximum total timeout exceeded",
{ maxTotalTimeout: 1000, totalElapsed: 1200 },
),
),
).toBe(false);
// A non-numeric `timeout` is not the SDK's shape either.
expect(
isRequestTimeoutError(
new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
timeout: "60s",
}),
),
).toBe(false);
});
it("rejects other SdkErrors, plain errors and non-errors", () => {
expect(
isRequestTimeoutError(
new SdkError(SdkErrorCode.InvalidResult, "bad", { timeout: 1 }),
),
).toBe(false);
expect(isRequestTimeoutError(new Error("Request timed out"))).toBe(false);
expect(isRequestTimeoutError("Request timed out")).toBe(false);
expect(isRequestTimeoutError(null)).toBe(false);
});
});
describe("annotateRequestTimeout", () => {
const diagnostics: ConnectionDiagnostics = {
capturedAt: NOW,
outstandingRequests: [
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
],
lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
notificationStream: {
url: "http://127.0.0.1:9779/mcp",
openedAt: NOW - seconds(62),
eventCount: 0,
},
};
it("rewrites the message to name the request, its budget and the connection state", () => {
const annotated = annotateRequestTimeout(
sdkTimeout(60_000),
"tools/list",
diagnostics,
);
expect(SdkError.isInstance(annotated)).toBe(true);
expect((annotated as SdkError).message).toBe(
"Request timed out after 1m00s (tools/list). " +
"1 request is unanswered: tools/list (sent 1m00s ago). " +
"Last response received 1m01s ago (initialize). " +
"Notification stream (GET /mcp) open for 1m02s, 0 events delivered.",
);
});
it("keeps the code, carries the method and snapshot in data, and links the original as cause", () => {
const original = sdkTimeout(300);
const annotated = annotateRequestTimeout(
original,
"ping",
diagnostics,
) as SdkError;
expect(annotated).not.toBe(original);
expect(annotated.code).toBe(SdkErrorCode.RequestTimeout);
expect(annotated.cause).toBe(original);
const data = annotated.data as AnnotatedRequestTimeoutData;
expect(data.timeout).toBe(300);
expect(data.method).toBe("ping");
expect(data.diagnostics).toBe(diagnostics);
// The rebuilt error still satisfies the predicate, so a consumer that
// checks before and after this runs sees the same answer.
expect(isRequestTimeoutError(annotated)).toBe(true);
});
it("returns anything that is not the per-request timeout untouched", () => {
const plain = new Error("boom");
expect(annotateRequestTimeout(plain, "ping", diagnostics)).toBe(plain);
const abort = new SdkError(SdkErrorCode.RequestTimeout, "cancelled");
expect(annotateRequestTimeout(abort, "ping", diagnostics)).toBe(abort);
expect(annotateRequestTimeout(undefined, "ping", diagnostics)).toBe(
undefined,
);
});
it("reads cleanly when nothing else is known about the connection", () => {
const annotated = annotateRequestTimeout(sdkTimeout(1000), "ping", {
capturedAt: NOW,
outstandingRequests: [],
}) as SdkError;
expect(annotated.message).toBe(
"Request timed out after 1s (ping). No requests are unanswered. " +
"No response has been received this session.",
);
});
});

View file

@ -1,13 +1,17 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { import {
createFetchTracker, createFetchTracker,
findHeader,
redactSensitiveHeaders, redactSensitiveHeaders,
redactBody, redactBody,
redactUrlQuery, redactUrlQuery,
REDACTED_HEADER_VALUE, REDACTED_HEADER_VALUE,
REDACTED_VALUE, REDACTED_VALUE,
} from "@inspector/core/mcp/fetchTracking.js"; } from "@inspector/core/mcp/fetchTracking.js";
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js"; import type {
FetchRequestEntryBase,
FetchStreamState,
} from "@inspector/core/mcp/types.js";
// The tracker fires `trackRequest` synchronously with an entry whose // The tracker fires `trackRequest` synchronously with an entry whose
// responseBody is always undefined, then reads the body in the background // responseBody is always undefined, then reads the body in the background
@ -696,3 +700,196 @@ describe("redactSensitiveHeaders", () => {
expect(input.authorization).toBe("Bearer x"); expect(input.authorization).toBe("Bearer x");
}); });
}); });
describe("findHeader", () => {
it("looks a header up case-insensitively and tolerates a missing record", () => {
expect(findHeader({ "Content-Type": "text/plain" }, "content-type")).toBe(
"text/plain",
);
expect(findHeader({ "content-type": "text/plain" }, "CONTENT-TYPE")).toBe(
"text/plain",
);
expect(findHeader({ accept: "*/*" }, "content-type")).toBeUndefined();
expect(findHeader(undefined, "content-type")).toBeUndefined();
});
});
describe("createFetchTracker long-lived stream watching (#2318)", () => {
const encoder = new TextEncoder();
/**
* A server-controlled SSE body: `push` writes a chunk, `end` closes it, and
* `fail` errors it — the three ways a real stream advances.
*/
function controlledStream() {
let controller!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
});
return {
stream,
push: (text: string) => controller.enqueue(encoder.encode(text)),
end: () => controller.close(),
fail: (err: Error) => controller.error(err),
};
}
/** Track a GET whose response is the given stream, collecting stream updates. */
async function trackStream(stream: ReadableStream<Uint8Array>) {
const updates: Array<{ id: string; stream: FetchStreamState }> = [];
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(
(async () =>
new Response(stream, {
headers: { "content-type": "text/event-stream" },
})) as typeof fetch,
{
trackRequest: (entry) => tracked.push(entry),
updateStream: (id, state) => updates.push({ id, stream: state }),
},
);
const response = await fetcher("https://example.com/mcp", {
method: "GET",
});
return { response, updates, id: tracked[0]!.id };
}
it("announces the stream as open with zero events as soon as it is watched", async () => {
const server = controlledStream();
const { updates, id } = await trackStream(server.stream);
// Before a byte arrives — this is the state the #2187 stall shows.
expect(updates).toEqual([{ id, stream: { eventCount: 0 } }]);
});
it("counts each data-carrying SSE event as it is delivered", async () => {
const server = controlledStream();
const { updates, id } = await trackStream(server.stream);
server.push('event: message\ndata: {"jsonrpc":"2.0","method":"a"}\n\n');
await flush();
expect(updates.at(-1)).toEqual({ id, stream: { eventCount: 1 } });
// An event split across chunks is still one event.
server.push('data: {"jsonrpc"');
server.push(':"2.0","method":"b"}\n');
await flush();
expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1]);
server.push("\n");
await flush();
expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
});
it("ignores keepalive comments and blocks with no data field, and accepts a bare `data` line and CRLF framing", async () => {
const server = controlledStream();
const { updates } = await trackStream(server.stream);
// A comment-only block (the keepalive shape) and an id-only block are
// not events; a bare `data` (no colon) is; so is a CRLF-delimited block.
server.push(": keepalive\n\n");
server.push("id: 7\n\n");
server.push("data\n\n");
server.push("data: x\r\n\r\n");
await flush();
expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
});
it("reports the close when the server ends the stream", async () => {
const server = controlledStream();
const { updates, id } = await trackStream(server.stream);
server.push("data: one\n\n");
server.end();
await flush();
expect(updates).toHaveLength(3);
expect(updates[2]!.id).toBe(id);
expect(updates[2]!.stream.eventCount).toBe(1);
expect(updates[2]!.stream.closedAt).toBeInstanceOf(Date);
// Only the close carries `closedAt`.
expect(updates[0]!.stream.closedAt).toBeUndefined();
expect(updates[1]!.stream.closedAt).toBeUndefined();
});
it("reports the close when the stream errors, with the count so far", async () => {
const server = controlledStream();
const { updates } = await trackStream(server.stream);
server.push("data: one\n\n");
server.fail(new Error("connection reset"));
await flush();
expect(updates).toHaveLength(3);
expect(updates[2]!.stream).toMatchObject({ eventCount: 1 });
expect(updates[2]!.stream.closedAt).toBeInstanceOf(Date);
});
it("leaves the transport's own copy of the stream intact", async () => {
const server = controlledStream();
const { response, updates } = await trackStream(server.stream);
server.push("data: hello\n\n");
server.end();
// The transport reads the original; the watcher read a tee'd clone.
expect(await response.text()).toBe("data: hello\n\n");
await flush();
expect(updates.at(-1)!.stream).toMatchObject({ eventCount: 1 });
});
it("does not clone the response when nobody subscribed to stream updates", async () => {
const response = new Response(controlledStream().stream, {
headers: { "content-type": "text/event-stream" },
});
const clone = vi.spyOn(response, "clone");
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
trackRequest: () => {},
});
await fetcher("https://example.com/mcp", { method: "GET" });
expect(clone).not.toHaveBeenCalled();
});
it("does not watch a bounded response", async () => {
const updates: unknown[] = [];
const fetcher = createFetchTracker(
(async () =>
new Response("data: x\n\n", {
headers: { "content-type": "text/event-stream" },
})) as typeof fetch,
{ updateStream: (id, state) => updates.push({ id, state }) },
);
// A POST SSE reply is bounded and goes down the body-capture path.
await fetcher("https://example.com/mcp", { method: "POST" });
await flush();
expect(updates).toEqual([]);
});
it("gives up quietly when the response cannot be cloned", async () => {
const response = new Response(controlledStream().stream, {
headers: { "content-type": "text/event-stream" },
});
vi.spyOn(response, "clone").mockImplementation(() => {
throw new TypeError("already consumed");
});
const updates: unknown[] = [];
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
updateStream: (id, state) => updates.push({ id, state }),
});
await expect(
fetcher("https://example.com/mcp", { method: "GET" }),
).resolves.toBe(response);
await flush();
expect(updates).toEqual([]);
});
it("gives up quietly when the clone has no body", async () => {
const response = new Response(controlledStream().stream, {
headers: { "content-type": "text/event-stream" },
});
vi.spyOn(response, "clone").mockImplementation(
() =>
new Response(null, {
headers: { "content-type": "text/event-stream" },
}),
);
const updates: unknown[] = [];
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
updateStream: (id, state) => updates.push({ id, state }),
});
await fetcher("https://example.com/mcp", { method: "GET" });
await flush();
expect(updates).toEqual([]);
});
});

View file

@ -146,6 +146,51 @@ describe("FetchRequestLogState", () => {
expect(seen).toHaveLength(1); expect(seen).toHaveLength(1);
}); });
it("patches the matching entry's stream state and re-emits on fetchRequestStreamUpdate (#2318)", () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
client.dispatchTypedEvent("fetchRequest", entry("b"));
const seen: FetchRequestEntry[][] = [];
state.addEventListener("fetchRequestsChange", (e) => seen.push(e.detail));
client.dispatchTypedEvent("fetchRequestStreamUpdate", {
id: "b",
stream: { eventCount: 2 },
});
const closedAt = new Date(2026, 4, 13, 0, 1);
client.dispatchTypedEvent("fetchRequestStreamUpdate", {
id: "b",
stream: { eventCount: 3, closedAt },
});
const entries = state.getFetchRequests();
expect(entries[0]?.stream).toBeUndefined();
// Each update replaces the previous state wholesale.
expect(entries[1]?.stream).toEqual({ eventCount: 3, closedAt });
expect(seen).toHaveLength(2);
});
it("ignores a stream update for an unknown id without emitting", () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
const seen: FetchRequestEntry[][] = [];
state.addEventListener("fetchRequestsChange", (e) => seen.push(e.detail));
client.dispatchTypedEvent("fetchRequestStreamUpdate", {
id: "gone",
stream: { eventCount: 1 },
});
expect(seen).toHaveLength(0);
expect(state.getFetchRequests()[0]?.stream).toBeUndefined();
});
it("stops patching stream state after destroy", () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
state.destroy();
client.dispatchTypedEvent("fetchRequestStreamUpdate", {
id: "a",
stream: { eventCount: 1 },
});
expect(state.getFetchRequests()).toEqual([]);
});
it("silently ignores a body update for an unknown id when the log is not full (benign straggler)", () => { it("silently ignores a body update for an unknown id when the log is not full (benign straggler)", () => {
// Below capacity, an idx === -1 means the entry was cleared or never // Below capacity, an idx === -1 means the entry was cleared or never
// existed — not a rotation drop — so it must NOT warn or emit the dropped // existed — not a rotation drop — so it must NOT warn or emit the dropped

View file

@ -68,6 +68,42 @@ describe("useInspectorClient", () => {
expect(result.current.instructions).toBe("after"); expect(result.current.instructions).toBe("after");
}); });
it("returns the client's connection diagnostics and re-reads them on change (#2318)", () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.connectionDiagnostics).toEqual({
capturedAt: 0,
outstandingRequests: [],
});
const next = {
capturedAt: 10,
outstandingRequests: [{ id: 1, method: "tools/list", sentAt: 5 }],
lastResponse: { method: "initialize", receivedAt: 4 },
};
act(() => {
client.setConnectionDiagnostics(next);
});
expect(result.current.connectionDiagnostics).toBe(next);
});
it("falls back to an empty snapshot for a client without the optional accessor", () => {
const client = new FakeInspectorClient();
// `getConnectionDiagnostics` is optional on the protocol; a double that
// predates it must still yield the one shape consumers expect.
(
client as unknown as { getConnectionDiagnostics?: unknown }
).getConnectionDiagnostics = undefined;
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.connectionDiagnostics).toEqual({
capturedAt: 0,
outstandingRequests: [],
});
const { result: absent } = renderHook(() => useInspectorClient(null));
expect(absent.current.connectionDiagnostics).toBe(
result.current.connectionDiagnostics,
);
});
it("subscribes to protocolVersionChange and updates", () => { it("subscribes to protocolVersionChange and updates", () => {
const client = new FakeInspectorClient(); const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client)); const { result } = renderHook(() => useInspectorClient(client));

View file

@ -0,0 +1,351 @@
import { describe, it, expect, afterEach } from "vitest";
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { FetchRequestLogState } from "@inspector/core/mcp/state/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
import type {
AnnotatedRequestTimeoutData,
ConnectionDiagnostics,
} from "@inspector/core/mcp/connectionDiagnostics.js";
/**
* Request-timeout diagnostics against a real connection (#2318).
*
* The #2187 shape: a server that answers `initialize`, holds the standalone
* `GET /mcp` notification stream open without ever emitting an event, and
* never answers `tools/list`. The SDK's per-request timeout fires and the
* Inspector's word on it used to be `Request timed out`. These tests drive the
* real transport, the real SDK client and the real timeout, and assert on what
* the rejection now says — plus the snapshot and the events it is built from.
*
* Hand-rolled rather than a composable test server because the fixture has
* to misbehave in ways the SDK's own server cannot be configured to: hold a
* request forever, and push raw bytes down the GET stream on cue.
*/
/** A short budget so the timeout fires in test time rather than in 60s. */
const REQUEST_TIMEOUT_MS = 300;
type ToolsListMode = "hang" | "error";
interface HangingServer {
url: string;
/** Every JSON-RPC method the server was sent, in order. */
calls: string[];
/** Write one SSE event onto every open GET stream. */
pushEvent: (message: Record<string, unknown>) => void;
/** Write a comment-only keepalive block onto every open GET stream. */
pushKeepalive: () => void;
/** How many GET streams have been opened so far. */
streamsOpened: () => number;
setToolsListMode: (mode: ToolsListMode) => void;
stop: () => Promise<void>;
}
function startHangingServer(): Promise<HangingServer> {
const calls: string[] = [];
const streams = new Set<ServerResponse>();
const held = new Set<ServerResponse>();
let streamsOpened = 0;
let toolsListMode: ToolsListMode = "hang";
const handler = async (req: IncomingMessage, res: ServerResponse) => {
if (req.method === "GET") {
streamsOpened += 1;
streams.add(res);
res.on("close", () => streams.delete(res));
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
});
// Headers out now, so the client's fetch resolves and the stream reads
// as open — without a byte of event data on it.
res.flushHeaders();
return;
}
if (req.method === "DELETE") {
res.writeHead(200).end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = JSON.parse(Buffer.concat(chunks).toString()) as {
id?: unknown;
method: string;
};
calls.push(body.method);
// Notifications (`notifications/initialized`, and the SDK's own
// `notifications/cancelled` when the timeout fires) get an empty 202.
if (body.id === undefined) {
res.writeHead(202).end();
return;
}
const reply = (payload: Record<string, unknown>) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, ...payload }));
};
if (body.method === "initialize") {
reply({
result: {
protocolVersion: "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "hanging-server", version: "1.0.0" },
},
});
return;
}
if (body.method === "tools/list") {
if (toolsListMode === "error") {
reply({ error: { code: -32603, message: "tools/list exploded" } });
return;
}
// Never answered. Kept so teardown can release the socket.
held.add(res);
res.on("close", () => held.delete(res));
return;
}
reply({ result: {} });
};
const server: Server = createServer((req, res) => {
void handler(req, res);
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
/* v8 ignore next -- listen() on a fresh server always yields an AddressInfo */
const port = typeof address === "object" && address ? address.port : 0;
resolve({
url: `http://127.0.0.1:${port}/mcp`,
calls,
pushEvent: (message) => {
for (const res of streams) {
res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
}
},
pushKeepalive: () => {
for (const res of streams) res.write(": keepalive\n\n");
},
streamsOpened: () => streamsOpened,
setToolsListMode: (mode) => {
toolsListMode = mode;
},
stop: () =>
new Promise((done) => {
for (const res of held) res.destroy();
for (const res of streams) res.destroy();
server.closeAllConnections();
server.close(() => done());
}),
});
});
});
}
/** Poll until `predicate` holds; the failure names what never became true. */
async function waitUntil(
what: string,
predicate: () => boolean,
timeoutMs = 5000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() > deadline) {
throw new Error(`timed out waiting for ${what}`);
}
await new Promise((r) => setTimeout(r, 10));
}
}
describe("InspectorClient request-timeout diagnostics (#2318)", () => {
let client: InspectorClient | null = null;
let server: HangingServer | null = null;
async function connectTo(url: string): Promise<InspectorClient> {
client = new InspectorClient(
{ type: "streamable-http", url },
{
environment: { transport: createTransportNode },
// Pinned legacy so the connect is exactly `initialize` and nothing
// else — the assertions below name the last response by method.
versionNegotiation: eraToVersionNegotiation("legacy"),
timeout: REQUEST_TIMEOUT_MS,
},
);
await client.connect();
return client;
}
/** The SDK opens the GET stream after `notifications/initialized`, off the connect path. */
async function waitForNotificationStream(c: InspectorClient): Promise<void> {
await waitUntil(
"the notification stream to be tracked",
() => c.getConnectionDiagnostics().notificationStream !== undefined,
);
}
afterEach(async () => {
try {
await client?.disconnect();
} catch {
// Teardown only — the assertions already ran.
}
client = null;
await server?.stop();
server = null;
});
it("annotates the timeout with the request, the unanswered set, the last response and the stream", async () => {
server = await startHangingServer();
const c = await connectTo(server.url);
await waitForNotificationStream(c);
server.pushKeepalive();
let caught: unknown;
try {
await c.listTools();
} catch (err) {
caught = err;
}
expect(SdkError.isInstance(caught)).toBe(true);
const error = caught as SdkError;
expect(error.code).toBe(SdkErrorCode.RequestTimeout);
expect(error.message).toMatch(
new RegExp(
"^Request timed out after 300ms \\(tools/list\\)\\. " +
"1 request is unanswered: tools/list \\(sent \\d+(ms|s) ago\\)\\. " +
"Last response received \\d+(ms|s) ago \\(initialize\\)\\. " +
"Notification stream \\(GET /mcp\\) open for \\d+(ms|s), 0 events delivered\\.$",
),
);
// The structured form rides along for consumers that want it.
const data = error.data as AnnotatedRequestTimeoutData;
expect(data.timeout).toBe(REQUEST_TIMEOUT_MS);
expect(data.method).toBe("tools/list");
expect(data.diagnostics.outstandingRequests.map((r) => r.method)).toEqual([
"tools/list",
]);
expect(data.diagnostics.lastResponse?.method).toBe("initialize");
expect(data.diagnostics.notificationStream?.eventCount).toBe(0);
// The SDK gave up on the request; the connection did not answer it. It is
// still unanswered, and the live snapshot keeps saying so.
expect(
c.getConnectionDiagnostics().outstandingRequests.map((r) => r.method),
).toEqual(["tools/list"]);
// The keepalive comment reached the client and was not counted.
expect(c.getConnectionDiagnostics().notificationStream?.eventCount).toBe(0);
// The SDK's legacy cancel rode the wire and the server saw the whole
// exchange in order. The cancel is fire-and-forget on the SDK side — the
// rejection lands before the POST does — so wait for it to arrive.
const calls = server.calls;
await waitUntil("the cancellation to reach the server", () =>
calls.includes("notifications/cancelled"),
);
expect(calls).toEqual([
"initialize",
"notifications/initialized",
"tools/list",
"notifications/cancelled",
]);
});
it("emits connectionDiagnosticsChange as the state moves, and counts stream events", async () => {
server = await startHangingServer();
const c = await connectTo(server.url);
const log = new FetchRequestLogState(c);
const snapshots: ConnectionDiagnostics[] = [];
c.addEventListener("connectionDiagnosticsChange", (event) =>
snapshots.push(event.detail),
);
try {
await waitForNotificationStream(c);
const stream = c.getConnectionDiagnostics().notificationStream!;
expect(stream.url).toBe(server.url);
expect(stream.closedAt).toBeUndefined();
// A server push on the notification stream is an event.
server.pushEvent({
jsonrpc: "2.0",
method: "notifications/tools/list_changed",
});
await waitUntil(
"the stream event to be counted",
() => c.getConnectionDiagnostics().notificationStream?.eventCount === 1,
);
// ...and the Network log's entry for the GET carries the same state.
const getEntry = log
.getFetchRequests()
.find((entry) => entry.method === "GET");
expect(getEntry?.stream).toEqual({ eventCount: 1 });
// The timeout path dispatched a snapshot with the request outstanding.
await expect(c.listTools()).rejects.toThrow(/Request timed out/);
expect(
snapshots.some((s) =>
s.outstandingRequests.some((r) => r.method === "tools/list"),
),
).toBe(true);
// Disconnecting aborts the stream: the watcher reports the close and
// the snapshot — which outlives the session until the next connect —
// records it.
await c.disconnect();
await waitUntil(
"the stream close to be recorded",
() =>
c.getConnectionDiagnostics().notificationStream?.closedAt !==
undefined,
);
expect(c.getConnectionDiagnostics().notificationStream).toMatchObject({
eventCount: 1,
});
} finally {
log.destroy();
}
});
it("starts the next session clean", async () => {
server = await startHangingServer();
const c = await connectTo(server.url);
await waitForNotificationStream(c);
await expect(c.listTools()).rejects.toThrow(/Request timed out/);
expect(c.getConnectionDiagnostics().outstandingRequests).toHaveLength(1);
await c.disconnect();
await c.connect();
const fresh = c.getConnectionDiagnostics();
expect(fresh.outstandingRequests).toEqual([]);
expect(fresh.lastResponse?.method).toBe("initialize");
// Whether the new session's GET has landed yet is timing; what must not be
// there is the previous session's stream.
await waitForNotificationStream(c);
expect(c.getConnectionDiagnostics().notificationStream?.closedAt).toBe(
undefined,
);
expect(server.streamsOpened()).toBe(2);
});
it("leaves a failure that is not a timeout exactly as the SDK threw it", async () => {
server = await startHangingServer();
server.setToolsListMode("error");
const c = await connectTo(server.url);
await expect(c.listTools()).rejects.toThrow(/tools\/list exploded/);
await expect(c.listTools()).rejects.not.toThrow(/unanswered/);
// Answered (with an error), so nothing is outstanding.
expect(c.getConnectionDiagnostics().outstandingRequests).toEqual([]);
expect(c.getConnectionDiagnostics().lastResponse?.method).toBe(
"tools/list",
);
});
});

View file

@ -194,6 +194,30 @@ describe("RemoteSession", () => {
}); });
}); });
it("onFetchStreamUpdate forwards the event count, serializing closedAt to ISO when present (#2318)", () => {
const session = new RemoteSession("s10a");
const received: { type: string; data: unknown }[] = [];
session.setEventConsumer((event) =>
received.push({ type: event.type, data: event.data }),
);
session.onFetchStreamUpdate("req-1", { eventCount: 2 });
session.onFetchStreamUpdate("req-1", {
eventCount: 3,
closedAt: new Date("2026-01-01T00:00:05Z"),
});
expect(received).toEqual([
{ type: "fetch_stream_update", data: { id: "req-1", eventCount: 2 } },
{
type: "fetch_stream_update",
data: {
id: "req-1",
eventCount: 3,
closedAt: "2026-01-01T00:00:05.000Z",
},
},
]);
});
it("onFetchRequest serializes a Date timestamp to an ISO string", () => { it("onFetchRequest serializes a Date timestamp to an ISO string", () => {
const session = new RemoteSession("s11"); const session = new RemoteSession("s11");
const received: { type: string; data: unknown }[] = []; const received: { type: string; data: unknown }[] = [];

View file

@ -340,6 +340,59 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
expect(onFetchResponseBody).toHaveBeenCalledWith("f1", "BODY"); expect(onFetchResponseBody).toHaveBeenCalledWith("f1", "BODY");
}); });
it("delivers fetch_stream_update events, rebuilding closedAt as a Date (#2318)", async () => {
const onFetchStreamUpdate = vi.fn();
const t = makeTransport(
{
events: () =>
sseResponse([
sseFrame({
type: "fetch_stream_update",
data: { id: "f1", eventCount: 2 },
}),
sseFrame({
type: "fetch_stream_update",
data: {
id: "f1",
eventCount: 3,
closedAt: "2026-01-01T00:00:05.000Z",
},
}),
]),
},
{ onFetchStreamUpdate },
);
await t.start();
await tick();
expect(onFetchStreamUpdate).toHaveBeenCalledTimes(2);
expect(onFetchStreamUpdate).toHaveBeenNthCalledWith(1, "f1", {
eventCount: 2,
});
expect(onFetchStreamUpdate).toHaveBeenNthCalledWith(2, "f1", {
eventCount: 3,
closedAt: new Date("2026-01-01T00:00:05.000Z"),
});
});
it("drops fetch_stream_update events when no handler is registered", async () => {
const onFetchRequest = vi.fn();
const t = makeTransport(
{
events: () =>
sseResponse([
sseFrame({
type: "fetch_stream_update",
data: { id: "f1", eventCount: 1 },
}),
]),
},
{ onFetchRequest },
);
await t.start();
await tick();
expect(onFetchRequest).not.toHaveBeenCalled();
});
it("delivers stdio_log events to onStderr", async () => { it("delivers stdio_log events to onStderr", async () => {
const onStderr = vi.fn(); const onStderr = vi.fn();
const t = makeTransport( const t = makeTransport(

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
formatLastResponse,
formatNotificationStream,
formatOutstandingRequests,
NO_NOTIFICATION_STREAM_LABEL,
NO_RESPONSE_YET_LABEL,
NO_STREAM_ON_STDIO_LABEL,
} from "./connectionActivity";
const NOW = 1_000_000;
const seconds = (n: number) => n * 1000;
describe("formatOutstandingRequests", () => {
it("renders one line per request, oldest first, against the given clock", () => {
expect(
formatOutstandingRequests(
{
capturedAt: NOW,
outstandingRequests: [
{ id: 1, method: "tools/list", sentAt: NOW - seconds(60) },
{ id: 2, method: "ping", sentAt: NOW - seconds(12) },
],
},
NOW,
),
).toEqual(["tools/list — sent 1m00s ago", "ping — sent 12s ago"]);
});
it("renders nothing when nothing is outstanding", () => {
expect(
formatOutstandingRequests(
{ capturedAt: NOW, outstandingRequests: [] },
NOW,
),
).toEqual([]);
});
});
describe("formatLastResponse", () => {
it("names the method and the age", () => {
expect(
formatLastResponse(
{
capturedAt: NOW,
outstandingRequests: [],
lastResponse: { method: "initialize", receivedAt: NOW - 500 },
},
NOW,
),
).toBe("initialize — 500ms ago");
});
it("says none yet before any response", () => {
expect(
formatLastResponse({ capturedAt: NOW, outstandingRequests: [] }, NOW),
).toBe(NO_RESPONSE_YET_LABEL);
});
});
describe("the clock argument", () => {
it("moves every duration with it, so a ticking caller renders live values", () => {
const diagnostics = {
capturedAt: NOW,
outstandingRequests: [
{ id: 1, method: "tools/list", sentAt: NOW - seconds(5) },
],
lastResponse: { method: "initialize", receivedAt: NOW - seconds(6) },
};
expect(formatOutstandingRequests(diagnostics, NOW + seconds(55))).toEqual([
"tools/list — sent 1m00s ago",
]);
expect(formatLastResponse(diagnostics, NOW + seconds(55))).toBe(
"initialize — 1m01s ago",
);
});
});
describe("formatNotificationStream", () => {
const open = {
url: "http://h/mcp",
openedAt: NOW - seconds(252),
eventCount: 1,
};
it("is not applicable on stdio, whatever the snapshot says", () => {
expect(formatNotificationStream(open, "stdio", NOW)).toBe(
NO_STREAM_ON_STDIO_LABEL,
);
});
it("says not opened when no stream has been tracked", () => {
expect(formatNotificationStream(undefined, "sse", NOW)).toBe(
NO_NOTIFICATION_STREAM_LABEL,
);
});
it("describes an open stream by endpoint, age and event count", () => {
expect(formatNotificationStream(open, "streamable-http", NOW)).toBe(
"GET /mcp — open for 4m12s, 1 event delivered",
);
});
it("describes a closed stream by when it closed and how long it lasted", () => {
expect(
formatNotificationStream(
{ ...open, eventCount: 2, closedAt: NOW - seconds(5) },
"streamable-http",
NOW,
),
).toBe("GET /mcp — closed 5s ago after 4m07s, 2 events delivered");
});
});

Some files were not shown because too many files have changed in this diff Show more