From d57801d7b3960e4f7cbed248387df39af998064b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 21:25:31 -0400 Subject: [PATCH 01/20] 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 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- clients/web/src/App.test.tsx | 1 + clients/web/src/App.tsx | 2 + .../ConnectionInfoContent.stories.tsx | 45 +++ .../ConnectionInfoContent.test.tsx | 85 +++++ .../ConnectionInfoContent.tsx | 59 +++ .../ConnectionInfoModal.test.tsx | 21 ++ .../ConnectionInfoModal.tsx | 5 + .../NetworkEntry/NetworkEntry.stories.tsx | 37 ++ .../groups/NetworkEntry/NetworkEntry.test.tsx | 18 + .../groups/NetworkEntry/NetworkEntry.tsx | 20 +- clients/web/src/hooks/useTickingClock.test.ts | 58 +++ clients/web/src/hooks/useTickingClock.ts | 27 ++ .../core/mcp/connectionDiagnostics.test.ts | 279 ++++++++++++++ .../src/test/core/mcp/fetchTracking.test.ts | 199 +++++++++- .../mcp/state/fetchRequestLogState.test.ts | 45 +++ .../core/react/useInspectorClient.test.tsx | 36 ++ ...nspectorClient-timeout-diagnostics.test.ts | 351 ++++++++++++++++++ .../mcp/remote/remote-session.test.ts | 24 ++ .../remote/remoteClientTransport-unit.test.ts | 53 +++ .../web/src/utils/connectionActivity.test.ts | 113 ++++++ clients/web/src/utils/connectionActivity.ts | 63 ++++ .../web/src/utils/uncapturedBodyNote.test.ts | 57 +++ clients/web/src/utils/uncapturedBodyNote.ts | 29 ++ core/mcp/__tests__/fakeInspectorClient.ts | 16 + core/mcp/connectionDiagnostics.ts | 252 +++++++++++++ core/mcp/fetchTracking.ts | 114 +++++- core/mcp/inspectorClient.ts | 181 ++++++++- core/mcp/inspectorClientEventTarget.ts | 16 + core/mcp/inspectorClientProtocol.ts | 9 + core/mcp/node/transport.ts | 4 + core/mcp/remote/createRemoteTransport.ts | 1 + core/mcp/remote/node/remote-session.ts | 13 +- core/mcp/remote/node/server.ts | 2 + core/mcp/remote/remoteClientTransport.ts | 14 +- core/mcp/remote/types.ts | 12 + core/mcp/state/fetchRequestLogState.ts | 30 ++ core/mcp/types.ts | 30 ++ core/react/useInspectorClient.ts | 26 ++ 38 files changed, 2315 insertions(+), 32 deletions(-) create mode 100644 clients/web/src/hooks/useTickingClock.test.ts create mode 100644 clients/web/src/hooks/useTickingClock.ts create mode 100644 clients/web/src/test/core/mcp/connectionDiagnostics.test.ts create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts create mode 100644 clients/web/src/utils/connectionActivity.test.ts create mode 100644 clients/web/src/utils/connectionActivity.ts create mode 100644 clients/web/src/utils/uncapturedBodyNote.test.ts create mode 100644 clients/web/src/utils/uncapturedBodyNote.ts create mode 100644 core/mcp/connectionDiagnostics.ts diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 64bb3cfc..cc86b827 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -714,6 +714,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType = { instructions: undefined, excludedTools: [], malformedListItems: [], + connectionDiagnostics: { capturedAt: 0, outstandingRequests: [] }, appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index bc62376e..62959775 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -401,6 +401,7 @@ function App() { discoverResult, excludedTools, malformedListItems, + connectionDiagnostics, lastError, } = useInspectorClient(inspectorClient); // What every settings write that landed on disk actually wrote, so a later @@ -2028,6 +2029,7 @@ function App() { transport={connectionInfoTransport} protocolEra={protocolEra} discoverResult={discoverResult} + diagnostics={connectionDiagnostics} oauth={connectionInfoOAuth} onClearOAuth={ connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index 50549076..8e63e5a3 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -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 = { args: { initializeResult: { diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index ba4fb132..00d6b61d 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -5,6 +5,13 @@ import type { InitializeResult, } from "@modelcontextprotocol/client"; 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 { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, ConnectionInfoContent, @@ -934,3 +941,81 @@ describe("ConnectionInfoContent", () => { 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( + , + ); + expect(screen.queryByText("Connection Activity")).not.toBeInTheDocument(); + }); + + it("renders the idle state: nothing unanswered, nothing received, stream not opened", () => { + renderWithMantine( + , + ); + 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( + , + ); + 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( + , + ); + // Both the Session row and the Notification stream row read N/A (stdio). + expect(screen.getAllByText(NO_STREAM_ON_STDIO_LABEL)).toHaveLength(2); + }); +}); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index 1c04c951..25290f67 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -18,6 +18,14 @@ import type { ServerCapabilities, } from "@modelcontextprotocol/client"; 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 { getSkillsExtension } from "@inspector/core/mcp/skills.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) */ 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; onClearOAuth?: () => void; } @@ -291,6 +309,7 @@ export function ConnectionInfoContent({ transport, protocolEra, discoverResult, + diagnostics, oauth, onClearOAuth, }: ConnectionInfoContentProps) { @@ -317,6 +336,10 @@ export function ConnectionInfoContent({ ? serverInfo.version?.trim() || "—" : 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 clientCaps = getCapabilityEntries( clientCapabilities, @@ -348,6 +371,42 @@ export function ConnectionInfoContent({ + {diagnostics && ( + + Connection Activity + + Unanswered requests + {/* A `Stack` so several in-flight requests read as a list, the + way the extension sections do; one line per request. */} + + {diagnostics.outstandingRequests.length === 0 ? ( + {NO_OUTSTANDING_REQUESTS_LABEL} + ) : ( + formatOutstandingRequests(diagnostics, now).map( + (line, index) => ( + + {line} + + ), + ) + )} + + + Last response + {formatLastResponse(diagnostics, now)} + + Notification stream + + {formatNotificationStream( + diagnostics.notificationStream, + transport, + now, + )} + + + + )} + {discoverResult && ( Discovery diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx index c73acff5..041745f3 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx @@ -56,6 +56,27 @@ describe("ConnectionInfoModal", () => { expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument(); }); + it("forwards connection diagnostics to ConnectionInfoContent (#2318)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Connection Activity")).toBeInTheDocument(); + expect(screen.getByText("tools/list — sent 1m00s ago")).toBeInTheDocument(); + }); + it("forwards oauth details to ConnectionInfoContent", () => { renderWithMantine( void; } @@ -51,6 +54,7 @@ export function ConnectionInfoModal({ transport, protocolEra, discoverResult, + diagnostics, oauth, onClearOAuth, }: ConnectionInfoModalProps) { @@ -79,6 +83,7 @@ export function ConnectionInfoModal({ transport={transport} protocolEra={protocolEra} discoverResult={discoverResult} + diagnostics={diagnostics} oauth={oauth} onClearOAuth={onClearOAuth} /> diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx index 21349a41..725702d2 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx @@ -86,6 +86,31 @@ const streamingEntry: FetchRequestEntry = { 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 = { id: "n-4", timestamp: new Date("2026-03-17T10:30:15Z"), @@ -166,6 +191,18 @@ export const StreamingResponse: Story = { 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 = { args: { entry: transportError, isListExpanded: true }, }; diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index e42d841c..e5fa1e70 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -150,6 +150,24 @@ describe("NetworkEntry", () => { ).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(); + 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", () => { const sse: FetchRequestEntry = { ...baseEntry, diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 8aecb0fc..bec6b08c 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -15,7 +15,10 @@ import { } from "@mantine/core"; import { RiErrorWarningLine } from "react-icons/ri"; 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 { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; @@ -158,13 +161,6 @@ function statusLabel(entry: FetchRequestEntry): string { : `${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 // 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 @@ -542,7 +538,7 @@ export function NetworkEntry({ {entry.duration != null && ( {formatDuration(entry.duration)} )} - {isLongLivedStream(entry) && SSE} + {isLongLivedStreamEntry(entry) && SSE} {statusLabel(entry)} @@ -647,11 +643,7 @@ export function NetworkEntry({ label="Response body" /> ) : ( - - {isLongLivedStream(entry) - ? "Long-lived stream — body not captured" - : "(empty)"} - + {uncapturedBodyNote(entry)} )} )} diff --git a/clients/web/src/hooks/useTickingClock.test.ts b/clients/web/src/hooks/useTickingClock.test.ts new file mode 100644 index 00000000..9242e1cb --- /dev/null +++ b/clients/web/src/hooks/useTickingClock.test.ts @@ -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(); + }); +}); diff --git a/clients/web/src/hooks/useTickingClock.ts b/clients/web/src/hooks/useTickingClock.ts new file mode 100644 index 00000000..d82ac3f7 --- /dev/null +++ b/clients/web/src/hooks/useTickingClock.ts @@ -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); +} diff --git a/clients/web/src/test/core/mcp/connectionDiagnostics.test.ts b/clients/web/src/test/core/mcp/connectionDiagnostics.test.ts new file mode 100644 index 00000000..d86a111c --- /dev/null +++ b/clients/web/src/test/core/mcp/connectionDiagnostics.test.ts @@ -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.", + ); + }); +}); diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index 76abcb0f..ae77fd26 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -1,13 +1,17 @@ import { describe, it, expect, vi } from "vitest"; import { createFetchTracker, + findHeader, redactSensitiveHeaders, redactBody, redactUrlQuery, REDACTED_HEADER_VALUE, REDACTED_VALUE, } 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 // responseBody is always undefined, then reads the body in the background @@ -696,3 +700,196 @@ describe("redactSensitiveHeaders", () => { 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; + const stream = new ReadableStream({ + 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) { + 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([]); + }); +}); diff --git a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts index d8b239b3..5e00da33 100644 --- a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts +++ b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts @@ -146,6 +146,51 @@ describe("FetchRequestLogState", () => { 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)", () => { // 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 diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx index 3f6c5793..613d329b 100644 --- a/clients/web/src/test/core/react/useInspectorClient.test.tsx +++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx @@ -68,6 +68,42 @@ describe("useInspectorClient", () => { 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", () => { const client = new FakeInspectorClient(); const { result } = renderHook(() => useInspectorClient(client)); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts new file mode 100644 index 00000000..7e3b1172 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts @@ -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) => 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; +} + +function startHangingServer(): Promise { + const calls: string[] = []; + const streams = new Set(); + const held = new Set(); + 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) => { + 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 { + 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 { + 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 { + 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", + ); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index c152b960..d11aee37 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -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", () => { const session = new RemoteSession("s11"); const received: { type: string; data: unknown }[] = []; diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index fddc10c9..ef7ebf5f 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -340,6 +340,59 @@ describe("RemoteClientTransport (focused branch coverage)", () => { 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 () => { const onStderr = vi.fn(); const t = makeTransport( diff --git a/clients/web/src/utils/connectionActivity.test.ts b/clients/web/src/utils/connectionActivity.test.ts new file mode 100644 index 00000000..db31480e --- /dev/null +++ b/clients/web/src/utils/connectionActivity.test.ts @@ -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"); + }); +}); diff --git a/clients/web/src/utils/connectionActivity.ts b/clients/web/src/utils/connectionActivity.ts new file mode 100644 index 00000000..649785f4 --- /dev/null +++ b/clients/web/src/utils/connectionActivity.ts @@ -0,0 +1,63 @@ +// Row formatters for the Connection Info modal's Connection Activity section +// (#2318): the same snapshot a request timeout's message is written from, +// rendered while the request is still in flight. Pure functions over the +// snapshot and an explicit clock — the caller decides what "now" is (the +// snapshot's own `capturedAt` on first paint, a ticking clock afterwards; see +// `useTickingClock`), so nothing here reads the wall clock and a render stays +// a pure function of its inputs. + +import { + describeStreamEndpoint, + formatDuration, + type ConnectionDiagnostics, + type NotificationStreamState, +} from "@inspector/core/mcp/connectionDiagnostics.js"; +import type { ServerType } from "@inspector/core/mcp/types.js"; + +export const NO_OUTSTANDING_REQUESTS_LABEL = "None"; +export const NO_RESPONSE_YET_LABEL = "None this session"; +export const NO_NOTIFICATION_STREAM_LABEL = "Not opened"; +export const NO_STREAM_ON_STDIO_LABEL = "N/A (stdio)"; + +/** + * One line per unanswered request, oldest first — `tools/list — sent 60s + * ago`. A request the SDK has already given up on stays listed: it is still + * unanswered, which is the fact this section reports. + */ +export function formatOutstandingRequests( + diagnostics: ConnectionDiagnostics, + now: number, +): string[] { + return diagnostics.outstandingRequests.map( + (request) => + `${request.method} — sent ${formatDuration(now - request.sentAt)} ago`, + ); +} + +export function formatLastResponse( + diagnostics: ConnectionDiagnostics, + now: number, +): string { + const last = diagnostics.lastResponse; + if (!last) return NO_RESPONSE_YET_LABEL; + return `${last.method} — ${formatDuration(now - last.receivedAt)} ago`; +} + +/** + * The notification stream row. stdio has no HTTP stream at all, so the row + * says so rather than "Not opened", which would read as a fault. + */ +export function formatNotificationStream( + stream: NotificationStreamState | undefined, + transport: ServerType, + now: number, +): string { + if (transport === "stdio") return NO_STREAM_ON_STDIO_LABEL; + if (!stream) return NO_NOTIFICATION_STREAM_LABEL; + const endpoint = describeStreamEndpoint(stream.url); + const events = `${stream.eventCount} event${stream.eventCount === 1 ? "" : "s"} delivered`; + if (stream.closedAt !== undefined) { + return `${endpoint} — closed ${formatDuration(now - stream.closedAt)} ago after ${formatDuration(stream.closedAt - stream.openedAt)}, ${events}`; + } + return `${endpoint} — open for ${formatDuration(now - stream.openedAt)}, ${events}`; +} diff --git a/clients/web/src/utils/uncapturedBodyNote.test.ts b/clients/web/src/utils/uncapturedBodyNote.test.ts new file mode 100644 index 00000000..d907286d --- /dev/null +++ b/clients/web/src/utils/uncapturedBodyNote.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; +import { + isLongLivedStreamEntry, + uncapturedBodyNote, +} from "./uncapturedBodyNote"; + +const streamEntry: FetchRequestEntry = { + id: "n-1", + timestamp: new Date("2026-03-17T10:00:00Z"), + method: "GET", + url: "https://example.com/mcp", + requestHeaders: {}, + responseStatus: 200, + responseHeaders: { "content-type": "text/event-stream" }, + category: "transport", +}; + +describe("isLongLivedStreamEntry", () => { + it("is true for a GET answered with an event stream and false otherwise", () => { + expect(isLongLivedStreamEntry(streamEntry)).toBe(true); + expect(isLongLivedStreamEntry({ ...streamEntry, method: "POST" })).toBe( + false, + ); + expect( + isLongLivedStreamEntry({ ...streamEntry, responseHeaders: undefined }), + ).toBe(false); + }); +}); + +describe("uncapturedBodyNote", () => { + it("says (empty) for a bounded response", () => { + expect(uncapturedBodyNote({ ...streamEntry, method: "POST" })).toBe( + "(empty)", + ); + }); + + it("says only that the body was not captured before the stream is watched", () => { + expect(uncapturedBodyNote(streamEntry)).toBe( + "Long-lived stream — body not captured", + ); + }); + + it("reports the event count and open state, with the singular for one event", () => { + expect( + uncapturedBodyNote({ ...streamEntry, stream: { eventCount: 3 } }), + ).toBe( + "Long-lived stream — 3 events delivered, still open; body not captured", + ); + expect( + uncapturedBodyNote({ + ...streamEntry, + stream: { eventCount: 1, closedAt: new Date() }, + }), + ).toBe("Long-lived stream — 1 event delivered, closed; body not captured"); + }); +}); diff --git a/clients/web/src/utils/uncapturedBodyNote.ts b/clients/web/src/utils/uncapturedBodyNote.ts new file mode 100644 index 00000000..636c7f33 --- /dev/null +++ b/clients/web/src/utils/uncapturedBodyNote.ts @@ -0,0 +1,29 @@ +// The Network entry's body placeholder for a response whose body was not +// captured (#2318). Pure: a string from the entry alone. + +import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; +import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; + +/** Whether the entry is an unbounded server-to-client stream (never buffered). */ +export function isLongLivedStreamEntry(entry: FetchRequestEntry): boolean { + return isLongLivedStreamResponse( + entry.method, + entry.responseHeaders?.["content-type"], + ); +} + +/** + * What stands in for a body that was not captured. A long-lived stream is + * never buffered, so its placeholder reports what the tracker learned by + * watching it: how many events it delivered and whether it is still open. + * Before the first update, or for a log restored from a session that + * predates the field, the note says only that the body was not captured. + */ +export function uncapturedBodyNote(entry: FetchRequestEntry): string { + if (!isLongLivedStreamEntry(entry)) return "(empty)"; + const stream = entry.stream; + if (!stream) return "Long-lived stream — body not captured"; + const events = `${stream.eventCount} event${stream.eventCount === 1 ? "" : "s"} delivered`; + const state = stream.closedAt ? "closed" : "still open"; + return `Long-lived stream — ${events}, ${state}; body not captured`; +} diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index bf77d19c..41bcfcc1 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -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; diff --git a/core/mcp/connectionDiagnostics.ts b/core/mcp/connectionDiagnostics.ts new file mode 100644 index 00000000..9163f557 --- /dev/null +++ b/core/mcp/connectionDiagnostics.ts @@ -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; +} diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 7e1e5864..fa4b63bd 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -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, +/** + * 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 | 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 | 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 => { + 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 diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 91cc7ff8..95b46f33 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -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(); + // 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(); + // 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(); + // 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 + ): Promise => { + 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) */ diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 58aa41f6..f9ce6c8c 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -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 diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 53c7134e..8eefd758 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -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 diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index dd3c8358..e0dadcbf 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -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; diff --git a/core/mcp/remote/createRemoteTransport.ts b/core/mcp/remote/createRemoteTransport.ts index 96803d8b..d93f9b06 100644 --- a/core/mcp/remote/createRemoteTransport.ts +++ b/core/mcp/remote/createRemoteTransport.ts @@ -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, }, diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index fc7c7654..2dba31f6 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -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", diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 15179eb1..f08a1f8b 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -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 diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 9cd22460..6a0ba873 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -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), diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 9e948401..661f1522 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -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; diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts index 080fd39b..2e547ad6 100644 --- a/core/mcp/state/fetchRequestLogState.ts +++ b/core/mcp/state/fetchRequestLogState.ts @@ -151,6 +151,28 @@ export class FetchRequestLogState extends TypedEventTarget, + ): 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; @@ -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. diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts index 1d22a94c..448144c5 100644 --- a/core/react/useInspectorClient.ts +++ b/core/react/useInspectorClient.ts @@ -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, From af7beab488b1bdd675c94663595ad84ee070d553 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 21:32:49 -0400 Subject: [PATCH 02/20] fix(web): put the SSE badge on the status variant so it clears the a11y contrast gate (#2318) The first story to render a long-lived GET entry tripped axe on the filled orange badge (white on #f76707, 3.04:1); the light variant is no better (3.03:1). ThemeBadge already pins status + orange to a mid amber fill with black text for this reason. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- .../components/groups/NetworkEntry/NetworkEntry.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index bec6b08c..8315db77 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -213,6 +213,17 @@ const SentinelTooltip = Tooltip.withProps({ w: 280, }); +// The long-lived-stream marker on a GET entry, on the `status` variant like +// the status chip beside it. Amber cannot clear WCAG AA as either a filled +// (white on orange, 3.04:1) or a light (orange on tint, 3.03:1) badge at this +// size; `ThemeBadge` pins `status` + orange to a mid amber fill with black +// text for exactly that reason, which is what the a11y gate needs the moment a +// story renders one (#2318 added the first). +const StreamBadge = Badge.withProps({ + color: "orange", + variant: "status", +}); + const Base64Badge = Badge.withProps({ size: "xs", color: "gray", @@ -538,7 +549,7 @@ export function NetworkEntry({ {entry.duration != null && ( {formatDuration(entry.duration)} )} - {isLongLivedStreamEntry(entry) && SSE} + {isLongLivedStreamEntry(entry) && SSE} {statusLabel(entry)} From 1a5dd6820d777b66452f7956da7f55c7b54875ce Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 22:03:00 -0400 Subject: [PATCH 03/20] fix: address Copilot review round 1 on #2318 - fetchTracking: count NDJSON streams line by line instead of leaving them at zero forever; justify the detached watcher promise; document that a partial SSE block at end-of-stream is dropped, as the SDK's own parser (no flush) and the SSE spec drop it. - uncapturedBodyNote: case-insensitive content-type lookup, and a stream label (SSE / NDJSON) for the Network badge. - InspectorClient: `openedAt` is when the response headers arrived, not when the GET went out; the snapshot copies its request records; a request the transport failed to send is rolled back out of the unanswered set via a new `trackSendFailure` callback; a raw-wire timeout is built as the SDK's own timeout shape and annotated the same way, so callers see one kind of timeout. - Remote session storage revives the nested `stream.closedAt` Date. - Hook test models a legacy double with `Object.defineProperty`, not a double cast. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- .../groups/NetworkEntry/NetworkEntry.tsx | 5 +- .../src/test/core/mcp/fetchTracking.test.ts | 34 ++++++++++++++ .../core/mcp/inspectorClient-raw-wire.test.ts | 11 ++++- .../core/mcp/messageTrackingTransport.test.ts | 24 ++++++++++ .../core/mcp/remote/sessionStorage.test.ts | 39 +++++++++++++++ .../core/react/useInspectorClient.test.tsx | 6 +-- ...nspectorClient-timeout-diagnostics.test.ts | 15 ++++++ .../web/src/utils/uncapturedBodyNote.test.ts | 22 +++++++++ clients/web/src/utils/uncapturedBodyNote.ts | 27 +++++++++-- core/mcp/fetchTracking.ts | 47 ++++++++++++++++--- core/mcp/inspectorClient.ts | 34 ++++++++++++-- core/mcp/messageTrackingTransport.ts | 15 +++++- core/mcp/remote/sessionStorage.ts | 8 +++- 13 files changed, 267 insertions(+), 20 deletions(-) diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 8315db77..b19fe362 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -17,6 +17,7 @@ import { RiErrorWarningLine } from "react-icons/ri"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import { isLongLivedStreamEntry, + longLivedStreamLabel, uncapturedBodyNote, } from "../../../utils/uncapturedBodyNote"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; @@ -549,7 +550,9 @@ export function NetworkEntry({ {entry.duration != null && ( {formatDuration(entry.duration)} )} - {isLongLivedStreamEntry(entry) && SSE} + {isLongLivedStreamEntry(entry) && ( + {longLivedStreamLabel(entry)} + )} {statusLabel(entry)} diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index ae77fd26..61dc817e 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -792,6 +792,40 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]); }); + it("counts every non-blank line on an NDJSON stream", async () => { + const server = controlledStream(); + const updates: Array<{ id: string; stream: FetchStreamState }> = []; + const fetcher = createFetchTracker( + (async () => + new Response(server.stream, { + headers: { "content-type": "application/x-ndjson" }, + })) as typeof fetch, + { updateStream: (id, state) => updates.push({ id, stream: state }) }, + ); + await fetcher("https://example.com/mcp", { method: "GET" }); + server.push('{"jsonrpc":"2.0","method":"a"}\n\n'); + server.push('{"jsonrpc":"2.0","method":"b"}\r\n'); + // A partial line is not an event until its newline arrives. + server.push('{"jsonrpc":"2.0",'); + await flush(); + expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]); + server.push('"method":"c"}\n'); + await flush(); + expect(updates.at(-1)!.stream.eventCount).toBe(3); + }); + + it("drops a partial SSE block at end-of-stream, as the SDK's parser does", async () => { + const server = controlledStream(); + const { updates } = await trackStream(server.stream); + // A data line with no dispatching blank line before the stream ends. + server.push("data: one\n\ndata: two\n"); + server.end(); + await flush(); + const last = updates.at(-1)!.stream; + expect(last.eventCount).toBe(1); + expect(last.closedAt).toBeInstanceOf(Date); + }); + it("reports the close when the server ends the stream", async () => { const server = controlledStream(); const { updates, id } = await trackStream(server.stream); diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index 2d9af6b1..c6679c3f 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import { SdkErrorCode } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js"; @@ -134,7 +135,15 @@ describe("InspectorClient raw-wire channel (#1631)", () => { {}, ModernGetTaskResultSchema, ); - const assertion = expect(promise).rejects.toThrow(/timed out/); + // The same annotated shape an SDK request's timeout carries (#2318): + // the SDK's code, the budget, the method, and the connection state. + const assertion = expect(promise).rejects.toMatchObject({ + code: SdkErrorCode.RequestTimeout, + message: expect.stringMatching( + /^Request timed out after 10ms \(tasks\/get\)\. No requests are unanswered\./, + ), + data: expect.objectContaining({ timeout: 10, method: "tasks/get" }), + }); await vi.advanceTimersByTimeAsync(20); await assertion; } finally { diff --git a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts index 5f7204a2..1d59b96a 100644 --- a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts +++ b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts @@ -33,6 +33,30 @@ function makeTracked() { } describe("MessageTrackingTransport.send", () => { + it("reports a request the base transport failed to send, after tracking it (#2318)", async () => { + const { callbacks, base, tracked } = makeTracked(); + const trackSendFailure = vi.fn(); + const failing = new MessageTrackingTransport(base, { + ...callbacks, + trackSendFailure, + }); + const boom = new Error("connection closed"); + base.send = async () => { + throw boom; + }; + const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const; + await expect(failing.send(request)).rejects.toBe(boom); + expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client"); + expect(trackSendFailure).toHaveBeenCalledWith(request, boom); + // Only requests roll back: a failed notification has no pending entry. + trackSendFailure.mockClear(); + await expect( + failing.send({ jsonrpc: "2.0", method: "notifications/initialized" }), + ).rejects.toBe(boom); + expect(trackSendFailure).not.toHaveBeenCalled(); + void tracked; + }); + it("tracks an outgoing request", async () => { const { callbacks, tracked } = makeTracked(); const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const; diff --git a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts index a8fa25bb..a0b5249f 100644 --- a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts +++ b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts @@ -110,6 +110,45 @@ describe("RemoteInspectorClientStorage", () => { ); }); + it("loadSession revives a stream's closedAt alongside the entry timestamp (#2318)", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ + fetchRequests: [ + { + id: "open", + timestamp: "2026-01-01T00:00:00.000Z", + method: "GET", + url: "http://example.com/mcp", + requestHeaders: {}, + stream: { eventCount: 2 }, + }, + { + id: "closed", + timestamp: "2026-01-01T00:00:00.000Z", + method: "GET", + url: "http://example.com/mcp", + requestHeaders: {}, + stream: { eventCount: 3, closedAt: "2026-01-01T00:00:05.000Z" }, + }, + ], + createdAt: 1735689600000, + updatedAt: 1735689600001, + }), + { status: 200 }, + ), + ); + const storage = makeStorage(fetchFn); + const state = await storage.loadSession("sid"); + // An open stream's state passes through untouched. + expect(state?.fetchRequests[0]?.stream).toEqual({ eventCount: 2 }); + const closed = state?.fetchRequests[1]?.stream; + expect(closed?.eventCount).toBe(3); + expect(closed?.closedAt).toBeInstanceOf(Date); + expect(closed?.closedAt?.toISOString()).toBe("2026-01-01T00:00:05.000Z"); + }); + it("loadSession preserves Date timestamps already provided as Date instances", async () => { const fetchFn = vi.fn( async () => diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx index 613d329b..107ba45e 100644 --- a/clients/web/src/test/core/react/useInspectorClient.test.tsx +++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx @@ -90,9 +90,9 @@ describe("useInspectorClient", () => { 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; + Object.defineProperty(client, "getConnectionDiagnostics", { + value: undefined, + }); const { result } = renderHook(() => useInspectorClient(client)); expect(result.current.connectionDiagnostics).toEqual({ capturedAt: 0, diff --git a/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts index 7e3b1172..fd4649b6 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-timeout-diagnostics.test.ts @@ -271,6 +271,21 @@ describe("InspectorClient request-timeout diagnostics (#2318)", () => { const stream = c.getConnectionDiagnostics().notificationStream!; expect(stream.url).toBe(server.url); expect(stream.closedAt).toBeUndefined(); + // Open from when the headers arrived, not from when the GET went out. + const getEntryAtOpen = log + .getFetchRequests() + .find((entry) => entry.method === "GET")!; + expect(stream.openedAt).toBe( + getEntryAtOpen.timestamp.getTime() + (getEntryAtOpen.duration ?? 0), + ); + // The snapshot is a copy: mutating it does not reach the client. + const snapshot = c.getConnectionDiagnostics(); + snapshot.outstandingRequests.push({ + id: "x", + method: "bogus", + sentAt: 0, + }); + expect(c.getConnectionDiagnostics().outstandingRequests).toEqual([]); // A server push on the notification stream is an event. server.pushEvent({ diff --git a/clients/web/src/utils/uncapturedBodyNote.test.ts b/clients/web/src/utils/uncapturedBodyNote.test.ts index d907286d..1bb88b0e 100644 --- a/clients/web/src/utils/uncapturedBodyNote.test.ts +++ b/clients/web/src/utils/uncapturedBodyNote.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import { isLongLivedStreamEntry, + longLivedStreamLabel, uncapturedBodyNote, } from "./uncapturedBodyNote"; @@ -26,6 +27,27 @@ describe("isLongLivedStreamEntry", () => { isLongLivedStreamEntry({ ...streamEntry, responseHeaders: undefined }), ).toBe(false); }); + + it("reads the content type whatever casing the entry recorded it with", () => { + expect( + isLongLivedStreamEntry({ + ...streamEntry, + responseHeaders: { "Content-Type": "text/event-stream" }, + }), + ).toBe(true); + }); +}); + +describe("longLivedStreamLabel", () => { + it("names the framing the stream uses", () => { + expect(longLivedStreamLabel(streamEntry)).toBe("SSE"); + expect( + longLivedStreamLabel({ + ...streamEntry, + responseHeaders: { "Content-Type": "application/x-ndjson" }, + }), + ).toBe("NDJSON"); + }); }); describe("uncapturedBodyNote", () => { diff --git a/clients/web/src/utils/uncapturedBodyNote.ts b/clients/web/src/utils/uncapturedBodyNote.ts index 636c7f33..e8e886fb 100644 --- a/clients/web/src/utils/uncapturedBodyNote.ts +++ b/clients/web/src/utils/uncapturedBodyNote.ts @@ -1,17 +1,38 @@ // The Network entry's body placeholder for a response whose body was not // captured (#2318). Pure: a string from the entry alone. -import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; +import { + findHeader, + isLongLivedStreamResponse, + longLivedStreamFraming, +} from "@inspector/core/mcp/fetchTracking.js"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; -/** Whether the entry is an unbounded server-to-client stream (never buffered). */ +/** + * Whether the entry is an unbounded server-to-client stream (never + * buffered). The header lookup is case-insensitive: a recorded entry keeps + * the wire casing (`Content-Type` on some hosts, and in a restored session), + * and a miss here would silently reclassify the stream as a bounded body. + */ export function isLongLivedStreamEntry(entry: FetchRequestEntry): boolean { return isLongLivedStreamResponse( entry.method, - entry.responseHeaders?.["content-type"], + findHeader(entry.responseHeaders, "content-type"), ); } +/** + * The badge label for a long-lived stream entry — `SSE` or `NDJSON`, by the + * same content-type split the tracker counts events with. + */ +export function longLivedStreamLabel(entry: FetchRequestEntry): string { + return longLivedStreamFraming( + findHeader(entry.responseHeaders, "content-type"), + ) === "ndjson" + ? "NDJSON" + : "SSE"; +} + /** * What stands in for a body that was not captured. A long-lived stream is * never buffered, so its placeholder reports what the tracker learned by diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index fa4b63bd..1928d1b7 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -251,15 +251,32 @@ export interface FetchTrackingCallbacks { } /** - * Count the SSE events on a long-lived stream without buffering it, and - * report when it ends. + * The framing a long-lived stream's events arrive in — the two content types + * `isLongLivedStreamResponse` accepts. SSE dispatches on a blank line; + * NDJSON dispatches on every line. + */ +export type LongLivedStreamFraming = "sse" | "ndjson"; + +/** The framing for a long-lived stream's content type. */ +export function longLivedStreamFraming( + contentType: string | null | undefined, +): LongLivedStreamFraming { + return contentType?.includes("application/x-ndjson") ? "ndjson" : "sse"; +} + +/** + * Count the 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. + * tee the bounded-body path relies on). Counting follows the framing the + * transport's own parser applies. For SSE 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; a + * partial block at end-of-stream is dropped, exactly as the SSE spec and the + * SDK's `EventSourceParserStream` (which has no flush) drop it. For NDJSON + * every non-blank line is one event. * * 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 @@ -273,6 +290,7 @@ export interface FetchTrackingCallbacks { function watchLongLivedStream( response: Response, id: string, + framing: LongLivedStreamFraming, callbacks: FetchTrackingCallbacks, ): void { const update = callbacks.updateStream; @@ -298,6 +316,12 @@ function watchLongLivedStream( const consumeLine = (rawLine: string): void => { const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (framing === "ndjson") { + if (line.trim() === "") return; + eventCount += 1; + update(id, { eventCount }); + return; + } if (line === "") { if (blockHasData) { eventCount += 1; @@ -331,6 +355,10 @@ function watchLongLivedStream( } update(id, { eventCount, closedAt: new Date() }); }; + // Deliberately not awaited: the fetch wrapper has to return the response + // now, and this watcher lives as long as the stream does. It owns its own + // failures — the read loop is wrapped in a `try` that treats an error as a + // close — so the promise cannot reject. void pump(); } @@ -481,7 +509,12 @@ export function createFetchTracker( // 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); + watchLongLivedStream( + response, + id, + longLivedStreamFraming(response.headers.get("content-type")), + callbacks, + ); } // Kick off a fire-and-forget read of the cloned body. The clone is an diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 95b46f33..ca5c80ba 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -270,6 +270,7 @@ import { findHeader, isLongLivedStreamResponse, } from "./fetchTracking.js"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { annotateRequestTimeout, type ConnectionDiagnostics, @@ -1130,6 +1131,15 @@ export class InspectorClient extends InspectorClientEventTarget { }; this.dispatchTypedEvent("message", entry); }, + trackSendFailure: (message: JSONRPCRequest) => { + // The frame never reached the wire, so it is not awaiting a response. + // Leaving it would report a request the server never saw as + // unanswered, in every timeout message and Connection Info row, until + // the session resets (#2318). + if (this.outstandingRequests.delete(message.id)) { + this.dispatchConnectionDiagnosticsChange(); + } + }, trackResponse: ( message: JSONRPCResultResponse | JSONRPCErrorResponse, origin: MessageOrigin, @@ -2915,8 +2925,19 @@ export class InspectorClient extends InspectorClientEventTarget { const raw = await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRawWireRequests.delete(id); + // The same error, with the same annotation, as an SDK request that + // times out: this path bypasses `Protocol.request`, so it builds the + // SDK's own timeout shape and runs it through the decorator's + // annotation by hand — a raw-wire caller sees one kind of timeout, + // not two (#2318). reject( - new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`), + annotateRequestTimeout( + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { + timeout: timeoutMs, + }), + method, + this.getConnectionDiagnostics(), + ), ); }, timeoutMs); this.pendingRawWireRequests.set(id, { resolve, reject, timer }); @@ -6083,7 +6104,9 @@ export class InspectorClient extends InspectorClientEventTarget { this.notificationStream = { id: entry.id, url: entry.url, - openedAt: entry.timestamp.getTime(), + // The entry's `timestamp` is when the request went out; the stream is + // open from when the response headers arrived, `duration` later. + openedAt: entry.timestamp.getTime() + (entry.duration ?? 0), eventCount: 0, }; this.dispatchConnectionDiagnosticsChange(); @@ -6098,7 +6121,12 @@ export class InspectorClient extends InspectorClientEventTarget { getConnectionDiagnostics(): ConnectionDiagnostics { return { capturedAt: Date.now(), - outstandingRequests: [...this.outstandingRequests.values()], + // Copies, not the tracked records: the snapshot is handed to callers + // and carried on errors, and a mutation there must not reach the + // correlation state. + outstandingRequests: [...this.outstandingRequests.values()].map( + (request) => ({ ...request }), + ), ...(this.lastResponse && { lastResponse: { ...this.lastResponse } }), ...(this.notificationStream && { notificationStream: this.publicStreamState(this.notificationStream), diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index 3402e244..b9dea9ca 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -24,6 +24,14 @@ export interface MessageTrackingCallbacks { message: JSONRPCNotification, origin: MessageOrigin, ) => void; + /** + * An outgoing request the base transport failed to send (the connection + * closed, the fetch failed before the frame reached the wire). Fires after + * `trackRequest` already recorded it, so a consumer keeping "requests still + * awaiting a response" can roll that entry back — a request that never went + * out is not unanswered (#2318). + */ + trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void; } /** @@ -95,7 +103,12 @@ export class MessageTrackingTransport implements Transport { "client", ); } else if ("method" in message) { - this.callbacks.trackRequest?.(message as JSONRPCRequest, "client"); + const request = message as JSONRPCRequest; + this.callbacks.trackRequest?.(request, "client"); + return this.baseTransport.send(message, options).catch((err) => { + this.callbacks.trackSendFailure?.(request, err); + throw err; + }); } } else if ("method" in message) { this.callbacks.trackNotification?.( diff --git a/core/mcp/remote/sessionStorage.ts b/core/mcp/remote/sessionStorage.ts index 9c24ee59..7d3764fb 100644 --- a/core/mcp/remote/sessionStorage.ts +++ b/core/mcp/remote/sessionStorage.ts @@ -119,7 +119,10 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage { const data = (await res.json()) as InspectorClientSessionState; - // Deserialize state (convert ISO strings back to Date objects) + // Deserialize state (convert ISO strings back to Date objects). The + // nested `stream.closedAt` is a Date too, and `JSON.stringify` turned it + // into an ISO string on the way out; left as a string it would fail the + // `getTime()` every consumer of the type performs (#2318). return { ...data, fetchRequests: data.fetchRequests.map((req) => ({ @@ -130,6 +133,9 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage { : req.timestamp instanceof Date ? req.timestamp : new Date(req.timestamp), + ...(req.stream?.closedAt !== undefined && { + stream: { ...req.stream, closedAt: new Date(req.stream.closedAt) }, + }), })), }; } From c565b56fcd192c3d8ec5cb4bc9ddbd5a8d04e806 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 22:27:54 -0400 Subject: [PATCH 04/20] fix: address Copilot review round 2 on #2318 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchTracking: media types and HTTP methods compare case-insensitively; a `Request` input's own method is honoured; the SSE line scanner accepts LF, CRLF and a bare CR, holding a trailing CR until the next chunk decides whether it is half of a CRLF. - MessageTrackingTransport does not report a send failure for a request the caller aborted through `requestSignal`, and InspectorClient keeps a request whose send failed with a request timeout — both are sent, unanswered requests, which is the state to keep reporting. - createTransportNode builds a fetch tracker when any tracking callback is supplied, not only `onFetchRequest`. - RemoteClientTransport's relay wait expires with the SDK's own timeout shape, so the web client's relay timeout is annotated like every other. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- .../core/mcp/authResyncPropagation.test.ts | 2 +- .../src/test/core/mcp/fetchTracking.test.ts | 50 +++++++++++++ ...pectorClient-diagnostics-callbacks.test.ts | 75 +++++++++++++++++++ .../core/mcp/messageTrackingTransport.test.ts | 12 +++ .../integration/mcp/node/transport.test.ts | 29 +++++++ .../remote/remoteClientTransport-unit.test.ts | 22 ++++++ core/mcp/fetchTracking.ts | 51 +++++++++---- core/mcp/inspectorClient.ts | 16 ++-- core/mcp/messageTrackingTransport.ts | 10 ++- core/mcp/node/transport.ts | 12 ++- core/mcp/remote/remoteClientTransport.ts | 14 +++- 11 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts diff --git a/clients/web/src/test/core/mcp/authResyncPropagation.test.ts b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts index 96e03fc3..604ac63b 100644 --- a/clients/web/src/test/core/mcp/authResyncPropagation.test.ts +++ b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts @@ -117,7 +117,7 @@ describe("RemoteClientTransport send contract", () => { const started = Date.now(); await expect( transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }), - ).rejects.toThrow(/Timed out waiting for MCP response on SSE/i); + ).rejects.toThrow(/Request timed out/); expect(Date.now() - started).toBeLessThan(5000); await transport.close(); diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index 61dc817e..3d834caa 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -826,6 +826,56 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { expect(last.closedAt).toBeInstanceOf(Date); }); + it("accepts bare CR line endings, and a CRLF split across chunks", async () => { + const server = controlledStream(); + const { updates } = await trackStream(server.stream); + server.push("data: a\r\rdata: b\r"); + await flush(); + // The trailing CR is held: it may be the first half of a CRLF. + expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1]); + server.push("\n\r\n"); + await flush(); + expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]); + }); + + it("classifies the content type and the method case-insensitively", async () => { + const server = controlledStream(); + const updates: unknown[] = []; + const fetcher = createFetchTracker( + (async () => + new Response(server.stream, { + headers: { "content-type": "Text/Event-Stream" }, + })) as typeof fetch, + { updateStream: (id, state) => updates.push({ id, state }) }, + ); + await fetcher("https://example.com/mcp", { method: "get" }); + expect(updates).toHaveLength(1); + }); + + it("takes the method from a Request input when init carries none", async () => { + // A bounded POST reply must go down the body-capture path, not the + // watcher, when the caller passed a `Request` rather than `init`. + const bodies: string[] = []; + const updates: unknown[] = []; + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker( + (async () => + new Response("data: x\n\n", { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch, + { + trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (_id, body) => bodies.push(body), + updateStream: (id, state) => updates.push({ id, state }), + }, + ); + await fetcher(new Request("https://example.com/mcp", { method: "POST" })); + await flush(); + expect(tracked[0]?.method).toBe("POST"); + expect(bodies).toEqual(["data: x\n\n"]); + expect(updates).toEqual([]); + }); + it("reports the close when the server ends the stream", async () => { const server = controlledStream(); const { updates, id } = await trackStream(server.stream); diff --git a/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts b/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts new file mode 100644 index 00000000..cae7981b --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import type { JSONRPCRequest } from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { MessageTrackingCallbacks } from "@inspector/core/mcp/messageTrackingTransport.js"; + +/** + * The outstanding-request bookkeeping behind the connection diagnostics + * (#2318), driven through the message-tracking callbacks the client hands its + * transport — the same seam the transport uses, without a connection. + */ +describe("InspectorClient diagnostics tracking callbacks", () => { + interface Internals { + createMessageTrackingCallbacks: () => MessageTrackingCallbacks; + } + + function makeClient(): { + client: InspectorClient; + callbacks: MessageTrackingCallbacks; + } { + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + // Never connects; the callbacks are exercised directly. + { environment: { transport: () => ({}) as never } }, + ); + const callbacks = ( + client as unknown as Internals + ).createMessageTrackingCallbacks(); + return { client, callbacks }; + } + + const request = (id: number): JSONRPCRequest => ({ + jsonrpc: "2.0", + id, + method: "tools/list", + }); + + const outstandingIds = (client: InspectorClient) => + client.getConnectionDiagnostics().outstandingRequests.map((r) => r.id); + + it("rolls a request back when its send failed before reaching the wire", () => { + const { client, callbacks } = makeClient(); + callbacks.trackRequest!(request(1), "client"); + expect(outstandingIds(client)).toEqual([1]); + callbacks.trackSendFailure!(request(1), new Error("connection closed")); + expect(outstandingIds(client)).toEqual([]); + }); + + it("keeps a request whose send failed with a request timeout", () => { + // The browser's remote transport awaits the response inside `send`, so + // its relay timeout surfaces as a send failure — the request reached the + // server and is still unanswered. + const { client, callbacks } = makeClient(); + callbacks.trackRequest!(request(1), "client"); + callbacks.trackSendFailure!( + request(1), + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { + timeout: 60_000, + }), + ); + expect(outstandingIds(client)).toEqual([1]); + }); + + it("only tracks client-originated requests, and ignores a rollback for an unknown id", () => { + const { client, callbacks } = makeClient(); + callbacks.trackRequest!(request(7), "server"); + expect(outstandingIds(client)).toEqual([]); + let changes = 0; + client.addEventListener("connectionDiagnosticsChange", () => { + changes += 1; + }); + callbacks.trackSendFailure!(request(7), new Error("nope")); + expect(changes).toBe(0); + }); +}); diff --git a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts index 1d59b96a..4265186f 100644 --- a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts +++ b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts @@ -48,6 +48,18 @@ describe("MessageTrackingTransport.send", () => { await expect(failing.send(request)).rejects.toBe(boom); expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client"); expect(trackSendFailure).toHaveBeenCalledWith(request, boom); + // A caller's own abort is a cancellation, not a send failure: the + // request may well have reached the server, and stays unanswered. + trackSendFailure.mockClear(); + const controller = new AbortController(); + controller.abort(); + await expect( + failing.send( + { jsonrpc: "2.0", id: 2, method: "tools/call" }, + { requestSignal: controller.signal }, + ), + ).rejects.toBe(boom); + expect(trackSendFailure).not.toHaveBeenCalled(); // Only requests roll back: a failed notification has no pending entry. trackSendFailure.mockClear(); await expect( diff --git a/clients/web/src/test/integration/mcp/node/transport.test.ts b/clients/web/src/test/integration/mcp/node/transport.test.ts index c813fdf0..e5326b52 100644 --- a/clients/web/src/test/integration/mcp/node/transport.test.ts +++ b/clients/web/src/test/integration/mcp/node/transport.test.ts @@ -188,6 +188,35 @@ describe("Transport", () => { } }); + it("tracks fetches when only onFetchResponseBody is supplied (#2318)", async () => { + // Any one of the tracking callbacks earns the tracker — a caller that + // wants only bodies (or only stream updates) must not be handed the + // bare fetch and silently receive nothing. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "streamable-http", + }); + try { + await server.start(); + const bodies: string[] = []; + const result = createTransportNode( + { type: "streamable-http", url: server.url }, + { onFetchResponseBody: (_id, body) => bodies.push(body) }, + ); + const client = new Client( + { name: "test-client", version: "1.0.0" }, + { capabilities: {} }, + ); + await client.connect(result.transport); + await client.listTools(); + await client.close(); + expect(bodies.some((body) => body.includes("tools"))).toBe(true); + } finally { + await server.stop(); + } + }); + it("should call onFetchRequest callback for streamable-http transport", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index ef7ebf5f..e4656e4d 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -16,6 +16,7 @@ import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteCl import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/client"; +import { SdkErrorCode } from "@modelcontextprotocol/client"; import { AuthChallengeError, AuthRecoveryRequiredError, @@ -615,6 +616,27 @@ describe("RemoteClientTransport (focused branch coverage)", () => { ).rejects.toThrow(/Transport is closed/); }); + it("rejects an expired SSE response wait with the SDK's own timeout shape (#2318)", async () => { + // The relay's budget, not the request's, can be the one that expires; + // shaped as the SDK's timeout so InspectorClient annotates it the same. + const t = makeTransport( + { + events: () => openEventsResponse(), + send: () => jsonResponse({ ok: true }), + }, + { sseResponseTimeoutMs: 50 }, + ); + await t.start(); + await expect( + t.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + ).rejects.toMatchObject({ + code: SdkErrorCode.RequestTimeout, + message: "Request timed out", + data: { timeout: 50 }, + }); + await t.close(); + }); + it("does not wait for an SSE response on subscriptions/listen (long-lived stream)", async () => { // A `subscriptions/listen` never yields a JSON-RPC response — only an // acknowledged notification over the event channel — so send() must diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 1928d1b7..fb3f117f 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -222,11 +222,13 @@ export function isLongLivedStreamResponse( method: string, contentType: string | null | undefined, ): boolean { - if (method !== "GET") return false; + if (method.toUpperCase() !== "GET") return false; if (!contentType) return false; + // Media types are case-insensitive (RFC 9110 §8.3.1); a server may send + // `Text/Event-Stream` and still be sending an event stream. + const type = contentType.toLowerCase(); return ( - contentType.includes("text/event-stream") || - contentType.includes("application/x-ndjson") + type.includes("text/event-stream") || type.includes("application/x-ndjson") ); } @@ -261,7 +263,9 @@ export type LongLivedStreamFraming = "sse" | "ndjson"; export function longLivedStreamFraming( contentType: string | null | undefined, ): LongLivedStreamFraming { - return contentType?.includes("application/x-ndjson") ? "ndjson" : "sse"; + return contentType?.toLowerCase().includes("application/x-ndjson") + ? "ndjson" + : "sse"; } /** @@ -314,8 +318,7 @@ function watchLongLivedStream( // 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; + const consumeLine = (line: string): void => { if (framing === "ndjson") { if (line.trim() === "") return; eventCount += 1; @@ -335,18 +338,35 @@ function watchLongLivedStream( if (line === "data" || line.startsWith("data:")) blockHasData = true; }; + // Split off every complete line in `pending`. The SSE grammar (and the + // SDK's parser) accept LF, CRLF and a bare CR as a line ending, so all three + // end a line here; a CR that is the last byte so far is held back, since + // the LF of a CRLF pair may still be in flight in the next chunk. + const consumeCompleteLines = (): void => { + for (;;) { + const cr = pending.indexOf("\r"); + const lf = pending.indexOf("\n"); + const end = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf); + if (end === -1) return; + if (pending[end] === "\r") { + if (end === pending.length - 1) return; + const width = pending[end + 1] === "\n" ? 2 : 1; + consumeLine(pending.slice(0, end)); + pending = pending.slice(end + width); + } else { + consumeLine(pending.slice(0, end)); + pending = pending.slice(end + 1); + } + } + }; + const pump = async (): Promise => { 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"); - } + consumeCompleteLines(); } } catch { // An errored or aborted stream has still ended — fall through to report @@ -384,7 +404,12 @@ export function createFetchTracker( : input instanceof URL ? input.toString() : input.url; - const method = init?.method || "GET"; + // The method decides the long-lived-stream classification below, so it + // has to be the one the request actually carries: a `Request` input + // carries its own, and HTTP methods compare case-insensitively. + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); // Extract headers, redacting sensitive values BEFORE they reach any // downstream sink (logger, in-memory list, persisted session storage). diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index ca5c80ba..72bafec9 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -273,6 +273,7 @@ import { import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { annotateRequestTimeout, + isRequestTimeoutError, type ConnectionDiagnostics, type LastResponse, type NotificationStreamState, @@ -1131,11 +1132,16 @@ export class InspectorClient extends InspectorClientEventTarget { }; this.dispatchTypedEvent("message", entry); }, - trackSendFailure: (message: JSONRPCRequest) => { - // The frame never reached the wire, so it is not awaiting a response. - // Leaving it would report a request the server never saw as - // unanswered, in every timeout message and Connection Info row, until - // the session resets (#2318). + trackSendFailure: (message: JSONRPCRequest, error: unknown) => { + // A send that failed because the request timed out is not a send + // that failed: the browser's remote transport awaits the response + // inside `send`, so its relay timeout surfaces here — the request + // reached the server and went unanswered, which is the state to + // keep reporting. Every other failure means the frame never reached + // the wire, and leaving it would report a request the server never + // saw as unanswered, in every timeout message and Connection Info + // row, until the session resets (#2318). + if (isRequestTimeoutError(error)) return; if (this.outstandingRequests.delete(message.id)) { this.dispatchConnectionDiagnosticsChange(); } diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index b9dea9ca..4a94df37 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -29,7 +29,11 @@ export interface MessageTrackingCallbacks { * closed, the fetch failed before the frame reached the wire). Fires after * `trackRequest` already recorded it, so a consumer keeping "requests still * awaiting a response" can roll that entry back — a request that never went - * out is not unanswered (#2318). + * out is not unanswered (#2318). Not fired when the caller aborted the + * request through `requestSignal`: the modern transport aborts an in-flight + * request's own stream to cancel it (and the SDK does the same on a + * per-request timeout), and a request that was sent and then given up on + * is exactly the unanswered kind. */ trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void; } @@ -106,7 +110,9 @@ export class MessageTrackingTransport implements Transport { const request = message as JSONRPCRequest; this.callbacks.trackRequest?.(request, "client"); return this.baseTransport.send(message, options).catch((err) => { - this.callbacks.trackSendFailure?.(request, err); + if (!options?.requestSignal?.aborted) { + this.callbacks.trackSendFailure?.(request, err); + } throw err; }); } diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index e0dadcbf..c9a8ee54 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -55,6 +55,12 @@ export function createTransportNode( interceptAuthChallenges = false, onAuthChallengeObserved, } = options; + // Any of the three tracking callbacks earns a tracker: each is optional on + // the tracker itself, and a caller that wants only stream updates (or only + // bodies) must not be silently handed the bare fetch (#2318). + const wantsFetchTracking = Boolean( + onFetchRequest || onFetchResponseBody || onFetchStreamUpdate, + ); // `optionsFetchFn` is the caller's whole fetch stack and already sits on top of // a proxy-aware base when one is needed — the Node clients install @@ -115,7 +121,7 @@ export function createTransportNode( const sseFetch = configuredSseFetch ? withChallengeObserver(configuredSseFetch) : fetchWithOptionalAuthIntercept; - const trackedFetch = onFetchRequest + const trackedFetch = wantsFetchTracking ? createFetchTracker(sseFetch, { trackRequest: onFetchRequest, updateResponseBody: onFetchResponseBody, @@ -136,7 +142,7 @@ export function createTransportNode( ...(headers && { headers }), }; - const postFetch = onFetchRequest + const postFetch = wantsFetchTracking ? createFetchTracker(fetchWithOptionalAuthIntercept, { trackRequest: onFetchRequest, updateResponseBody: onFetchResponseBody, @@ -164,7 +170,7 @@ export function createTransportNode( ...(headers && { headers }), }; - const transportFetch = onFetchRequest + const transportFetch = wantsFetchTracking ? createFetchTracker(fetchWithOptionalAuthIntercept, { trackRequest: onFetchRequest, updateResponseBody: onFetchResponseBody, diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 6a0ba873..a5c918b9 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -11,6 +11,7 @@ import type { JSONRPCMessage, MessageExtraInfo, } from "@modelcontextprotocol/client"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { InspectorServerSettings, StderrLogEntry } from "../types.js"; import type { FetchRequestEntryBase, FetchStreamState } from "../types.js"; import type { @@ -689,10 +690,17 @@ export class RemoteClientTransport implements Transport { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.sseResponseWaits.delete(requestId); + // The SDK's own per-request timeout shape, not a plain `Error`: this + // wait can expire before the SDK's timer does (its budget is the + // relay's, not the request's), and the rejection travels up through + // `Client.request`, where `InspectorClient` annotates a timeout of + // exactly this shape with the connection's state (#2318). A plain + // error would pass that decorator untouched and reach the user as a + // bare timeout on the web client alone. reject( - new Error( - `Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`, - ), + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { + timeout: this.sseResponseTimeoutMs, + }), ); }, this.sseResponseTimeoutMs); this.sseResponseWaits.set(requestId, { From ec09628705595f5bd817f6103b5c68b2f2f723dd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 22:53:08 -0400 Subject: [PATCH 05/20] fix: address Copilot review round 3 on #2318 - fetchTracking: the close report is guarded so a throwing listener cannot become an unhandled rejection off the discarded watcher; a bare CR that is the last byte of the stream terminates its event; a moving event count is coalesced to one report per interval (default 250ms, 0 in tests), with the open and the close still immediate. - MessageTrackingTransport rolls back a request whose signal was already aborted before the send began, and suppresses the rollback only for an abort that lands during the send. - useTickingClock takes an `enabled` flag; Connection Info installs no interval when there is no activity section. - The diagnostics-callbacks test justifies its private-seam cast. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- .../ConnectionInfoContent.tsx | 9 +- clients/web/src/hooks/useTickingClock.test.ts | 22 +++++ clients/web/src/hooks/useTickingClock.ts | 12 ++- .../src/test/core/mcp/fetchTracking.test.ts | 93 ++++++++++++++++++- ...pectorClient-diagnostics-callbacks.test.ts | 6 ++ .../core/mcp/messageTrackingTransport.test.ts | 30 +++++- core/mcp/fetchTracking.ts | 58 ++++++++++-- core/mcp/messageTrackingTransport.ts | 16 +++- 8 files changed, 221 insertions(+), 25 deletions(-) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index 25290f67..6c604880 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -337,8 +337,13 @@ export function ConnectionInfoContent({ : 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); + // the wall clock once a second so "sent 5s ago" keeps counting. No timer + // at all when there is no activity section to tick. + const now = useTickingClock( + diagnostics?.capturedAt ?? 0, + 1000, + diagnostics !== undefined, + ); const serverCaps = getServerCapabilityEntries(capabilities, protocolEra); const clientCaps = getCapabilityEntries( diff --git a/clients/web/src/hooks/useTickingClock.test.ts b/clients/web/src/hooks/useTickingClock.test.ts index 9242e1cb..dc459353 100644 --- a/clients/web/src/hooks/useTickingClock.test.ts +++ b/clients/web/src/hooks/useTickingClock.test.ts @@ -47,6 +47,28 @@ describe("useTickingClock", () => { expect(result.current).toBe(5_002_500); }); + it("installs no timer and returns the seed when disabled", () => { + vi.useFakeTimers(); + vi.setSystemTime(5_000_000); + const setSpy = vi.spyOn(globalThis, "setInterval"); + const { result, rerender } = renderHook( + ({ enabled }) => useTickingClock(1_000_000, 1000, enabled), + { initialProps: { enabled: false } }, + ); + expect(setSpy).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current).toBe(1_000_000); + // Enabling later starts the clock. + rerender({ enabled: true }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(5_004_000); + setSpy.mockRestore(); + }); + it("stops ticking on unmount", () => { vi.useFakeTimers(); const clearSpy = vi.spyOn(globalThis, "clearInterval"); diff --git a/clients/web/src/hooks/useTickingClock.ts b/clients/web/src/hooks/useTickingClock.ts index d82ac3f7..4dc47123 100644 --- a/clients/web/src/hooks/useTickingClock.ts +++ b/clients/web/src/hooks/useTickingClock.ts @@ -16,12 +16,20 @@ import { useEffect, useState } from "react"; * 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. + * `enabled: false` installs no timer at all and returns the seed. */ -export function useTickingClock(seed: number, intervalMs = 1000): number { +export function useTickingClock( + seed: number, + intervalMs = 1000, + enabled = true, +): number { const [now, setNow] = useState(seed); useEffect(() => { + // No timer for a consumer with nothing to tick — a component that + // renders no durations must not pay for a repeating interval. + if (!enabled) return; const id = setInterval(() => setNow(Date.now()), intervalMs); return () => clearInterval(id); - }, [intervalMs]); + }, [intervalMs, enabled]); return Math.max(now, seed); } diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index 3d834caa..fef4764c 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -748,6 +748,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { { trackRequest: (entry) => tracked.push(entry), updateStream: (id, state) => updates.push({ id, stream: state }), + streamUpdateIntervalMs: 0, }, ); const response = await fetcher("https://example.com/mcp", { @@ -800,7 +801,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { new Response(server.stream, { headers: { "content-type": "application/x-ndjson" }, })) as typeof fetch, - { updateStream: (id, state) => updates.push({ id, stream: state }) }, + { + updateStream: (id, state) => updates.push({ id, stream: state }), + streamUpdateIntervalMs: 0, + }, ); await fetcher("https://example.com/mcp", { method: "GET" }); server.push('{"jsonrpc":"2.0","method":"a"}\n\n'); @@ -846,7 +850,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { new Response(server.stream, { headers: { "content-type": "Text/Event-Stream" }, })) as typeof fetch, - { updateStream: (id, state) => updates.push({ id, state }) }, + { + updateStream: (id, state) => updates.push({ id, state }), + streamUpdateIntervalMs: 0, + }, ); await fetcher("https://example.com/mcp", { method: "get" }); expect(updates).toHaveLength(1); @@ -867,6 +874,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { trackRequest: (entry) => tracked.push(entry), updateResponseBody: (_id, body) => bodies.push(body), updateStream: (id, state) => updates.push({ id, state }), + streamUpdateIntervalMs: 0, }, ); await fetcher(new Request("https://example.com/mcp", { method: "POST" })); @@ -876,6 +884,80 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { expect(updates).toEqual([]); }); + it("coalesces a moving count to one report per interval, and reports the close at once", async () => { + vi.useFakeTimers(); + try { + const server = controlledStream(); + const updates: Array<{ id: string; stream: FetchStreamState }> = []; + const fetcher = createFetchTracker( + (async () => + new Response(server.stream, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch, + { + updateStream: (id, state) => updates.push({ id, stream: state }), + streamUpdateIntervalMs: 100, + }, + ); + await fetcher("https://example.com/mcp", { method: "GET" }); + // The open is immediate. + expect(updates.map((u) => u.stream.eventCount)).toEqual([0]); + server.push("data: a\n\ndata: b\n\ndata: c\n\n"); + await vi.advanceTimersByTimeAsync(0); + // Three events, no report yet — they are inside one window. + expect(updates.map((u) => u.stream.eventCount)).toEqual([0]); + await vi.advanceTimersByTimeAsync(100); + expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 3]); + // A close cancels a pending window and reports immediately. + server.push("data: d\n\n"); + server.end(); + await vi.advanceTimersByTimeAsync(0); + expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 3, 4]); + expect(updates.at(-1)!.stream.closedAt).toBeInstanceOf(Date); + await vi.advanceTimersByTimeAsync(200); + expect(updates).toHaveLength(3); + } finally { + vi.useRealTimers(); + } + }); + + it("counts an event whose bare-CR terminator is the last byte of the stream", async () => { + const server = controlledStream(); + const { updates } = await trackStream(server.stream); + server.push("data: x\r\r"); + await flush(); + // Held while the stream is open: the CR could be half of a CRLF. + expect(updates.map((u) => u.stream.eventCount)).toEqual([0]); + server.end(); + await flush(); + expect(updates.at(-1)!.stream).toMatchObject({ eventCount: 1 }); + expect(updates.at(-1)!.stream.closedAt).toBeInstanceOf(Date); + }); + + it("survives a stream-update listener that throws on the close", async () => { + // The watcher's promise is discarded, so a throw from the final report + // would otherwise be an unhandled rejection — which fails the whole run. + const server = controlledStream(); + let reports = 0; + const fetcher = createFetchTracker( + (async () => + new Response(server.stream, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch, + { + updateStream: (_id, state) => { + reports += 1; + if (state.closedAt) throw new Error("listener exploded"); + }, + streamUpdateIntervalMs: 0, + }, + ); + await fetcher("https://example.com/mcp", { method: "GET" }); + server.end(); + await flush(); + expect(reports).toBe(2); + }); + it("reports the close when the server ends the stream", async () => { const server = controlledStream(); const { updates, id } = await trackStream(server.stream); @@ -932,7 +1014,10 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { new Response("data: x\n\n", { headers: { "content-type": "text/event-stream" }, })) as typeof fetch, - { updateStream: (id, state) => updates.push({ id, state }) }, + { + updateStream: (id, state) => updates.push({ id, state }), + streamUpdateIntervalMs: 0, + }, ); // A POST SSE reply is bounded and goes down the body-capture path. await fetcher("https://example.com/mcp", { method: "POST" }); @@ -950,6 +1035,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { const updates: unknown[] = []; const fetcher = createFetchTracker((async () => response) as typeof fetch, { updateStream: (id, state) => updates.push({ id, state }), + streamUpdateIntervalMs: 0, }); await expect( fetcher("https://example.com/mcp", { method: "GET" }), @@ -971,6 +1057,7 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { const updates: unknown[] = []; const fetcher = createFetchTracker((async () => response) as typeof fetch, { updateStream: (id, state) => updates.push({ id, state }), + streamUpdateIntervalMs: 0, }); await fetcher("https://example.com/mcp", { method: "GET" }); await flush(); diff --git a/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts b/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts index cae7981b..35adb3c6 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-diagnostics-callbacks.test.ts @@ -23,6 +23,12 @@ describe("InspectorClient diagnostics tracking callbacks", () => { // Never connects; the callbacks are exercised directly. { environment: { transport: () => ({}) as never } }, ); + // `createMessageTrackingCallbacks` is private: it is the seam the client + // hands its transport, and driving it directly is what lets this test + // exercise the bookkeeping without a connection. The double cast is the + // documented test-only exception for reaching a private member — the + // same one `inspectorClient-raw-wire.test.ts` uses — and `Internals` + // names exactly the one method it reaches. const callbacks = ( client as unknown as Internals ).createMessageTrackingCallbacks(); diff --git a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts index 4265186f..ea69da47 100644 --- a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts +++ b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts @@ -48,18 +48,38 @@ describe("MessageTrackingTransport.send", () => { await expect(failing.send(request)).rejects.toBe(boom); expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client"); expect(trackSendFailure).toHaveBeenCalledWith(request, boom); - // A caller's own abort is a cancellation, not a send failure: the + // A signal already aborted before the send began: the frame never went + // out, so this is a failed send and rolls back. + trackSendFailure.mockClear(); + const preAborted = new AbortController(); + preAborted.abort(); + const preAbortedRequest = { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + } as const; + await expect( + failing.send(preAbortedRequest, { requestSignal: preAborted.signal }), + ).rejects.toBe(boom); + expect(trackSendFailure).toHaveBeenCalledWith(preAbortedRequest, boom); + // A signal aborted while the send is in flight is a cancellation: the // request may well have reached the server, and stays unanswered. trackSendFailure.mockClear(); - const controller = new AbortController(); - controller.abort(); + const midSend = new AbortController(); + base.send = async () => { + midSend.abort(); + throw boom; + }; await expect( failing.send( - { jsonrpc: "2.0", id: 2, method: "tools/call" }, - { requestSignal: controller.signal }, + { jsonrpc: "2.0", id: 3, method: "tools/call" }, + { requestSignal: midSend.signal }, ), ).rejects.toBe(boom); expect(trackSendFailure).not.toHaveBeenCalled(); + base.send = async () => { + throw boom; + }; // Only requests roll back: a failed notification has no pending entry. trackSendFailure.mockClear(); await expect( diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index fb3f117f..19b00d23 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -250,8 +250,21 @@ export interface FetchTrackingCallbacks { * server serializes requests behind it (#2318). */ updateStream?: (id: string, stream: FetchStreamState) => void; + /** + * How often, at most, `updateStream` reports a moving event count, in ms. + * The first report (open, zero events) and the close are immediate; counts + * in between are coalesced so a server pushing hundreds of notifications a + * second costs one update per window rather than one per event — each + * update re-publishes the whole fetch log and, on the web client, crosses + * the backend-to-browser event stream. Defaults to + * `DEFAULT_STREAM_UPDATE_INTERVAL_MS`; `0` reports every event, for tests. + */ + streamUpdateIntervalMs?: number; } +/** See `FetchTrackingCallbacks.streamUpdateIntervalMs`. */ +export const DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250; + /** * The framing a long-lived stream's events arrive in — the two content types * `isLongLivedStreamResponse` accepts. SSE dispatches on a blank line; @@ -297,8 +310,10 @@ function watchLongLivedStream( framing: LongLivedStreamFraming, callbacks: FetchTrackingCallbacks, ): void { - const update = callbacks.updateStream; - if (!update) return; + const report = callbacks.updateStream; + if (!report) return; + const intervalMs = + callbacks.streamUpdateIntervalMs ?? DEFAULT_STREAM_UPDATE_INTERVAL_MS; let body: ReadableStream | null; try { body = response.clone().body; @@ -313,23 +328,37 @@ function watchLongLivedStream( let eventCount = 0; let pending = ""; let blockHasData = false; + // A moving count is reported at most once per interval (see + // `streamUpdateIntervalMs`); the open and the close go out at once. + let reportTimer: ReturnType | null = null; + const reportCount = (): void => { + if (intervalMs <= 0) { + report(id, { eventCount }); + return; + } + if (reportTimer !== null) return; + reportTimer = setTimeout(() => { + reportTimer = null; + report(id, { eventCount }); + }, intervalMs); + }; // 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 }); + report(id, { eventCount }); const consumeLine = (line: string): void => { if (framing === "ndjson") { if (line.trim() === "") return; eventCount += 1; - update(id, { eventCount }); + reportCount(); return; } if (line === "") { if (blockHasData) { eventCount += 1; blockHasData = false; - update(id, { eventCount }); + reportCount(); } return; } @@ -368,17 +397,30 @@ function watchLongLivedStream( pending += decoder.decode(value, { stream: true }); consumeCompleteLines(); } + // A CR held back as a possible half of a CRLF is, at end-of-stream, a + // whole line ending — `data: x\r\r` is a complete event. Anything after + // it is a partial block and is dropped, as above. + if (pending.endsWith("\r")) consumeLine(pending.slice(0, -1)); } 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() }); + if (reportTimer !== null) { + clearTimeout(reportTimer); + reportTimer = null; + } + try { + report(id, { eventCount, closedAt: new Date() }); + } catch { + // A listener that throws must not become an unhandled rejection off a + // promise nothing awaits; the stream is closed either way. + } }; // Deliberately not awaited: the fetch wrapper has to return the response // now, and this watcher lives as long as the stream does. It owns its own - // failures — the read loop is wrapped in a `try` that treats an error as a - // close — so the promise cannot reject. + // failures — the read loop and the final report are each wrapped in a + // `try` — so the promise cannot reject. void pump(); } diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index 4a94df37..c7645dcc 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -30,10 +30,11 @@ export interface MessageTrackingCallbacks { * `trackRequest` already recorded it, so a consumer keeping "requests still * awaiting a response" can roll that entry back — a request that never went * out is not unanswered (#2318). Not fired when the caller aborted the - * request through `requestSignal`: the modern transport aborts an in-flight - * request's own stream to cancel it (and the SDK does the same on a - * per-request timeout), and a request that was sent and then given up on - * is exactly the unanswered kind. + * request through `requestSignal` *during* the send: the modern transport + * aborts an in-flight request's own stream to cancel it (and the SDK does + * the same on a per-request timeout), and a request that was sent and then + * given up on is exactly the unanswered kind. A signal already aborted + * before the send began is a failed send like any other. */ trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void; } @@ -109,8 +110,13 @@ export class MessageTrackingTransport implements Transport { } else if ("method" in message) { const request = message as JSONRPCRequest; this.callbacks.trackRequest?.(request, "client"); + // A signal already aborted here means the frame will not go out at + // all — that is a failed send. One that becomes aborted while the + // send is in flight is a cancellation of a request that may well + // have reached the server, which stays unanswered. + const abortedBeforeSend = options?.requestSignal?.aborted === true; return this.baseTransport.send(message, options).catch((err) => { - if (!options?.requestSignal?.aborted) { + if (abortedBeforeSend || !options?.requestSignal?.aborted) { this.callbacks.trackSendFailure?.(request, err); } throw err; From abc49672981abe76fcf03c7f33d91cd4230a384c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 23:20:28 -0400 Subject: [PATCH 06/20] fix: address Copilot review round 4 on #2318 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchTracking: one non-throwing reporter fronts every stream update — the open (inside the fetch wrapper), each coalesced count (from a timer) and the close (off the discarded watcher). - rawWireRequest annotates a request timeout its transport's send rejects with, so the browser's relay timeout on a raw-wire call reads like every other. - RemoteClientTransport reports still-open streams closed on close(), since the backend watcher's own close cannot reach a channel that the disconnect has already torn down. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- .../src/test/core/mcp/fetchTracking.test.ts | 22 +++++++ .../core/mcp/inspectorClient-raw-wire.test.ts | 42 +++++++++++++- .../remote/remoteClientTransport-unit.test.ts | 57 +++++++++++++++++++ core/mcp/fetchTracking.ts | 34 ++++++----- core/mcp/inspectorClient.ts | 12 +++- core/mcp/remote/remoteClientTransport.ts | 28 +++++++++ 6 files changed, 180 insertions(+), 15 deletions(-) diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index fef4764c..0419b840 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -934,6 +934,28 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => { expect(updates.at(-1)!.stream.closedAt).toBeInstanceOf(Date); }); + it("returns the response even when the listener throws on the open report", async () => { + // The open is reported synchronously inside the fetch wrapper; a throw + // there must not turn a successful response into a rejected fetch. + const server = controlledStream(); + const response = new Response(server.stream, { + headers: { "content-type": "text/event-stream" }, + }); + const fetcher = createFetchTracker((async () => response) as typeof fetch, { + updateStream: () => { + throw new Error("sink closed"); + }, + streamUpdateIntervalMs: 0, + }); + await expect( + fetcher("https://example.com/mcp", { method: "GET" }), + ).resolves.toBe(response); + // ...and a throw from a coalesced count does not escape the timer either. + server.push("data: a\n\n"); + server.end(); + await flush(); + }); + it("survives a stream-update listener that throws on the close", async () => { // The watcher's promise is discarded, so a throw from the final report // would otherwise be an unhandled rejection — which fails the whole run. diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index c6679c3f..638891b5 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { SdkErrorCode } from "@modelcontextprotocol/client"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js"; @@ -151,6 +151,46 @@ describe("InspectorClient raw-wire channel (#1631)", () => { } }); + it("annotates a request timeout the transport's send rejects with (#2318)", async () => { + // The browser's remote transport awaits the response inside `send`, so + // its relay wait can expire before the local timer; it arrives here as + // the SDK's timeout shape and gets the same annotation. + const client = makeClient(); + internals(client).transport = { + send: vi.fn().mockRejectedValue( + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { + timeout: 60_000, + }), + ), + }; + await expect( + internals(client).rawWireRequest( + "tasks/get", + {}, + ModernGetTaskResultSchema, + ), + ).rejects.toMatchObject({ + code: SdkErrorCode.RequestTimeout, + message: expect.stringMatching( + /^Request timed out after 1m00s \(tasks\/get\)\. /, + ), + data: expect.objectContaining({ timeout: 60_000, method: "tasks/get" }), + }); + }); + + it("leaves a non-timeout send failure untouched", async () => { + const client = makeClient(); + const boom = new Error("connection closed"); + internals(client).transport = { send: vi.fn().mockRejectedValue(boom) }; + await expect( + internals(client).rawWireRequest( + "tasks/get", + {}, + ModernGetTaskResultSchema, + ), + ).rejects.toBe(boom); + }); + it("rejects all pending requests on teardown", async () => { const client = makeClient(); internals(client).transport = { diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index e4656e4d..8583b19f 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -375,6 +375,63 @@ describe("RemoteClientTransport (focused branch coverage)", () => { }); }); + it("reports a still-open stream closed on close(), since the backend's own close cannot arrive (#2318)", async () => { + const onFetchStreamUpdate = vi.fn(); + const { response, push } = createPushableEventStream(); + const t = makeTransport( + { events: () => response }, + { onFetchStreamUpdate, onFetchRequest: vi.fn() }, + ); + await t.start(); + const entry = (id: string, method: string, contentType: string) => + ({ + id, + method, + url: "http://x/mcp", + timestamp: new Date().toISOString(), + requestHeaders: {}, + responseStatus: 200, + responseHeaders: { "Content-Type": contentType }, + }) as never; + push({ + type: "fetch_request", + data: entry("stream", "GET", "text/event-stream"), + }); + push({ + type: "fetch_request", + data: entry("post", "POST", "text/event-stream"), + }); + push({ + type: "fetch_stream_update", + data: { id: "stream", eventCount: 2 }, + }); + push({ + type: "fetch_request", + data: entry("earlier", "GET", "text/event-stream"), + }); + push({ + type: "fetch_stream_update", + data: { + id: "earlier", + eventCount: 1, + closedAt: new Date().toISOString(), + }, + }); + await tick(); + onFetchStreamUpdate.mockClear(); + await t.close(); + // Only the stream still open is reported closed, with its last count; + // the bounded POST and the already-closed stream are not. + expect(onFetchStreamUpdate).toHaveBeenCalledTimes(1); + expect(onFetchStreamUpdate).toHaveBeenCalledWith("stream", { + eventCount: 2, + closedAt: expect.any(Date), + }); + // A second close reports nothing more. + await t.close(); + expect(onFetchStreamUpdate).toHaveBeenCalledTimes(1); + }); + it("drops fetch_stream_update events when no handler is registered", async () => { const onFetchRequest = vi.fn(); const t = makeTransport( diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 19b00d23..10eb1d00 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -310,8 +310,21 @@ function watchLongLivedStream( framing: LongLivedStreamFraming, callbacks: FetchTrackingCallbacks, ): void { - const report = callbacks.updateStream; - if (!report) return; + const listener = callbacks.updateStream; + if (!listener) return; + // Every report goes through here: the listener is consumer code (a relay + // sink that may already be closed, a state store), and it runs from three + // places none of which may throw outward — synchronously inside the fetch + // wrapper (the open), from a timer (a coalesced count), and off the + // discarded watcher promise (the close). A throw is the listener's + // problem; the stream's bookkeeping carries on. + const report = (state: FetchStreamState): void => { + try { + listener(id, state); + } catch { + // See above. + } + }; const intervalMs = callbacks.streamUpdateIntervalMs ?? DEFAULT_STREAM_UPDATE_INTERVAL_MS; let body: ReadableStream | null; @@ -333,19 +346,19 @@ function watchLongLivedStream( let reportTimer: ReturnType | null = null; const reportCount = (): void => { if (intervalMs <= 0) { - report(id, { eventCount }); + report({ eventCount }); return; } if (reportTimer !== null) return; reportTimer = setTimeout(() => { reportTimer = null; - report(id, { eventCount }); + report({ eventCount }); }, intervalMs); }; // 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. - report(id, { eventCount }); + report({ eventCount }); const consumeLine = (line: string): void => { if (framing === "ndjson") { @@ -410,17 +423,12 @@ function watchLongLivedStream( clearTimeout(reportTimer); reportTimer = null; } - try { - report(id, { eventCount, closedAt: new Date() }); - } catch { - // A listener that throws must not become an unhandled rejection off a - // promise nothing awaits; the stream is closed either way. - } + report({ eventCount, closedAt: new Date() }); }; // Deliberately not awaited: the fetch wrapper has to return the response // now, and this watcher lives as long as the stream does. It owns its own - // failures — the read loop and the final report are each wrapped in a - // `try` — so the promise cannot reject. + // failures — the read loop is wrapped in a `try`, and every report goes + // through the non-throwing `report` above — so the promise cannot reject. void pump(); } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 43b05f68..72f3c192 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -2980,7 +2980,17 @@ export class InspectorClient extends InspectorClientEventTarget { clearTimeout(pending.timer); this.pendingRawWireRequests.delete(id); } - reject(err instanceof Error ? err : new Error(String(err))); + // The browser's remote transport awaits the response inside `send`, + // so its relay wait can expire here first, as the SDK's timeout + // shape; annotate it exactly as the local timer above does (a + // non-timeout error passes through untouched). + reject( + annotateRequestTimeout( + err instanceof Error ? err : new Error(String(err)), + method, + this.getConnectionDiagnostics(), + ), + ); }); }); return resultSchema.parse(raw); diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 8200b32a..202310c4 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -12,6 +12,7 @@ import type { MessageExtraInfo, } from "@modelcontextprotocol/client"; import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { findHeader, isLongLivedStreamResponse } from "../fetchTracking.js"; import type { InspectorServerSettings, StderrLogEntry } from "../types.js"; import type { FetchRequestEntryBase, FetchStreamState } from "../types.js"; import type { @@ -265,6 +266,14 @@ export class RemoteClientTransport implements Transport { private eventStreamConsumeTask: Promise | null = null; private restartingEventStream = false; private closed = false; + /** + * Long-lived streams the backend has reported open, by fetch entry id, with + * the last event count seen for each. `close()` reports them closed + * itself: it aborts the event channel before asking the backend to + * disconnect, so the backend watcher's own close report lands on an + * unconsumed queue and is lost with the session (#2318). + */ + private openStreams = new Map(); private readonly sseResponseWaits = new Map< string | number, SseResponseWait @@ -548,6 +557,14 @@ export class RemoteClientTransport implements Transport { this.options.onFetchRequest ) { const entry = parsed.data; + if ( + isLongLivedStreamResponse( + entry.method, + findHeader(entry.responseHeaders, "content-type"), + ) + ) { + this.openStreams.set(entry.id, 0); + } this.options.onFetchRequest({ ...entry, timestamp: @@ -568,6 +585,10 @@ export class RemoteClientTransport implements Transport { this.options.onFetchStreamUpdate ) { const { id, eventCount, closedAt } = parsed.data; + if (closedAt !== undefined) this.openStreams.delete(id); + else if (this.openStreams.has(id)) { + this.openStreams.set(id, eventCount); + } this.options.onFetchStreamUpdate(id, { eventCount, ...(closedAt !== undefined && { closedAt: new Date(closedAt) }), @@ -856,6 +877,13 @@ export class RemoteClientTransport implements Transport { this.closed = true; this.cancelAllSseWaits(new Error("Transport closed")); await this.stopEventStream(); + // The disconnect below ends every stream the backend still holds open, + // and its close reports cannot reach us any more (see `openStreams`). + const closedAt = new Date(); + for (const [id, eventCount] of this.openStreams) { + this.options.onFetchStreamUpdate?.(id, { eventCount, closedAt }); + } + this.openStreams.clear(); if (this._sessionId) { try { From 2c1d3fdba80b011c2a34c0f5d91ab141a752cb47 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 23:35:30 -0400 Subject: [PATCH 07/20] docs: add an environment-variable reference (#2365) Every variable that changes runtime behavior now lives on one page, docs/environment-variables.md: which client reads it, its default, and its effect. It covers three variables that were undocumented (MCP_LOG_FILE, MCP_INSPECTOR_LOG_DIR, LOG_LEVEL), the secret-store variables previously only in the Docker guide, and the Node TLS variables that answer #1936 (a self-signed https://localhost server). The README docs table, the migration guide, docker.md and the four client READMEs link to it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva Signed-off-by: cliffhall --- README.md | 1 + clients/cli/README.md | 2 +- clients/launcher/README.md | 2 +- clients/tui/README.md | 2 + clients/web/README.md | 2 +- docs/docker.md | 2 +- docs/environment-variables.md | 107 ++++++++++++++++++++++++++++++++++ docs/v1-to-v2-migration.md | 2 + 8 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 docs/environment-variables.md diff --git a/README.md b/README.md index 1b813e38..29b74bd9 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Each client has its own README with client-specific detail: | [Publishing](./docs/publishing.md) | What ships in the tarball, the packaging invariants, and `pack:verify` | | [Docker](./docs/docker.md) | Running the container image — ports, volumes, and where secrets go | | [Migrating from v1 to v2](./docs/v1-to-v2-migration.md) | CLI flag mapping, `--config` vs. `--catalog`, the Node engine bump, env-var renames | +| [Environment variables](./docs/environment-variables.md) | Every variable that changes runtime behavior — auth, ports, storage, the secret store, logging, proxies — plus the Node TLS variables for a self-signed server | | [MCP server configuration](./docs/mcp-server-configuration.md) | Which server(s) the Inspector connects to, and the config file format | | [Reviewing an MCP App](./docs/mcp-app-review.md) | The CLI-first → one-shot-web recipe for automated App-tool review | | [Smoke-testing an MCP server](./docs/cli-smoke-testing.md) | The connect → list → call → assert workflow for a shell or CI job: `--format json` + `jq`, the exit-code map, and keeping OAuth non-interactive | diff --git a/clients/cli/README.md b/clients/cli/README.md index 175c8937..c1fe9467 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -84,7 +84,7 @@ When a server is loaded from a `--catalog`/`--config` file, its per-server setti The file is the only durable way to give a run its roots: there is no roots flag, and `--method roots/set` applies only to that one short-lived connection. Roots configured for a server (the same field the web UI's Server Settings writes) are advertised at connect, so a server that asks for `roots/list` — `@modelcontextprotocol/server-filesystem` does, to learn its allowed directories — gets them. -**Environment-variable semantics.** `MCP_CATALOG_PATH` is honored only when no ad-hoc target is given (positional command, `--server-url`, or `--transport`) — so a shell that exports it can still run one-off ad-hoc invocations without hitting the catalog/ad-hoc conflict. `MCP_STORAGE_DIR` sets the storage directory used by the OAuth persist backend (`/oauth.json`); the per-file `MCP_INSPECTOR_OAUTH_STATE_PATH` override still takes precedence over it. +**Environment-variable semantics.** `MCP_CATALOG_PATH` is honored only when no ad-hoc target is given (positional command, `--server-url`, or `--transport`) — so a shell that exports it can still run one-off ad-hoc invocations without hitting the catalog/ad-hoc conflict. `MCP_STORAGE_DIR` sets the storage directory used by the OAuth persist backend (`/oauth.json`); the per-file `MCP_INSPECTOR_OAUTH_STATE_PATH` override still takes precedence over it. Every variable the CLI reads is listed in [Environment variables](../../docs/environment-variables.md). ### HTTP proxy support diff --git a/clients/launcher/README.md b/clients/launcher/README.md index e69cf0cb..207c2a2c 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -10,7 +10,7 @@ The launcher is the package that provides the global `mcp-inspector` binary (e.g All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret config or env vars. -**Error reporting.** A `--cli` failure is routed through the CLI's own error sink, so `mcp-inspector --cli` preserves the CLI exit-code map (`1` usage, `2` no-app, `3` auth-required, `4` unreachable, `5` tool-error) and its machine-readable `{"error":{…}}` stderr envelope — the same as invoking the CLI bin directly. `--web` / `--tui` failures print a human-readable `Error: ` and exit `1` (append `MCP_DEBUG=1` for the stack). +**Error reporting.** A `--cli` failure is routed through the CLI's own error sink, so `mcp-inspector --cli` preserves the CLI exit-code map (`1` usage, `2` no-app, `3` auth-required, `4` unreachable, `5` tool-error) and its machine-readable `{"error":{…}}` stderr envelope — the same as invoking the CLI bin directly. `--web` / `--tui` failures print a human-readable `Error: ` and exit `1` (append `MCP_DEBUG=1` for the stack). `MCP_DEBUG` (or `DEBUG`) is the only variable the launcher reads itself; the rest are listed in [Environment variables](../../docs/environment-variables.md). ## Web server-list flags (`--web`) diff --git a/clients/tui/README.md b/clients/tui/README.md index 17fdb0c9..54cc8352 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -52,6 +52,8 @@ OAuth redirect URIs must match **exactly** what you register on the authorizatio #### Flags +Variables with no flag of their own — the secret store, `MCP_INSPECTOR_LOG_DIR`, `LOG_LEVEL`, proxies — are listed in [Environment variables](../../docs/environment-variables.md). + | Option | Env | Description | | ----------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--client-config ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | diff --git a/clients/web/README.md b/clients/web/README.md index 02d97a92..b2952821 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -384,7 +384,7 @@ Storybook is first-class here because the components are presentational — each ## Auth token -The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer `. The browser recovers the token, in priority order (see `App.tsx` `getAuthToken()`): the `window.__INSPECTOR_API_TOKEN__` global injected into `index.html` on every page load (`server/inject-auth-token.ts`), then a `?MCP_INSPECTOR_API_TOKEN=…` query param, then `sessionStorage`. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`). See the root [AGENTS.md](../../AGENTS.md) for the full rationale. +The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer `. The browser recovers the token, in priority order (see `App.tsx` `getAuthToken()`): the `window.__INSPECTOR_API_TOKEN__` global injected into `index.html` on every page load (`server/inject-auth-token.ts`), then a `?MCP_INSPECTOR_API_TOKEN=…` query param, then `sessionStorage`. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`). See the root [AGENTS.md](../../AGENTS.md) for the full rationale, and [Environment variables](../../docs/environment-variables.md) for every variable the backend reads. ## Host binding & the origin allow-list diff --git a/docs/docker.md b/docs/docker.md index ed93353c..46d6278a 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -77,7 +77,7 @@ If another process holds the lock and will not let go, the save **fails** rather It is also what covers the lock being unavailable. This store exists for boxes where the usual mechanism isn't there, so a directory that can't hold a lock file — a read-only `$HOME`, a mount owned by another uid — makes the save proceed unlocked with a warning, rather than turning every `set` into a failure on exactly the deployments the store was written for. -Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. +Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. These variables apply outside a container too; every runtime variable is listed in [Environment variables](./environment-variables.md). **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 00000000..56a64f9b --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,107 @@ +# Environment variables + +Every environment variable that changes how the Inspector behaves at runtime, in one place. Set them in the shell that launches `mcp-inspector` (or with `-e` for the [Docker image](./docker.md)). + +The **Read by** column names the client whose process reads the variable: **web** is the Node backend that `--web` starts (the browser itself reads no environment), **CLI** and **TUI** are those clients, and **launcher** is the `mcp-inspector` bin that picks one of them. A variable read in shared `core/` code is marked with every client that reaches it. + +Unless a row says otherwise, an unset variable and an empty one behave the same. + +## Authentication and network exposure + +These guard the web backend, which spawns processes on request. Read [Host binding and the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list) before widening any of them. + +| Variable | Read by | Default | Effect | +| --------------------------------- | -------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_INSPECTOR_API_TOKEN` | web, CLI | a random token per launch | Bearer token guarding every `/api/*` route (`x-mcp-remote-auth: Bearer `). Set it to use a known token instead of the generated one printed in the launch banner. The CLI reads it only to fill the `autoConnect` parameter of the deep link it emits. | +| `MCP_PROXY_AUTH_TOKEN` | web | — | **Deprecated** v1 name for `MCP_INSPECTOR_API_TOKEN`, used only when the new name is unset. | +| `DANGEROUSLY_OMIT_AUTH` | web | unset | Disables the API token entirely. ⚠️ **Any non-empty value turns auth off, including `false` and `0`** — unset the variable to keep auth on. | +| `HOST` | web, CLI | `127.0.0.1` | Address the web server binds. An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES` is enabled. The CLI reads it only to build its deep link. | +| `DANGEROUSLY_BIND_ALL_INTERFACES` | web | off | Opts in to an all-interfaces `HOST`. Only `true` or `1` (case-insensitive) enable it, so `false` reads as off. The Docker image sets it. | +| `ALLOWED_ORIGINS` | web | the loopback origins | Comma-separated origins allowed to call the API. **Replaces** the default list rather than adding to it, so list every form you browse from. Each entry must include the scheme (`http://localhost:6274`). | + +## Ports + +The web client needs **fixed** ports: the origin allow-list and the MCP Apps sandbox CSP are both derived from them. + +| Variable | Read by | Default | Effect | +| --------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLIENT_PORT` | web, CLI | `6274` | Web UI port. Must be an integer in 1–65535; `0` (a dynamic port) is rejected at startup. | +| `MCP_SANDBOX_PORT` | web, CLI | `6275` | Port of the MCP Apps sandbox server. An invalid value is ignored with a warning. | +| `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for `MCP_SANDBOX_PORT` when that is unset. | +| `MCP_APP_ORIGIN_PORT` | web, CLI | `6278` | Port of the dedicated app-origin server, used only by an MCP App whose UI resource declares `_meta.ui.domain`. An invalid value is ignored with a warning. | + +The CLI reads the three port variables and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. + +## Behavior + +| Variable | Read by | Default | Effect | +| ------------------------ | ------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_AUTO_OPEN_ENABLED` | web, CLI, TUI | open | `false` never opens a browser; `true` always does. In the CLI and TUI it also governs the interactive OAuth flow, and `true` opens the browser even when stderr is not a TTY. Any other value behaves as unset. | +| `MCP_CATALOG_PATH` | web, CLI, TUI | `~/.mcp-inspector/mcp.json` | Default writable catalog, used when no `--catalog` is given. The CLI honors it only when no ad-hoc target (positional command, `--server-url`, or `--transport`) is given. See [MCP server configuration](./mcp-server-configuration.md). | +| `MCP_OAUTH_CALLBACK_URL` | CLI, TUI | `http://127.0.0.1:6276/oauth/callback` | Loopback redirect URL for the CLI/TUI OAuth flow. `--callback-url` takes precedence. | +| `NO_COLOR` | CLI | unset | Any value disables ANSI styling in the CLI's human-readable output. | + +## Storage and state + +| Variable | Read by | Default | Effect | +| -------------------------------- | ------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_STORAGE_DIR` | web, CLI, TUI | `~/.mcp-inspector/storage` | Storage directory. Relocates the OAuth state file (`oauth.json`) and the secrets file (`secrets.json`) for every client. For the **web** backend it also relocates `client.json`; the CLI and TUI find `client.json` through `MCP_CLIENT_CONFIG_PATH` instead. | +| `MCP_INSPECTOR_OAUTH_STATE_PATH` | web, CLI, TUI | `/oauth.json` | Names the OAuth state file outright. Lookup order: this variable, then `/oauth.json`, then `~/.mcp-inspector/storage/oauth.json`. ⚠️ Setting `MCP_STORAGE_DIR` alone does not isolate a run if this variable is also exported. | +| `MCP_CLIENT_CONFIG_PATH` | CLI, TUI | `~/.mcp-inspector/storage/client.json` | Install-level client config (CIMD, enterprise IdP). `--client-config` takes precedence. | + +## Secret store + +Where server secrets (headers, client secrets) are kept. The details — the keychain probe, the file format, encryption and locking — are in the [Docker guide](./docker.md); these variables apply to every install, not only containers. + +| Variable | Read by | Default | Effect | +| ---------------------------- | ------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. Any other value is ignored with a warning. | +| `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `/secrets.json` | Path of the file store. Falls back to `secrets.json` in `MCP_STORAGE_DIR`, then to `~/.mcp-inspector/secrets.json`. | +| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | + +When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. + +## Logging and debugging + +| Variable | Read by | Default | Effect | +| ----------------------- | -------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_DEBUG` / `DEBUG` | launcher | off | Prints the full stack trace when the launcher exits on an error. Any value other than `0` or `false` (case-insensitive) turns it on. | +| `MCP_LOG_FILE` | web | unset (no log) | Appends the web backend's structured (pino, JSON lines) log to this file, creating its directory if needed. | +| `MCP_INSPECTOR_LOG_DIR` | TUI | `~/.mcp-inspector` | Directory of the TUI's `auth.log`. The TUI logs to a file so its output does not corrupt the terminal UI. | +| `LOG_LEVEL` | TUI | `info` | Level of the TUI's `auth.log` (`trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`). | + +## Outbound proxy + +| Variable | Read by | Default | Effect | +| ------------------------------- | ------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HTTPS_PROXY` / `HTTP_PROXY` | web, CLI, TUI | unset | Route connections to remote HTTP/SSE servers, including OAuth discovery and token requests, through a proxy. Lowercase forms are honored too. See [HTTP proxy support](../clients/cli/README.md#http-proxy-support). | +| `NO_PROXY` | web, CLI, TUI | unset | Hosts exempted from the proxy. | + +## Node.js variables + +These belong to Node, not the Inspector, but they are the answer to some common connection problems. Because the web client connects to MCP servers **from its Node backend**, not from the browser, they apply to all three clients. + +### Connecting to a server with a self-signed certificate + +A server at `https://localhost`, or anywhere else with a self-signed or privately issued certificate, fails to connect with a generic fetch error, because Node does not trust the certificate. Trusting the browser's exception does not help — the browser is not the one connecting ([#1936](https://github.com/modelcontextprotocol/inspector/issues/1936)). + +**Prefer trusting the certificate's CA.** `NODE_EXTRA_CA_CERTS` adds certificates to the ones Node trusts, and leaves verification on for every other connection: + +```sh +NODE_EXTRA_CA_CERTS=/path/to/your-ca.pem npx @modelcontextprotocol/inspector +``` + +For a self-signed certificate with no separate CA, point it at the certificate itself. Node reads the variable once at startup, so it must be set before launching. + +**Or turn verification off, for local development only:** + +```sh +NODE_TLS_REJECT_UNAUTHORIZED=0 npx @modelcontextprotocol/inspector +``` + +⚠️ This disables certificate verification for **every** TLS connection the Inspector process makes — every MCP server, every OAuth authorization server and token endpoint — not just the one you are testing, and Node prints a warning saying so. A connection that would have been refused for impersonation is then accepted. Never set it in a shell profile, a shared environment, or a deployment. + +| Variable | Default | Effect | +| ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------- | +| `NODE_EXTRA_CA_CERTS` | unset | PEM file of extra CA certificates to trust, in addition to Node's bundled ones. Read once at startup. | +| `NODE_TLS_REJECT_UNAUTHORIZED` | `1` | `0` disables TLS certificate verification for the whole process. Development only. | diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 752ecc12..62043b82 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -268,6 +268,8 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th ## Environment variables +This table maps v1 names to v2. Defaults, accepted values, and the variables with no v1 counterpart are in [Environment variables](./environment-variables.md). + | v1 | v2 | Notes | | ------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed, but the **old name still works** as a deprecated fallback when the new one is unset — an existing deployment keeps running while you migrate. Guards `/api/*` via `x-mcp-remote-auth: Bearer `; the browser also receives it injected into `index.html`, so a bare reload keeps working. (The `?MCP_PROXY_AUTH_TOKEN=` **query param** has no such fallback — see [Web UI](#web-ui).) | From a38f1322c18945f8dbeef6a682138333cc430985 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 23:40:03 -0400 Subject: [PATCH 08/20] fix: address Copilot review round 5 on #2318 RemoteClientTransport keeps its open-stream bookkeeping whether or not the consumer logs requests, so onFetchStreamUpdate works on its own and close() can synthesize the terminal update. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- .../remote/remoteClientTransport-unit.test.ts | 32 +++++++++++++++++++ core/mcp/remote/remoteClientTransport.ts | 10 +++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 8583b19f..51e93874 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -432,6 +432,38 @@ describe("RemoteClientTransport (focused branch coverage)", () => { expect(onFetchStreamUpdate).toHaveBeenCalledTimes(1); }); + it("synthesizes the close on close() for a consumer that registered only onFetchStreamUpdate", async () => { + const onFetchStreamUpdate = vi.fn(); + const { response, push } = createPushableEventStream(); + const t = makeTransport( + { events: () => response }, + { onFetchStreamUpdate }, + ); + await t.start(); + push({ + type: "fetch_request", + data: { + id: "stream", + method: "GET", + url: "http://x/mcp", + timestamp: new Date().toISOString(), + requestHeaders: {}, + responseStatus: 200, + responseHeaders: { "content-type": "text/event-stream" }, + } as never, + }); + push({ + type: "fetch_stream_update", + data: { id: "stream", eventCount: 3 }, + }); + await tick(); + await t.close(); + expect(onFetchStreamUpdate).toHaveBeenLastCalledWith("stream", { + eventCount: 3, + closedAt: expect.any(Date), + }); + }); + it("drops fetch_stream_update events when no handler is registered", async () => { const onFetchRequest = vi.fn(); const t = makeTransport( diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 202310c4..c8c8c92e 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -552,11 +552,11 @@ export class RemoteClientTransport implements Transport { this.resetSseResponseWait(progressToken); } this.onmessage?.(msg, undefined); - } else if ( - parsed.type === "fetch_request" && - this.options.onFetchRequest - ) { + } else if (parsed.type === "fetch_request") { const entry = parsed.data; + // Stream bookkeeping runs whether or not the consumer logs + // requests: `onFetchStreamUpdate` is documented to work on its + // own, and `close()` needs to know which streams are open. if ( isLongLivedStreamResponse( entry.method, @@ -565,7 +565,7 @@ export class RemoteClientTransport implements Transport { ) { this.openStreams.set(entry.id, 0); } - this.options.onFetchRequest({ + this.options.onFetchRequest?.({ ...entry, timestamp: typeof entry.timestamp === "string" From fde8fae0ffe53be5b3f7d0ee05326b3514b87089 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 23:49:40 -0400 Subject: [PATCH 09/20] docs: correct env-var reference per Copilot round 1 (#2365) - MCP_AUTO_OPEN_ENABLED: the TUI never reads it; the CLI's unset default opens only on a stderr TTY. Migration guide row fixed too. - Drop the blanket "empty equals unset" rule, which HOST="" and the path variables contradict; state empty handling per row instead. - MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT accept 0; only CLIENT_PORT must be fixed. - NO_COLOR: an empty value counts as unset. - MCP_INSPECTOR_SECRET_FILE default is ~/.mcp-inspector/secrets.json, beside the storage directory, not inside it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva Signed-off-by: cliffhall --- docs/environment-variables.md | 94 +++++++++++++++++------------------ docs/v1-to-v2-migration.md | 2 +- 2 files changed, 47 insertions(+), 49 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 56a64f9b..0d6ca3a5 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -4,78 +4,76 @@ Every environment variable that changes how the Inspector behaves at runtime, in The **Read by** column names the client whose process reads the variable: **web** is the Node backend that `--web` starts (the browser itself reads no environment), **CLI** and **TUI** are those clients, and **launcher** is the `mcp-inspector` bin that picks one of them. A variable read in shared `core/` code is marked with every client that reaches it. -Unless a row says otherwise, an unset variable and an empty one behave the same. +⚠️ **Unset a variable rather than setting it to an empty string.** The two are not interchangeable: `HOST=""` is read as an all-interfaces bind and refused, and an empty path variable such as `MCP_STORAGE_DIR=` or `MCP_INSPECTOR_LOG_DIR=` can resolve relative to the working directory instead of falling back to the default. A row says so explicitly where an empty value is treated as unset. ## Authentication and network exposure These guard the web backend, which spawns processes on request. Read [Host binding and the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list) before widening any of them. -| Variable | Read by | Default | Effect | -| --------------------------------- | -------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_INSPECTOR_API_TOKEN` | web, CLI | a random token per launch | Bearer token guarding every `/api/*` route (`x-mcp-remote-auth: Bearer `). Set it to use a known token instead of the generated one printed in the launch banner. The CLI reads it only to fill the `autoConnect` parameter of the deep link it emits. | -| `MCP_PROXY_AUTH_TOKEN` | web | — | **Deprecated** v1 name for `MCP_INSPECTOR_API_TOKEN`, used only when the new name is unset. | -| `DANGEROUSLY_OMIT_AUTH` | web | unset | Disables the API token entirely. ⚠️ **Any non-empty value turns auth off, including `false` and `0`** — unset the variable to keep auth on. | -| `HOST` | web, CLI | `127.0.0.1` | Address the web server binds. An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES` is enabled. The CLI reads it only to build its deep link. | -| `DANGEROUSLY_BIND_ALL_INTERFACES` | web | off | Opts in to an all-interfaces `HOST`. Only `true` or `1` (case-insensitive) enable it, so `false` reads as off. The Docker image sets it. | -| `ALLOWED_ORIGINS` | web | the loopback origins | Comma-separated origins allowed to call the API. **Replaces** the default list rather than adding to it, so list every form you browse from. Each entry must include the scheme (`http://localhost:6274`). | +| Variable | Read by | Default | Effect | +| --------------------------------- | -------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_INSPECTOR_API_TOKEN` | web, CLI | a random token per launch | Bearer token guarding every `/api/*` route (`x-mcp-remote-auth: Bearer `). Set it to use a known token instead of the generated one printed in the launch banner. The CLI reads it only to fill the `autoConnect` parameter of the deep link it emits. | +| `MCP_PROXY_AUTH_TOKEN` | web | — | **Deprecated** v1 name for `MCP_INSPECTOR_API_TOKEN`, used only when the new name is unset. | +| `DANGEROUSLY_OMIT_AUTH` | web | unset | Disables the API token entirely. ⚠️ **Any non-empty value turns auth off, including `false` and `0`** — unset the variable to keep auth on. | +| `HOST` | web, CLI | `127.0.0.1` | Address the web server binds. An all-interfaces host (`0.0.0.0`, `::`, an empty string, and equivalent spellings) is **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES` is enabled. The CLI reads it only to build its deep link. | +| `DANGEROUSLY_BIND_ALL_INTERFACES` | web | off | Opts in to an all-interfaces `HOST`. Only `true` or `1` (case-insensitive) enable it, so `false` reads as off. The Docker image sets it. | +| `ALLOWED_ORIGINS` | web | the loopback origins | Comma-separated origins allowed to call the API. **Replaces** the default list rather than adding to it, so list every form you browse from. Each entry must include the scheme (`http://localhost:6274`). | ## Ports -The web client needs **fixed** ports: the origin allow-list and the MCP Apps sandbox CSP are both derived from them. +| Variable | Read by | Default | Effect | +| --------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLIENT_PORT` | web, CLI | `6274` | Web UI port. Must be a **fixed** integer in 1–65535: `0` (an OS-assigned port) is rejected at startup, because the origin allow-list and the MCP Apps sandbox CSP are derived from it. | +| `MCP_SANDBOX_PORT` | web, CLI | `6275` | Port of the MCP Apps sandbox server, 0–65535. `0` asks the OS for a free port. An invalid value is ignored with a warning. | +| `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for `MCP_SANDBOX_PORT` when that is unset. | +| `MCP_APP_ORIGIN_PORT` | web, CLI | `6278` | Port of the dedicated app-origin server, used only by an MCP App whose UI resource declares `_meta.ui.domain`; 0–65535, where `0` asks the OS for a free port. An invalid value is ignored with a warning. Pin it if your app's backend allowlists that origin. | -| Variable | Read by | Default | Effect | -| --------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CLIENT_PORT` | web, CLI | `6274` | Web UI port. Must be an integer in 1–65535; `0` (a dynamic port) is rejected at startup. | -| `MCP_SANDBOX_PORT` | web, CLI | `6275` | Port of the MCP Apps sandbox server. An invalid value is ignored with a warning. | -| `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for `MCP_SANDBOX_PORT` when that is unset. | -| `MCP_APP_ORIGIN_PORT` | web, CLI | `6278` | Port of the dedicated app-origin server, used only by an MCP App whose UI resource declares `_meta.ui.domain`. An invalid value is ignored with a warning. | - -The CLI reads the three port variables and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. +The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. ## Behavior -| Variable | Read by | Default | Effect | -| ------------------------ | ------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_AUTO_OPEN_ENABLED` | web, CLI, TUI | open | `false` never opens a browser; `true` always does. In the CLI and TUI it also governs the interactive OAuth flow, and `true` opens the browser even when stderr is not a TTY. Any other value behaves as unset. | -| `MCP_CATALOG_PATH` | web, CLI, TUI | `~/.mcp-inspector/mcp.json` | Default writable catalog, used when no `--catalog` is given. The CLI honors it only when no ad-hoc target (positional command, `--server-url`, or `--transport`) is given. See [MCP server configuration](./mcp-server-configuration.md). | -| `MCP_OAUTH_CALLBACK_URL` | CLI, TUI | `http://127.0.0.1:6276/oauth/callback` | Loopback redirect URL for the CLI/TUI OAuth flow. `--callback-url` takes precedence. | -| `NO_COLOR` | CLI | unset | Any value disables ANSI styling in the CLI's human-readable output. | +| Variable | Read by | Default | Effect | +| ------------------------ | ------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_AUTO_OPEN_ENABLED` | web, CLI | see effect | Whether a browser is opened for you. `false` never opens one; `true` always does. Unset (or any other value), the web client opens the UI at launch, and the CLI opens the OAuth authorization page only when stderr is a TTY. **The TUI does not read it** and always opens the OAuth page. | +| `MCP_CATALOG_PATH` | web, CLI, TUI | `~/.mcp-inspector/mcp.json` | Default writable catalog, used when no `--catalog` is given. The CLI honors it only when no ad-hoc target (positional command, `--server-url`, or `--transport`) is given. See [MCP server configuration](./mcp-server-configuration.md). | +| `MCP_OAUTH_CALLBACK_URL` | CLI, TUI | `http://127.0.0.1:6276/oauth/callback` | Loopback redirect URL for the CLI/TUI OAuth flow. `--callback-url` takes precedence. | +| `NO_COLOR` | CLI | unset | Any non-empty value disables ANSI styling in the CLI's human-readable output. An empty `NO_COLOR=` counts as unset. | ## Storage and state -| Variable | Read by | Default | Effect | -| -------------------------------- | ------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_STORAGE_DIR` | web, CLI, TUI | `~/.mcp-inspector/storage` | Storage directory. Relocates the OAuth state file (`oauth.json`) and the secrets file (`secrets.json`) for every client. For the **web** backend it also relocates `client.json`; the CLI and TUI find `client.json` through `MCP_CLIENT_CONFIG_PATH` instead. | -| `MCP_INSPECTOR_OAUTH_STATE_PATH` | web, CLI, TUI | `/oauth.json` | Names the OAuth state file outright. Lookup order: this variable, then `/oauth.json`, then `~/.mcp-inspector/storage/oauth.json`. ⚠️ Setting `MCP_STORAGE_DIR` alone does not isolate a run if this variable is also exported. | -| `MCP_CLIENT_CONFIG_PATH` | CLI, TUI | `~/.mcp-inspector/storage/client.json` | Install-level client config (CIMD, enterprise IdP). `--client-config` takes precedence. | +| Variable | Read by | Default | Effect | +| -------------------------------- | ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_STORAGE_DIR` | web, CLI, TUI | `~/.mcp-inspector/storage` | Storage directory. Relocates the OAuth state file (`oauth.json`) and the secrets file (`secrets.json`) for every client. For the **web** backend it also relocates `client.json`; the CLI and TUI find `client.json` through `MCP_CLIENT_CONFIG_PATH` instead. | +| `MCP_INSPECTOR_OAUTH_STATE_PATH` | web, CLI, TUI | `~/.mcp-inspector/storage/oauth.json` | Names the OAuth state file outright. Lookup order: this variable, then `/oauth.json`, then `~/.mcp-inspector/storage/oauth.json`. ⚠️ Setting `MCP_STORAGE_DIR` alone does not isolate a run if this variable is also exported. | +| `MCP_CLIENT_CONFIG_PATH` | CLI, TUI | `~/.mcp-inspector/storage/client.json` | Install-level client config (CIMD, enterprise IdP). `--client-config` takes precedence. | ## Secret store Where server secrets (headers, client secrets) are kept. The details — the keychain probe, the file format, encryption and locking — are in the [Docker guide](./docker.md); these variables apply to every install, not only containers. -| Variable | Read by | Default | Effect | -| ---------------------------- | ------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. Any other value is ignored with a warning. | -| `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `/secrets.json` | Path of the file store. Falls back to `secrets.json` in `MCP_STORAGE_DIR`, then to `~/.mcp-inspector/secrets.json`. | -| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | +| Variable | Read by | Default | Effect | +| ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. Any other value is ignored with a warning. | +| `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | +| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. ## Logging and debugging -| Variable | Read by | Default | Effect | -| ----------------------- | -------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_DEBUG` / `DEBUG` | launcher | off | Prints the full stack trace when the launcher exits on an error. Any value other than `0` or `false` (case-insensitive) turns it on. | -| `MCP_LOG_FILE` | web | unset (no log) | Appends the web backend's structured (pino, JSON lines) log to this file, creating its directory if needed. | -| `MCP_INSPECTOR_LOG_DIR` | TUI | `~/.mcp-inspector` | Directory of the TUI's `auth.log`. The TUI logs to a file so its output does not corrupt the terminal UI. | -| `LOG_LEVEL` | TUI | `info` | Level of the TUI's `auth.log` (`trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`). | +| Variable | Read by | Default | Effect | +| ----------------------- | -------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_DEBUG` / `DEBUG` | launcher | off | Prints the full stack trace when the launcher exits on an error. Any value other than empty, `0` or `false` (case-insensitive) turns it on. | +| `MCP_LOG_FILE` | web | unset (no log) | Appends the web backend's structured (pino, JSON lines) log to this file, creating its directory if needed. An empty value counts as unset. | +| `MCP_INSPECTOR_LOG_DIR` | TUI | `~/.mcp-inspector` | Directory of the TUI's `auth.log`. The TUI logs to a file so its output does not corrupt the terminal UI. | +| `LOG_LEVEL` | TUI | `info` | Level of the TUI's `auth.log`: one of `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`. An empty value is not replaced by `info`. | ## Outbound proxy -| Variable | Read by | Default | Effect | -| ------------------------------- | ------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `HTTPS_PROXY` / `HTTP_PROXY` | web, CLI, TUI | unset | Route connections to remote HTTP/SSE servers, including OAuth discovery and token requests, through a proxy. Lowercase forms are honored too. See [HTTP proxy support](../clients/cli/README.md#http-proxy-support). | -| `NO_PROXY` | web, CLI, TUI | unset | Hosts exempted from the proxy. | +| Variable | Read by | Default | Effect | +| ---------------------------- | ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HTTPS_PROXY` / `HTTP_PROXY` | web, CLI, TUI | unset | Route connections to remote HTTP/SSE servers, including OAuth discovery and token requests, through a proxy. Lowercase forms are honored too. See [HTTP proxy support](../clients/cli/README.md#http-proxy-support). | +| `NO_PROXY` | web, CLI, TUI | unset | Hosts exempted from the proxy. | ## Node.js variables @@ -101,7 +99,7 @@ NODE_TLS_REJECT_UNAUTHORIZED=0 npx @modelcontextprotocol/inspector ⚠️ This disables certificate verification for **every** TLS connection the Inspector process makes — every MCP server, every OAuth authorization server and token endpoint — not just the one you are testing, and Node prints a warning saying so. A connection that would have been refused for impersonation is then accepted. Never set it in a shell profile, a shared environment, or a deployment. -| Variable | Default | Effect | -| ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------- | -| `NODE_EXTRA_CA_CERTS` | unset | PEM file of extra CA certificates to trust, in addition to Node's bundled ones. Read once at startup. | -| `NODE_TLS_REJECT_UNAUTHORIZED` | `1` | `0` disables TLS certificate verification for the whole process. Development only. | +| Variable | Default | Effect | +| ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------- | +| `NODE_EXTRA_CA_CERTS` | unset | PEM file of extra CA certificates to trust, in addition to Node's bundled ones. Read once at startup. | +| `NODE_TLS_REJECT_UNAUTHORIZED` | `1` | `0` disables TLS certificate verification for the whole process. Development only. | diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 62043b82..f552c9e3 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -279,7 +279,7 @@ This table maps v1 names to v2. Defaults, accepted values, and the variables wit | `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | | `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | | `DANGEROUSLY_OMIT_AUTH` | same | | -| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | +| `MCP_AUTO_OPEN_ENABLED` | same | Also governs opening the OAuth page in the CLI (`true` opens it even when stderr is not a TTY). The TUI does not read it | | — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | | — | `MCP_CATALOG_PATH` | Default catalog path | | — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (fixed `6275` by default) | From eb41185c37aba493114207286daf23cc203b3215 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 13 Sep 2026 23:54:29 -0400 Subject: [PATCH 10/20] feat: make the skills verification catalog budget configurable per server (#2294) Adds a Skills section to Server Settings with Maximum Number of Skills and Maximum Catalog Size fields, defaulting to SKILL_MAX_CATALOG_SKILLS (256) and SKILL_MAX_CATALOG_BYTES (64 MiB). The values persist as skillCatalogMaxSkills / skillCatalogMaxBytes on the server's mcp.json entry (omitted at the default), are validated as positive integers by the /api/servers routes, and are read by verifySkills through getServerSettings(), so the CLI's --verify and the TUI Skills pane both honor them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe Signed-off-by: cliffhall --- clients/cli/README.md | 7 +- .../ServerSettingsForm.test.tsx | 91 +++++++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 75 +++++++++++++++ .../ServerSettingsModal.test.tsx | 21 +++++ .../ServerSettingsModal.tsx | 10 ++ .../web/src/test/core/mcp/serverList.test.ts | 49 ++++++++++ .../test/core/mcp/skillsVerification.test.ts | 58 ++++++++++++ .../mcp/remote/server-extra-coverage.test.ts | 13 +++ core/mcp/remote/node/server.ts | 33 +++++++ core/mcp/serverList.ts | 37 ++++++++ core/mcp/skills.ts | 41 +++++++++ core/mcp/skillsVerification.ts | 15 ++- core/mcp/types.ts | 23 +++++ docs/mcp-server-configuration.md | 2 + 14 files changed, 469 insertions(+), 6 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 175c8937..b218b05e 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -391,7 +391,12 @@ not read has not been cleared of anything: | Per skill, on the wire | 16 MiB actually served | The declared sizes are server-controlled; this one cannot be lied past. | | Per run | 256 skills / 64 MiB | SEP-2640 bounds a skill and deliberately does not bound a *catalog*. Every entry costs at least one `resources/read`, so without this a large listing — hostile or merely big — is unbounded work against the tool inspecting it. | -The run bound is this tool's, not the spec's. A skill past it is still reported, +The run bound is this tool's, not the spec's, and it is configurable per server: +set `skillCatalogMaxSkills` / `skillCatalogMaxBytes` on the server's entry in +`mcp.json`, or edit them under **Skills** in the web client's Server Settings +(see [the configuration reference](../../docs/mcp-server-configuration.md)). +Both must be positive integers — there is no unlimited value, since the bound +is what makes the run terminate. A skill past it is still reported, with its static conformance findings and an `incomplete` reason saying nothing about its files was checked; verify it on its own with `--method skills/get --uri ` to get a verdict for it. diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 081b5cc2..26488227 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -70,6 +70,7 @@ const baseHandlers = { onPaginatedListsChange: vi.fn(), onAdvertisedExtensionChange: vi.fn(), onMaxFetchRequestsChange: vi.fn(), + onSkillCatalogLimitChange: vi.fn(), onProtocolEraChange: vi.fn(), onModernLogLevelChange: vi.fn(), onOAuthChange: vi.fn(), @@ -559,6 +560,96 @@ describe("ServerSettingsForm", () => { expect(onMaxFetchRequestsChange).toHaveBeenLastCalledWith(2500); }); + describe("Skills section (#2294)", () => { + it("renders the default catalog budget when the server sets none", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Skills")).toBeInTheDocument(); + expect(screen.getByLabelText(/Maximum Number of Skills/)).toHaveValue( + "256", + ); + expect(screen.getByLabelText(/Maximum Catalog Size/)).toHaveValue( + "67,108,864 bytes", + ); + }); + + it("renders configured limits", () => { + renderWithMantine( + , + ); + expect(screen.getByLabelText(/Maximum Number of Skills/)).toHaveValue( + "10", + ); + expect(screen.getByLabelText(/Maximum Catalog Size/)).toHaveValue( + "2,048 bytes", + ); + }); + + it("emits the typed number for each field", async () => { + const user = userEvent.setup(); + const onSkillCatalogLimitChange = vi.fn(); + renderWithMantine( + , + ); + await user.type(screen.getByLabelText(/Maximum Number of Skills/), "2"); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxSkills", + 12, + ); + await user.type(screen.getByLabelText(/Maximum Catalog Size/), "5"); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxBytes", + 15, + ); + }); + + it("keeps the current limit when a field is cleared", async () => { + // A cleared budget is not a usable setting, so it must not persist as 0. + const user = userEvent.setup(); + const onSkillCatalogLimitChange = vi.fn(); + renderWithMantine( + , + ); + await user.clear(screen.getByLabelText(/Maximum Number of Skills/)); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxSkills", + 256, + ); + await user.clear(screen.getByLabelText(/Maximum Catalog Size/)); + expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith( + "skillCatalogMaxBytes", + 4096, + ); + }); + }); + describe("stdio Working Directory (Options) / Environment Variables section", () => { it("hides the Working Directory field and the Environment Variables section for non-stdio servers", () => { renderWithMantine( diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 0ac753f9..c9b802c2 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -38,6 +38,10 @@ import { } from "@inspector/core/auth/authorizationParams.js"; import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js"; import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js"; +import { + isSkillCatalogLimit, + resolveSkillCatalogBudget, +} from "@inspector/core/mcp/skills.js"; import type { Root } from "@modelcontextprotocol/client"; /** @@ -60,9 +64,15 @@ export type ServerSettingsSection = | "headers" | "metadata" | "timeouts" + | "skills" | "oauth" | "roots"; +/** The two per-server skills verification budget fields (#2294). */ +export type SkillCatalogLimitField = + | "skillCatalogMaxSkills" + | "skillCatalogMaxBytes"; + export interface ServerSettingsFormProps { settings: InspectorServerSettings; /** Transport type — EMA checkbox is hidden for stdio. Defaults to streamable-http. */ @@ -101,6 +111,14 @@ export interface ServerSettingsFormProps { */ onAdvertisedExtensionChange: (key: string, checked: boolean) => void; onMaxFetchRequestsChange: (value: number) => void; + /** + * Set one skills verification budget limit. Only ever called with a positive + * integer: a cleared or invalid entry re-emits the current value (#2294). + */ + onSkillCatalogLimitChange: ( + field: SkillCatalogLimitField, + value: number, + ) => void; onProtocolEraChange: (value: ServerProtocolEra) => void; onModernLogLevelChange: (value: ModernLogLevel) => void; /** @@ -166,6 +184,22 @@ const TimeoutInput = NumberInput.withProps({ suffix: " ms", }); +// Positive integers only — `0` is not "unlimited" for a verification budget. +const SkillCountInput = NumberInput.withProps({ + min: 1, + allowDecimal: false, + allowNegative: false, + thousandSeparator: ",", +}); + +const SkillBytesInput = NumberInput.withProps({ + min: 1, + allowDecimal: false, + allowNegative: false, + thousandSeparator: ",", + suffix: " bytes", +}); + const ExtensionsHint = Text.withProps({ size: "xs", c: "var(--inspector-text-secondary)", @@ -447,6 +481,7 @@ export function ServerSettingsForm({ onPaginatedListsChange, onAdvertisedExtensionChange, onMaxFetchRequestsChange, + onSkillCatalogLimitChange, onProtocolEraChange, onModernLogLevelChange, negotiatedEra, @@ -496,6 +531,20 @@ export function ServerSettingsForm({ Number.isNaN(parsed) ? settings.maxFetchRequests : parsed, ); }; + // Absent settings render as the defaults `verifySkills` will actually apply. + const skillCatalogBudget = resolveSkillCatalogBudget(settings); + const handleSkillCatalogLimitChange = + (field: SkillCatalogLimitField, current: number) => + (value: number | string) => { + // Mantine emits "" on a clear. Like the Network Log Size field, keep the + // current value rather than persisting something `verifySkills` would + // discard — a cleared or zero budget is not a meaningful setting here. + const parsed = typeof value === "number" ? value : parseInt(value, 10); + onSkillCatalogLimitChange( + field, + isSkillCatalogLimit(parsed) ? parsed : current, + ); + }; const handleTimeoutChange = (field: "connectionTimeout" | "requestTimeout" | "taskTtl") => (value: number | string) => { @@ -816,6 +865,32 @@ export function ServerSettingsForm({ + + Skills + + + + + + + + Roots diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 0d704e42..d35dedea 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -679,6 +679,27 @@ describe("ServerSettingsModal", () => { expect(typeof call.maxFetchRequests).toBe("number"); }); + it("calls onSettingsChange when changing a skills catalog limit (#2294)", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Skills" })); + await user.type(screen.getByLabelText(/Maximum Number of Skills/), "0"); + expect(onSettingsChange).toHaveBeenLastCalledWith({ + ...emptySettings, + skillCatalogMaxSkills: 30, + }); + }); + it("preserves sibling rows when editing one of several headers", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index b8a422c0..db2b63a4 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -17,6 +17,7 @@ import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info import { ServerSettingsForm, type ServerSettingsSection, + type SkillCatalogLimitField, } from "../ServerSettingsForm/ServerSettingsForm"; // The "environment" section only renders for stdio servers and the "oauth" @@ -34,6 +35,7 @@ function allSectionsFor( "headers", "metadata", "timeouts", + "skills", ...(isOAuthCapableServerType(serverType) ? (["oauth"] as const) : []), "roots", ]; @@ -226,6 +228,13 @@ export function ServerSettingsModal({ onSettingsChange({ ...settings, maxFetchRequests: value }); } + function handleSkillCatalogLimitChange( + field: SkillCatalogLimitField, + value: number, + ) { + onSettingsChange({ ...settings, [field]: value }); + } + function handleProtocolEraChange(value: ServerProtocolEra) { onSettingsChange({ ...settings, protocolEra: value }); } @@ -299,6 +308,7 @@ export function ServerSettingsModal({ onPaginatedListsChange={handlePaginatedListsChange} onAdvertisedExtensionChange={handleAdvertisedExtensionChange} onMaxFetchRequestsChange={handleMaxFetchRequestsChange} + onSkillCatalogLimitChange={handleSkillCatalogLimitChange} onProtocolEraChange={handleProtocolEraChange} onModernLogLevelChange={handleModernLogLevelChange} negotiatedEra={negotiatedEra} diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index f695b69d..eb3ebc22 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -456,6 +456,55 @@ describe("serverEntriesToMcpConfig", () => { expect(round).toEqual(original); }); + it("round-trips the skills catalog budget (#2294)", () => { + const original: MCPConfig = { + mcpServers: { + sk: { + type: "streamable-http", + url: "https://x.test/mcp", + skillCatalogMaxSkills: 10, + skillCatalogMaxBytes: 2048, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.skillCatalogMaxSkills).toBe(10); + expect(entry?.settings?.skillCatalogMaxBytes).toBe(2048); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect(round).toEqual(original); + }); + + it("drops an unusable skills catalog limit on read and omits the default on write", () => { + const [entry] = mcpConfigToServerEntries({ + mcpServers: { + sk: { + type: "streamable-http", + url: "https://x.test/mcp", + skillCatalogMaxSkills: 0, + skillCatalogMaxBytes: 1.5, + }, + }, + }); + // Present on disk still materializes a settings node, but neither value + // is usable, so both read back as absent (the default budget). + expect(entry?.settings).toBeDefined(); + expect(entry?.settings?.skillCatalogMaxSkills).toBeUndefined(); + expect(entry?.settings?.skillCatalogMaxBytes).toBeUndefined(); + + const round = serverEntriesToMcpConfig([ + { + ...entry!, + settings: { + ...entry!.settings!, + skillCatalogMaxSkills: 256, + skillCatalogMaxBytes: 64 * 1024 * 1024, + }, + }, + ]); + expect("skillCatalogMaxSkills" in (round.mcpServers.sk ?? {})).toBe(false); + expect("skillCatalogMaxBytes" in (round.mcpServers.sk ?? {})).toBe(false); + }); + it("round-trips protocolEra: lifts a non-default value to settings and back to disk", () => { const original: MCPConfig = { mcpServers: { diff --git a/clients/web/src/test/core/mcp/skillsVerification.test.ts b/clients/web/src/test/core/mcp/skillsVerification.test.ts index e6ff5b07..ca04551a 100644 --- a/clients/web/src/test/core/mcp/skillsVerification.test.ts +++ b/clients/web/src/test/core/mcp/skillsVerification.test.ts @@ -790,6 +790,64 @@ describe("verifySkills (#2248)", () => { expect(reports[0].outcome).toBe("verified"); }); + it("honors the server's configured catalog budget (#2294)", async () => { + // Read through `getServerSettings()`, so neither caller has to pass it. + const enc = new TextEncoder(); + const mdFor = (i: number) => + `---\nname: c${i}\ndescription: A demo\n---\n\n# c${i}\n`; + const skills = await Promise.all( + Array.from({ length: 4 }, async (_, i): Promise => { + const bytes = enc.encode(mdFor(i)); + return entry({ + uri: `skill://c${i}/SKILL.md`, + frontmatter: { name: `c${i}`, description: "A demo" }, + resources: [ + { + uri: `skill://c${i}/SKILL.md`, + digest: await sha256Digest(bytes), + size: bytes.byteLength, + }, + ], + }); + }), + ); + const readResource = vi.fn(async (uri: string) => ({ + result: { + contents: [ + { uri, text: mdFor(Number(/c(\d+)/.exec(uri)?.[1] ?? "0")) }, + ], + }, + })); + const client = { + readResource, + getServerSettings: () => ({ skillCatalogMaxSkills: 2 }), + } as unknown as InspectorClientProtocol; + const reports = await verifySkills(client, skills); + expect(readResource.mock.calls.length).toBe(2); + expect(reports.map((r) => r.outcome)).toEqual([ + "verified", + "verified", + "incomplete", + "incomplete", + ]); + // The reason names the limit that actually applied, not the default. + expect(reports[2].incomplete).toMatch(/budget of 2 skills \/ 67108864/); + }); + + it("falls back to the default budget for an unusable configured limit", async () => { + const { skill, client } = await truncatable({ name: "fb", count: 2 }); + const withSettings = { + ...client, + readResource: client.readResource, + getServerSettings: () => ({ + skillCatalogMaxSkills: 0, + skillCatalogMaxBytes: -1, + }), + } as unknown as InspectorClientProtocol; + const [report] = await verifySkills(withSettings, [skill]); + expect(report.outcome).toBe("verified"); + }); + it("is not incomplete when the budget is crossed by the LAST entry", async () => { // Crossing the line on the final row stopped nothing: every manifest entry // was fetched and checked. Reporting "Stopped after 4 of 4" there both diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 7314d514..b30a3fbc 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -832,6 +832,13 @@ describe("server.ts supplemental coverage", () => { expect((await res.json()).error).toMatch(/maxFetchRequests/); }); + it("rejects a non-positive skills catalog limit (#2294)", async () => { + const skills = await postSettings({ ...base, skillCatalogMaxSkills: 0 }); + expect((await skills.json()).error).toMatch(/skillCatalogMaxSkills/); + const bytes = await postSettings({ ...base, skillCatalogMaxBytes: 1.5 }); + expect((await bytes.json()).error).toMatch(/skillCatalogMaxBytes/); + }); + it("rejects a non-string OAuth field", async () => { const res = await postSettings({ ...base, oauthClientId: 7 }); expect((await res.json()).error).toMatch(/oauthClientId/); @@ -960,6 +967,8 @@ describe("server.ts supplemental coverage", () => { paginatedLists: true, advertisedExtensions: { "io.modelcontextprotocol/tasks": false }, maxFetchRequests: 5, + skillCatalogMaxSkills: 10, + skillCatalogMaxBytes: 2048, protocolEra: "modern", modernLogLevel: "off", oauthClientId: "cid", @@ -999,6 +1008,8 @@ describe("server.ts supplemental coverage", () => { requestTimeout: -1, taskTtl: "x", maxFetchRequests: -1, + skillCatalogMaxSkills: 0, + skillCatalogMaxBytes: "big", // unknown era literal → isProtocolEra branch protocolEra: "future", // unknown modern log level → isModernLogLevel branch (#1629) @@ -1030,6 +1041,8 @@ describe("server.ts supplemental coverage", () => { "requestTimeout", "taskTtl", "maxFetchRequests", + "skillCatalogMaxSkills", + "skillCatalogMaxBytes", "protocolEra", "modernLogLevel", "oauth", diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 15179eb1..9c2bf660 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -65,6 +65,7 @@ import { stripInspectorFields, } from "../../serverList.js"; import { toRecord } from "../../../json/jsonUtils.js"; +import { isSkillCatalogLimit } from "../../skills.js"; import { resolveImportSource } from "../../import/resolveSource.js"; import { RemoteSession } from "./remote-session.js"; import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; @@ -1681,6 +1682,19 @@ export function createRemoteApp( ); delete valObj.maxFetchRequests; } + // #2294 — positive integers only; `0` is not "unlimited" for these. + for (const key of [ + "skillCatalogMaxSkills", + "skillCatalogMaxBytes", + ] as const) { + if (key in valObj && !isSkillCatalogLimit(valObj[key])) { + logWarn( + { route: "/api/servers", id, droppedKey: key }, + `Dropping malformed \`${key}\` field — expected a positive integer.`, + ); + delete valObj[key]; + } + } if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, @@ -1953,6 +1967,18 @@ export function createRemoteApp( error: "settings.maxFetchRequests must be a non-negative number", }; } + // #2294 — optional on the wire; absent means the default budget. + for (const key of [ + "skillCatalogMaxSkills", + "skillCatalogMaxBytes", + ] as const) { + if (obj[key] !== undefined && !isSkillCatalogLimit(obj[key])) { + return { + ok: false, + error: `settings.${key} must be a positive integer`, + }; + } + } for (const optional of [ "oauthClientId", "oauthClientSecret", @@ -2099,6 +2125,13 @@ export function createRemoteApp( // writes no spurious `roots` field. roots: isRootArray(obj.roots) ? obj.roots : [], }; + // Validated above; absent stays absent (the default budget). + if (isSkillCatalogLimit(obj.skillCatalogMaxSkills)) { + value.skillCatalogMaxSkills = obj.skillCatalogMaxSkills; + } + if (isSkillCatalogLimit(obj.skillCatalogMaxBytes)) { + value.skillCatalogMaxBytes = obj.skillCatalogMaxBytes; + } if (typeof obj.oauthClientId === "string" && obj.oauthClientId !== "") { value.oauthClientId = obj.oauthClientId; } diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 04728a58..a0b42700 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -13,6 +13,11 @@ import { DEFAULT_TASK_TTL_MS, isModernLogLevel, } from "./types.js"; +import { + isSkillCatalogLimit, + SKILL_MAX_CATALOG_BYTES, + SKILL_MAX_CATALOG_SKILLS, +} from "./skills.js"; import type { Root } from "@modelcontextprotocol/client"; import type { InspectorServerSettings, @@ -160,6 +165,8 @@ type StoredInspectorFields = Pick< | "paginatedLists" | "advertisedExtensions" | "maxFetchRequests" + | "skillCatalogMaxSkills" + | "skillCatalogMaxBytes" | "oauth" | "roots" >; @@ -521,6 +528,8 @@ export function storedFieldsToInspectorSettings( stored.paginatedLists !== undefined || stored.advertisedExtensions !== undefined || stored.maxFetchRequests !== undefined || + stored.skillCatalogMaxSkills !== undefined || + stored.skillCatalogMaxBytes !== undefined || stored.oauth !== undefined || stored.roots !== undefined || stored.protocolEra !== undefined || @@ -557,6 +566,16 @@ export function storedFieldsToInspectorSettings( // `[]`, which `inspectorSettingsToStoredFields` then omits on write. roots: stored.roots ?? [], }; + // Lifted only when usable (#2294). Unlike maxFetchRequests these stay + // optional in memory — absent means the default, which + // `resolveSkillCatalogBudget` supplies — and a hand-edited `0` or `-1` is + // dropped here rather than carried into a form that would re-persist it. + if (isSkillCatalogLimit(stored.skillCatalogMaxSkills)) { + settings.skillCatalogMaxSkills = stored.skillCatalogMaxSkills; + } + if (isSkillCatalogLimit(stored.skillCatalogMaxBytes)) { + settings.skillCatalogMaxBytes = stored.skillCatalogMaxBytes; + } // Absent on disk reads back as the default era; the write side then omits the // default so a byte-stable round-trip never injects `protocolEra` into files // that never set it. An unknown literal from a hand-edited file is dropped @@ -737,6 +756,22 @@ export function inspectorSettingsToStoredFields( out.maxFetchRequests = settings.maxFetchRequests; } + // Same omit-the-default rule (#2294). An unusable value writes nothing, so + // it reads back as the default rather than being persisted and then + // rejected on the next load. + if ( + isSkillCatalogLimit(settings.skillCatalogMaxSkills) && + settings.skillCatalogMaxSkills !== SKILL_MAX_CATALOG_SKILLS + ) { + out.skillCatalogMaxSkills = settings.skillCatalogMaxSkills; + } + if ( + isSkillCatalogLimit(settings.skillCatalogMaxBytes) && + settings.skillCatalogMaxBytes !== SKILL_MAX_CATALOG_BYTES + ) { + out.skillCatalogMaxBytes = settings.skillCatalogMaxBytes; + } + const oauthFields: NonNullable = {}; if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId; if (settings.oauthClientSecret) @@ -815,6 +850,8 @@ const INSPECTOR_FIELD_KEY_MAP = { paginatedLists: true, advertisedExtensions: true, maxFetchRequests: true, + skillCatalogMaxSkills: true, + skillCatalogMaxBytes: true, oauth: true, roots: true, } as const satisfies Record; diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 61dd7593..b76177b6 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -76,6 +76,47 @@ export const SKILL_MAX_CATALOG_SKILLS = 256; /** @see {@link SKILL_MAX_CATALOG_SKILLS} — 64 MiB across the whole run. */ export const SKILL_MAX_CATALOG_BYTES = 64 * 1024 * 1024; +/** + * Whether `value` is usable as a per-server catalog budget override (#2294): + * a positive safe integer. + * + * ⚠️ **`0` is deliberately not "unlimited"**, unlike `maxFetchRequests`. The + * budget exists so a `--verify` against a huge catalog terminates; an + * unlimited setting would reintroduce exactly the unbounded run it closed. A + * user who wants more raises the number. + */ +export function isSkillCatalogLimit(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +/** A verification run's catalog budget, resolved from per-server settings. */ +export interface SkillCatalogBudget { + maxSkills: number; + maxBytes: number; +} + +/** + * The catalog budget for a run: the server's configured limits where they are + * usable, else {@link SKILL_MAX_CATALOG_SKILLS} / {@link SKILL_MAX_CATALOG_BYTES}. + * + * A malformed value falls back to the default rather than throwing — settings + * can arrive from a hand-edited `mcp.json`, and a verification that refused to + * run over a typo would be a worse outcome than one that ran at the default. + */ +export function resolveSkillCatalogBudget(settings?: { + skillCatalogMaxSkills?: number; + skillCatalogMaxBytes?: number; +}): SkillCatalogBudget { + return { + maxSkills: isSkillCatalogLimit(settings?.skillCatalogMaxSkills) + ? settings.skillCatalogMaxSkills + : SKILL_MAX_CATALOG_SKILLS, + maxBytes: isSkillCatalogLimit(settings?.skillCatalogMaxBytes) + ? settings.skillCatalogMaxBytes + : SKILL_MAX_CATALOG_BYTES, + }; +} + /** The suffix every skill URI ends with; the segment before it is the name. */ export const SKILL_FILE_SUFFIX = "/SKILL.md"; diff --git a/core/mcp/skillsVerification.ts b/core/mcp/skillsVerification.ts index c034b465..91ca25a7 100644 --- a/core/mcp/skillsVerification.ts +++ b/core/mcp/skillsVerification.ts @@ -32,9 +32,8 @@ import { checkSkillConformance, checkSkillFrontmatterMatch, checkSkillNameCollisions, + resolveSkillCatalogBudget, skillDisplayName, - SKILL_MAX_CATALOG_BYTES, - SKILL_MAX_CATALOG_SKILLS, SKILL_MAX_RESOURCE_ENTRIES, SKILL_MAX_TOTAL_BYTES, skillFileBytes, @@ -323,6 +322,13 @@ export async function verifySkills( // thousand skills, each individually conforming, made `--verify` run // indefinitely and transfer unboundedly (Copilot). See // {@link SKILL_MAX_CATALOG_SKILLS}. + // + // The limits are the server's configured ones when set (#2294). Read through + // `getServerSettings()` rather than taken as a parameter, so every caller — + // the CLI's `--verify` and the TUI pane — honors the setting without having + // to remember to pass it. Optional-chained because a test double need not + // implement the accessor. + const budget = resolveSkillCatalogBudget(client.getServerSettings?.()); let walkedSkills = 0; let catalogBytes = 0; for (const entry of entries) { @@ -330,8 +336,7 @@ export async function verifySkills( // past the budget is still reported on, just not read. What stops is the // reading. const withinBudget = - walkedSkills < SKILL_MAX_CATALOG_SKILLS && - catalogBytes <= SKILL_MAX_CATALOG_BYTES; + walkedSkills < budget.maxSkills && catalogBytes <= budget.maxBytes; // The entry's own SKILL.md, read once and used twice — for its digest and // for the frontmatter cross-check. Reading it twice would double the load // on the server and, worse, could compare a digest against one snapshot @@ -368,7 +373,7 @@ export async function verifySkills( // total, by definition) is never truncated. const manifest = withinBudget ? boundedManifest(declared) : []; let incomplete = !withinBudget - ? `Not read: this run already reached its catalog budget of ${SKILL_MAX_CATALOG_SKILLS} skills / ${SKILL_MAX_CATALOG_BYTES} bytes. Nothing about this skill's files has been checked — verify it on its own with \`--method skills/get --uri\` to get a verdict.` + ? `Not read: this run already reached its catalog budget of ${budget.maxSkills} skills / ${budget.maxBytes} bytes (raise it in the server's Skills settings). Nothing about this skill's files has been checked — verify it on its own with \`--method skills/get --uri\` to get a verdict.` : manifest.length < declared.length ? `Only ${manifest.length} of ${declared.length} manifest entries were read: the skill exceeds the ${SKILL_MAX_RESOURCE_ENTRIES}-entry / ${SKILL_MAX_TOTAL_BYTES}-byte interoperability limits, so the rest were not fetched and cannot be reported on.` : undefined; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 87dc7dbf..651b85ee 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -171,6 +171,18 @@ export type StoredMCPServer = MCPServerConfig & { * file diff minimal for servers that never tuned it. `0` means unlimited. */ maxFetchRequests?: number; + /** + * Most skills one skills verification run reads from (#2294). Omitted on + * disk when it equals `SKILL_MAX_CATALOG_SKILLS`. Must be a positive integer + * — there is no unlimited value, since the bound is what makes `--verify` + * terminate. + */ + skillCatalogMaxSkills?: number; + /** + * Byte ceiling across one skills verification run (#2294). Omitted on disk + * when it equals `SKILL_MAX_CATALOG_BYTES`. Positive integer, as above. + */ + skillCatalogMaxBytes?: number; /** * Pre-configured OAuth client credentials for HTTP transports. Nested to * match Claude Code's `.mcp.json` shape; lifted into the flat `oauthClientId` @@ -900,6 +912,17 @@ export interface InspectorServerSettings { * render; defaults to `DEFAULT_MAX_FETCH_REQUESTS`. `0` means unlimited. */ maxFetchRequests: number; + /** + * Catalog budget for skills verification (#2294): the most skills, and the + * most bytes, one run reads. Optional rather than concrete like + * `maxFetchRequests` — absent means `SKILL_MAX_CATALOG_SKILLS` / + * `SKILL_MAX_CATALOG_BYTES`, resolved by `resolveSkillCatalogBudget`, and + * the form renders those defaults itself. Read by `verifySkills` through + * `getServerSettings()`, so the CLI's `--verify` and the TUI's Skills pane + * both honor it. + */ + skillCatalogMaxSkills?: number; + skillCatalogMaxBytes?: number; /** * Roots advertised to the server via the `roots` client capability. Each * root carries a required `uri` and an optional `name` (SDK `Root`). The diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index b938e53f..89c91737 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -188,6 +188,8 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | +| `skillCatalogMaxSkills` | `256` | Most skills one skills verification run reads (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value | +| `skillCatalogMaxBytes` | `67108864` | Most bytes one skills verification run reads (`SKILL_MAX_CATALOG_BYTES`, 64 MiB). Positive integer | | `oauth` | — | `{ clientId, clientSecret, scopes, requestRefreshToken, revokeOnClear, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | `metadata` is a JSON **object**, and its values may be any JSON — an object, an array, a number, a boolean, `null` — not only a string. The MCP spec does not restrict `_meta` value types in general, and the SDK models the field as a passthrough object, so the Inspector does not narrow it either ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). Edit it in Server Settings → Request Metadata, which is a JSON editor rather than the key/value rows `headers` and `env` use; text that is not a JSON object is flagged inline and not applied. From 528a37be3817b8df9272ea22679451d26949703c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:00:49 -0400 Subject: [PATCH 11/20] fix: accept a path-less skill root URI in normalizeSkillUri (#2295) `skill://data-analysis` has an authority and an empty path, which RFC 3986 permits, but the guard required a pathname starting with "/". A server echoing the skill root back in a directory read was therefore labelled "outside this skill" instead of "not a direct child". Opaque URIs (`skill:demo`) stay rejected; a path-less root still names no skill file, so it fails every check that needs one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VQ2eszDCUqTmhtTMaznw3W Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 25 +++++++++++++++++++ clients/web/src/test/core/mcp/skills.test.ts | 25 +++++++++++++++++++ core/mcp/skills.ts | 14 ++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index a4855e55..997e1f39 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -2184,6 +2184,31 @@ describe("SkillsScreen directory browsing (#2248)", () => { ).not.toBeInTheDocument(); }); + it("names a skill root echoed back among its own children as not a direct child (#2295)", async () => { + // The root is path-less (`skill://data-analysis`), which + // `normalizeSkillUri` used to reject — so the containment check failed and + // the row read "outside this skill" while being the skill itself. + const user = userEvent.setup(); + const ROOT_SELF = { + uri: ROOT, + name: "data-analysis", + mimeType: "inode/directory", + }; + await openRoot( + user, + directoryReader({ [ROOT]: { resources: [CHILD_FILE, ROOT_SELF] } }), + ); + const table = within(screen.getByTestId("skill-directory")); + expect(table.getByText(/not a direct child/)).toBeInTheDocument(); + expect(table.queryByText(/outside this skill/)).not.toBeInTheDocument(); + expect( + table.queryByRole("button", { name: `Open directory ${ROOT}` }), + ).not.toBeInTheDocument(); + expect( + table.getByRole("button", { name: `View ${CHILD_FILE.uri}` }), + ).toBeInTheDocument(); + }); + it("navigates on the normalized URI, so Up cannot walk into a `..` segment", async () => { // Containment was decided on the normalized URI while navigation sent and // stored the raw one, so for `skill://root/a/../templates` the first Up diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 04a250b5..2391d389 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -174,6 +174,31 @@ describe("normalizeSkillUri", () => { // `skill:demo/../x.md` parses but keeps its `..` verbatim, so containment // could not be decided on it — accepting it would reopen the hole. expect(normalizeSkillUri("skill:demo/SKILL.md")).toBeUndefined(); + // Path-less but still opaque: no authority, so it stays rejected. + expect(normalizeSkillUri("skill:demo")).toBeUndefined(); + }); + + it("accepts a path-less authority URI — a skill's root directory (#2295)", () => { + // RFC 3986 gives an authority URI an empty path, so `skill://data-analysis` + // is well-formed; rejecting it mislabelled a root echoed back by a + // directory read as "outside this skill". + expect(normalizeSkillUri("skill://data-analysis")).toBe( + "skill://data-analysis", + ); + expect(normalizeSkillUri("skill://%64ata-analysis")).toBe( + "skill://data-analysis", + ); + }); + + it("still treats a path-less root as naming no skill file", () => { + // Accepting the root must not let it pass a check that needs a file. + expect(skillNameFromUri("skill://data-analysis")).toBeUndefined(); + const issues = checkSkillConformance({ + uri: "skill://data-analysis", + frontmatter: { name: "data-analysis", description: "d" }, + resources: [], + }); + expect(issues.map((issue) => issue.code)).toContain("malformed-uri"); }); it("decodes escapes that stand for unreserved characters", () => { diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 61dd7593..bb55f9fe 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -171,6 +171,16 @@ export function isSkillsExtensionSupported( * rejected too: containment could not be decided on it, and silently accepting * one would reintroduce exactly the hole this function closes. * + * A **path-less authority** URI (`skill://data-analysis`) is accepted, and is + * not the opaque case above: RFC 3986 gives a URI with an authority an empty + * path, and the parser reports that as `pathname === ""`. It has no segments, + * so nothing survives unnormalized. It is what a skill's root directory looks + * like once `/SKILL.md` is removed, so rejecting it made a server echoing that + * root back in a directory read read as "outside this skill" when it is the + * skill itself (#2295). It names no file, so it still fails every check that + * needs one — `skillNameFromUri` wants the `/SKILL.md` suffix, and a manifest + * entry spelled this way does not start with `/`. + * * The scheme is deliberately **not** constrained. `skill://` is what SEP-2640 * recommends and what this repo's fixture serves, but the SEP only says servers * SHOULD use it and explicitly allows a domain-native scheme (`github://…`), so @@ -186,7 +196,9 @@ export function normalizeSkillUri(uri: string): string | undefined { } catch { return undefined; } - if (!parsed.pathname.startsWith("/")) return undefined; + if (parsed.pathname !== "" && !parsed.pathname.startsWith("/")) { + return undefined; + } return canonicalizePercentEncoding(parsed.href); } From d639ab268b446e279a73d99640299fc702083d82 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:03:14 -0400 Subject: [PATCH 12/20] docs: correct env-var reference per Copilot round 2 (#2365) - Launcher README: narrow "does not interpret env vars" to server and client configuration, so it no longer contradicts MCP_DEBUG. - Document HOME / USERPROFILE as the base of every ~/.mcp-inspector default, including the cwd fallback when neither is set. - SERVER_PORT is the sandbox fallback whenever MCP_SANDBOX_PORT is not a valid port (unset, empty or invalid), not only when unset. - MCP_INSPECTOR_OAUTH_STATE_PATH is read by the CLI and TUI only; the web backend always uses /oauth.json. - MCP_DEBUG / DEBUG are also read by the standalone TUI entry point. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva Signed-off-by: cliffhall --- clients/launcher/README.md | 2 +- docs/environment-variables.md | 49 +++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/clients/launcher/README.md b/clients/launcher/README.md index 207c2a2c..14624fae 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -8,7 +8,7 @@ The launcher is the package that provides the global `mcp-inspector` binary (e.g - Forward all following arguments unchanged (including tokens that look like mode flags). - Dynamically import that app’s runner from `clients/{web,cli,tui}/build/index.js` (relative to the launcher build output) and call it **in-process** (no `spawn()`). -All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret config or env vars. +All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret server or client configuration, from files or from env vars. Its one exception is the debug switch described below. **Error reporting.** A `--cli` failure is routed through the CLI's own error sink, so `mcp-inspector --cli` preserves the CLI exit-code map (`1` usage, `2` no-app, `3` auth-required, `4` unreachable, `5` tool-error) and its machine-readable `{"error":{…}}` stderr envelope — the same as invoking the CLI bin directly. `--web` / `--tui` failures print a human-readable `Error: ` and exit `1` (append `MCP_DEBUG=1` for the stack). `MCP_DEBUG` (or `DEBUG`) is the only variable the launcher reads itself; the rest are listed in [Environment variables](../../docs/environment-variables.md). diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 0d6ca3a5..8bd97df7 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1,11 +1,13 @@ # Environment variables -Every environment variable that changes how the Inspector behaves at runtime, in one place. Set them in the shell that launches `mcp-inspector` (or with `-e` for the [Docker image](./docker.md)). +Every environment variable that changes how the Inspector behaves at runtime, in one place: the Inspector's own variables, plus the standard system and Node variables it reads (`HOME`, the proxy variables, the Node TLS variables). Set them in the shell that launches `mcp-inspector` (or with `-e` for the [Docker image](./docker.md)). The **Read by** column names the client whose process reads the variable: **web** is the Node backend that `--web` starts (the browser itself reads no environment), **CLI** and **TUI** are those clients, and **launcher** is the `mcp-inspector` bin that picks one of them. A variable read in shared `core/` code is marked with every client that reaches it. ⚠️ **Unset a variable rather than setting it to an empty string.** The two are not interchangeable: `HOST=""` is read as an all-interfaces bind and refused, and an empty path variable such as `MCP_STORAGE_DIR=` or `MCP_INSPECTOR_LOG_DIR=` can resolve relative to the working directory instead of falling back to the default. A row says so explicitly where an empty value is treated as unset. +A `~` in a default below means the home directory as described under [Home directory](#home-directory). + ## Authentication and network exposure These guard the web backend, which spawns processes on request. Read [Host binding and the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list) before widening any of them. @@ -25,10 +27,10 @@ These guard the web backend, which spawns processes on request. Read [Host bindi | --------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CLIENT_PORT` | web, CLI | `6274` | Web UI port. Must be a **fixed** integer in 1–65535: `0` (an OS-assigned port) is rejected at startup, because the origin allow-list and the MCP Apps sandbox CSP are derived from it. | | `MCP_SANDBOX_PORT` | web, CLI | `6275` | Port of the MCP Apps sandbox server, 0–65535. `0` asks the OS for a free port. An invalid value is ignored with a warning. | -| `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for `MCP_SANDBOX_PORT` when that is unset. | +| `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for the sandbox port: used whenever `MCP_SANDBOX_PORT` does not yield a valid port — unset, empty, **or invalid**. | | `MCP_APP_ORIGIN_PORT` | web, CLI | `6278` | Port of the dedicated app-origin server, used only by an MCP App whose UI resource declares `_meta.ui.domain`; 0–65535, where `0` asks the OS for a free port. An invalid value is ignored with a warning. Pin it if your app's backend allowlists that origin. | -The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. +The sandbox port resolves as: a valid `MCP_SANDBOX_PORT`, else a valid `SERVER_PORT`, else `6275`. The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them, and it does not apply the `SERVER_PORT` fallback. ## Behavior @@ -41,32 +43,41 @@ The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST ## Storage and state -| Variable | Read by | Default | Effect | -| -------------------------------- | ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_STORAGE_DIR` | web, CLI, TUI | `~/.mcp-inspector/storage` | Storage directory. Relocates the OAuth state file (`oauth.json`) and the secrets file (`secrets.json`) for every client. For the **web** backend it also relocates `client.json`; the CLI and TUI find `client.json` through `MCP_CLIENT_CONFIG_PATH` instead. | -| `MCP_INSPECTOR_OAUTH_STATE_PATH` | web, CLI, TUI | `~/.mcp-inspector/storage/oauth.json` | Names the OAuth state file outright. Lookup order: this variable, then `/oauth.json`, then `~/.mcp-inspector/storage/oauth.json`. ⚠️ Setting `MCP_STORAGE_DIR` alone does not isolate a run if this variable is also exported. | -| `MCP_CLIENT_CONFIG_PATH` | CLI, TUI | `~/.mcp-inspector/storage/client.json` | Install-level client config (CIMD, enterprise IdP). `--client-config` takes precedence. | +| Variable | Read by | Default | Effect | +| -------------------------------- | ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_STORAGE_DIR` | web, CLI, TUI | `~/.mcp-inspector/storage` | Storage directory. Relocates the OAuth state file (`oauth.json`) and the secrets file (`secrets.json`) for every client. For the **web** backend it also relocates `client.json`; the CLI and TUI find `client.json` through `MCP_CLIENT_CONFIG_PATH` instead. | +| `MCP_INSPECTOR_OAUTH_STATE_PATH` | CLI, TUI | `~/.mcp-inspector/storage/oauth.json` | Names the OAuth state file outright. Lookup order: this variable, then `/oauth.json`, then `~/.mcp-inspector/storage/oauth.json`. ⚠️ Setting `MCP_STORAGE_DIR` alone does not isolate a CLI or TUI run if this variable is also exported. **The web backend does not read it** — it always uses `/oauth.json`. | +| `MCP_CLIENT_CONFIG_PATH` | CLI, TUI | `~/.mcp-inspector/storage/client.json` | Install-level client config (CIMD, enterprise IdP). `--client-config` takes precedence. | + +### Home directory + +Every default above that starts with `~` is built from the home directory the process sees, not from the OS account database: + +| Variable | Read by | Effect | +| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `HOME` | web, CLI, TUI | Base of `~/.mcp-inspector`: the default catalog, storage directory (`oauth.json`, `client.json`), secrets file, and TUI log directory. | +| `USERPROFILE` | web, CLI, TUI | Used in place of `HOME` when `HOME` is unset or empty — the normal case on Windows. ⚠️ If **neither** is set, as under some service managers, those defaults resolve against the **current working directory** instead. Set `HOME` or the specific path variables above. | ## Secret store Where server secrets (headers, client secrets) are kept. The details — the keychain probe, the file format, encryption and locking — are in the [Docker guide](./docker.md); these variables apply to every install, not only containers. -| Variable | Read by | Default | Effect | -| ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. Any other value is ignored with a warning. | -| `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | -| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | +| Variable | Read by | Default | Effect | +| ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. Any other value is ignored with a warning. | +| `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | +| `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | When no store is configured, the choice also depends on whether the Inspector is running in a container, which it detects from `KUBERNETES_SERVICE_HOST` (or Docker's and Podman's marker files). That variable is set by the orchestrator, not by you. ## Logging and debugging -| Variable | Read by | Default | Effect | -| ----------------------- | -------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_DEBUG` / `DEBUG` | launcher | off | Prints the full stack trace when the launcher exits on an error. Any value other than empty, `0` or `false` (case-insensitive) turns it on. | -| `MCP_LOG_FILE` | web | unset (no log) | Appends the web backend's structured (pino, JSON lines) log to this file, creating its directory if needed. An empty value counts as unset. | -| `MCP_INSPECTOR_LOG_DIR` | TUI | `~/.mcp-inspector` | Directory of the TUI's `auth.log`. The TUI logs to a file so its output does not corrupt the terminal UI. | -| `LOG_LEVEL` | TUI | `info` | Level of the TUI's `auth.log`: one of `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`. An empty value is not replaced by `info`. | +| Variable | Read by | Default | Effect | +| ----------------------- | ------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_DEBUG` / `DEBUG` | launcher, TUI | off | Prints the full stack trace when the process exits on an error — the launcher for any `--web`/`--tui` failure, and the standalone TUI entry point for a startup failure. Any value other than empty, `0` or `false` (case-insensitive) turns it on. | +| `MCP_LOG_FILE` | web | unset (no log) | Appends the web backend's structured (pino, JSON lines) log to this file, creating its directory if needed. An empty value counts as unset. | +| `MCP_INSPECTOR_LOG_DIR` | TUI | `~/.mcp-inspector` | Directory of the TUI's `auth.log`. The TUI logs to a file so its output does not corrupt the terminal UI. | +| `LOG_LEVEL` | TUI | `info` | Level of the TUI's `auth.log`: one of `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`. An empty value is not replaced by `info`. | ## Outbound proxy From 091db9b6dd432f00737d590a3c31e7715de23f86 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:03:34 -0400 Subject: [PATCH 13/20] fix: address Copilot review round 6 on #2318 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteClientTransport tracks open streams only when onFetchStreamUpdate is registered — the bookkeeping serves that callback alone, and without it the set would grow by one entry per reconnect. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn Signed-off-by: cliffhall --- core/mcp/remote/remoteClientTransport.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index c8c8c92e..30dff297 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -556,8 +556,11 @@ export class RemoteClientTransport implements Transport { const entry = parsed.data; // Stream bookkeeping runs whether or not the consumer logs // requests: `onFetchStreamUpdate` is documented to work on its - // own, and `close()` needs to know which streams are open. + // own, and `close()` needs to know which streams are open. It + // exists only to serve that callback, though — without one the + // set would only ever grow, one entry per reconnect. if ( + this.options.onFetchStreamUpdate && isLongLivedStreamResponse( entry.method, findHeader(entry.responseHeaders, "content-type"), From b51211c71a86ab83869e0eec0af614630b3d050b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:10:54 -0400 Subject: [PATCH 14/20] docs: correct env-var reference per Copilot round 3 (#2365) - The CLI passes CLIENT_PORT / MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT into its hand-off as-is: no validation, no warning, no fallback. - MCP_AUTO_OPEN_ENABLED=true also lets CLI interactive OAuth start with no TTY on stdin or stderr. - An empty MCP_INSPECTOR_SECRET_STORE counts as unset, silently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva Signed-off-by: cliffhall --- docs/environment-variables.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 8bd97df7..ab5e4c81 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -30,13 +30,13 @@ These guard the web backend, which spawns processes on request. Read [Host bindi | `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for the sandbox port: used whenever `MCP_SANDBOX_PORT` does not yield a valid port — unset, empty, **or invalid**. | | `MCP_APP_ORIGIN_PORT` | web, CLI | `6278` | Port of the dedicated app-origin server, used only by an MCP App whose UI resource declares `_meta.ui.domain`; 0–65535, where `0` asks the OS for a free port. An invalid value is ignored with a warning. Pin it if your app's backend allowlists that origin. | -The sandbox port resolves as: a valid `MCP_SANDBOX_PORT`, else a valid `SERVER_PORT`, else `6275`. The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them, and it does not apply the `SERVER_PORT` fallback. +The sandbox port resolves as: a valid `MCP_SANDBOX_PORT`, else a valid `SERVER_PORT`, else `6275`. The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. It also validates none of them: any non-empty value is copied into the URLs and port-forwarding command as-is, with no range check, no warning, and no `SERVER_PORT` fallback, so a malformed value produces a broken hand-off rather than an error. ## Behavior | Variable | Read by | Default | Effect | | ------------------------ | ------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_AUTO_OPEN_ENABLED` | web, CLI | see effect | Whether a browser is opened for you. `false` never opens one; `true` always does. Unset (or any other value), the web client opens the UI at launch, and the CLI opens the OAuth authorization page only when stderr is a TTY. **The TUI does not read it** and always opens the OAuth page. | +| `MCP_AUTO_OPEN_ENABLED` | web, CLI | see effect | Whether a browser is opened for you. `false` never opens one; `true` always does. Unset (or any other value), the web client opens the UI at launch, and the CLI opens the OAuth authorization page only when stderr is a TTY. In the CLI, `true` also lets interactive OAuth **start** when neither stdin nor stderr is a TTY; otherwise that case fails with an auth-required error pointing at `--stored-auth-only`. **The TUI does not read it** and always opens the OAuth page. | | `MCP_CATALOG_PATH` | web, CLI, TUI | `~/.mcp-inspector/mcp.json` | Default writable catalog, used when no `--catalog` is given. The CLI honors it only when no ad-hoc target (positional command, `--server-url`, or `--transport`) is given. See [MCP server configuration](./mcp-server-configuration.md). | | `MCP_OAUTH_CALLBACK_URL` | CLI, TUI | `http://127.0.0.1:6276/oauth/callback` | Loopback redirect URL for the CLI/TUI OAuth flow. `--callback-url` takes precedence. | | `NO_COLOR` | CLI | unset | Any non-empty value disables ANSI styling in the CLI's human-readable output. An empty `NO_COLOR=` counts as unset. | @@ -64,7 +64,7 @@ Where server secrets (headers, client secrets) are kept. The details — the key | Variable | Read by | Default | Effect | | ---------------------------- | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. Any other value is ignored with a warning. | +| `MCP_INSPECTOR_SECRET_STORE` | web, CLI, TUI | probe the OS keychain | `keyring`, `file`, or `memory` (case-insensitive) picks the store outright and skips the probe. An empty or whitespace-only value counts as unset and silently runs automatic selection; any other value is ignored with a warning and also falls back to automatic selection. | | `MCP_INSPECTOR_SECRET_FILE` | web, CLI, TUI | `~/.mcp-inspector/secrets.json` | Path of the file store. Lookup order: this variable, then `secrets.json` in `MCP_STORAGE_DIR` when that is set, then `~/.mcp-inspector/secrets.json`. ⚠️ The default sits **beside** the storage directory, not inside it. | | `MCP_INSPECTOR_SECRET_KEY` | web, CLI, TUI | unset (file is plaintext, `0600`) | Passphrase that encrypts the file store; an empty or whitespace-only value counts as unset. Use a generated, high-entropy value. ⚠️ Changing or losing it makes the existing file unreadable; see the Docker guide before rotating it. | From 55db703196ac58848c5c8b47edc214715f61ae15 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:11:56 -0400 Subject: [PATCH 15/20] feat: add --protocol-era launch flag for ad-hoc servers (#2208) An ad-hoc --server-url / command launch always negotiated the legacy era, because protocolEra was only settable per server in mcp.json. A wrapper that mints a short-lived bearer token therefore had to write that token to disk to exercise a server under auto or modern. --protocol-era now feeds settings.protocolEra in all three clients through a shared parseProtocolEra coercer: - CLI/TUI overlay it in loadServerEntries alongside --header, onto an ad-hoc target and onto a --catalog/--config entry for that run. - Web stamps it on the in-memory ad-hoc entry, which the backend lifts into settings like headers; as with --header it is rejected alongside --catalog/--config, but unlike --header it applies to stdio too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x Signed-off-by: cliffhall --- clients/cli/README.md | 2 +- .../__tests__/programmatic-ergonomics.test.ts | 32 +++++++ clients/cli/src/cli.ts | 11 +++ clients/launcher/README.md | 7 +- clients/tui/README.md | 2 +- clients/tui/tui.tsx | 9 ++ clients/web/server/run-web.ts | 42 +++++++-- .../web/src/test/core/mcp/node/config.test.ts | 13 +++ .../src/test/core/mcp/node/servers.test.ts | 68 +++++++++++++++ .../test/integration/server/run-web.test.ts | 87 +++++++++++++++++++ core/mcp/node/config.ts | 17 +++- core/mcp/node/index.ts | 1 + core/mcp/node/servers.ts | 60 +++++++++---- docs/mcp-server-configuration.md | 3 +- 14 files changed, 324 insertions(+), 30 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 175c8937..bd59c5d8 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -102,7 +102,7 @@ Because undici's `Response` is a different class from `globalThis.Response`, the ### MCP server (which server to connect to) -Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. +Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc run negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. ### CLI-specific (what to invoke) diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 88f42f10..1cef354d 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -227,6 +227,38 @@ describe("CLI --stored-auth-only wiring", () => { }); }); +describe("--protocol-era", () => { + it.each(["legacy", "auto"])( + "accepts %s on an ad-hoc target and connects", + async (era) => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--protocol-era", + era, + "--method", + "tools/list", + ]); + expectCliSuccess(result); + }, + ); + + it("rejects an unknown era before connecting", async () => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--protocol-era", + "future", + "--method", + "tools/list", + ]); + expectCliFailure(result); + expect(result.stderr).toContain("Invalid protocol era: future"); + }); +}); + describe("MCP_CATALOG_PATH with an ad-hoc target", () => { it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => { const { command, args } = getTestMcpServerCommand(); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 990267d0..43a5c7da 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -5,6 +5,7 @@ import type { InspectorServerSettings, MCPServerConfig, InspectorClientEnvironment, + ServerProtocolEra, } from "@inspector/core/mcp/types.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { @@ -23,6 +24,7 @@ import { selectServerEntry, parseKeyValuePair as parseEnvPair, parseHeaderPair, + parseProtocolEra, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js"; @@ -773,6 +775,11 @@ async function parseArgs(argv?: string[]): Promise { return n; }, ) + .option( + "--protocol-era ", + "Protocol era to negotiate: legacy, auto, or modern. Overrides the file-level protocolEra for --catalog/--config runs; ad-hoc --server-url / target runs otherwise default to legacy.", + parseProtocolEra, + ) .option( "--format ", "Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).", @@ -870,6 +877,7 @@ async function parseArgs(argv?: string[]): Promise { verify?: boolean; cursor?: string; connectTimeout?: number; + protocolEra?: ServerProtocolEra; format?: OutputFormat; toolArgsJson?: string; clientConfig?: string; @@ -1017,6 +1025,9 @@ async function parseArgs(argv?: string[]): Promise { // `--header` is merged into the resolved server's settings (overriding any // file-level headers); file timeouts/OAuth are preserved. See #1482. headers: options.header as Record | undefined, + // `--protocol-era` feeds `settings.protocolEra` the same way, so an ad-hoc + // launch can pick a non-legacy era without an mcp.json entry (#2208). + protocolEra: options.protocolEra, }; // Catalog list / show — no MCP connection. Run before stored-auth refresh so diff --git a/clients/launcher/README.md b/clients/launcher/README.md index e69cf0cb..1f4409c1 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -25,8 +25,11 @@ editable (see [specification/v2_catalog_launch_config.md](../../specification/v2 | `mcp-inspector --web --server-url --transport http --header "Name: Value"` (or a positional command) | One ad-hoc server held in memory, connectable with the given `--header`s | No | Rules: `--catalog` and `--config` are mutually exclusive; neither combines with -an ad-hoc target or `--header`; `--header` requires an ad-hoc HTTP/SSE server -and is applied to that connection (it is no longer a warn-only no-op). +an ad-hoc target, `--header` or `--protocol-era`; `--header` requires an ad-hoc +HTTP/SSE server and is applied to that connection (it is no longer a warn-only +no-op). `--protocol-era ` requires an ad-hoc server of any +transport and sets the era that connection negotiates, so a non-legacy era +needs no `mcp.json` entry. **Seed contents are web-specific.** When the web backend creates a missing writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a diff --git a/clients/tui/README.md b/clients/tui/README.md index 17fdb0c9..b604ad23 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -29,7 +29,7 @@ npx @modelcontextprotocol/inspector --tui --config mcp.json # read-only sessi ### MCP server (which server(s) to connect to) -Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. +Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc server negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target. ### TUI-specific (OAuth for HTTP servers) diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index 3169a790..17686148 100644 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -5,7 +5,9 @@ import { render } from "ink"; import { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, } from "@inspector/core/mcp/node/index.js"; +import type { ServerProtocolEra } from "@inspector/core/mcp/types.js"; import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; import { parseRunnerOAuthCallbackUrl, @@ -41,6 +43,11 @@ export async function runTui(args?: string[]): Promise { parseHeaderPair, {}, ) + .option( + "--protocol-era ", + "Protocol era to negotiate: legacy, auto, or modern (overrides the file's protocolEra; default legacy)", + parseProtocolEra, + ) .option( "--client-id ", "OAuth client ID (static client) for HTTP servers", @@ -78,6 +85,7 @@ export async function runTui(args?: string[]): Promise { e?: Record; cwd?: string; header?: Record; + protocolEra?: ServerProtocolEra; clientId?: string; clientSecret?: string; clientMetadataUrl?: string; @@ -95,6 +103,7 @@ export async function runTui(args?: string[]): Promise { cwd: options.cwd?.trim() || undefined, env: options.e, headers: options.header, + protocolEra: options.protocolEra, transport: options.transport, serverUrl: options.serverUrl?.trim() || undefined, }; diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index f1ed5a11..a76791fa 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -10,12 +10,14 @@ import { Command } from "commander"; import type { MCPConfig, MCPServerConfig, + ServerProtocolEra, StoredMCPServer, } from "../../../core/mcp/types.ts"; import { resolveServerConfigs, parseKeyValuePair, parseHeaderPair, + parseProtocolEra, type ServerConfigOptions, } from "../../../core/mcp/node/config.ts"; import { @@ -89,6 +91,11 @@ export async function runWeb(argv: string[]): Promise { parseHeaderPair, {}, ) + .option( + "--protocol-era ", + "protocol era for the ad-hoc server: legacy, auto, or modern (default legacy)", + parseProtocolEra, + ) .option("--dev", "run in development mode (Vite)") .parse(preArgs); @@ -101,6 +108,7 @@ export async function runWeb(argv: string[]): Promise { serverUrl?: string; cwd?: string; header?: Record; + protocolEra?: ServerProtocolEra; dev?: boolean; }; @@ -124,6 +132,10 @@ export async function runWeb(argv: string[]): Promise { const hasCatalog = !!catalogPath; const hasConfig = !!opts.config; const hasHeaders = !!opts.header && Object.keys(opts.header).length > 0; + // Like `--header`, web applies `--protocol-era` only to an ad-hoc server: a + // catalog/config entry carries its own `protocolEra`, editable in Server + // Settings, and web never overlays launch flags onto a file's settings. + const hasProtocolEra = opts.protocolEra !== undefined; const hasAdHocServer = target.length > 0 || !!opts.serverUrl || @@ -137,11 +149,15 @@ export async function runWeb(argv: string[]): Promise { ? "--catalog cannot be combined with an ad-hoc server URL/command." : hasCatalog && hasHeaders ? "--header cannot be combined with --catalog. Set per-server headers in the catalog file." - : hasConfig && hasAdHocServer - ? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file." - : hasConfig && hasHeaders - ? "--header cannot be combined with --config. Set per-server headers inside the config file instead." - : null; + : hasCatalog && hasProtocolEra + ? "--protocol-era cannot be combined with --catalog. Set protocolEra per server in the catalog file." + : hasConfig && hasAdHocServer + ? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file." + : hasConfig && hasHeaders + ? "--header cannot be combined with --config. Set per-server headers inside the config file instead." + : hasConfig && hasProtocolEra + ? "--protocol-era cannot be combined with --config. Set protocolEra per server inside the config file instead." + : null; if (conflict) { console.error(`Error: ${conflict}`); process.exit(1); @@ -217,13 +233,18 @@ export async function runWeb(argv: string[]): Promise { initialMcpConfig = config; - // In-memory session entry: the SDK config plus the flat `headers` record, - // which the backend lifts into `settings.headers` on read. Nothing is - // written to disk — the ad-hoc server is a read-only session list. + // In-memory session entry: the SDK config plus the flat `headers` record + // and `protocolEra`, which the backend lifts into `settings` on read. + // Nothing is written to disk — the ad-hoc server is a read-only session + // list. The era is transport-agnostic, so unlike `--header` it applies to + // stdio too. const entry: StoredMCPServer = hasHeaders && config.type !== "stdio" && config.type !== undefined ? { ...config, headers: opts.header } : { ...config }; + if (opts.protocolEra !== undefined) { + entry.protocolEra = opts.protocolEra; + } initialServers = { mcpServers: { [deriveSeedServerId(config)]: entry }, }; @@ -233,6 +254,11 @@ export async function runWeb(argv: string[]): Promise { "Error: --header requires an ad-hoc --server-url or command target.", ); process.exit(1); + } else if (hasProtocolEra) { + console.error( + "Error: --protocol-era requires an ad-hoc --server-url or command target.", + ); + process.exit(1); } let webConfig: WebServerConfig; diff --git a/clients/web/src/test/core/mcp/node/config.test.ts b/clients/web/src/test/core/mcp/node/config.test.ts index 72983440..7e1e1689 100644 --- a/clients/web/src/test/core/mcp/node/config.test.ts +++ b/clients/web/src/test/core/mcp/node/config.test.ts @@ -12,6 +12,7 @@ import { join } from "node:path"; import { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, withDefaultCatalogPath, resolveServerConfigs, resolveServerSource, @@ -44,6 +45,18 @@ describe("parseKeyValuePair", () => { }); }); +describe("parseProtocolEra", () => { + it.each(["legacy", "auto", "modern"])("accepts %s", (era) => { + expect(parseProtocolEra(era)).toBe(era); + }); + + it("rejects an unknown era, naming the valid ones", () => { + expect(() => parseProtocolEra("Modern")).toThrow( + "Invalid protocol era: Modern. Valid eras are: legacy, auto, modern", + ); + }); +}); + describe("parseHeaderPair", () => { it("splits on the first colon and trims both sides", () => { expect(parseHeaderPair("X-Test: value")).toEqual({ diff --git a/clients/web/src/test/core/mcp/node/servers.test.ts b/clients/web/src/test/core/mcp/node/servers.test.ts index e03242e6..3cd372d8 100644 --- a/clients/web/src/test/core/mcp/node/servers.test.ts +++ b/clients/web/src/test/core/mcp/node/servers.test.ts @@ -234,6 +234,74 @@ describe("loadServerEntries", () => { ).rejects.toThrow(/--catalog cannot be combined/); }); + it("overrides the disk protocolEra with --protocol-era, preserving the rest", async () => { + const configPath = join(tempDir, "mcp.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + web: { + type: "streamable-http", + url: "http://x/mcp", + headers: { Authorization: "Bearer disk" }, + protocolEra: "auto", + requestTimeout: 9000, + }, + }, + }), + ); + + const servers = await loadServerEntries({ + configPath, + protocolEra: "modern", + }); + expect(servers.web?.settings?.protocolEra).toBe("modern"); + expect(servers.web?.settings?.requestTimeout).toBe(9000); + expect(servers.web?.settings?.headers).toEqual([ + { key: "Authorization", value: "Bearer disk" }, + ]); + }); + + it("applies --protocol-era to a server that has no disk settings", async () => { + const configPath = join(tempDir, "mcp.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + web: { type: "streamable-http", url: "http://x/mcp" }, + }, + }), + ); + + const servers = await loadServerEntries({ + configPath, + protocolEra: "auto", + }); + expect(servers.web?.settings).toMatchObject({ + protocolEra: "auto", + headers: [], + connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, + }); + }); + + it("applies --protocol-era and --header together to an ad-hoc URL", async () => { + const servers = await loadServerEntries({ + serverUrl: "http://x/mcp", + transport: "http", + headers: { Authorization: "Bearer t" }, + protocolEra: "modern", + }); + expect(servers.default?.settings).toMatchObject({ + protocolEra: "modern", + headers: [{ key: "Authorization", value: "Bearer t" }], + }); + }); + + it("gives an ad-hoc target with neither flag no settings (legacy default)", async () => { + const servers = await loadServerEntries({ target: ["my-server"] }); + expect(servers.default?.settings).toBeUndefined(); + }); + it("builds a single ad-hoc server from a positional target", async () => { const servers = await loadServerEntries({ target: ["my-server", "--flag"], diff --git a/clients/web/src/test/integration/server/run-web.test.ts b/clients/web/src/test/integration/server/run-web.test.ts index 65bd2340..bf32d88a 100644 --- a/clients/web/src/test/integration/server/run-web.test.ts +++ b/clients/web/src/test/integration/server/run-web.test.ts @@ -327,6 +327,83 @@ describe("runWeb", () => { expect(startHonoServer).not.toHaveBeenCalled(); }); + it("plumbs --protocol-era onto the seeded ad-hoc HTTP server", async () => { + void runWeb([ + "node", + "run-web", + "--server-url", + "https://example.com/mcp", + "--transport", + "http", + "--header", + "Authorization: Bearer x", + "--protocol-era", + "modern", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).toMatchObject({ + type: "streamable-http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer x" }, + protocolEra: "modern", + }); + }); + + it("plumbs --protocol-era onto a seeded ad-hoc stdio server", async () => { + void runWeb([ + "node", + "run-web", + "--protocol-era", + "auto", + "node", + "server.js", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).toMatchObject({ + command: "node", + args: ["server.js"], + protocolEra: "auto", + }); + }); + + it("omits protocolEra from the seeded entry when the flag is absent", async () => { + void runWeb([ + "node", + "run-web", + "https://example.com/mcp", + "--transport", + "http", + ]); + await expectServerStarted(startHonoServer); + + expect(soleSeededEntry()).not.toHaveProperty("protocolEra"); + }); + + it("rejects an unknown --protocol-era value", async () => { + await expect( + runWeb([ + "node", + "run-web", + "https://example.com/mcp", + "--protocol-era", + "future", + ]), + ).rejects.toThrow(); + expect(startHonoServer).not.toHaveBeenCalled(); + }); + + it("rejects --protocol-era with no ad-hoc server to attach it to", async () => { + await expect( + runWeb(["node", "run-web", "--protocol-era", "modern"]), + ).rejects.toThrow("process.exit:1"); + expect( + errorLines.some((l) => l.includes("--protocol-era requires an ad-hoc")), + ).toBe(true); + expect(startHonoServer).not.toHaveBeenCalled(); + }); + it("exits when an ad-hoc config resolves to no entries", async () => { vi.spyOn(nodeConfig, "resolveServerConfigs").mockReturnValueOnce([]); @@ -548,6 +625,16 @@ describe("runWeb", () => { argv: ["--config", "/a.json", "--header", "Authorization: Bearer x"], match: "--header cannot be combined with --config", }, + { + name: "--catalog + --protocol-era", + argv: ["--catalog", "/a.json", "--protocol-era", "modern"], + match: "--protocol-era cannot be combined with --catalog", + }, + { + name: "--config + --protocol-era", + argv: ["--config", "/a.json", "--protocol-era", "modern"], + match: "--protocol-era cannot be combined with --config", + }, ])("rejects $name", async ({ argv, match }) => { await expect(runWeb(["node", "run-web", ...argv])).rejects.toThrow( "process.exit:1", diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts index c90a9d6e..3f23913f 100644 --- a/core/mcp/node/config.ts +++ b/core/mcp/node/config.ts @@ -8,7 +8,8 @@ import type { SseServerConfig, StreamableHttpServerConfig, } from "../types.js"; -import { normalizeServerType } from "../serverList.js"; +import { isProtocolEra, normalizeServerType } from "../serverList.js"; +import type { ServerProtocolEra } from "../types.js"; import { toRecord } from "../../json/jsonUtils.js"; /** @@ -86,6 +87,20 @@ export function parseHeaderPair( return { ...previous, [key]: val }; } +/** + * Validate a `--protocol-era` value. Used as the Commander option coerce for + * all three clients, so an ad-hoc launch can pick the era without writing an + * `mcp.json` entry for it (#2208). Pure function; no Commander dependency. + */ +export function parseProtocolEra(value: string): ServerProtocolEra { + if (!isProtocolEra(value)) { + throw new Error( + `Invalid protocol era: ${value}. Valid eras are: legacy, auto, modern`, + ); + } + return value; +} + /** On-disk contents of a freshly seeded empty catalog (pretty-printed). */ const EMPTY_CATALOG_CONTENT = `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`; diff --git a/core/mcp/node/index.ts b/core/mcp/node/index.ts index c3e657f4..f8fa91fc 100644 --- a/core/mcp/node/index.ts +++ b/core/mcp/node/index.ts @@ -1,6 +1,7 @@ export { parseKeyValuePair, parseHeaderPair, + parseProtocolEra, withDefaultCatalogPath, resolveServerConfigs, getNamedServerConfigs, diff --git a/core/mcp/node/servers.ts b/core/mcp/node/servers.ts index 85f4bdfb..8b7a2e34 100644 --- a/core/mcp/node/servers.ts +++ b/core/mcp/node/servers.ts @@ -1,6 +1,10 @@ import type { SecretStore } from "../../auth/node/secret-store.js"; import { defaultSecretStore } from "../../auth/node/secret-store-selection.js"; -import type { InspectorServerSettings, MCPServerConfig } from "../types.js"; +import type { + InspectorServerSettings, + MCPServerConfig, + ServerProtocolEra, +} from "../types.js"; import { DEFAULT_CONNECTION_TIMEOUT_MS, DEFAULT_MAX_FETCH_REQUESTS, @@ -30,10 +34,14 @@ export type ResolvedServer = { settings?: InspectorServerSettings; }; -/** Loader options: the shared source flags plus a single `--header` set that is - * broadcast into every resolved server's settings. */ +/** Loader options: the shared source flags plus a single `--header` set and an + * optional `--protocol-era`, both broadcast into every resolved server's + * settings. */ export type ServerLoadOptions = ServerConfigOptions & { headers?: Record; + /** `--protocol-era`: overrides the file's `protocolEra` (or the legacy + * default) the way `--header` overrides its headers (#2208). */ + protocolEra?: ServerProtocolEra; /** Test injection; defaults to the selected store (see * `secret-store-selection.ts`) for catalog/config loads. */ secretStore?: SecretStore; @@ -51,7 +59,15 @@ export function headersToServerSettings( return undefined; } return { + ...defaultServerSettings(), headers: Object.entries(headers).map(([key, value]) => ({ key, value })), + }; +} + +/** A settings node with every field at its product default. */ +function defaultServerSettings(): InspectorServerSettings { + return { + headers: [], env: [], metadata: {}, connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS, @@ -65,18 +81,29 @@ export function headersToServerSettings( } /** - * Overlay CLI `--header` values onto the settings lifted from the file. Only the - * `headers` field is overridden — timeouts, OAuth, and the rest of the file's - * settings are preserved. + * Overlay `--header` / `--protocol-era` onto the settings lifted from the file + * (or onto nothing, for an ad-hoc target). Only those two fields are overridden + * — timeouts, OAuth, and the rest of the file's settings are preserved. Returns + * `base` untouched when neither flag was given. */ function mergeSettings( base: InspectorServerSettings | undefined, - headers?: Record, + overrides: Pick, ): InspectorServerSettings | undefined { - const fromHeaders = headersToServerSettings(headers); - if (!fromHeaders) return base; - if (!base) return fromHeaders; - return { ...base, headers: fromHeaders.headers }; + let settings = base; + const fromHeaders = headersToServerSettings(overrides.headers); + if (fromHeaders) { + settings = settings + ? { ...settings, headers: fromHeaders.headers } + : fromHeaders; + } + if (overrides.protocolEra) { + settings = { + ...(settings ?? defaultServerSettings()), + protocolEra: overrides.protocolEra, + }; + } + return settings; } /** @@ -117,10 +144,11 @@ export async function loadServerEntries( env: serverOptions.env, cwd: serverOptions.cwd, }), - // Deliberate broadcast: a single `--header` set is merged into EVERY - // server in the catalog/config (fine for the common single-server case; - // for multi-server files, prefer per-server headers in the file itself). - settings: mergeSettings(entry.settings, serverOptions.headers), + // Deliberate broadcast: a single `--header` set (and `--protocol-era`) + // is merged into EVERY server in the catalog/config (fine for the + // common single-server case; for multi-server files, prefer per-server + // settings in the file itself). + settings: mergeSettings(entry.settings, serverOptions), }; } return result; @@ -135,7 +163,7 @@ export async function loadServerEntries( return { default: { config: configs[0]!, - settings: headersToServerSettings(serverOptions.headers), + settings: mergeSettings(undefined, serverOptions), }, }; } diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index b938e53f..e1b6799c 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -11,7 +11,7 @@ Client-specific options (the web server port, the CLI method to invoke, TUI navi The two do not mix. `--catalog` and `--config` are mutually exclusive with each other, and neither combines with an ad-hoc target. All three clients apply the same **source-selection** rules — the CLI and TUI through the shared `serverSourceConflict` helper (`core/mcp/node/config.ts`), web through an equivalent inline matrix in `clients/web/server/run-web.ts`. The two implementations diverge on two narrow axes, in opposite directions: -- **Web is stricter on `--header`:** it also rejects `--header` alongside `--catalog`/`--config`, because the CLI and TUI merge `--header` into per-server settings and web does not. +- **Web is stricter on `--header` and `--protocol-era`:** it also rejects either alongside `--catalog`/`--config`, because the CLI and TUI merge them into per-server settings and web does not. - **Web is looser on `--transport stdio`:** the CLI and TUI treat _any_ `--transport` as an ad-hoc marker, so `--catalog c.json --transport stdio` is rejected as a catalog/ad-hoc conflict there; web excludes `stdio` from that test and accepts the same combination, silently ignoring the flag. ## From a file: `--catalog` vs. `--config` @@ -125,6 +125,7 @@ Without a `--` on the line the target is only the leading run of **non-dash** to | `--cwd ` | Working directory for a stdio server process | | | `-e ` | Environment variable for a stdio server; repeatable | | | `--header "Name: Value"` | HTTP header for an HTTP/SSE server; repeatable | On web, requires an ad-hoc HTTP/SSE server | +| `--protocol-era ` | `legacy`, `auto`, or `modern` — the era to negotiate | Sets [`protocolEra`](#inspector-specific-per-server-fields) without a file, on any transport. CLI/TUI also override a file's `protocolEra` with it; web, like `--header`, requires an ad-hoc target | | `[target...]` | Positional command or URL for one ad-hoc server | | **`MCP_CATALOG_PATH` and ad-hoc targets differ by client.** The **CLI** ignores the env var when an ad-hoc target is given (a positional command, `--server-url`, or `--transport`), so a shell that exports it can still run one-off ad-hoc invocations without tripping the catalog/ad-hoc conflict. **Web and TUI read it unconditionally** — with it exported, an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as `--catalog cannot be combined with an ad-hoc server URL/command`. Unset the variable for that invocation on those two surfaces. From da2c972e5a33bf18eb9b1c770bb2e8af5760867c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:17:46 -0400 Subject: [PATCH 16/20] docs: correct env-var reference per Copilot round 4 (#2365) - Scope the CLI pass-through warning to the three port variables; the CLI normalizes HOST (wildcard -> localhost, others canonicalized). - ALLOWED_ORIGINS default is derived from HOST: loopback origins, plus the two wildcard origins for an all-interfaces bind, or only the configured host's origin for a specific non-loopback host. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TRJ64MnDTg1TpzCTEV9Xva Signed-off-by: cliffhall --- docs/environment-variables.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index ab5e4c81..70546de2 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -19,7 +19,7 @@ These guard the web backend, which spawns processes on request. Read [Host bindi | `DANGEROUSLY_OMIT_AUTH` | web | unset | Disables the API token entirely. ⚠️ **Any non-empty value turns auth off, including `false` and `0`** — unset the variable to keep auth on. | | `HOST` | web, CLI | `127.0.0.1` | Address the web server binds. An all-interfaces host (`0.0.0.0`, `::`, an empty string, and equivalent spellings) is **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES` is enabled. The CLI reads it only to build its deep link. | | `DANGEROUSLY_BIND_ALL_INTERFACES` | web | off | Opts in to an all-interfaces `HOST`. Only `true` or `1` (case-insensitive) enable it, so `false` reads as off. The Docker image sets it. | -| `ALLOWED_ORIGINS` | web | the loopback origins | Comma-separated origins allowed to call the API. **Replaces** the default list rather than adding to it, so list every form you browse from. Each entry must include the scheme (`http://localhost:6274`). | +| `ALLOWED_ORIGINS` | web | derived from `HOST` | Comma-separated origins allowed to call the API. Unset, the list follows `HOST` at `CLIENT_PORT`: the loopback origins for a loopback host, the loopback origins plus `http://0.0.0.0` and `http://[::]` for an all-interfaces bind, and otherwise only the configured host's own origin (so binding a LAN address does **not** also allow `localhost`). **Replaces** the default list rather than adding to it, so list every form you browse from. Each entry must include the scheme (`http://localhost:6274`). | ## Ports @@ -30,7 +30,7 @@ These guard the web backend, which spawns processes on request. Read [Host bindi | `SERVER_PORT` | web | — | v1's proxy port, now only a fallback for the sandbox port: used whenever `MCP_SANDBOX_PORT` does not yield a valid port — unset, empty, **or invalid**. | | `MCP_APP_ORIGIN_PORT` | web, CLI | `6278` | Port of the dedicated app-origin server, used only by an MCP App whose UI resource declares `_meta.ui.domain`; 0–65535, where `0` asks the OS for a free port. An invalid value is ignored with a warning. Pin it if your app's backend allowlists that origin. | -The sandbox port resolves as: a valid `MCP_SANDBOX_PORT`, else a valid `SERVER_PORT`, else `6275`. The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. It also validates none of them: any non-empty value is copied into the URLs and port-forwarding command as-is, with no range check, no warning, and no `SERVER_PORT` fallback, so a malformed value produces a broken hand-off rather than an error. +The sandbox port resolves as: a valid `MCP_SANDBOX_PORT`, else a valid `SERVER_PORT`, else `6275`. The CLI reads `CLIENT_PORT`, `MCP_SANDBOX_PORT`, `MCP_APP_ORIGIN_PORT` and `HOST` only to build the deep link and port list it hands to a web session; it binds none of them. It normalizes `HOST` (an all-interfaces host becomes `localhost`, any other host is canonicalized), but it validates none of the three **port** variables: any non-empty port value is copied into the URLs and port-forwarding command as-is, with no range check, no warning, and no `SERVER_PORT` fallback, so a malformed value produces a broken hand-off rather than an error. ## Behavior From 195f20935948f1ee7049a70aecea7e6605675c45 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:24:10 -0400 Subject: [PATCH 17/20] fix: require an authority for the path-less root form (#2295) `skill:`, `mailto:` and `skill://` also parse to an empty pathname, with an empty host, so an empty path alone accepted authority-less URIs the guard exists to reject. Accept the empty path only when a host is present (Copilot review round 1). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VQ2eszDCUqTmhtTMaznw3W Signed-off-by: cliffhall --- clients/web/src/test/core/mcp/skills.test.ts | 5 +++++ core/mcp/skills.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 2391d389..552b02e0 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -176,6 +176,11 @@ describe("normalizeSkillUri", () => { expect(normalizeSkillUri("skill:demo/SKILL.md")).toBeUndefined(); // Path-less but still opaque: no authority, so it stays rejected. expect(normalizeSkillUri("skill:demo")).toBeUndefined(); + // Empty path AND no authority — the parser reports `pathname === ""` for + // these too, so an empty path alone must not be read as the root form. + expect(normalizeSkillUri("skill:")).toBeUndefined(); + expect(normalizeSkillUri("mailto:")).toBeUndefined(); + expect(normalizeSkillUri("skill://")).toBeUndefined(); }); it("accepts a path-less authority URI — a skill's root directory (#2295)", () => { diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index bb55f9fe..1bbb5175 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -173,7 +173,8 @@ export function isSkillsExtensionSupported( * * A **path-less authority** URI (`skill://data-analysis`) is accepted, and is * not the opaque case above: RFC 3986 gives a URI with an authority an empty - * path, and the parser reports that as `pathname === ""`. It has no segments, + * path, and the parser reports that as `pathname === ""`. The authority has to + * be non-empty — `skill:` parses to an empty path too, with no host. It has no segments, * so nothing survives unnormalized. It is what a skill's root directory looks * like once `/SKILL.md` is removed, so rejecting it made a server echoing that * root back in a directory read read as "outside this skill" when it is the @@ -196,9 +197,10 @@ export function normalizeSkillUri(uri: string): string | undefined { } catch { return undefined; } - if (parsed.pathname !== "" && !parsed.pathname.startsWith("/")) { - return undefined; - } + // An empty path is only the root form when an authority carries the identity: + // `skill:` and `mailto:` also parse to `pathname === ""`, with no host. + const pathlessRoot = parsed.pathname === "" && parsed.host !== ""; + if (!pathlessRoot && !parsed.pathname.startsWith("/")) return undefined; return canonicalizePercentEncoding(parsed.href); } From 467c97307e6a6b258197e1c024ae8ea79c8a53d2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:25:19 -0400 Subject: [PATCH 18/20] fix: charge the fallback SKILL.md read to the catalog byte budget; review round 1 (#2294) - A "dynamic" skill's only read is the fallback SKILL.md fetch, which was not charged, so a catalog of dynamic skills never advanced catalogBytes and a configured byte budget bounded nothing. Charge it like a manifest read. - Add verifySkills cases for a configured byte budget and for dynamic skills. - Wire onSkillCatalogLimitChange into the ServerSettingsForm story. - Reword the new field descriptions, JSDoc and config table rows; rename the route test to cover every invalid value it sends. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe Signed-off-by: cliffhall --- .../ServerSettingsForm.stories.tsx | 7 ++ .../ServerSettingsForm/ServerSettingsForm.tsx | 4 +- .../test/core/mcp/skillsVerification.test.ts | 78 ++++++++++++++++++- .../mcp/remote/server-extra-coverage.test.ts | 2 +- core/mcp/skillsVerification.ts | 8 ++ core/mcp/types.ts | 8 +- docs/mcp-server-configuration.md | 4 +- 7 files changed, 102 insertions(+), 9 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx index c6e6fb69..245feb66 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx @@ -130,6 +130,12 @@ function InteractiveRender(args: ServerSettingsFormProps) { settings: { ...args.settings, maxFetchRequests: value }, }); }} + onSkillCatalogLimitChange={(field, value) => { + args.onSkillCatalogLimitChange(field, value); + updateArgs({ + settings: { ...args.settings, [field]: value }, + }); + }} onProtocolEraChange={(value) => { args.onProtocolEraChange(value); updateArgs({ @@ -178,6 +184,7 @@ const meta: Meta = { onPaginatedListsChange: fn(), onAdvertisedExtensionChange: fn(), onMaxFetchRequestsChange: fn(), + onSkillCatalogLimitChange: fn(), onProtocolEraChange: fn(), onModernLogLevelChange: fn(), onOAuthChange: fn(), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index c9b802c2..2ad4c5ae 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -871,7 +871,7 @@ export function ServerSettingsForm({ { expect(reports[2].incomplete).toMatch(/budget of 2 skills \/ 67108864/); }); + it("honors a configured catalog BYTE budget (#2294)", async () => { + // Each skill's SKILL.md is ~50 bytes, so a 60-byte budget is spent by the + // second skill: the third is where reading stops. + const enc = new TextEncoder(); + const mdFor = (i: number) => + `---\nname: b${i}\ndescription: A demo\n---\n\n# b${i}\n`; + const skills = await Promise.all( + Array.from({ length: 4 }, async (_, i): Promise => { + const bytes = enc.encode(mdFor(i)); + return entry({ + uri: `skill://b${i}/SKILL.md`, + frontmatter: { name: `b${i}`, description: "A demo" }, + resources: [ + { + uri: `skill://b${i}/SKILL.md`, + digest: await sha256Digest(bytes), + size: bytes.byteLength, + }, + ], + }); + }), + ); + const readResource = vi.fn(async (uri: string) => ({ + result: { + contents: [ + { uri, text: mdFor(Number(/b(\d+)/.exec(uri)?.[1] ?? "0")) }, + ], + }, + })); + const client = { + readResource, + getServerSettings: () => ({ skillCatalogMaxBytes: 60 }), + } as unknown as InspectorClientProtocol; + const reports = await verifySkills(client, skills); + expect(readResource.mock.calls.length).toBe(2); + expect(reports.map((r) => r.outcome)).toEqual([ + "verified", + "verified", + "incomplete", + "incomplete", + ]); + expect(reports[2].incomplete).toMatch(/256 skills \/ 60 bytes/); + }); + + it("charges a dynamic skill's SKILL.md read to the byte budget", async () => { + // A `"dynamic"` skill has no manifest, so its only read is the fallback + // SKILL.md fetch. Uncharged, `catalogBytes` stayed 0 and a byte budget of + // 1 read every dynamic skill in the catalog (Copilot). + const mdFor = (i: number) => + `---\nname: d${i}\ndescription: A demo\n---\n\n# d${i}\n`; + const skills: SkillEntry[] = Array.from({ length: 3 }, (_, i) => ({ + uri: `skill://d${i}/SKILL.md`, + frontmatter: { name: `d${i}`, description: "A demo" }, + resources: DYNAMIC_RESOURCES, + })); + const readResource = vi.fn(async (uri: string) => ({ + result: { + contents: [ + { uri, text: mdFor(Number(/d(\d+)/.exec(uri)?.[1] ?? "0")) }, + ], + }, + })); + const client = { + readResource, + getServerSettings: () => ({ skillCatalogMaxBytes: 1 }), + } as unknown as InspectorClientProtocol; + const reports = await verifySkills(client, skills); + expect(readResource.mock.calls.length).toBe(1); + expect(reports[0].outcome).not.toBe("incomplete"); + expect(reports[1].outcome).toBe("incomplete"); + expect(reports[2].outcome).toBe("incomplete"); + }); + it("falls back to the default budget for an unusable configured limit", async () => { const { skill, client } = await truncatable({ name: "fb", count: 2 }); const withSettings = { diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index b30a3fbc..4ebd734b 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -832,7 +832,7 @@ describe("server.ts supplemental coverage", () => { expect((await res.json()).error).toMatch(/maxFetchRequests/); }); - it("rejects a non-positive skills catalog limit (#2294)", async () => { + it("rejects invalid skills catalog limits (#2294)", async () => { const skills = await postSettings({ ...base, skillCatalogMaxSkills: 0 }); expect((await skills.json()).error).toMatch(/skillCatalogMaxSkills/); const bytes = await postSettings({ ...base, skillCatalogMaxBytes: 1.5 }); diff --git a/core/mcp/skillsVerification.ts b/core/mcp/skillsVerification.ts index 91ca25a7..b3392230 100644 --- a/core/mcp/skillsVerification.ts +++ b/core/mcp/skillsVerification.ts @@ -504,6 +504,12 @@ export async function verifySkills( files.push({ uri: entry.uri, status: "read-error", reason }); try { const invocation = await client.readResource(entry.uri, metadata); + // ⚠️ Charged like a manifest read — raw response, or the decoded + // length when larger. Uncharged, a catalog of `"dynamic"` skills (whose + // ONLY read is this one) left `catalogBytes` at zero, so the run's byte + // budget bounded nothing for it and even a configured limit of 1 read + // every skill up to the count limit (Copilot, #2294). + let charged = responseBytes(invocation.result); const contents = contentsFor(invocation.result, entry.uri); if (!contents) { fail( @@ -512,10 +518,12 @@ export async function verifySkills( } else { try { entryBytes = skillFileBytes(contents); + charged = Math.max(charged, entryBytes.byteLength); } catch (err) { fail(reasonOf(err)); } } + receivedBytes += charged; // If the DECLARED manifest lists this file but the read bounds // excluded it, verify it here too. The fallback exists for the // frontmatter check, but reading a file and then not checking the diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 651b85ee..4dcd79c6 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -172,14 +172,16 @@ export type StoredMCPServer = MCPServerConfig & { */ maxFetchRequests?: number; /** - * Most skills one skills verification run reads from (#2294). Omitted on - * disk when it equals `SKILL_MAX_CATALOG_SKILLS`. Must be a positive integer + * The maximum number of skills whose files are read in one skills + * verification run (#2294). Omitted on disk when it equals + * `SKILL_MAX_CATALOG_SKILLS`. Must be a positive integer * — there is no unlimited value, since the bound is what makes `--verify` * terminate. */ skillCatalogMaxSkills?: number; /** - * Byte ceiling across one skills verification run (#2294). Omitted on disk + * The maximum number of bytes read across all skills in one skills + * verification run (#2294). Omitted on disk * when it equals `SKILL_MAX_CATALOG_BYTES`. Positive integer, as above. */ skillCatalogMaxBytes?: number; diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 89c91737..8165c250 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -188,8 +188,8 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | -| `skillCatalogMaxSkills` | `256` | Most skills one skills verification run reads (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value | -| `skillCatalogMaxBytes` | `67108864` | Most bytes one skills verification run reads (`SKILL_MAX_CATALOG_BYTES`, 64 MiB). Positive integer | +| `skillCatalogMaxSkills` | `256` | The maximum number of skills whose files are read in one verification run (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value | +| `skillCatalogMaxBytes` | `67108864` | The maximum number of bytes read across all skills in one verification run (`SKILL_MAX_CATALOG_BYTES`, 64 MiB). Positive integer | | `oauth` | — | `{ clientId, clientSecret, scopes, requestRefreshToken, revokeOnClear, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | `metadata` is a JSON **object**, and its values may be any JSON — an object, an array, a number, a boolean, `null` — not only a string. The MCP spec does not restrict `_meta` value types in general, and the SDK models the field as a passthrough object, so the Inspector does not narrow it either ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). Edit it in Server Settings → Request Metadata, which is a JSON editor rather than the key/value rows `headers` and `env` use; text that is not a JSON object is flagged inline and not applied. From 940c8479a1fbef1403105accd620b3acdc8a02c6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:44:31 -0400 Subject: [PATCH 19/20] fix: stop reading skills once the byte budget is reached, not only exceeded; review round 2 (#2294) The byte side of the catalog budget compared with `<=`, so a run whose charged bytes exactly equalled the limit still read one more skill. Use a strict `<`, matching the skill count and the setting's description. The skill whose reads cross the limit is still reported in full. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe Signed-off-by: cliffhall --- .../test/core/mcp/skillsVerification.test.ts | 44 +++++++++++++++++++ core/mcp/skillsVerification.ts | 8 +++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/mcp/skillsVerification.test.ts b/clients/web/src/test/core/mcp/skillsVerification.test.ts index 2021b717..6ca26d48 100644 --- a/clients/web/src/test/core/mcp/skillsVerification.test.ts +++ b/clients/web/src/test/core/mcp/skillsVerification.test.ts @@ -881,6 +881,50 @@ describe("verifySkills (#2248)", () => { expect(reports[2].incomplete).toMatch(/256 skills \/ 60 bytes/); }); + it("stops reading once the byte budget is exactly REACHED, not only exceeded", async () => { + // Every name is one digit, so every SKILL.md has the same length. With the + // budget set to exactly one file's charge, the first skill spends it and + // the second must not be read (Copilot, #2294). + const enc = new TextEncoder(); + const mdFor = (i: number) => + `---\nname: e${i}\ndescription: A demo\n---\n\n# e${i}\n`; + const oneFile = enc.encode(mdFor(0)).byteLength; + const skills = await Promise.all( + Array.from({ length: 3 }, async (_, i): Promise => { + const bytes = enc.encode(mdFor(i)); + return entry({ + uri: `skill://e${i}/SKILL.md`, + frontmatter: { name: `e${i}`, description: "A demo" }, + resources: [ + { + uri: `skill://e${i}/SKILL.md`, + digest: await sha256Digest(bytes), + size: bytes.byteLength, + }, + ], + }); + }), + ); + const readResource = vi.fn(async (uri: string) => ({ + result: { + contents: [ + { uri, text: mdFor(Number(/e(\d+)/.exec(uri)?.[1] ?? "0")) }, + ], + }, + })); + const client = { + readResource, + getServerSettings: () => ({ skillCatalogMaxBytes: oneFile }), + } as unknown as InspectorClientProtocol; + const reports = await verifySkills(client, skills); + expect(readResource.mock.calls.length).toBe(1); + expect(reports.map((r) => r.outcome)).toEqual([ + "verified", + "incomplete", + "incomplete", + ]); + }); + it("charges a dynamic skill's SKILL.md read to the byte budget", async () => { // A `"dynamic"` skill has no manifest, so its only read is the fallback // SKILL.md fetch. Uncharged, `catalogBytes` stayed 0 and a byte budget of diff --git a/core/mcp/skillsVerification.ts b/core/mcp/skillsVerification.ts index b3392230..a3aa63ff 100644 --- a/core/mcp/skillsVerification.ts +++ b/core/mcp/skillsVerification.ts @@ -335,8 +335,14 @@ export async function verifySkills( // Static checks still run for every entry — they cost no I/O, so a skill // past the budget is still reported on, just not read. What stops is the // reading. + // + // ⚠️ STRICT on bytes, like the count. A run whose charged bytes exactly + // equal the limit has REACHED it, and `<=` read one more skill past a + // budget that was already spent (Copilot, #2294). The skill whose reads + // cross the limit is still reported in full — that is decided by charging + // after the entry, below, not by this comparison. const withinBudget = - walkedSkills < budget.maxSkills && catalogBytes <= budget.maxBytes; + walkedSkills < budget.maxSkills && catalogBytes < budget.maxBytes; // The entry's own SKILL.md, read once and used twice — for its digest and // for the frontmatter cross-check. Reading it twice would double the load // on the server and, worse, could compare a digest against one snapshot From 70cd4f05acdbe946c8dd4eca61c517c042025ea1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 14 Sep 2026 00:59:23 -0400 Subject: [PATCH 20/20] test: discriminate --protocol-era wiring against a modern-only server (#2208) The legacy and auto e2e cases both connect to the legacy stdio fixture whether or not the flag reaches negotiation, since auto falls back. Run initialize against an HTTP test server that rejects the legacy handshake: the default must fail, and --protocol-era modern must connect and report the 2026-07-28 protocol version. Deleting the serverOptions assignment in cli.ts now fails this case (Copilot review, PR #2372). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SZ4rrvH6xaCfrst75gJh7x Signed-off-by: cliffhall --- .../__tests__/programmatic-ergonomics.test.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 1cef354d..8459644e 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect } from "vitest"; -import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { + createEchoTool, + createTestServerHttp, + createTestServerInfo, + getTestMcpServerCommand, +} from "@modelcontextprotocol/inspector-test-server"; import { runCli } from "./helpers/cli-runner.js"; import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js"; -import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import { + MODERN_PROTOCOL_VERSION, + type InspectorServerSettings, +} from "@inspector/core/mcp/types.js"; import { expectCliFailure, expectCliSuccess, @@ -228,6 +236,38 @@ describe("CLI --stored-auth-only wiring", () => { }); describe("--protocol-era", () => { + // The discriminating case: `legacy` and `auto` both connect to a legacy + // fixture whether or not the flag is wired, since `auto` falls back. A + // modern-only server rejects the default legacy handshake, so only a flag + // that actually reaches negotiation turns the failure into a modern session. + it("negotiates the modern era against a modern-only server", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + modern: { legacy: "reject" }, + }); + try { + await server.start(); + const adHoc = [server.url, "--transport", "http"]; + + const withoutFlag = await runCli([...adHoc, "--method", "initialize"]); + expectCliFailure(withoutFlag); + + const withFlag = await runCli([ + ...adHoc, + "--protocol-era", + "modern", + "--method", + "initialize", + ]); + expectCliSuccess(withFlag); + const json = JSON.parse(withFlag.stdout) as { protocolVersion: string }; + expect(json.protocolVersion).toBe(MODERN_PROTOCOL_VERSION); + } finally { + await server.stop(); + } + }); + it.each(["legacy", "auto"])( "accepts %s on an ad-hoc target and connects", async (era) => {