Merge remote-tracking branch 'origin/v2/main' into v2/feat/1862-sandbox-public-url
This commit is contained in:
commit
3c6f12dc6c
74 changed files with 4279 additions and 96 deletions
|
|
@ -102,7 +102,7 @@ Because undici's `Response` is a different class from `globalThis.Response`, the
|
||||||
|
|
||||||
### MCP server (which server to connect to)
|
### 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)
|
### 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 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. |
|
| 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
|
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
|
about its files was checked; verify it on its own with `--method skills/get
|
||||||
--uri <skill>` to get a verdict for it.
|
--uri <skill>` to get a verdict for it.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { describe, it, expect } from "vitest";
|
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 { runCli } from "./helpers/cli-runner.js";
|
||||||
import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.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 {
|
import {
|
||||||
expectCliFailure,
|
expectCliFailure,
|
||||||
expectCliSuccess,
|
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", () => {
|
describe("MCP_CATALOG_PATH with an ad-hoc target", () => {
|
||||||
it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => {
|
it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => {
|
||||||
const { command, args } = getTestMcpServerCommand();
|
const { command, args } = getTestMcpServerCommand();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
InspectorServerSettings,
|
InspectorServerSettings,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
InspectorClientEnvironment,
|
InspectorClientEnvironment,
|
||||||
|
ServerProtocolEra,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,6 +24,7 @@ import {
|
||||||
selectServerEntry,
|
selectServerEntry,
|
||||||
parseKeyValuePair as parseEnvPair,
|
parseKeyValuePair as parseEnvPair,
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
|
parseProtocolEra,
|
||||||
} from "@inspector/core/mcp/node/index.js";
|
} from "@inspector/core/mcp/node/index.js";
|
||||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||||
import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js";
|
import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js";
|
||||||
|
|
@ -773,6 +775,11 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
||||||
return n;
|
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(
|
.option(
|
||||||
"--format <format>",
|
"--format <format>",
|
||||||
"Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).",
|
"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;
|
verify?: boolean;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
connectTimeout?: number;
|
connectTimeout?: number;
|
||||||
|
protocolEra?: ServerProtocolEra;
|
||||||
format?: OutputFormat;
|
format?: OutputFormat;
|
||||||
toolArgsJson?: string;
|
toolArgsJson?: string;
|
||||||
clientConfig?: 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
|
// `--header` is merged into the resolved server's settings (overriding any
|
||||||
// file-level headers); file timeouts/OAuth are preserved. See #1482.
|
// file-level headers); file timeouts/OAuth are preserved. See #1482.
|
||||||
headers: options.header as Record<string, string> | undefined,
|
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
|
// Catalog list / show — no MCP connection. Run before stored-auth refresh so
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| `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
|
Rules: `--catalog` and `--config` are mutually exclusive; neither combines with
|
||||||
an ad-hoc target or `--header`; `--header` requires an ad-hoc HTTP/SSE server
|
an ad-hoc target, `--header` or `--protocol-era`; `--header` requires an ad-hoc
|
||||||
and is applied to that connection (it is no longer a warn-only no-op).
|
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
|
**Seed contents are web-specific.** When the web backend creates a missing
|
||||||
writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a
|
writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ npx @modelcontextprotocol/inspector --tui --config mcp.json # read-only sessi
|
||||||
|
|
||||||
### MCP server (which server(s) to connect to)
|
### 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)
|
### TUI-specific (OAuth for HTTP servers)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import { render } from "ink";
|
||||||
import {
|
import {
|
||||||
parseKeyValuePair,
|
parseKeyValuePair,
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
|
parseProtocolEra,
|
||||||
} from "@inspector/core/mcp/node/index.js";
|
} 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 { loadRunnerClientConfig } from "@inspector/core/client/runner.js";
|
||||||
import {
|
import {
|
||||||
parseRunnerOAuthCallbackUrl,
|
parseRunnerOAuthCallbackUrl,
|
||||||
|
|
@ -41,6 +43,11 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--protocol-era <era>",
|
||||||
|
"Protocol era to negotiate: legacy, auto, or modern (overrides the file's protocolEra; default legacy)",
|
||||||
|
parseProtocolEra,
|
||||||
|
)
|
||||||
.option(
|
.option(
|
||||||
"--client-id <id>",
|
"--client-id <id>",
|
||||||
"OAuth client ID (static client) for HTTP servers",
|
"OAuth client ID (static client) for HTTP servers",
|
||||||
|
|
@ -78,6 +85,7 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
e?: Record<string, string>;
|
e?: Record<string, string>;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
header?: Record<string, string>;
|
header?: Record<string, string>;
|
||||||
|
protocolEra?: ServerProtocolEra;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
clientMetadataUrl?: string;
|
clientMetadataUrl?: string;
|
||||||
|
|
@ -95,6 +103,7 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
cwd: options.cwd?.trim() || undefined,
|
cwd: options.cwd?.trim() || undefined,
|
||||||
env: options.e,
|
env: options.e,
|
||||||
headers: options.header,
|
headers: options.header,
|
||||||
|
protocolEra: options.protocolEra,
|
||||||
transport: options.transport,
|
transport: options.transport,
|
||||||
serverUrl: options.serverUrl?.trim() || undefined,
|
serverUrl: options.serverUrl?.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ import { Command } from "commander";
|
||||||
import type {
|
import type {
|
||||||
MCPConfig,
|
MCPConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
ServerProtocolEra,
|
||||||
StoredMCPServer,
|
StoredMCPServer,
|
||||||
} from "../../../core/mcp/types.ts";
|
} from "../../../core/mcp/types.ts";
|
||||||
import {
|
import {
|
||||||
resolveServerConfigs,
|
resolveServerConfigs,
|
||||||
parseKeyValuePair,
|
parseKeyValuePair,
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
|
parseProtocolEra,
|
||||||
type ServerConfigOptions,
|
type ServerConfigOptions,
|
||||||
} from "../../../core/mcp/node/config.ts";
|
} from "../../../core/mcp/node/config.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -89,6 +91,11 @@ export async function runWeb(argv: string[]): Promise<number> {
|
||||||
parseHeaderPair,
|
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)")
|
.option("--dev", "run in development mode (Vite)")
|
||||||
.parse(preArgs);
|
.parse(preArgs);
|
||||||
|
|
||||||
|
|
@ -101,6 +108,7 @@ export async function runWeb(argv: string[]): Promise<number> {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
header?: Record<string, string>;
|
header?: Record<string, string>;
|
||||||
|
protocolEra?: ServerProtocolEra;
|
||||||
dev?: boolean;
|
dev?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -124,6 +132,10 @@ export async function runWeb(argv: string[]): Promise<number> {
|
||||||
const hasCatalog = !!catalogPath;
|
const hasCatalog = !!catalogPath;
|
||||||
const hasConfig = !!opts.config;
|
const hasConfig = !!opts.config;
|
||||||
const hasHeaders = !!opts.header && Object.keys(opts.header).length > 0;
|
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 =
|
const hasAdHocServer =
|
||||||
target.length > 0 ||
|
target.length > 0 ||
|
||||||
!!opts.serverUrl ||
|
!!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."
|
? "--catalog cannot be combined with an ad-hoc server URL/command."
|
||||||
: hasCatalog && hasHeaders
|
: hasCatalog && hasHeaders
|
||||||
? "--header cannot be combined with --catalog. Set per-server headers in the catalog file."
|
? "--header cannot be combined with --catalog. Set per-server headers in the catalog file."
|
||||||
: hasConfig && hasAdHocServer
|
: hasCatalog && hasProtocolEra
|
||||||
? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file."
|
? "--protocol-era cannot be combined with --catalog. Set protocolEra per server in the catalog file."
|
||||||
: hasConfig && hasHeaders
|
: hasConfig && hasAdHocServer
|
||||||
? "--header cannot be combined with --config. Set per-server headers inside the config file instead."
|
? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file."
|
||||||
: null;
|
: 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) {
|
if (conflict) {
|
||||||
console.error(`Error: ${conflict}`);
|
console.error(`Error: ${conflict}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
@ -217,13 +233,18 @@ export async function runWeb(argv: string[]): Promise<number> {
|
||||||
|
|
||||||
initialMcpConfig = config;
|
initialMcpConfig = config;
|
||||||
|
|
||||||
// In-memory session entry: the SDK config plus the flat `headers` record,
|
// In-memory session entry: the SDK config plus the flat `headers` record
|
||||||
// which the backend lifts into `settings.headers` on read. Nothing is
|
// and `protocolEra`, which the backend lifts into `settings` on read.
|
||||||
// written to disk — the ad-hoc server is a read-only session list.
|
// 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 =
|
const entry: StoredMCPServer =
|
||||||
hasHeaders && config.type !== "stdio" && config.type !== undefined
|
hasHeaders && config.type !== "stdio" && config.type !== undefined
|
||||||
? { ...config, headers: opts.header }
|
? { ...config, headers: opts.header }
|
||||||
: { ...config };
|
: { ...config };
|
||||||
|
if (opts.protocolEra !== undefined) {
|
||||||
|
entry.protocolEra = opts.protocolEra;
|
||||||
|
}
|
||||||
initialServers = {
|
initialServers = {
|
||||||
mcpServers: { [deriveSeedServerId(config)]: entry },
|
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.",
|
"Error: --header requires an ad-hoc --server-url or command target.",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
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;
|
let webConfig: WebServerConfig;
|
||||||
|
|
|
||||||
|
|
@ -714,6 +714,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
|
||||||
instructions: undefined,
|
instructions: undefined,
|
||||||
excludedTools: [],
|
excludedTools: [],
|
||||||
malformedListItems: [],
|
malformedListItems: [],
|
||||||
|
connectionDiagnostics: { capturedAt: 0, outstandingRequests: [] },
|
||||||
appRendererClient: null,
|
appRendererClient: null,
|
||||||
connect: vi.fn().mockResolvedValue(undefined),
|
connect: vi.fn().mockResolvedValue(undefined),
|
||||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,7 @@ function App() {
|
||||||
discoverResult,
|
discoverResult,
|
||||||
excludedTools,
|
excludedTools,
|
||||||
malformedListItems,
|
malformedListItems,
|
||||||
|
connectionDiagnostics,
|
||||||
lastError,
|
lastError,
|
||||||
} = useInspectorClient(inspectorClient);
|
} = useInspectorClient(inspectorClient);
|
||||||
// What every settings write that landed on disk actually wrote, so a later
|
// What every settings write that landed on disk actually wrote, so a later
|
||||||
|
|
@ -2028,6 +2029,7 @@ function App() {
|
||||||
transport={connectionInfoTransport}
|
transport={connectionInfoTransport}
|
||||||
protocolEra={protocolEra}
|
protocolEra={protocolEra}
|
||||||
discoverResult={discoverResult}
|
discoverResult={discoverResult}
|
||||||
|
diagnostics={connectionDiagnostics}
|
||||||
oauth={connectionInfoOAuth}
|
oauth={connectionInfoOAuth}
|
||||||
onClearOAuth={
|
onClearOAuth={
|
||||||
connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined
|
connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,51 @@ export const FullCapabilities: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The snapshot clock, read once when the stories load. The rows start from it
|
||||||
|
// and then tick against the wall clock, so the fixture has to be "now" — a
|
||||||
|
// fixed historical date would read as months ago a second after mounting.
|
||||||
|
const STORY_NOW = Date.now();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Connection Activity section, in the #2187 shape: the notification
|
||||||
|
* stream has been open the whole session and delivered nothing, `tools/list`
|
||||||
|
* has been outstanding for a minute, and nothing has been answered since
|
||||||
|
* `initialize`. The same facts a request timeout's message reports, shown
|
||||||
|
* while the request is still in flight (#2318).
|
||||||
|
*/
|
||||||
|
export const WithConnectionActivity: Story = {
|
||||||
|
args: {
|
||||||
|
initializeResult: fullResult,
|
||||||
|
clientCapabilities: fullClientCaps,
|
||||||
|
transport: "streamable-http",
|
||||||
|
diagnostics: {
|
||||||
|
capturedAt: STORY_NOW,
|
||||||
|
outstandingRequests: [
|
||||||
|
{ id: 2, method: "tools/list", sentAt: STORY_NOW - 60_000 },
|
||||||
|
{ id: 3, method: "ping", sentAt: STORY_NOW - 12_000 },
|
||||||
|
],
|
||||||
|
lastResponse: { method: "initialize", receivedAt: STORY_NOW - 61_000 },
|
||||||
|
notificationStream: {
|
||||||
|
url: "http://127.0.0.1:9779/mcp",
|
||||||
|
openedAt: STORY_NOW - 252_000,
|
||||||
|
eventCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByText("Connection Activity")).toBeInTheDocument();
|
||||||
|
// Ticking: the rows may have advanced a second or two since the fixture
|
||||||
|
// was stamped, so match the shape rather than the exact second.
|
||||||
|
await expect(
|
||||||
|
canvas.getByText(/^tools\/list — sent 1m0\ds ago$/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await expect(
|
||||||
|
canvas.getByText(/^GET \/mcp — open for 4m1\ds, 0 events delivered$/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ModernEra: Story = {
|
export const ModernEra: Story = {
|
||||||
args: {
|
args: {
|
||||||
initializeResult: {
|
initializeResult: {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ import type {
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
|
||||||
|
import {
|
||||||
|
NO_NOTIFICATION_STREAM_LABEL,
|
||||||
|
NO_OUTSTANDING_REQUESTS_LABEL,
|
||||||
|
NO_RESPONSE_YET_LABEL,
|
||||||
|
NO_STREAM_ON_STDIO_LABEL,
|
||||||
|
} from "../../../utils/connectionActivity";
|
||||||
import {
|
import {
|
||||||
CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||||
ConnectionInfoContent,
|
ConnectionInfoContent,
|
||||||
|
|
@ -934,3 +941,81 @@ describe("ConnectionInfoContent", () => {
|
||||||
expect(onClearOAuth).toHaveBeenCalledTimes(1);
|
expect(onClearOAuth).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ConnectionInfoContent connection activity (#2318)", () => {
|
||||||
|
const NOW = 1_000_000;
|
||||||
|
const seconds = (n: number) => n * 1000;
|
||||||
|
|
||||||
|
it("omits the section when no diagnostics are supplied", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Connection Activity")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the idle state: nothing unanswered, nothing received, stream not opened", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
diagnostics={{ capturedAt: NOW, outstandingRequests: [] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Connection Activity")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(NO_OUTSTANDING_REQUESTS_LABEL)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(NO_RESPONSE_YET_LABEL)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(NO_NOTIFICATION_STREAM_LABEL)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists each unanswered request on its own line, with the last response and the open stream", () => {
|
||||||
|
const diagnostics: ConnectionDiagnostics = {
|
||||||
|
capturedAt: NOW,
|
||||||
|
outstandingRequests: [
|
||||||
|
{ id: 2, method: "tools/list", sentAt: NOW - seconds(60) },
|
||||||
|
{ id: 3, method: "ping", sentAt: NOW - seconds(12) },
|
||||||
|
],
|
||||||
|
lastResponse: { method: "initialize", receivedAt: NOW - seconds(61) },
|
||||||
|
notificationStream: {
|
||||||
|
url: "http://127.0.0.1:9779/mcp",
|
||||||
|
openedAt: NOW - seconds(252),
|
||||||
|
eventCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
diagnostics={diagnostics}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const outstanding = screen.getByTestId("connection-activity-outstanding");
|
||||||
|
expect(outstanding.children).toHaveLength(2);
|
||||||
|
expect(outstanding.children[0]?.textContent).toBe(
|
||||||
|
"tools/list — sent 1m00s ago",
|
||||||
|
);
|
||||||
|
expect(outstanding.children[1]?.textContent).toBe("ping — sent 12s ago");
|
||||||
|
expect(screen.getByText("initialize — 1m01s ago")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("GET /mcp — open for 4m12s, 0 events delivered"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the stream row as not applicable on stdio", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="stdio"
|
||||||
|
diagnostics={{ capturedAt: NOW, outstandingRequests: [] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Both the Session row and the Notification stream row read N/A (stdio).
|
||||||
|
expect(screen.getAllByText(NO_STREAM_ON_STDIO_LABEL)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ import type {
|
||||||
ServerCapabilities,
|
ServerCapabilities,
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import type { ServerType } from "@inspector/core/mcp/types.js";
|
import type { ServerType } from "@inspector/core/mcp/types.js";
|
||||||
|
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
|
||||||
|
import {
|
||||||
|
formatLastResponse,
|
||||||
|
formatNotificationStream,
|
||||||
|
formatOutstandingRequests,
|
||||||
|
NO_OUTSTANDING_REQUESTS_LABEL,
|
||||||
|
} from "../../../utils/connectionActivity";
|
||||||
|
import { useTickingClock } from "../../../hooks/useTickingClock";
|
||||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||||
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
|
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
|
||||||
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
|
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
|
||||||
|
|
@ -75,6 +83,16 @@ export interface ConnectionInfoContentProps {
|
||||||
* capabilities, and extensions learned up front. Undefined on legacy. (#1626)
|
* capabilities, and extensions learned up front. Undefined on legacy. (#1626)
|
||||||
*/
|
*/
|
||||||
discoverResult?: DiscoverResult;
|
discoverResult?: DiscoverResult;
|
||||||
|
/**
|
||||||
|
* What the client is still waiting on, when it last heard back, and the
|
||||||
|
* state of the notification stream (#2318). Renders the Connection Activity
|
||||||
|
* section when present — the same facts a request timeout reports, shown
|
||||||
|
* here while the request is still in flight. Durations first read against
|
||||||
|
* the snapshot's own `capturedAt` and then tick once a second while the
|
||||||
|
* panel is open (`useTickingClock`), so an in-flight request's age keeps
|
||||||
|
* moving rather than freezing at the moment it went out.
|
||||||
|
*/
|
||||||
|
diagnostics?: ConnectionDiagnostics;
|
||||||
oauth?: OAuthDetails;
|
oauth?: OAuthDetails;
|
||||||
onClearOAuth?: () => void;
|
onClearOAuth?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -291,6 +309,7 @@ export function ConnectionInfoContent({
|
||||||
transport,
|
transport,
|
||||||
protocolEra,
|
protocolEra,
|
||||||
discoverResult,
|
discoverResult,
|
||||||
|
diagnostics,
|
||||||
oauth,
|
oauth,
|
||||||
onClearOAuth,
|
onClearOAuth,
|
||||||
}: ConnectionInfoContentProps) {
|
}: ConnectionInfoContentProps) {
|
||||||
|
|
@ -317,6 +336,15 @@ export function ConnectionInfoContent({
|
||||||
? serverInfo.version?.trim() || "—"
|
? serverInfo.version?.trim() || "—"
|
||||||
: SERVER_INFO_NOT_REPORTED_LABEL;
|
: SERVER_INFO_NOT_REPORTED_LABEL;
|
||||||
|
|
||||||
|
// The activity rows' clock: the snapshot's own on first paint (pure), then
|
||||||
|
// the wall clock once a second so "sent 5s ago" keeps counting. 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 serverCaps = getServerCapabilityEntries(capabilities, protocolEra);
|
||||||
const clientCaps = getCapabilityEntries(
|
const clientCaps = getCapabilityEntries(
|
||||||
clientCapabilities,
|
clientCapabilities,
|
||||||
|
|
@ -348,6 +376,42 @@ export function ConnectionInfoContent({
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{diagnostics && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<SectionHeading>Connection Activity</SectionHeading>
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<FieldLabel>Unanswered requests</FieldLabel>
|
||||||
|
{/* A `Stack` so several in-flight requests read as a list, the
|
||||||
|
way the extension sections do; one line per request. */}
|
||||||
|
<Stack gap={2} data-testid="connection-activity-outstanding">
|
||||||
|
{diagnostics.outstandingRequests.length === 0 ? (
|
||||||
|
<ValueText>{NO_OUTSTANDING_REQUESTS_LABEL}</ValueText>
|
||||||
|
) : (
|
||||||
|
formatOutstandingRequests(diagnostics, now).map(
|
||||||
|
(line, index) => (
|
||||||
|
<ValueText key={diagnostics.outstandingRequests[index]!.id}>
|
||||||
|
{line}
|
||||||
|
</ValueText>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<FieldLabel>Last response</FieldLabel>
|
||||||
|
<ValueText>{formatLastResponse(diagnostics, now)}</ValueText>
|
||||||
|
|
||||||
|
<FieldLabel>Notification stream</FieldLabel>
|
||||||
|
<ValueText>
|
||||||
|
{formatNotificationStream(
|
||||||
|
diagnostics.notificationStream,
|
||||||
|
transport,
|
||||||
|
now,
|
||||||
|
)}
|
||||||
|
</ValueText>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{discoverResult && (
|
{discoverResult && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<SectionHeading>Discovery</SectionHeading>
|
<SectionHeading>Discovery</SectionHeading>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,27 @@ describe("ConnectionInfoModal", () => {
|
||||||
expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument();
|
expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards connection diagnostics to ConnectionInfoContent (#2318)", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoModal
|
||||||
|
opened
|
||||||
|
onClose={vi.fn()}
|
||||||
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported
|
||||||
|
clientCapabilities={clientCapabilities}
|
||||||
|
transport="streamable-http"
|
||||||
|
diagnostics={{
|
||||||
|
capturedAt: 1_000_000,
|
||||||
|
outstandingRequests: [
|
||||||
|
{ id: 2, method: "tools/list", sentAt: 1_000_000 - 60_000 },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Connection Activity")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("tools/list — sent 1m00s ago")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards oauth details to ConnectionInfoContent", () => {
|
it("forwards oauth details to ConnectionInfoContent", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ConnectionInfoModal
|
<ConnectionInfoModal
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
ProtocolEra,
|
ProtocolEra,
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import type { ServerType } from "@inspector/core/mcp/types.js";
|
import type { ServerType } from "@inspector/core/mcp/types.js";
|
||||||
|
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
|
||||||
import {
|
import {
|
||||||
ConnectionInfoContent,
|
ConnectionInfoContent,
|
||||||
type OAuthDetails,
|
type OAuthDetails,
|
||||||
|
|
@ -38,6 +39,8 @@ export interface ConnectionInfoModalProps {
|
||||||
transport: ServerType;
|
transport: ServerType;
|
||||||
protocolEra?: ProtocolEra;
|
protocolEra?: ProtocolEra;
|
||||||
discoverResult?: DiscoverResult;
|
discoverResult?: DiscoverResult;
|
||||||
|
/** Live connection activity for the Connection Activity section (#2318). */
|
||||||
|
diagnostics?: ConnectionDiagnostics;
|
||||||
oauth?: OAuthDetails;
|
oauth?: OAuthDetails;
|
||||||
onClearOAuth?: () => void;
|
onClearOAuth?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +54,7 @@ export function ConnectionInfoModal({
|
||||||
transport,
|
transport,
|
||||||
protocolEra,
|
protocolEra,
|
||||||
discoverResult,
|
discoverResult,
|
||||||
|
diagnostics,
|
||||||
oauth,
|
oauth,
|
||||||
onClearOAuth,
|
onClearOAuth,
|
||||||
}: ConnectionInfoModalProps) {
|
}: ConnectionInfoModalProps) {
|
||||||
|
|
@ -79,6 +83,7 @@ export function ConnectionInfoModal({
|
||||||
transport={transport}
|
transport={transport}
|
||||||
protocolEra={protocolEra}
|
protocolEra={protocolEra}
|
||||||
discoverResult={discoverResult}
|
discoverResult={discoverResult}
|
||||||
|
diagnostics={diagnostics}
|
||||||
oauth={oauth}
|
oauth={oauth}
|
||||||
onClearOAuth={onClearOAuth}
|
onClearOAuth={onClearOAuth}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,31 @@ const streamingEntry: FetchRequestEntry = {
|
||||||
category: "transport",
|
category: "transport",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The standalone `GET /mcp` notification stream, still open, as the tracker
|
||||||
|
* watches it: the body is never captured, so the placeholder reports what the
|
||||||
|
* stream has delivered instead (#2318).
|
||||||
|
*/
|
||||||
|
const notificationStreamEntry: FetchRequestEntry = {
|
||||||
|
id: "n-notify",
|
||||||
|
timestamp: new Date("2026-03-17T10:30:01Z"),
|
||||||
|
method: "GET",
|
||||||
|
url: "http://localhost:3000/mcp",
|
||||||
|
requestHeaders: {
|
||||||
|
accept: "text/event-stream",
|
||||||
|
"mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb",
|
||||||
|
},
|
||||||
|
responseStatus: 200,
|
||||||
|
responseStatusText: "OK",
|
||||||
|
responseHeaders: {
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
},
|
||||||
|
duration: 12,
|
||||||
|
stream: { eventCount: 0 },
|
||||||
|
category: "transport",
|
||||||
|
};
|
||||||
|
|
||||||
const transportError: FetchRequestEntry = {
|
const transportError: FetchRequestEntry = {
|
||||||
id: "n-4",
|
id: "n-4",
|
||||||
timestamp: new Date("2026-03-17T10:30:15Z"),
|
timestamp: new Date("2026-03-17T10:30:15Z"),
|
||||||
|
|
@ -166,6 +191,18 @@ export const StreamingResponse: Story = {
|
||||||
args: { entry: streamingEntry, isListExpanded: true },
|
args: { entry: streamingEntry, isListExpanded: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const NotificationStreamOpen: Story = {
|
||||||
|
args: { entry: notificationStreamEntry, isListExpanded: true },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(
|
||||||
|
canvas.getByText(
|
||||||
|
"Long-lived stream — 0 events delivered, still open; body not captured",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const FetchError: Story = {
|
export const FetchError: Story = {
|
||||||
args: { entry: transportError, isListExpanded: true },
|
args: { entry: transportError, isListExpanded: true },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,24 @@ describe("NetworkEntry", () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the watched stream's event count and open state in place of the body (#2318)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const open: FetchRequestEntry = {
|
||||||
|
...baseEntry,
|
||||||
|
method: "GET",
|
||||||
|
responseHeaders: { "content-type": "text/event-stream" },
|
||||||
|
responseBody: undefined,
|
||||||
|
stream: { eventCount: 3 },
|
||||||
|
};
|
||||||
|
renderWithMantine(<NetworkEntry entry={open} isListExpanded={false} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Long-lived stream — 3 events delivered, still open; body not captured",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows an SSE badge on long-lived GET event-stream entries", () => {
|
it("shows an SSE badge on long-lived GET event-stream entries", () => {
|
||||||
const sse: FetchRequestEntry = {
|
const sse: FetchRequestEntry = {
|
||||||
...baseEntry,
|
...baseEntry,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ import {
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { RiErrorWarningLine } from "react-icons/ri";
|
import { RiErrorWarningLine } from "react-icons/ri";
|
||||||
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
|
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
|
||||||
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js";
|
import {
|
||||||
|
isLongLivedStreamEntry,
|
||||||
|
longLivedStreamLabel,
|
||||||
|
uncapturedBodyNote,
|
||||||
|
} from "../../../utils/uncapturedBodyNote";
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
|
import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
|
||||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||||
|
|
@ -158,13 +162,6 @@ function statusLabel(entry: FetchRequestEntry): string {
|
||||||
: `${entry.responseStatus}`;
|
: `${entry.responseStatus}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLongLivedStream(entry: FetchRequestEntry): boolean {
|
|
||||||
return isLongLivedStreamResponse(
|
|
||||||
entry.method,
|
|
||||||
entry.responseHeaders?.["content-type"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header-table cell text. A modern MCP-mirrored header name gets a violet accent
|
// Header-table cell text. A modern MCP-mirrored header name gets a violet accent
|
||||||
// so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version)
|
// so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version)
|
||||||
// stand out from ordinary ones; a value that disagrees with the request body is
|
// stand out from ordinary ones; a value that disagrees with the request body is
|
||||||
|
|
@ -217,6 +214,17 @@ const SentinelTooltip = Tooltip.withProps({
|
||||||
w: 280,
|
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({
|
const Base64Badge = Badge.withProps({
|
||||||
size: "xs",
|
size: "xs",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
|
|
@ -542,7 +550,9 @@ export function NetworkEntry({
|
||||||
{entry.duration != null && (
|
{entry.duration != null && (
|
||||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||||
)}
|
)}
|
||||||
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
|
{isLongLivedStreamEntry(entry) && (
|
||||||
|
<StreamBadge>{longLivedStreamLabel(entry)}</StreamBadge>
|
||||||
|
)}
|
||||||
<Badge color={statusColor(entry)} variant="status">
|
<Badge color={statusColor(entry)} variant="status">
|
||||||
{statusLabel(entry)}
|
{statusLabel(entry)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -647,11 +657,7 @@ export function NetworkEntry({
|
||||||
label="Response body"
|
label="Response body"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DimmedNote>
|
<DimmedNote>{uncapturedBodyNote(entry)}</DimmedNote>
|
||||||
{isLongLivedStream(entry)
|
|
||||||
? "Long-lived stream — body not captured"
|
|
||||||
: "(empty)"}
|
|
||||||
</DimmedNote>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
|
||||||
settings: { ...args.settings, maxFetchRequests: value },
|
settings: { ...args.settings, maxFetchRequests: value },
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onSkillCatalogLimitChange={(field, value) => {
|
||||||
|
args.onSkillCatalogLimitChange(field, value);
|
||||||
|
updateArgs({
|
||||||
|
settings: { ...args.settings, [field]: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
onProtocolEraChange={(value) => {
|
onProtocolEraChange={(value) => {
|
||||||
args.onProtocolEraChange(value);
|
args.onProtocolEraChange(value);
|
||||||
updateArgs({
|
updateArgs({
|
||||||
|
|
@ -178,6 +184,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
||||||
onPaginatedListsChange: fn(),
|
onPaginatedListsChange: fn(),
|
||||||
onAdvertisedExtensionChange: fn(),
|
onAdvertisedExtensionChange: fn(),
|
||||||
onMaxFetchRequestsChange: fn(),
|
onMaxFetchRequestsChange: fn(),
|
||||||
|
onSkillCatalogLimitChange: fn(),
|
||||||
onProtocolEraChange: fn(),
|
onProtocolEraChange: fn(),
|
||||||
onModernLogLevelChange: fn(),
|
onModernLogLevelChange: fn(),
|
||||||
onOAuthChange: fn(),
|
onOAuthChange: fn(),
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ const baseHandlers = {
|
||||||
onPaginatedListsChange: vi.fn(),
|
onPaginatedListsChange: vi.fn(),
|
||||||
onAdvertisedExtensionChange: vi.fn(),
|
onAdvertisedExtensionChange: vi.fn(),
|
||||||
onMaxFetchRequestsChange: vi.fn(),
|
onMaxFetchRequestsChange: vi.fn(),
|
||||||
|
onSkillCatalogLimitChange: vi.fn(),
|
||||||
onProtocolEraChange: vi.fn(),
|
onProtocolEraChange: vi.fn(),
|
||||||
onModernLogLevelChange: vi.fn(),
|
onModernLogLevelChange: vi.fn(),
|
||||||
onOAuthChange: vi.fn(),
|
onOAuthChange: vi.fn(),
|
||||||
|
|
@ -559,6 +560,96 @@ describe("ServerSettingsForm", () => {
|
||||||
expect(onMaxFetchRequestsChange).toHaveBeenLastCalledWith(2500);
|
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", () => {
|
describe("stdio Working Directory (Options) / Environment Variables section", () => {
|
||||||
it("hides the Working Directory field and the Environment Variables section for non-stdio servers", () => {
|
it("hides the Working Directory field and the Environment Variables section for non-stdio servers", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ import {
|
||||||
} from "@inspector/core/auth/authorizationParams.js";
|
} from "@inspector/core/auth/authorizationParams.js";
|
||||||
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
|
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
|
||||||
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.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";
|
import type { Root } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,9 +64,15 @@ export type ServerSettingsSection =
|
||||||
| "headers"
|
| "headers"
|
||||||
| "metadata"
|
| "metadata"
|
||||||
| "timeouts"
|
| "timeouts"
|
||||||
|
| "skills"
|
||||||
| "oauth"
|
| "oauth"
|
||||||
| "roots";
|
| "roots";
|
||||||
|
|
||||||
|
/** The two per-server skills verification budget fields (#2294). */
|
||||||
|
export type SkillCatalogLimitField =
|
||||||
|
| "skillCatalogMaxSkills"
|
||||||
|
| "skillCatalogMaxBytes";
|
||||||
|
|
||||||
export interface ServerSettingsFormProps {
|
export interface ServerSettingsFormProps {
|
||||||
settings: InspectorServerSettings;
|
settings: InspectorServerSettings;
|
||||||
/** Transport type — EMA checkbox is hidden for stdio. Defaults to streamable-http. */
|
/** 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;
|
onAdvertisedExtensionChange: (key: string, checked: boolean) => void;
|
||||||
onMaxFetchRequestsChange: (value: number) => 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;
|
onProtocolEraChange: (value: ServerProtocolEra) => void;
|
||||||
onModernLogLevelChange: (value: ModernLogLevel) => void;
|
onModernLogLevelChange: (value: ModernLogLevel) => void;
|
||||||
/**
|
/**
|
||||||
|
|
@ -166,6 +184,22 @@ const TimeoutInput = NumberInput.withProps({
|
||||||
suffix: " ms",
|
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({
|
const ExtensionsHint = Text.withProps({
|
||||||
size: "xs",
|
size: "xs",
|
||||||
c: "var(--inspector-text-secondary)",
|
c: "var(--inspector-text-secondary)",
|
||||||
|
|
@ -447,6 +481,7 @@ export function ServerSettingsForm({
|
||||||
onPaginatedListsChange,
|
onPaginatedListsChange,
|
||||||
onAdvertisedExtensionChange,
|
onAdvertisedExtensionChange,
|
||||||
onMaxFetchRequestsChange,
|
onMaxFetchRequestsChange,
|
||||||
|
onSkillCatalogLimitChange,
|
||||||
onProtocolEraChange,
|
onProtocolEraChange,
|
||||||
onModernLogLevelChange,
|
onModernLogLevelChange,
|
||||||
negotiatedEra,
|
negotiatedEra,
|
||||||
|
|
@ -496,6 +531,20 @@ export function ServerSettingsForm({
|
||||||
Number.isNaN(parsed) ? settings.maxFetchRequests : parsed,
|
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 =
|
const handleTimeoutChange =
|
||||||
(field: "connectionTimeout" | "requestTimeout" | "taskTtl") =>
|
(field: "connectionTimeout" | "requestTimeout" | "taskTtl") =>
|
||||||
(value: number | string) => {
|
(value: number | string) => {
|
||||||
|
|
@ -816,6 +865,32 @@ export function ServerSettingsForm({
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</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.Item value="roots">
|
||||||
<Accordion.Control>Roots</Accordion.Control>
|
<Accordion.Control>Roots</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue