Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). |
| `--verify` | With `--method skills/list` or `--method skills/get`: run the SEP-2640 conformance, digest and frontmatter checks over the skills returned, emit one JSON report per skill on stdout, and exit `7` if any fails. See [Skill verification](#skill-verification---verify). |
| `--require-digests` | With `--verify`: exit `9` when a skill advertises no digests (`resources: "dynamic"`), instead of reporting it `unverifiable` and exiting `0`. See [Skill verification](#skill-verification---verify). |
| `--skill-catalog-max-skills <n>` | With `--verify` (required; rejected without it, since it would bound nothing): the skills catalog budget, the most skills whose files one run reads (default `256`). Positive integer; anything else is rejected before connecting. Overrides the server's [`skillCatalogMaxSkills`](../../docs/mcp-server-configuration.md#inspector-specific-per-server-fields) from the catalog/config file — the same setting the web client exposes per server in Server Settings. Skills past the budget are reported `incomplete` (exit `8`). |
| `--skill-catalog-max-bytes <n>` | With `--verify` (required): the skills catalog budget, the most bytes one run reads across all skills (default `67108864`, 64 MiB). Positive integer. Overrides the server's [`skillCatalogMaxBytes`](../../docs/mcp-server-configuration.md#inspector-specific-per-server-fields). |
| `--format <text\|json>` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. |
| `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. |
| `--no-revoke` | With `--relogin`, skip the [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) revocation request that would otherwise end the grant at the authorization server when the local state is deleted. The per-server `oauth.revokeOnClear` setting is the persistent form of the same opt-out; either one is enough to skip it. See [Revoking on `--relogin`](#revoking-on---relogin). |
Expand Down
80 changes: 80 additions & 0 deletions clients/cli/__tests__/programmatic-ergonomics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,86 @@ describe("--protocol-era", () => {
});
});

describe("--skill-catalog-max-skills / --skill-catalog-max-bytes (#2420)", () => {
// The flags must reach `verifySkills`' run-level budget, not just parse. A
// budget of one skill leaves every later skill unread, which the report
// records as an `incomplete` outcome — without the flag the fixture's whole
// catalog fits the default budget and no skill is incomplete.
it("caps the --verify run at the flag's skill budget", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
skills: true,
});
try {
await server.start();
const verify = [
server.url,
"--transport",
"http",
"--method",
"skills/list",
"--verify",
];

const unbounded = await runCli(verify);
expect(reportOutcomes(unbounded.stdout)).not.toContain("incomplete");

const capped = await runCli([
...verify,
"--skill-catalog-max-skills",
"1",
]);
expect(reportOutcomes(capped.stdout)).toContain("incomplete");
} finally {
await server.stop();
}
});

it.each([
["--skill-catalog-max-skills", "0"],
["--skill-catalog-max-bytes", "1.5"],
])("rejects %s %s before connecting", async (flag, value) => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
flag,
value,
"--method",
"tools/list",
]);
expectCliFailure(result);
expect(result.stderr).toContain(
`Invalid ${flag}: ${value}. Expected a positive integer written as plain decimal digits, at most 9007199254740991.`,
);
});

it.each(["--skill-catalog-max-skills", "--skill-catalog-max-bytes"])(
"rejects %s without --verify, where it would bound nothing",
async (flag) => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
flag,
"5",
"--method",
"tools/list",
]);
expectCliFailure(result);
expect(result.stderr).toContain(`${flag} requires --verify.`);
},
);
});

/** The `outcome` of each NDJSON `--verify` report line. */
function reportOutcomes(stdout: string): string[] {
return stdout
.trim()
.split("\n")
.map((line) => (JSON.parse(line) as { outcome: string }).outcome);
}

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();
Expand Down
26 changes: 26 additions & 0 deletions clients/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
parseKeyValuePair as parseEnvPair,
parseHeaderPair,
parseProtocolEra,
skillCatalogLimitParser,
} 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";
Expand Down Expand Up @@ -855,6 +856,16 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
"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(
"--skill-catalog-max-skills <n>",
"Skills catalog budget for --verify: the most skills one run reads (positive integer; default 256). Overrides the server's skillCatalogMaxSkills in the catalog/config file.",
skillCatalogLimitParser("--skill-catalog-max-skills"),
)
.option(
"--skill-catalog-max-bytes <n>",
"Skills catalog budget for --verify: the most bytes one run reads across all skills (positive integer; default 67108864, 64 MiB). Overrides the server's skillCatalogMaxBytes in the catalog/config file.",
skillCatalogLimitParser("--skill-catalog-max-bytes"),
)
.option(
"--format <format>",
"Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).",
Expand Down Expand Up @@ -955,6 +966,8 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
cursor?: string;
connectTimeout?: number;
protocolEra?: ServerProtocolEra;
skillCatalogMaxSkills?: number;
skillCatalogMaxBytes?: number;
format?: OutputFormat;
toolArgsJson?: string;
clientConfig?: string;
Expand Down Expand Up @@ -1041,6 +1054,15 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
if (options.requireDigests && !options.verify) {
throw new Error("--require-digests requires --verify.");
}
// The skills catalog budget is read only by `verifySkills`, so without
// `--verify` it bounds nothing — and a job that set it would look bounded
// when it is not (#2420).
if (options.skillCatalogMaxSkills !== undefined && !options.verify) {
throw new Error("--skill-catalog-max-skills requires --verify.");
}
if (options.skillCatalogMaxBytes !== undefined && !options.verify) {
throw new Error("--skill-catalog-max-bytes requires --verify.");
Comment on lines +1060 to +1064

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined. --method skills/get --verify is a valid --verify run, and the budget there behaves exactly as the same mcp.json setting does: it is read and applied, it just cannot truncate a one-entry run. The --verify pairing exists to reject a flag on a run where no budget is ever read. Narrowing further, to skills/list only, is extra hardening beyond #2420, so I am leaving it out of this PR.

}

// `--advertise-apps` is checked here for the same reason: it shapes the
// `initialize` handshake, and the short-circuit paths below never open an
Expand Down Expand Up @@ -1125,6 +1147,10 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
// `--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,
// `--skill-catalog-max-*` feed the per-server skills catalog budget the
// web sets in Server Settings, read by `verifySkills` (#2420).
skillCatalogMaxSkills: options.skillCatalogMaxSkills,
skillCatalogMaxBytes: options.skillCatalogMaxBytes,
};

// Catalog list / show — no MCP connection. Run before stored-auth refresh so
Expand Down
2 changes: 2 additions & 0 deletions clients/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ npx @modelcontextprotocol/inspector --tui --config mcp.json # read-only sessi

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.

`--skill-catalog-max-skills <n>` and `--skill-catalog-max-bytes <n>` set the per-server skills catalog budget (defaults `256` skills and `67108864` bytes, 64 MiB), the same setting the web client exposes in Server Settings. Each takes a positive integer (anything else is rejected at startup) and overrides the file's [`skillCatalogMaxSkills` / `skillCatalogMaxBytes`](../../docs/mcp-server-configuration.md#inspector-specific-per-server-fields) for every server loaded, like `--header`. ⚠️ The budget bounds a verification run that covers **several** skills. The **Skills** pane verifies one skill per **Enter**, which always reads that skill in full, so today neither the flags nor the file values change what the pane does ([#2590](https://github.com/modelcontextprotocol/inspector/issues/2590)). To verify a whole catalog under the budget, use the CLI's `--verify`.

### TUI-specific (OAuth for HTTP servers)

The TUI supports OAuth for **SSE** and **Streamable HTTP** servers. Per-server OAuth fields in `mcp.json` (static client id/secret, scopes, enterprise-managed flag) are applied automatically when loaded from `--catalog` or `--config`. Install-wide settings (CIMD, enterprise IdP) come from **`~/.mcp-inspector/storage/client.json`** — the same file the web **Client Settings** dialog writes. You can point at a different file with `--client-config` or `MCP_CLIENT_CONFIG_PATH`.
Expand Down
63 changes: 63 additions & 0 deletions clients/tui/__tests__/tui-entry-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import type { ServerLoadOptions } from "@inspector/core/mcp/node/servers.js";

/**
* `runTui`'s Commander registration and the forwarding of parsed flags into
* the shared server loader (#2420). `tui.tsx` sits outside the TUI coverage
* `include` (`src/**`), and `tui-servers.test.ts` calls the loader directly,
* so without this a misspelt option or a dropped forwarding field would pass
* every other test.
*
* The loader is mocked to throw a sentinel carrying the options it received,
* which stops `runTui` before it touches stdout or renders Ink.
*/
class Captured extends Error {
constructor(readonly options: ServerLoadOptions) {
super("captured");
}
}

vi.mock("../src/tui-servers.js", () => ({
loadTuiServers: (options: ServerLoadOptions) => {
throw new Captured(options);
},
}));

async function loaderOptionsFor(argv: string[]): Promise<ServerLoadOptions> {
const { runTui } = await import("../tui.js");
try {
await runTui(["node", "mcp-inspector-tui", ...argv]);
} catch (err) {
if (err instanceof Captured) return err.options;
throw err;
}
throw new Error("runTui returned without calling the server loader");
}

describe("runTui --skill-catalog-max-* forwarding (#2420)", () => {
it("forwards both budget flags, parsed as numbers, to the server loader", async () => {
const options = await loaderOptionsFor([
"--server-url",
"http://127.0.0.1:1/mcp",
"--transport",
"http",
"--skill-catalog-max-skills",
"3",
"--skill-catalog-max-bytes",
"4096",
]);
expect(options.skillCatalogMaxSkills).toBe(3);
expect(options.skillCatalogMaxBytes).toBe(4096);
});

it("leaves both unset when the flags are absent", async () => {
const options = await loaderOptionsFor([
"--server-url",
"http://127.0.0.1:1/mcp",
"--transport",
"http",
]);
expect(options.skillCatalogMaxSkills).toBeUndefined();
expect(options.skillCatalogMaxBytes).toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions clients/tui/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
parseKeyValuePair,
parseHeaderPair,
parseProtocolEra,
skillCatalogLimitParser,
} from "@inspector/core/mcp/node/index.js";
import type { ServerProtocolEra } from "@inspector/core/mcp/types.js";
import { loadRunnerClientConfig } from "@inspector/core/client/runner.js";
Expand Down Expand Up @@ -48,6 +49,16 @@ export async function runTui(args?: string[]): Promise<void> {
"Protocol era to negotiate: legacy, auto, or modern (overrides the file's protocolEra; default legacy)",
parseProtocolEra,
)
.option(
"--skill-catalog-max-skills <n>",
"Skills catalog budget: the most skills one multi-skill verification run reads (positive integer; overrides the file's skillCatalogMaxSkills; default 256). The Skills pane verifies one skill at a time, so it is not bounded by this today (#2590)",
skillCatalogLimitParser("--skill-catalog-max-skills"),
)
.option(
"--skill-catalog-max-bytes <n>",
"Skills catalog budget: the most bytes one multi-skill verification run reads (positive integer; overrides the file's skillCatalogMaxBytes; default 64 MiB). Not applied by the one-skill-at-a-time Skills pane today (#2590)",
skillCatalogLimitParser("--skill-catalog-max-bytes"),
)
.option(
"--client-id <id>",
"OAuth client ID (static client) for HTTP servers",
Expand Down Expand Up @@ -86,6 +97,8 @@ export async function runTui(args?: string[]): Promise<void> {
cwd?: string;
header?: Record<string, string>;
protocolEra?: ServerProtocolEra;
skillCatalogMaxSkills?: number;
skillCatalogMaxBytes?: number;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
Expand All @@ -104,6 +117,8 @@ export async function runTui(args?: string[]): Promise<void> {
env: options.e,
headers: options.header,
protocolEra: options.protocolEra,
skillCatalogMaxSkills: options.skillCatalogMaxSkills,
skillCatalogMaxBytes: options.skillCatalogMaxBytes,
transport: options.transport,
serverUrl: options.serverUrl?.trim() || undefined,
};
Expand Down
23 changes: 23 additions & 0 deletions clients/web/src/test/core/mcp/node/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
parseKeyValuePair,
parseHeaderPair,
parseProtocolEra,
skillCatalogLimitParser,
withDefaultCatalogPath,
resolveServerConfigs,
resolveServerSource,
Expand Down Expand Up @@ -57,6 +58,28 @@ describe("parseProtocolEra", () => {
});
});

describe("skillCatalogLimitParser", () => {
const parse = skillCatalogLimitParser("--skill-catalog-max-skills");

it.each([
["1", 1],
["256", 256],
[" 42 ", 42],
["67108864", 67108864],
])("accepts %j as %d", (value, expected) => {
expect(parse(value)).toBe(expected);
});

it.each(["0", "-1", "1.5", "1e3", "0x10", "", "abc", "9007199254740993"])(
"rejects %j, naming the flag",
(value) => {
expect(() => parse(value)).toThrow(
`Invalid --skill-catalog-max-skills: ${value}. Expected a positive integer written as plain decimal digits, at most 9007199254740991.`,
);
},
);
});

describe("parseHeaderPair", () => {
it("splits on the first colon and trims both sides", () => {
expect(parseHeaderPair("X-Test: value")).toEqual({
Expand Down
68 changes: 68 additions & 0 deletions clients/web/src/test/core/mcp/node/servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,74 @@ describe("loadServerEntries", () => {
});
});

it("overrides the disk skills catalog budget with --skill-catalog-max-*, preserving the rest", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
web: {
type: "streamable-http",
url: "http://x/mcp",
skillCatalogMaxSkills: 10,
skillCatalogMaxBytes: 2048,
requestTimeout: 9000,
},
},
}),
);

const fromFile = await loadServerEntries({ configPath });
expect(fromFile.web?.settings).toMatchObject({
skillCatalogMaxSkills: 10,
skillCatalogMaxBytes: 2048,
});

const servers = await loadServerEntries({
configPath,
skillCatalogMaxSkills: 3,
skillCatalogMaxBytes: 4096,
});
expect(servers.web?.settings).toMatchObject({
skillCatalogMaxSkills: 3,
skillCatalogMaxBytes: 4096,
requestTimeout: 9000,
});

const onlyBytes = await loadServerEntries({
configPath,
skillCatalogMaxBytes: 1,
});
expect(onlyBytes.web?.settings).toMatchObject({
skillCatalogMaxSkills: 10,
skillCatalogMaxBytes: 1,
});
});

it("applies --skill-catalog-max-* to an ad-hoc URL with no other settings", async () => {
const servers = await loadServerEntries({
serverUrl: "http://x/mcp",
transport: "http",
skillCatalogMaxSkills: 5,
});
expect(servers.default?.settings).toMatchObject({
skillCatalogMaxSkills: 5,
headers: [],
connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS,
});
expect(servers.default?.settings?.skillCatalogMaxBytes).toBeUndefined();

const bytesOnly = await loadServerEntries({
serverUrl: "http://x/mcp",
transport: "http",
skillCatalogMaxBytes: 7,
});
expect(bytesOnly.default?.settings).toMatchObject({
skillCatalogMaxBytes: 7,
headers: [],
});
});

it("gives an ad-hoc target with neither flag no settings (legacy default)", async () => {
const servers = await loadServerEntries({ target: ["my-server"] });
expect(servers.default?.settings).toBeUndefined();
Expand Down
Loading
Loading