Merge remote-tracking branch 'origin/v2/main' into v2/fix/2295-pathless-skill-uri
This commit is contained in:
commit
bee2b3f463
34 changed files with 3357 additions and 62 deletions
|
|
@ -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 (`<MCP_STORAGE_DIR>/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 (`<MCP_STORAGE_DIR>/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
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ 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: <message>` 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: <message>` 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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
|
||||
|
|
|
|||
|
|
@ -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 <MCP_INSPECTOR_API_TOKEN>`. 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 <MCP_INSPECTOR_API_TOKEN>`. 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
|
||||
|
||||
|
|
|
|||
|
|
@ -714,6 +714,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
|
|||
instructions: undefined,
|
||||
excludedTools: [],
|
||||
malformedListItems: [],
|
||||
connectionDiagnostics: { capturedAt: 0, outstandingRequests: [] },
|
||||
appRendererClient: null,
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Connection Activity")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the idle state: nothing unanswered, nothing received, stream not opened", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
diagnostics={{ capturedAt: NOW, outstandingRequests: [] }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Connection Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText(NO_OUTSTANDING_REQUESTS_LABEL)).toBeInTheDocument();
|
||||
expect(screen.getByText(NO_RESPONSE_YET_LABEL)).toBeInTheDocument();
|
||||
expect(screen.getByText(NO_NOTIFICATION_STREAM_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists each unanswered request on its own line, with the last response and the open stream", () => {
|
||||
const diagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [
|
||||
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
|
||||
{ id: 3, method: "ping", sentAt: NOW - seconds(12) },
|
||||
],
|
||||
lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
|
||||
notificationStream: {
|
||||
url: "http://127.0.0.1:9779/mcp",
|
||||
openedAt: NOW - seconds(252),
|
||||
eventCount: 0,
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
diagnostics={diagnostics}
|
||||
/>,
|
||||
);
|
||||
const outstanding = screen.getByTestId("connection-activity-outstanding");
|
||||
expect(outstanding.children).toHaveLength(2);
|
||||
expect(outstanding.children[0]?.textContent).toBe(
|
||||
"tools/list — sent 1m00s ago",
|
||||
);
|
||||
expect(outstanding.children[1]?.textContent).toBe("ping — sent 12s ago");
|
||||
expect(screen.getByText("initialize — 1m01s ago")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("GET /mcp — open for 4m12s, 0 events delivered"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the stream row as not applicable on stdio", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="stdio"
|
||||
diagnostics={{ capturedAt: NOW, outstandingRequests: [] }}
|
||||
/>,
|
||||
);
|
||||
// Both the Session row and the Notification stream row read N/A (stdio).
|
||||
expect(screen.getAllByText(NO_STREAM_ON_STDIO_LABEL)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,15 @@ 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. 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(
|
||||
clientCapabilities,
|
||||
|
|
@ -348,6 +376,42 @@ export function ConnectionInfoContent({
|
|||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
{diagnostics && (
|
||||
<Stack gap="xs">
|
||||
<SectionHeading>Connection Activity</SectionHeading>
|
||||
<SimpleGrid cols={2}>
|
||||
<FieldLabel>Unanswered requests</FieldLabel>
|
||||
{/* A `Stack` so several in-flight requests read as a list, the
|
||||
way the extension sections do; one line per request. */}
|
||||
<Stack gap={2} data-testid="connection-activity-outstanding">
|
||||
{diagnostics.outstandingRequests.length === 0 ? (
|
||||
<ValueText>{NO_OUTSTANDING_REQUESTS_LABEL}</ValueText>
|
||||
) : (
|
||||
formatOutstandingRequests(diagnostics, now).map(
|
||||
(line, index) => (
|
||||
<ValueText key={diagnostics.outstandingRequests[index]!.id}>
|
||||
{line}
|
||||
</ValueText>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<FieldLabel>Last response</FieldLabel>
|
||||
<ValueText>{formatLastResponse(diagnostics, now)}</ValueText>
|
||||
|
||||
<FieldLabel>Notification stream</FieldLabel>
|
||||
<ValueText>
|
||||
{formatNotificationStream(
|
||||
diagnostics.notificationStream,
|
||||
transport,
|
||||
now,
|
||||
)}
|
||||
</ValueText>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{discoverResult && (
|
||||
<Stack gap="xs">
|
||||
<SectionHeading>Discovery</SectionHeading>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,27 @@ describe("ConnectionInfoModal", () => {
|
|||
expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards connection diagnostics to ConnectionInfoContent (#2318)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoModal
|
||||
opened
|
||||
onClose={vi.fn()}
|
||||
initializeResult={initializeResult}
|
||||
serverInfoReported
|
||||
clientCapabilities={clientCapabilities}
|
||||
transport="streamable-http"
|
||||
diagnostics={{
|
||||
capturedAt: 1_000_000,
|
||||
outstandingRequests: [
|
||||
{ id: 2, method: "tools/list", sentAt: 1_000_000 - 60_000 },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Connection Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("tools/list — sent 1m00s ago")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards oauth details to ConnectionInfoContent", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoModal
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
ProtocolEra,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { ServerType } from "@inspector/core/mcp/types.js";
|
||||
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
|
||||
import {
|
||||
ConnectionInfoContent,
|
||||
type OAuthDetails,
|
||||
|
|
@ -38,6 +39,8 @@ export interface ConnectionInfoModalProps {
|
|||
transport: ServerType;
|
||||
protocolEra?: ProtocolEra;
|
||||
discoverResult?: DiscoverResult;
|
||||
/** Live connection activity for the Connection Activity section (#2318). */
|
||||
diagnostics?: ConnectionDiagnostics;
|
||||
oauth?: OAuthDetails;
|
||||
onClearOAuth?: () => 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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(<NetworkEntry entry={open} isListExpanded={false} />);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Long-lived stream — 3 events delivered, still open; body not captured",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an SSE badge on long-lived GET event-stream entries", () => {
|
||||
const sse: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ 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,
|
||||
longLivedStreamLabel,
|
||||
uncapturedBodyNote,
|
||||
} from "../../../utils/uncapturedBodyNote";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
|
|
@ -158,13 +162,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
|
||||
|
|
@ -217,6 +214,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",
|
||||
|
|
@ -542,7 +550,9 @@ export function NetworkEntry({
|
|||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
|
||||
{isLongLivedStreamEntry(entry) && (
|
||||
<StreamBadge>{longLivedStreamLabel(entry)}</StreamBadge>
|
||||
)}
|
||||
<Badge color={statusColor(entry)} variant="status">
|
||||
{statusLabel(entry)}
|
||||
</Badge>
|
||||
|
|
@ -647,11 +657,7 @@ export function NetworkEntry({
|
|||
label="Response body"
|
||||
/>
|
||||
) : (
|
||||
<DimmedNote>
|
||||
{isLongLivedStream(entry)
|
||||
? "Long-lived stream — body not captured"
|
||||
: "(empty)"}
|
||||
</DimmedNote>
|
||||
<DimmedNote>{uncapturedBodyNote(entry)}</DimmedNote>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
|
|
|||
80
clients/web/src/hooks/useTickingClock.test.ts
Normal file
80
clients/web/src/hooks/useTickingClock.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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("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");
|
||||
const { unmount } = renderHook(() => useTickingClock(0, 250));
|
||||
unmount();
|
||||
expect(clearSpy).toHaveBeenCalled();
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
35
clients/web/src/hooks/useTickingClock.ts
Normal file
35
clients/web/src/hooks/useTickingClock.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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.
|
||||
* `enabled: false` installs no timer at all and returns the seed.
|
||||
*/
|
||||
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, enabled]);
|
||||
return Math.max(now, seed);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
279
clients/web/src/test/core/mcp/connectionDiagnostics.test.ts
Normal file
279
clients/web/src/test/core/mcp/connectionDiagnostics.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
annotateRequestTimeout,
|
||||
describeConnectionDiagnostics,
|
||||
describeStreamEndpoint,
|
||||
formatDuration,
|
||||
isRequestTimeoutError,
|
||||
MAX_LISTED_OUTSTANDING_REQUESTS,
|
||||
type AnnotatedRequestTimeoutData,
|
||||
type ConnectionDiagnostics,
|
||||
} from "@inspector/core/mcp/connectionDiagnostics.js";
|
||||
|
||||
// A fixed clock so every duration in these assertions is exact.
|
||||
const NOW = 1_000_000;
|
||||
const seconds = (n: number) => n * 1000;
|
||||
|
||||
/** The SDK's own per-request timeout, exactly as `Protocol.request` throws it. */
|
||||
function sdkTimeout(timeout = 60_000): SdkError {
|
||||
return new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("renders sub-second values in milliseconds", () => {
|
||||
expect(formatDuration(0)).toBe("0ms");
|
||||
expect(formatDuration(850)).toBe("850ms");
|
||||
expect(formatDuration(999.6)).toBe("1000ms");
|
||||
});
|
||||
|
||||
it("renders seconds, minutes and hours at increasing granularity", () => {
|
||||
expect(formatDuration(seconds(1))).toBe("1s");
|
||||
expect(formatDuration(seconds(59))).toBe("59s");
|
||||
expect(formatDuration(seconds(60))).toBe("1m00s");
|
||||
expect(formatDuration(seconds(252))).toBe("4m12s");
|
||||
expect(formatDuration(seconds(3600))).toBe("1h00m");
|
||||
expect(formatDuration(seconds(3600 + 120 + 5))).toBe("1h02m");
|
||||
});
|
||||
|
||||
it("clamps a negative value to zero rather than rendering nonsense", () => {
|
||||
// A clock skew between two timestamps must not print `-3s`.
|
||||
expect(formatDuration(-3000)).toBe("0ms");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeStreamEndpoint", () => {
|
||||
it("reduces a stream URL to its method and path", () => {
|
||||
expect(describeStreamEndpoint("https://host.example:8443/mcp?x=1")).toBe(
|
||||
"GET /mcp",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the raw string for an unparseable URL", () => {
|
||||
expect(describeStreamEndpoint("not a url")).toBe("GET not a url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeConnectionDiagnostics", () => {
|
||||
it("says so when nothing is outstanding and nothing has been received", () => {
|
||||
expect(
|
||||
describeConnectionDiagnostics({
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [],
|
||||
}),
|
||||
).toBe(
|
||||
"No requests are unanswered. No response has been received this session.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names each unanswered request with how long ago it went out", () => {
|
||||
const diagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [
|
||||
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
|
||||
{ id: 3, method: "ping", sentAt: NOW - seconds(12) },
|
||||
],
|
||||
lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
|
||||
};
|
||||
expect(describeConnectionDiagnostics(diagnostics)).toBe(
|
||||
"2 requests are unanswered: tools/list (sent 1m00s ago), ping (sent 12s ago). " +
|
||||
"Last response received 1m01s ago (initialize).",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the singular for exactly one unanswered request", () => {
|
||||
const diagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [
|
||||
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
|
||||
],
|
||||
};
|
||||
expect(describeConnectionDiagnostics(diagnostics)).toContain(
|
||||
"1 request is unanswered: tools/list (sent 1m00s ago).",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses the tail of a long outstanding list into a count", () => {
|
||||
const outstandingRequests = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: i,
|
||||
method: `m${i}`,
|
||||
sentAt: NOW - seconds(i + 1),
|
||||
}));
|
||||
const text = describeConnectionDiagnostics({
|
||||
capturedAt: NOW,
|
||||
outstandingRequests,
|
||||
});
|
||||
expect(text).toContain("8 requests are unanswered: ");
|
||||
for (let i = 0; i < MAX_LISTED_OUTSTANDING_REQUESTS; i++) {
|
||||
expect(text).toContain(`m${i} (sent`);
|
||||
}
|
||||
expect(text).not.toContain("m5 (sent");
|
||||
expect(text).toContain(
|
||||
`, and ${8 - MAX_LISTED_OUTSTANDING_REQUESTS} more.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("describes an open notification stream by its endpoint, age and event count", () => {
|
||||
const diagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [],
|
||||
notificationStream: {
|
||||
url: "http://127.0.0.1:9779/mcp",
|
||||
openedAt: NOW - seconds(252),
|
||||
eventCount: 0,
|
||||
},
|
||||
};
|
||||
expect(describeConnectionDiagnostics(diagnostics)).toContain(
|
||||
"Notification stream (GET /mcp) open for 4m12s, 0 events delivered.",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes a closed notification stream by when it closed and how long it lasted", () => {
|
||||
const diagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [],
|
||||
notificationStream: {
|
||||
url: "http://127.0.0.1:9779/mcp",
|
||||
openedAt: NOW - seconds(185),
|
||||
eventCount: 1,
|
||||
closedAt: NOW - seconds(5),
|
||||
},
|
||||
};
|
||||
expect(describeConnectionDiagnostics(diagnostics)).toContain(
|
||||
"Notification stream (GET /mcp) closed 5s ago after 3m00s, 1 event delivered.",
|
||||
);
|
||||
});
|
||||
|
||||
it("measures every duration against the snapshot's own clock, not the wall clock", () => {
|
||||
// A snapshot taken long ago still reads the way it read when it was
|
||||
// taken — that is what makes a timeout message and a later render of the
|
||||
// same snapshot agree.
|
||||
const diagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [
|
||||
{ id: 1, method: "tools/list", sentAt: NOW - seconds(5) },
|
||||
],
|
||||
};
|
||||
expect(describeConnectionDiagnostics(diagnostics)).toBe(
|
||||
"1 request is unanswered: tools/list (sent 5s ago). " +
|
||||
"No response has been received this session.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRequestTimeoutError", () => {
|
||||
it("recognises the SDK's per-request timeout", () => {
|
||||
expect(isRequestTimeoutError(sdkTimeout())).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects the other RequestTimeout-coded rejections, which are not timeouts", () => {
|
||||
// An abort through `options.signal` with a non-SdkError reason: the SDK
|
||||
// wraps it in the same code with no `{ timeout }` data.
|
||||
expect(
|
||||
isRequestTimeoutError(
|
||||
new SdkError(SdkErrorCode.RequestTimeout, "AbortError: cancelled"),
|
||||
),
|
||||
).toBe(false);
|
||||
// The max-total-timeout variant carries different data.
|
||||
expect(
|
||||
isRequestTimeoutError(
|
||||
new SdkError(
|
||||
SdkErrorCode.RequestTimeout,
|
||||
"Maximum total timeout exceeded",
|
||||
{ maxTotalTimeout: 1000, totalElapsed: 1200 },
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
// A non-numeric `timeout` is not the SDK's shape either.
|
||||
expect(
|
||||
isRequestTimeoutError(
|
||||
new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
|
||||
timeout: "60s",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects other SdkErrors, plain errors and non-errors", () => {
|
||||
expect(
|
||||
isRequestTimeoutError(
|
||||
new SdkError(SdkErrorCode.InvalidResult, "bad", { timeout: 1 }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isRequestTimeoutError(new Error("Request timed out"))).toBe(false);
|
||||
expect(isRequestTimeoutError("Request timed out")).toBe(false);
|
||||
expect(isRequestTimeoutError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("annotateRequestTimeout", () => {
|
||||
const diagnostics: ConnectionDiagnostics = {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [
|
||||
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
|
||||
],
|
||||
lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
|
||||
notificationStream: {
|
||||
url: "http://127.0.0.1:9779/mcp",
|
||||
openedAt: NOW - seconds(62),
|
||||
eventCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
it("rewrites the message to name the request, its budget and the connection state", () => {
|
||||
const annotated = annotateRequestTimeout(
|
||||
sdkTimeout(60_000),
|
||||
"tools/list",
|
||||
diagnostics,
|
||||
);
|
||||
expect(SdkError.isInstance(annotated)).toBe(true);
|
||||
expect((annotated as SdkError).message).toBe(
|
||||
"Request timed out after 1m00s (tools/list). " +
|
||||
"1 request is unanswered: tools/list (sent 1m00s ago). " +
|
||||
"Last response received 1m01s ago (initialize). " +
|
||||
"Notification stream (GET /mcp) open for 1m02s, 0 events delivered.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the code, carries the method and snapshot in data, and links the original as cause", () => {
|
||||
const original = sdkTimeout(300);
|
||||
const annotated = annotateRequestTimeout(
|
||||
original,
|
||||
"ping",
|
||||
diagnostics,
|
||||
) as SdkError;
|
||||
expect(annotated).not.toBe(original);
|
||||
expect(annotated.code).toBe(SdkErrorCode.RequestTimeout);
|
||||
expect(annotated.cause).toBe(original);
|
||||
const data = annotated.data as AnnotatedRequestTimeoutData;
|
||||
expect(data.timeout).toBe(300);
|
||||
expect(data.method).toBe("ping");
|
||||
expect(data.diagnostics).toBe(diagnostics);
|
||||
// The rebuilt error still satisfies the predicate, so a consumer that
|
||||
// checks before and after this runs sees the same answer.
|
||||
expect(isRequestTimeoutError(annotated)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns anything that is not the per-request timeout untouched", () => {
|
||||
const plain = new Error("boom");
|
||||
expect(annotateRequestTimeout(plain, "ping", diagnostics)).toBe(plain);
|
||||
const abort = new SdkError(SdkErrorCode.RequestTimeout, "cancelled");
|
||||
expect(annotateRequestTimeout(abort, "ping", diagnostics)).toBe(abort);
|
||||
expect(annotateRequestTimeout(undefined, "ping", diagnostics)).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads cleanly when nothing else is known about the connection", () => {
|
||||
const annotated = annotateRequestTimeout(sdkTimeout(1000), "ping", {
|
||||
capturedAt: NOW,
|
||||
outstandingRequests: [],
|
||||
}) as SdkError;
|
||||
expect(annotated.message).toBe(
|
||||
"Request timed out after 1s (ping). No requests are unanswered. " +
|
||||
"No response has been received this session.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,389 @@ 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<Uint8Array>;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
controller = c;
|
||||
},
|
||||
});
|
||||
return {
|
||||
stream,
|
||||
push: (text: string) => controller.enqueue(encoder.encode(text)),
|
||||
end: () => controller.close(),
|
||||
fail: (err: Error) => controller.error(err),
|
||||
};
|
||||
}
|
||||
|
||||
/** Track a GET whose response is the given stream, collecting stream updates. */
|
||||
async function trackStream(stream: ReadableStream<Uint8Array>) {
|
||||
const updates: Array<{ id: string; stream: FetchStreamState }> = [];
|
||||
const tracked: FetchRequestEntryBase[] = [];
|
||||
const fetcher = createFetchTracker(
|
||||
(async () =>
|
||||
new Response(stream, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})) as typeof fetch,
|
||||
{
|
||||
trackRequest: (entry) => tracked.push(entry),
|
||||
updateStream: (id, state) => updates.push({ id, stream: state }),
|
||||
streamUpdateIntervalMs: 0,
|
||||
},
|
||||
);
|
||||
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("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 }),
|
||||
streamUpdateIntervalMs: 0,
|
||||
},
|
||||
);
|
||||
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("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 }),
|
||||
streamUpdateIntervalMs: 0,
|
||||
},
|
||||
);
|
||||
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 }),
|
||||
streamUpdateIntervalMs: 0,
|
||||
},
|
||||
);
|
||||
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("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("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.
|
||||
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);
|
||||
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 }),
|
||||
streamUpdateIntervalMs: 0,
|
||||
},
|
||||
);
|
||||
// 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 }),
|
||||
streamUpdateIntervalMs: 0,
|
||||
});
|
||||
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 }),
|
||||
streamUpdateIntervalMs: 0,
|
||||
});
|
||||
await fetcher("https://example.com/mcp", { method: "GET" });
|
||||
await flush();
|
||||
expect(updates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
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 } },
|
||||
);
|
||||
// `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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue