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:
parent
3e728a3b8c
commit
eb41185c37
14 changed files with 469 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue