Merge remote-tracking branch 'origin/v2/main' into v2/feat/1862-sandbox-public-url

This commit is contained in:
cliffhall 2026-09-14 01:53:22 -04:00
commit 3c6f12dc6c
50 changed files with 4279 additions and 96 deletions

View file

@ -102,7 +102,7 @@ Because undici's `Response` is a different class from `globalThis.Response`, the
### MCP server (which server to connect to)
Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target.
Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc run negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target.
### CLI-specific (what to invoke)
@ -391,7 +391,12 @@ not read has not been cleared of anything:
| Per skill, on the wire | 16 MiB actually served | The declared sizes are server-controlled; this one cannot be lied past. |
| Per run | 256 skills / 64 MiB | SEP-2640 bounds a skill and deliberately does not bound a *catalog*. Every entry costs at least one `resources/read`, so without this a large listing — hostile or merely big — is unbounded work against the tool inspecting it. |
The run bound is this tool's, not the spec's. A skill past it is still reported,
The run bound is this tool's, not the spec's, and it is configurable per server:
set `skillCatalogMaxSkills` / `skillCatalogMaxBytes` on the server's entry in
`mcp.json`, or edit them under **Skills** in the web client's Server Settings
(see [the configuration reference](../../docs/mcp-server-configuration.md)).
Both must be positive integers — there is no unlimited value, since the bound
is what makes the run terminate. A skill past it is still reported,
with its static conformance findings and an `incomplete` reason saying nothing
about its files was checked; verify it on its own with `--method skills/get
--uri <skill>` to get a verdict for it.

View file

@ -1,8 +1,16 @@
import { describe, it, expect } from "vitest";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import {
createEchoTool,
createTestServerHttp,
createTestServerInfo,
getTestMcpServerCommand,
} from "@modelcontextprotocol/inspector-test-server";
import { runCli } from "./helpers/cli-runner.js";
import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import {
MODERN_PROTOCOL_VERSION,
type InspectorServerSettings,
} from "@inspector/core/mcp/types.js";
import {
expectCliFailure,
expectCliSuccess,
@ -227,6 +235,70 @@ describe("CLI --stored-auth-only wiring", () => {
});
});
describe("--protocol-era", () => {
// The discriminating case: `legacy` and `auto` both connect to a legacy
// fixture whether or not the flag is wired, since `auto` falls back. A
// modern-only server rejects the default legacy handshake, so only a flag
// that actually reaches negotiation turns the failure into a modern session.
it("negotiates the modern era against a modern-only server", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
modern: { legacy: "reject" },
});
try {
await server.start();
const adHoc = [server.url, "--transport", "http"];
const withoutFlag = await runCli([...adHoc, "--method", "initialize"]);
expectCliFailure(withoutFlag);
const withFlag = await runCli([
...adHoc,
"--protocol-era",
"modern",
"--method",
"initialize",
]);
expectCliSuccess(withFlag);
const json = JSON.parse(withFlag.stdout) as { protocolVersion: string };
expect(json.protocolVersion).toBe(MODERN_PROTOCOL_VERSION);
} finally {
await server.stop();
}
});
it.each(["legacy", "auto"])(
"accepts %s on an ad-hoc target and connects",
async (era) => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--protocol-era",
era,
"--method",
"tools/list",
]);
expectCliSuccess(result);
},
);
it("rejects an unknown era before connecting", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--protocol-era",
"future",
"--method",
"tools/list",
]);
expectCliFailure(result);
expect(result.stderr).toContain("Invalid protocol era: future");
});
});
describe("MCP_CATALOG_PATH with an ad-hoc target", () => {
it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => {
const { command, args } = getTestMcpServerCommand();

View file

@ -5,6 +5,7 @@ import type {
InspectorServerSettings,
MCPServerConfig,
InspectorClientEnvironment,
ServerProtocolEra,
} from "@inspector/core/mcp/types.js";
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
import {
@ -23,6 +24,7 @@ import {
selectServerEntry,
parseKeyValuePair as parseEnvPair,
parseHeaderPair,
parseProtocolEra,
} from "@inspector/core/mcp/node/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js";
@ -773,6 +775,11 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
return n;
},
)
.option(
"--protocol-era <era>",
"Protocol era to negotiate: legacy, auto, or modern. Overrides the file-level protocolEra for --catalog/--config runs; ad-hoc --server-url / target runs otherwise default to legacy.",
parseProtocolEra,
)
.option(
"--format <format>",
"Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).",
@ -870,6 +877,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
verify?: boolean;
cursor?: string;
connectTimeout?: number;
protocolEra?: ServerProtocolEra;
format?: OutputFormat;
toolArgsJson?: string;
clientConfig?: string;
@ -1017,6 +1025,9 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
// `--header` is merged into the resolved server's settings (overriding any
// file-level headers); file timeouts/OAuth are preserved. See #1482.
headers: options.header as Record<string, string> | undefined,
// `--protocol-era` feeds `settings.protocolEra` the same way, so an ad-hoc
// launch can pick a non-legacy era without an mcp.json entry (#2208).
protocolEra: options.protocolEra,
};
// Catalog list / show — no MCP connection. Run before stored-auth refresh so

View file

@ -25,8 +25,11 @@ editable (see [specification/v2_catalog_launch_config.md](../../specification/v2
| `mcp-inspector --web --server-url <url> --transport http --header "Name: Value"` (or a positional command) | One ad-hoc server held in memory, connectable with the given `--header`s | No |
Rules: `--catalog` and `--config` are mutually exclusive; neither combines with
an ad-hoc target or `--header`; `--header` requires an ad-hoc HTTP/SSE server
and is applied to that connection (it is no longer a warn-only no-op).
an ad-hoc target, `--header` or `--protocol-era`; `--header` requires an ad-hoc
HTTP/SSE server and is applied to that connection (it is no longer a warn-only
no-op). `--protocol-era <legacy|auto|modern>` requires an ad-hoc server of any
transport and sets the era that connection negotiates, so a non-legacy era
needs no `mcp.json` entry.
**Seed contents are web-specific.** When the web backend creates a missing
writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a

View file

@ -29,7 +29,7 @@ npx @modelcontextprotocol/inspector --tui --config mcp.json # read-only sessi
### MCP server (which server(s) to connect to)
Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target.
Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded **empty** if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `-e`, `--cwd`, `--header`, `--protocol-era` (`legacy`/`auto`/`modern`; sets the era an ad-hoc server negotiates, or overrides a file's `protocolEra`), `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target.
### TUI-specific (OAuth for HTTP servers)

View file

@ -5,7 +5,9 @@ import { render } from "ink";
import {
parseKeyValuePair,
parseHeaderPair,
parseProtocolEra,
} from "@inspector/core/mcp/node/index.js";
import type { ServerProtocolEra } from "@inspector/core/mcp/types.js";
import { loadRunnerClientConfig } from "@inspector/core/client/runner.js";
import {
parseRunnerOAuthCallbackUrl,
@ -41,6 +43,11 @@ export async function runTui(args?: string[]): Promise<void> {
parseHeaderPair,
{},
)
.option(
"--protocol-era <era>",
"Protocol era to negotiate: legacy, auto, or modern (overrides the file's protocolEra; default legacy)",
parseProtocolEra,
)
.option(
"--client-id <id>",
"OAuth client ID (static client) for HTTP servers",
@ -78,6 +85,7 @@ export async function runTui(args?: string[]): Promise<void> {
e?: Record<string, string>;
cwd?: string;
header?: Record<string, string>;
protocolEra?: ServerProtocolEra;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
@ -95,6 +103,7 @@ export async function runTui(args?: string[]): Promise<void> {
cwd: options.cwd?.trim() || undefined,
env: options.e,
headers: options.header,
protocolEra: options.protocolEra,
transport: options.transport,
serverUrl: options.serverUrl?.trim() || undefined,
};

View file

@ -10,12 +10,14 @@ import { Command } from "commander";
import type {
MCPConfig,
MCPServerConfig,
ServerProtocolEra,
StoredMCPServer,
} from "../../../core/mcp/types.ts";
import {
resolveServerConfigs,
parseKeyValuePair,
parseHeaderPair,
parseProtocolEra,
type ServerConfigOptions,
} from "../../../core/mcp/node/config.ts";
import {
@ -89,6 +91,11 @@ export async function runWeb(argv: string[]): Promise<number> {
parseHeaderPair,
{},
)
.option(
"--protocol-era <era>",
"protocol era for the ad-hoc server: legacy, auto, or modern (default legacy)",
parseProtocolEra,
)
.option("--dev", "run in development mode (Vite)")
.parse(preArgs);
@ -101,6 +108,7 @@ export async function runWeb(argv: string[]): Promise<number> {
serverUrl?: string;
cwd?: string;
header?: Record<string, string>;
protocolEra?: ServerProtocolEra;
dev?: boolean;
};
@ -124,6 +132,10 @@ export async function runWeb(argv: string[]): Promise<number> {
const hasCatalog = !!catalogPath;
const hasConfig = !!opts.config;
const hasHeaders = !!opts.header && Object.keys(opts.header).length > 0;
// Like `--header`, web applies `--protocol-era` only to an ad-hoc server: a
// catalog/config entry carries its own `protocolEra`, editable in Server
// Settings, and web never overlays launch flags onto a file's settings.
const hasProtocolEra = opts.protocolEra !== undefined;
const hasAdHocServer =
target.length > 0 ||
!!opts.serverUrl ||
@ -137,11 +149,15 @@ export async function runWeb(argv: string[]): Promise<number> {
? "--catalog cannot be combined with an ad-hoc server URL/command."
: hasCatalog && hasHeaders
? "--header cannot be combined with --catalog. Set per-server headers in the catalog file."
: hasConfig && hasAdHocServer
? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file."
: hasConfig && hasHeaders
? "--header cannot be combined with --config. Set per-server headers inside the config file instead."
: null;
: hasCatalog && hasProtocolEra
? "--protocol-era cannot be combined with --catalog. Set protocolEra per server in the catalog file."
: hasConfig && hasAdHocServer
? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file."
: hasConfig && hasHeaders
? "--header cannot be combined with --config. Set per-server headers inside the config file instead."
: hasConfig && hasProtocolEra
? "--protocol-era cannot be combined with --config. Set protocolEra per server inside the config file instead."
: null;
if (conflict) {
console.error(`Error: ${conflict}`);
process.exit(1);
@ -217,13 +233,18 @@ export async function runWeb(argv: string[]): Promise<number> {
initialMcpConfig = config;
// In-memory session entry: the SDK config plus the flat `headers` record,
// which the backend lifts into `settings.headers` on read. Nothing is
// written to disk — the ad-hoc server is a read-only session list.
// In-memory session entry: the SDK config plus the flat `headers` record
// and `protocolEra`, which the backend lifts into `settings` on read.
// Nothing is written to disk — the ad-hoc server is a read-only session
// list. The era is transport-agnostic, so unlike `--header` it applies to
// stdio too.
const entry: StoredMCPServer =
hasHeaders && config.type !== "stdio" && config.type !== undefined
? { ...config, headers: opts.header }
: { ...config };
if (opts.protocolEra !== undefined) {
entry.protocolEra = opts.protocolEra;
}
initialServers = {
mcpServers: { [deriveSeedServerId(config)]: entry },
};
@ -233,6 +254,11 @@ export async function runWeb(argv: string[]): Promise<number> {
"Error: --header requires an ad-hoc --server-url or command target.",
);
process.exit(1);
} else if (hasProtocolEra) {
console.error(
"Error: --protocol-era requires an ad-hoc --server-url or command target.",
);
process.exit(1);
}
let webConfig: WebServerConfig;

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>

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