Merge pull request #2367 from modelcontextprotocol/v2/feat/2294-skills-catalog-budget-settings

feat: make the skills verification catalog budget configurable per server (#2294)
This commit is contained in:
Cliff Hall 2026-09-14 01:40:52 -04:00 • committed by GitHub
commit 2f3099f764
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 613 additions and 7 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

@ -130,6 +130,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
settings: { ...args.settings, maxFetchRequests: value },
});
}}
onSkillCatalogLimitChange={(field, value) => {
args.onSkillCatalogLimitChange(field, value);
updateArgs({
settings: { ...args.settings, [field]: value },
});
}}
onProtocolEraChange={(value) => {
args.onProtocolEraChange(value);
updateArgs({
@ -178,6 +184,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
onPaginatedListsChange: fn(),
onAdvertisedExtensionChange: fn(),
onMaxFetchRequestsChange: fn(),
onSkillCatalogLimitChange: fn(),
onProtocolEraChange: fn(),
onModernLogLevelChange: fn(),
onOAuthChange: fn(),

View file

@ -70,6 +70,7 @@ const baseHandlers = {
onPaginatedListsChange: vi.fn(),
onAdvertisedExtensionChange: vi.fn(),
onMaxFetchRequestsChange: vi.fn(),
onSkillCatalogLimitChange: vi.fn(),
onProtocolEraChange: vi.fn(),
onModernLogLevelChange: vi.fn(),
onOAuthChange: vi.fn(),
@ -559,6 +560,96 @@ describe("ServerSettingsForm", () => {
expect(onMaxFetchRequestsChange).toHaveBeenLastCalledWith(2500);
});
describe("Skills section (#2294)", () => {
it("renders the default catalog budget when the server sets none", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["skills"]}
/>,
);
expect(screen.getByText("Skills")).toBeInTheDocument();
expect(screen.getByLabelText(/Maximum Number of Skills/)).toHaveValue(
"256",
);
expect(screen.getByLabelText(/Maximum Catalog Size/)).toHaveValue(
"67,108,864 bytes",
);
});
it("renders configured limits", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
skillCatalogMaxSkills: 10,
skillCatalogMaxBytes: 2048,
}}
expandedSections={["skills"]}
/>,
);
expect(screen.getByLabelText(/Maximum Number of Skills/)).toHaveValue(
"10",
);
expect(screen.getByLabelText(/Maximum Catalog Size/)).toHaveValue(
"2,048 bytes",
);
});
it("emits the typed number for each field", async () => {
const user = userEvent.setup();
const onSkillCatalogLimitChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onSkillCatalogLimitChange={onSkillCatalogLimitChange}
settings={{
...emptySettings,
skillCatalogMaxSkills: 1,
skillCatalogMaxBytes: 1,
}}
expandedSections={["skills"]}
/>,
);
await user.type(screen.getByLabelText(/Maximum Number of Skills/), "2");
expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith(
"skillCatalogMaxSkills",
12,
);
await user.type(screen.getByLabelText(/Maximum Catalog Size/), "5");
expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith(
"skillCatalogMaxBytes",
15,
);
});
it("keeps the current limit when a field is cleared", async () => {
// A cleared budget is not a usable setting, so it must not persist as 0.
const user = userEvent.setup();
const onSkillCatalogLimitChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onSkillCatalogLimitChange={onSkillCatalogLimitChange}
settings={{ ...emptySettings, skillCatalogMaxBytes: 4096 }}
expandedSections={["skills"]}
/>,
);
await user.clear(screen.getByLabelText(/Maximum Number of Skills/));
expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith(
"skillCatalogMaxSkills",
256,
);
await user.clear(screen.getByLabelText(/Maximum Catalog Size/));
expect(onSkillCatalogLimitChange).toHaveBeenLastCalledWith(
"skillCatalogMaxBytes",
4096,
);
});
});
describe("stdio Working Directory (Options) / Environment Variables section", () => {
it("hides the Working Directory field and the Environment Variables section for non-stdio servers", () => {
renderWithMantine(

View file

@ -38,6 +38,10 @@ import {
} from "@inspector/core/auth/authorizationParams.js";
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import {
isSkillCatalogLimit,
resolveSkillCatalogBudget,
} from "@inspector/core/mcp/skills.js";
import type { Root } from "@modelcontextprotocol/client";
/**
@ -60,9 +64,15 @@ export type ServerSettingsSection =
| "headers"
| "metadata"
| "timeouts"
| "skills"
| "oauth"
| "roots";
/** The two per-server skills verification budget fields (#2294). */
export type SkillCatalogLimitField =
| "skillCatalogMaxSkills"
| "skillCatalogMaxBytes";
export interface ServerSettingsFormProps {
settings: InspectorServerSettings;
/** Transport type — EMA checkbox is hidden for stdio. Defaults to streamable-http. */
@ -101,6 +111,14 @@ export interface ServerSettingsFormProps {
*/
onAdvertisedExtensionChange: (key: string, checked: boolean) => void;
onMaxFetchRequestsChange: (value: number) => void;
/**
* Set one skills verification budget limit. Only ever called with a positive
* integer: a cleared or invalid entry re-emits the current value (#2294).
*/
onSkillCatalogLimitChange: (
field: SkillCatalogLimitField,
value: number,
) => void;
onProtocolEraChange: (value: ServerProtocolEra) => void;
onModernLogLevelChange: (value: ModernLogLevel) => void;
/**
@ -166,6 +184,22 @@ const TimeoutInput = NumberInput.withProps({
suffix: " ms",
});
// Positive integers only — `0` is not "unlimited" for a verification budget.
const SkillCountInput = NumberInput.withProps({
min: 1,
allowDecimal: false,
allowNegative: false,
thousandSeparator: ",",
});
const SkillBytesInput = NumberInput.withProps({
min: 1,
allowDecimal: false,
allowNegative: false,
thousandSeparator: ",",
suffix: " bytes",
});
const ExtensionsHint = Text.withProps({
size: "xs",
c: "var(--inspector-text-secondary)",
@ -447,6 +481,7 @@ export function ServerSettingsForm({
onPaginatedListsChange,
onAdvertisedExtensionChange,
onMaxFetchRequestsChange,
onSkillCatalogLimitChange,
onProtocolEraChange,
onModernLogLevelChange,
negotiatedEra,
@ -496,6 +531,20 @@ export function ServerSettingsForm({
Number.isNaN(parsed) ? settings.maxFetchRequests : parsed,
);
};
// Absent settings render as the defaults `verifySkills` will actually apply.
const skillCatalogBudget = resolveSkillCatalogBudget(settings);
const handleSkillCatalogLimitChange =
(field: SkillCatalogLimitField, current: number) =>
(value: number | string) => {
// Mantine emits "" on a clear. Like the Network Log Size field, keep the
// current value rather than persisting something `verifySkills` would
// discard — a cleared or zero budget is not a meaningful setting here.
const parsed = typeof value === "number" ? value : parseInt(value, 10);
onSkillCatalogLimitChange(
field,
isSkillCatalogLimit(parsed) ? parsed : current,
);
};
const handleTimeoutChange =
(field: "connectionTimeout" | "requestTimeout" | "taskTtl") =>
(value: number | string) => {
@ -816,6 +865,32 @@ export function ServerSettingsForm({
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="skills">
<Accordion.Control>Skills</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<SkillCountInput
label="Maximum Number of Skills"
description="The maximum number of skills whose files are read in one verification run (the CLI's --verify, or the TUI's Skills pane). Skills past the limit are still listed, and reported as incomplete rather than checked."
value={skillCatalogBudget.maxSkills}
onChange={handleSkillCatalogLimitChange(
"skillCatalogMaxSkills",
skillCatalogBudget.maxSkills,
)}
/>
<SkillBytesInput
label="Maximum Catalog Size"
description="The maximum number of bytes read across all skills in one verification run. Once it is reached, no further skills are read, and the remaining ones are reported as incomplete."
value={skillCatalogBudget.maxBytes}
onChange={handleSkillCatalogLimitChange(
"skillCatalogMaxBytes",
skillCatalogBudget.maxBytes,
)}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="roots">
<Accordion.Control>Roots</Accordion.Control>
<Accordion.Panel>

View file

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

View file

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

View file

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

@ -1,6 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import type { InspectorClientProtocol } from "@inspector/core/mcp/inspectorClientProtocol.js";
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
import {
DYNAMIC_RESOURCES,
type SkillEntry,
} from "@inspector/core/mcp/skillsSchemas.js";
import {
SKILL_MAX_CATALOG_SKILLS,
sha256Digest,
@ -790,6 +793,181 @@ 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("honors a configured catalog BYTE budget (#2294)", async () => {
// Each skill's SKILL.md is ~50 bytes, so a 60-byte budget is spent by the
// second skill: the third is where reading stops.
const enc = new TextEncoder();
const mdFor = (i: number) =>
`---\nname: b${i}\ndescription: A demo\n---\n\n# b${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://b${i}/SKILL.md`,
frontmatter: { name: `b${i}`, description: "A demo" },
resources: [
{
uri: `skill://b${i}/SKILL.md`,
digest: await sha256Digest(bytes),
size: bytes.byteLength,
},
],
});
}),
);
const readResource = vi.fn(async (uri: string) => ({
result: {
contents: [
{ uri, text: mdFor(Number(/b(\d+)/.exec(uri)?.[1] ?? "0")) },
],
},
}));
const client = {
readResource,
getServerSettings: () => ({ skillCatalogMaxBytes: 60 }),
} 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",
]);
expect(reports[2].incomplete).toMatch(/256 skills \/ 60 bytes/);
});
it("stops reading once the byte budget is exactly REACHED, not only exceeded", async () => {
// Every name is one digit, so every SKILL.md has the same length. With the
// budget set to exactly one file's charge, the first skill spends it and
// the second must not be read (Copilot, #2294).
const enc = new TextEncoder();
const mdFor = (i: number) =>
`---\nname: e${i}\ndescription: A demo\n---\n\n# e${i}\n`;
const oneFile = enc.encode(mdFor(0)).byteLength;
const skills = await Promise.all(
Array.from({ length: 3 }, async (_, i): Promise<SkillEntry> => {
const bytes = enc.encode(mdFor(i));
return entry({
uri: `skill://e${i}/SKILL.md`,
frontmatter: { name: `e${i}`, description: "A demo" },
resources: [
{
uri: `skill://e${i}/SKILL.md`,
digest: await sha256Digest(bytes),
size: bytes.byteLength,
},
],
});
}),
);
const readResource = vi.fn(async (uri: string) => ({
result: {
contents: [
{ uri, text: mdFor(Number(/e(\d+)/.exec(uri)?.[1] ?? "0")) },
],
},
}));
const client = {
readResource,
getServerSettings: () => ({ skillCatalogMaxBytes: oneFile }),
} as unknown as InspectorClientProtocol;
const reports = await verifySkills(client, skills);
expect(readResource.mock.calls.length).toBe(1);
expect(reports.map((r) => r.outcome)).toEqual([
"verified",
"incomplete",
"incomplete",
]);
});
it("charges a dynamic skill's SKILL.md read to the byte budget", async () => {
// A `"dynamic"` skill has no manifest, so its only read is the fallback
// SKILL.md fetch. Uncharged, `catalogBytes` stayed 0 and a byte budget of
// 1 read every dynamic skill in the catalog (Copilot).
const mdFor = (i: number) =>
`---\nname: d${i}\ndescription: A demo\n---\n\n# d${i}\n`;
const skills: SkillEntry[] = Array.from({ length: 3 }, (_, i) => ({
uri: `skill://d${i}/SKILL.md`,
frontmatter: { name: `d${i}`, description: "A demo" },
resources: DYNAMIC_RESOURCES,
}));
const readResource = vi.fn(async (uri: string) => ({
result: {
contents: [
{ uri, text: mdFor(Number(/d(\d+)/.exec(uri)?.[1] ?? "0")) },
],
},
}));
const client = {
readResource,
getServerSettings: () => ({ skillCatalogMaxBytes: 1 }),
} as unknown as InspectorClientProtocol;
const reports = await verifySkills(client, skills);
expect(readResource.mock.calls.length).toBe(1);
expect(reports[0].outcome).not.toBe("incomplete");
expect(reports[1].outcome).toBe("incomplete");
expect(reports[2].outcome).toBe("incomplete");
});
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 invalid skills catalog limits (#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";
@ -1683,6 +1684,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" },
@ -1955,6 +1969,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",
@ -2101,6 +2127,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,15 +322,27 @@ 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) {
// Static checks still run for every entry — they cost no I/O, so a skill
// past the budget is still reported on, just not read. What stops is the
// reading.
//
// ⚠️ STRICT on bytes, like the count. A run whose charged bytes exactly
// equal the limit has REACHED it, and `<=` read one more skill past a
// budget that was already spent (Copilot, #2294). The skill whose reads
// cross the limit is still reported in full — that is decided by charging
// after the entry, below, not by this comparison.
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 +379,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;
@ -499,6 +510,12 @@ export async function verifySkills(
files.push({ uri: entry.uri, status: "read-error", reason });
try {
const invocation = await client.readResource(entry.uri, metadata);
// ⚠️ Charged like a manifest read — raw response, or the decoded
// length when larger. Uncharged, a catalog of `"dynamic"` skills (whose
// ONLY read is this one) left `catalogBytes` at zero, so the run's byte
// budget bounded nothing for it and even a configured limit of 1 read
// every skill up to the count limit (Copilot, #2294).
let charged = responseBytes(invocation.result);
const contents = contentsFor(invocation.result, entry.uri);
if (!contents) {
fail(
@ -507,10 +524,12 @@ export async function verifySkills(
} else {
try {
entryBytes = skillFileBytes(contents);
charged = Math.max(charged, entryBytes.byteLength);
} catch (err) {
fail(reasonOf(err));
}
}
receivedBytes += charged;
// If the DECLARED manifest lists this file but the read bounds
// excluded it, verify it here too. The fallback exists for the
// frontmatter check, but reading a file and then not checking the

View file

@ -171,6 +171,20 @@ export type StoredMCPServer = MCPServerConfig & {
* file diff minimal for servers that never tuned it. `0` means unlimited.
*/
maxFetchRequests?: number;
/**
* The maximum number of skills whose files are read in one skills
* verification run (#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;
/**
* The maximum number of bytes read across all skills in 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`
@ -922,6 +936,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

@ -189,6 +189,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` | The maximum number of skills whose files are read in one verification run (`SKILL_MAX_CATALOG_SKILLS`) — the CLI's `--verify` and the TUI Skills pane. Positive integer; there is no unlimited value |
| `skillCatalogMaxBytes` | `67108864` | The maximum number of bytes read across all skills in one verification run (`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.