Merge remote-tracking branch 'origin/v2/main' into v2/fix/2297-401-response-body

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	core/mcp/node/transport.ts
This commit is contained in:
cliffhall 2026-09-14 01:47:25 -04:00
commit 860b8d27ad
43 changed files with 4402 additions and 100 deletions

View file

@ -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),

View file

@ -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

View file

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

View file

@ -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);
});
});

View file

@ -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>

View file

@ -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

View file

@ -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}
/>

View file

@ -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 },
};

View file

@ -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,

View file

@ -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>
)}

View file

@ -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<typeof ServerSettingsForm> = {
onPaginatedListsChange: fn(),
onAdvertisedExtensionChange: fn(),
onMaxFetchRequestsChange: fn(),
onSkillCatalogLimitChange: fn(),
onProtocolEraChange: fn(),
onModernLogLevelChange: fn(),
onOAuthChange: fn(),

View file

@ -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(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["skills"]}
/>,
);
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(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
skillCatalogMaxSkills: 10,
skillCatalogMaxBytes: 2048,
}}
expandedSections={["skills"]}
/>,
);
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(
<ServerSettingsForm
{...baseHandlers}
onSkillCatalogLimitChange={onSkillCatalogLimitChange}
settings={{
...emptySettings,
skillCatalogMaxSkills: 1,
skillCatalogMaxBytes: 1,
}}
expandedSections={["skills"]}
/>,
);
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(
<ServerSettingsForm
{...baseHandlers}
onSkillCatalogLimitChange={onSkillCatalogLimitChange}
settings={{ ...emptySettings, skillCatalogMaxBytes: 4096 }}
expandedSections={["skills"]}
/>,
);
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(

View file

@ -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({
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="skills">
<Accordion.Control>Skills</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<SkillCountInput
label="Maximum Number of Skills"
description="The maximum number of skills whose files are read in one verification run (the CLI's --verify, or the TUI's Skills pane). Skills past the limit are still listed, and reported as incomplete rather than checked."
value={skillCatalogBudget.maxSkills}
onChange={handleSkillCatalogLimitChange(
"skillCatalogMaxSkills",
skillCatalogBudget.maxSkills,
)}
/>
<SkillBytesInput
label="Maximum Catalog Size"
description="The maximum number of bytes read across all skills in one verification run. Once it is reached, no further skills are read, and the remaining ones are reported as incomplete."
value={skillCatalogBudget.maxBytes}
onChange={handleSkillCatalogLimitChange(
"skillCatalogMaxBytes",
skillCatalogBudget.maxBytes,
)}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="roots">
<Accordion.Control>Roots</Accordion.Control>
<Accordion.Panel>

View file

@ -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(
<ServerSettingsModal
opened
settings={{ ...emptySettings, skillCatalogMaxSkills: 3 }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
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();

View file

@ -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}

View file

@ -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

View 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();
});
});

View 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);
}

View file

@ -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();

View file

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

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