feat: make the skills verification catalog budget configurable per server (#2294)

Adds a Skills section to Server Settings with Maximum Number of Skills and
Maximum Catalog Size fields, defaulting to SKILL_MAX_CATALOG_SKILLS (256) and
SKILL_MAX_CATALOG_BYTES (64 MiB). The values persist as skillCatalogMaxSkills /
skillCatalogMaxBytes on the server's mcp.json entry (omitted at the default),
are validated as positive integers by the /api/servers routes, and are read by
verifySkills through getServerSettings(), so the CLI's --verify and the TUI
Skills pane both honor them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6i1HBSdSS9kL4npZ2dDSe
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-13 23:54:29 -04:00
commit eb41185c37
14 changed files with 469 additions and 6 deletions

View file

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

@ -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="Most skills one skills verification run (the CLI's --verify, or the TUI's Skills pane) reads files from. Skills past the limit are still listed, reported as incomplete rather than checked."
value={skillCatalogBudget.maxSkills}
onChange={handleSkillCatalogLimitChange(
"skillCatalogMaxSkills",
skillCatalogBudget.maxSkills,
)}
/>
<SkillBytesInput
label="Maximum Catalog Size"
description="Most bytes one skills verification run reads across every skill. Reaching it stops the read, and the remaining skills are reported as incomplete."
value={skillCatalogBudget.maxBytes}
onChange={handleSkillCatalogLimitChange(
"skillCatalogMaxBytes",
skillCatalogBudget.maxBytes,
)}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="roots">
<Accordion.Control>Roots</Accordion.Control>
<Accordion.Panel>

View file

@ -679,6 +679,27 @@ describe("ServerSettingsModal", () => {
expect(typeof call.maxFetchRequests).toBe("number");
});
it("calls onSettingsChange when changing a skills catalog limit (#2294)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, skillCatalogMaxSkills: 3 }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Skills" }));
await user.type(screen.getByLabelText(/Maximum Number of Skills/), "0");
expect(onSettingsChange).toHaveBeenLastCalledWith({
...emptySettings,
skillCatalogMaxSkills: 30,
});
});
it("preserves sibling rows when editing one of several headers", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();

View file

@ -17,6 +17,7 @@ import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info
import {
ServerSettingsForm,
type ServerSettingsSection,
type SkillCatalogLimitField,
} from "../ServerSettingsForm/ServerSettingsForm";
// The "environment" section only renders for stdio servers and the "oauth"
@ -34,6 +35,7 @@ function allSectionsFor(
"headers",
"metadata",
"timeouts",
"skills",
...(isOAuthCapableServerType(serverType) ? (["oauth"] as const) : []),
"roots",
];
@ -226,6 +228,13 @@ export function ServerSettingsModal({
onSettingsChange({ ...settings, maxFetchRequests: value });
}
function handleSkillCatalogLimitChange(
field: SkillCatalogLimitField,
value: number,
) {
onSettingsChange({ ...settings, [field]: value });
}
function handleProtocolEraChange(value: ServerProtocolEra) {
onSettingsChange({ ...settings, protocolEra: value });
}
@ -299,6 +308,7 @@ export function ServerSettingsModal({
onPaginatedListsChange={handlePaginatedListsChange}
onAdvertisedExtensionChange={handleAdvertisedExtensionChange}
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
onSkillCatalogLimitChange={handleSkillCatalogLimitChange}
onProtocolEraChange={handleProtocolEraChange}
onModernLogLevelChange={handleModernLogLevelChange}
negotiatedEra={negotiatedEra}

View file

@ -456,6 +456,55 @@ describe("serverEntriesToMcpConfig", () => {
expect(round).toEqual(original);
});
it("round-trips the skills catalog budget (#2294)", () => {
const original: MCPConfig = {
mcpServers: {
sk: {
type: "streamable-http",
url: "https://x.test/mcp",
skillCatalogMaxSkills: 10,
skillCatalogMaxBytes: 2048,
},
},
};
const [entry] = mcpConfigToServerEntries(original);
expect(entry?.settings?.skillCatalogMaxSkills).toBe(10);
expect(entry?.settings?.skillCatalogMaxBytes).toBe(2048);
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
expect(round).toEqual(original);
});
it("drops an unusable skills catalog limit on read and omits the default on write", () => {
const [entry] = mcpConfigToServerEntries({
mcpServers: {
sk: {
type: "streamable-http",
url: "https://x.test/mcp",
skillCatalogMaxSkills: 0,
skillCatalogMaxBytes: 1.5,
},
},
});
// Present on disk still materializes a settings node, but neither value
// is usable, so both read back as absent (the default budget).
expect(entry?.settings).toBeDefined();
expect(entry?.settings?.skillCatalogMaxSkills).toBeUndefined();
expect(entry?.settings?.skillCatalogMaxBytes).toBeUndefined();
const round = serverEntriesToMcpConfig([
{
...entry!,
settings: {
...entry!.settings!,
skillCatalogMaxSkills: 256,
skillCatalogMaxBytes: 64 * 1024 * 1024,
},
},
]);
expect("skillCatalogMaxSkills" in (round.mcpServers.sk ?? {})).toBe(false);
expect("skillCatalogMaxBytes" in (round.mcpServers.sk ?? {})).toBe(false);
});
it("round-trips protocolEra: lifts a non-default value to settings and back to disk", () => {
const original: MCPConfig = {
mcpServers: {

View file

@ -790,6 +790,64 @@ describe("verifySkills (#2248)", () => {
expect(reports[0].outcome).toBe("verified");
});
it("honors the server's configured catalog budget (#2294)", async () => {
// Read through `getServerSettings()`, so neither caller has to pass it.
const enc = new TextEncoder();
const mdFor = (i: number) =>
`---\nname: c${i}\ndescription: A demo\n---\n\n# c${i}\n`;
const skills = await Promise.all(
Array.from({ length: 4 }, async (_, i): Promise<SkillEntry> => {
const bytes = enc.encode(mdFor(i));
return entry({
uri: `skill://c${i}/SKILL.md`,
frontmatter: { name: `c${i}`, description: "A demo" },
resources: [
{
uri: `skill://c${i}/SKILL.md`,
digest: await sha256Digest(bytes),
size: bytes.byteLength,
},
],
});
}),
);
const readResource = vi.fn(async (uri: string) => ({
result: {
contents: [
{ uri, text: mdFor(Number(/c(\d+)/.exec(uri)?.[1] ?? "0")) },
],
},
}));
const client = {
readResource,
getServerSettings: () => ({ skillCatalogMaxSkills: 2 }),
} as unknown as InspectorClientProtocol;
const reports = await verifySkills(client, skills);
expect(readResource.mock.calls.length).toBe(2);
expect(reports.map((r) => r.outcome)).toEqual([
"verified",
"verified",
"incomplete",
"incomplete",
]);
// The reason names the limit that actually applied, not the default.
expect(reports[2].incomplete).toMatch(/budget of 2 skills \/ 67108864/);
});
it("falls back to the default budget for an unusable configured limit", async () => {
const { skill, client } = await truncatable({ name: "fb", count: 2 });
const withSettings = {
...client,
readResource: client.readResource,
getServerSettings: () => ({
skillCatalogMaxSkills: 0,
skillCatalogMaxBytes: -1,
}),
} as unknown as InspectorClientProtocol;
const [report] = await verifySkills(withSettings, [skill]);
expect(report.outcome).toBe("verified");
});
it("is not incomplete when the budget is crossed by the LAST entry", async () => {
// Crossing the line on the final row stopped nothing: every manifest entry
// was fetched and checked. Reporting "Stopped after 4 of 4" there both

View file

@ -832,6 +832,13 @@ describe("server.ts supplemental coverage", () => {
expect((await res.json()).error).toMatch(/maxFetchRequests/);
});
it("rejects a non-positive skills catalog limit (#2294)", async () => {
const skills = await postSettings({ ...base, skillCatalogMaxSkills: 0 });
expect((await skills.json()).error).toMatch(/skillCatalogMaxSkills/);
const bytes = await postSettings({ ...base, skillCatalogMaxBytes: 1.5 });
expect((await bytes.json()).error).toMatch(/skillCatalogMaxBytes/);
});
it("rejects a non-string OAuth field", async () => {
const res = await postSettings({ ...base, oauthClientId: 7 });
expect((await res.json()).error).toMatch(/oauthClientId/);
@ -960,6 +967,8 @@ describe("server.ts supplemental coverage", () => {
paginatedLists: true,
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
maxFetchRequests: 5,
skillCatalogMaxSkills: 10,
skillCatalogMaxBytes: 2048,
protocolEra: "modern",
modernLogLevel: "off",
oauthClientId: "cid",
@ -999,6 +1008,8 @@ describe("server.ts supplemental coverage", () => {
requestTimeout: -1,
taskTtl: "x",
maxFetchRequests: -1,
skillCatalogMaxSkills: 0,
skillCatalogMaxBytes: "big",
// unknown era literal → isProtocolEra branch
protocolEra: "future",
// unknown modern log level → isModernLogLevel branch (#1629)
@ -1030,6 +1041,8 @@ describe("server.ts supplemental coverage", () => {
"requestTimeout",
"taskTtl",
"maxFetchRequests",
"skillCatalogMaxSkills",
"skillCatalogMaxBytes",
"protocolEra",
"modernLogLevel",
"oauth",

View file

@ -65,6 +65,7 @@ import {
stripInspectorFields,
} from "../../serverList.js";
import { toRecord } from "../../../json/jsonUtils.js";
import { isSkillCatalogLimit } from "../../skills.js";
import { resolveImportSource } from "../../import/resolveSource.js";
import { RemoteSession } from "./remote-session.js";
import { createRemoteAuthProvider } from "./tokenAuthProvider.js";
@ -1681,6 +1682,19 @@ export function createRemoteApp(
);
delete valObj.maxFetchRequests;
}
// #2294 — positive integers only; `0` is not "unlimited" for these.
for (const key of [
"skillCatalogMaxSkills",
"skillCatalogMaxBytes",
] as const) {
if (key in valObj && !isSkillCatalogLimit(valObj[key])) {
logWarn(
{ route: "/api/servers", id, droppedKey: key },
`Dropping malformed \`${key}\` field — expected a positive integer.`,
);
delete valObj[key];
}
}
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
logWarn(
{ route: "/api/servers", id, droppedKey: "oauth" },
@ -1953,6 +1967,18 @@ export function createRemoteApp(
error: "settings.maxFetchRequests must be a non-negative number",
};
}
// #2294 — optional on the wire; absent means the default budget.
for (const key of [
"skillCatalogMaxSkills",
"skillCatalogMaxBytes",
] as const) {
if (obj[key] !== undefined && !isSkillCatalogLimit(obj[key])) {
return {
ok: false,
error: `settings.${key} must be a positive integer`,
};
}
}
for (const optional of [
"oauthClientId",
"oauthClientSecret",
@ -2099,6 +2125,13 @@ export function createRemoteApp(
// writes no spurious `roots` field.
roots: isRootArray(obj.roots) ? obj.roots : [],
};
// Validated above; absent stays absent (the default budget).
if (isSkillCatalogLimit(obj.skillCatalogMaxSkills)) {
value.skillCatalogMaxSkills = obj.skillCatalogMaxSkills;
}
if (isSkillCatalogLimit(obj.skillCatalogMaxBytes)) {
value.skillCatalogMaxBytes = obj.skillCatalogMaxBytes;
}
if (typeof obj.oauthClientId === "string" && obj.oauthClientId !== "") {
value.oauthClientId = obj.oauthClientId;
}

View file

@ -13,6 +13,11 @@ import {
DEFAULT_TASK_TTL_MS,
isModernLogLevel,
} from "./types.js";
import {
isSkillCatalogLimit,
SKILL_MAX_CATALOG_BYTES,
SKILL_MAX_CATALOG_SKILLS,
} from "./skills.js";
import type { Root } from "@modelcontextprotocol/client";
import type {
InspectorServerSettings,
@ -160,6 +165,8 @@ type StoredInspectorFields = Pick<
| "paginatedLists"
| "advertisedExtensions"
| "maxFetchRequests"
| "skillCatalogMaxSkills"
| "skillCatalogMaxBytes"
| "oauth"
| "roots"
>;
@ -521,6 +528,8 @@ export function storedFieldsToInspectorSettings(
stored.paginatedLists !== undefined ||
stored.advertisedExtensions !== undefined ||
stored.maxFetchRequests !== undefined ||
stored.skillCatalogMaxSkills !== undefined ||
stored.skillCatalogMaxBytes !== undefined ||
stored.oauth !== undefined ||
stored.roots !== undefined ||
stored.protocolEra !== undefined ||
@ -557,6 +566,16 @@ export function storedFieldsToInspectorSettings(
// `[]`, which `inspectorSettingsToStoredFields` then omits on write.
roots: stored.roots ?? [],
};
// Lifted only when usable (#2294). Unlike maxFetchRequests these stay
// optional in memory — absent means the default, which
// `resolveSkillCatalogBudget` supplies — and a hand-edited `0` or `-1` is
// dropped here rather than carried into a form that would re-persist it.
if (isSkillCatalogLimit(stored.skillCatalogMaxSkills)) {
settings.skillCatalogMaxSkills = stored.skillCatalogMaxSkills;
}
if (isSkillCatalogLimit(stored.skillCatalogMaxBytes)) {
settings.skillCatalogMaxBytes = stored.skillCatalogMaxBytes;
}
// Absent on disk reads back as the default era; the write side then omits the
// default so a byte-stable round-trip never injects `protocolEra` into files
// that never set it. An unknown literal from a hand-edited file is dropped
@ -737,6 +756,22 @@ export function inspectorSettingsToStoredFields(
out.maxFetchRequests = settings.maxFetchRequests;
}
// Same omit-the-default rule (#2294). An unusable value writes nothing, so
// it reads back as the default rather than being persisted and then
// rejected on the next load.
if (
isSkillCatalogLimit(settings.skillCatalogMaxSkills) &&
settings.skillCatalogMaxSkills !== SKILL_MAX_CATALOG_SKILLS
) {
out.skillCatalogMaxSkills = settings.skillCatalogMaxSkills;
}
if (
isSkillCatalogLimit(settings.skillCatalogMaxBytes) &&
settings.skillCatalogMaxBytes !== SKILL_MAX_CATALOG_BYTES
) {
out.skillCatalogMaxBytes = settings.skillCatalogMaxBytes;
}
const oauthFields: NonNullable<StoredMCPServer["oauth"]> = {};
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
if (settings.oauthClientSecret)
@ -815,6 +850,8 @@ const INSPECTOR_FIELD_KEY_MAP = {
paginatedLists: true,
advertisedExtensions: true,
maxFetchRequests: true,
skillCatalogMaxSkills: true,
skillCatalogMaxBytes: true,
oauth: true,
roots: true,
} as const satisfies Record<keyof StoredInspectorFields, true>;

View file

@ -76,6 +76,47 @@ export const SKILL_MAX_CATALOG_SKILLS = 256;
/** @see {@link SKILL_MAX_CATALOG_SKILLS} — 64 MiB across the whole run. */
export const SKILL_MAX_CATALOG_BYTES = 64 * 1024 * 1024;
/**
* Whether `value` is usable as a per-server catalog budget override (#2294):
* a positive safe integer.
*
* ⚠️ **`0` is deliberately not "unlimited"**, unlike `maxFetchRequests`. The
* budget exists so a `--verify` against a huge catalog terminates; an
* unlimited setting would reintroduce exactly the unbounded run it closed. A
* user who wants more raises the number.
*/
export function isSkillCatalogLimit(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}
/** A verification run's catalog budget, resolved from per-server settings. */
export interface SkillCatalogBudget {
maxSkills: number;
maxBytes: number;
}
/**
* The catalog budget for a run: the server's configured limits where they are
* usable, else {@link SKILL_MAX_CATALOG_SKILLS} / {@link SKILL_MAX_CATALOG_BYTES}.
*
* A malformed value falls back to the default rather than throwing — settings
* can arrive from a hand-edited `mcp.json`, and a verification that refused to
* run over a typo would be a worse outcome than one that ran at the default.
*/
export function resolveSkillCatalogBudget(settings?: {
skillCatalogMaxSkills?: number;
skillCatalogMaxBytes?: number;
}): SkillCatalogBudget {
return {
maxSkills: isSkillCatalogLimit(settings?.skillCatalogMaxSkills)
? settings.skillCatalogMaxSkills
: SKILL_MAX_CATALOG_SKILLS,
maxBytes: isSkillCatalogLimit(settings?.skillCatalogMaxBytes)
? settings.skillCatalogMaxBytes
: SKILL_MAX_CATALOG_BYTES,
};
}
/** The suffix every skill URI ends with; the segment before it is the name. */
export const SKILL_FILE_SUFFIX = "/SKILL.md";

View file

@ -32,9 +32,8 @@ import {
checkSkillConformance,
checkSkillFrontmatterMatch,
checkSkillNameCollisions,
resolveSkillCatalogBudget,
skillDisplayName,
SKILL_MAX_CATALOG_BYTES,
SKILL_MAX_CATALOG_SKILLS,
SKILL_MAX_RESOURCE_ENTRIES,
SKILL_MAX_TOTAL_BYTES,
skillFileBytes,
@ -323,6 +322,13 @@ export async function verifySkills(
// thousand skills, each individually conforming, made `--verify` run
// indefinitely and transfer unboundedly (Copilot). See
// {@link SKILL_MAX_CATALOG_SKILLS}.
//
// The limits are the server's configured ones when set (#2294). Read through
// `getServerSettings()` rather than taken as a parameter, so every caller —
// the CLI's `--verify` and the TUI pane — honors the setting without having
// to remember to pass it. Optional-chained because a test double need not
// implement the accessor.
const budget = resolveSkillCatalogBudget(client.getServerSettings?.());
let walkedSkills = 0;
let catalogBytes = 0;
for (const entry of entries) {
@ -330,8 +336,7 @@ export async function verifySkills(
// past the budget is still reported on, just not read. What stops is the
// reading.
const withinBudget =
walkedSkills < SKILL_MAX_CATALOG_SKILLS &&
catalogBytes <= SKILL_MAX_CATALOG_BYTES;
walkedSkills < budget.maxSkills && catalogBytes <= budget.maxBytes;
// The entry's own SKILL.md, read once and used twice — for its digest and
// for the frontmatter cross-check. Reading it twice would double the load
// on the server and, worse, could compare a digest against one snapshot
@ -368,7 +373,7 @@ export async function verifySkills(
// total, by definition) is never truncated.
const manifest = withinBudget ? boundedManifest(declared) : [];
let incomplete = !withinBudget
? `Not read: this run already reached its catalog budget of ${SKILL_MAX_CATALOG_SKILLS} skills / ${SKILL_MAX_CATALOG_BYTES} bytes. Nothing about this skill's files has been checked — verify it on its own with \`--method skills/get --uri\` to get a verdict.`
? `Not read: this run already reached its catalog budget of ${budget.maxSkills} skills / ${budget.maxBytes} bytes (raise it in the server's Skills settings). Nothing about this skill's files has been checked — verify it on its own with \`--method skills/get --uri\` to get a verdict.`
: manifest.length < declared.length
? `Only ${manifest.length} of ${declared.length} manifest entries were read: the skill exceeds the ${SKILL_MAX_RESOURCE_ENTRIES}-entry / ${SKILL_MAX_TOTAL_BYTES}-byte interoperability limits, so the rest were not fetched and cannot be reported on.`
: undefined;

View file

@ -171,6 +171,18 @@ export type StoredMCPServer = MCPServerConfig & {
* file diff minimal for servers that never tuned it. `0` means unlimited.
*/
maxFetchRequests?: number;
/**
* Most skills one skills verification run reads from (#2294). Omitted on
* disk when it equals `SKILL_MAX_CATALOG_SKILLS`. Must be a positive integer
* — there is no unlimited value, since the bound is what makes `--verify`
* terminate.
*/
skillCatalogMaxSkills?: number;
/**
* Byte ceiling across one skills verification run (#2294). Omitted on disk
* when it equals `SKILL_MAX_CATALOG_BYTES`. Positive integer, as above.
*/
skillCatalogMaxBytes?: number;
/**
* Pre-configured OAuth client credentials for HTTP transports. Nested to
* match Claude Code's `.mcp.json` shape; lifted into the flat `oauthClientId`
@ -900,6 +912,17 @@ export interface InspectorServerSettings {
* render; defaults to `DEFAULT_MAX_FETCH_REQUESTS`. `0` means unlimited.
*/
maxFetchRequests: number;
/**
* Catalog budget for skills verification (#2294): the most skills, and the
* most bytes, one run reads. Optional rather than concrete like
* `maxFetchRequests` — absent means `SKILL_MAX_CATALOG_SKILLS` /
* `SKILL_MAX_CATALOG_BYTES`, resolved by `resolveSkillCatalogBudget`, and
* the form renders those defaults itself. Read by `verifySkills` through
* `getServerSettings()`, so the CLI's `--verify` and the TUI's Skills pane
* both honor it.
*/
skillCatalogMaxSkills?: number;
skillCatalogMaxBytes?: number;
/**
* Roots advertised to the server via the `roots` client capability. Each
* root carries a required `uri` and an optional `name` (SDK `Root`). The

View file

@ -188,6 +188,8 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w
| `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating |
| `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` |
| `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited |
| `skillCatalogMaxSkills` | `256` | Most skills one skills verification run reads (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value |
| `skillCatalogMaxBytes` | `67108864` | Most bytes one skills verification run reads (`SKILL_MAX_CATALOG_BYTES`, 64 MiB). Positive integer |
| `oauth` | — | `{ clientId, clientSecret, scopes, requestRefreshToken, revokeOnClear, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` |
`metadata` is a JSON **object**, and its values may be any JSON — an object, an array, a number, a boolean, `null` — not only a string. The MCP spec does not restrict `_meta` value types in general, and the SDK models the field as a passthrough object, so the Inspector does not narrow it either ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). Edit it in Server Settings → Request Metadata, which is a JSON editor rather than the key/value rows `headers` and `env` use; text that is not a JSON object is flagged inline and not applied.