fix: charge the fallback SKILL.md read to the catalog byte budget; review round 1 (#2294)

- A "dynamic" skill's only read is the fallback SKILL.md fetch, which was not
  charged, so a catalog of dynamic skills never advanced catalogBytes and a
  configured byte budget bounded nothing. Charge it like a manifest read.
- Add verifySkills cases for a configured byte budget and for dynamic skills.
- Wire onSkillCatalogLimitChange into the ServerSettingsForm story.
- Reword the new field descriptions, JSDoc and config table rows; rename the
  route test to cover every invalid value it sends.

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-14 00:25:19 -04:00
commit 467c97307e
4 changed files with 102 additions and 9 deletions

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

@ -871,7 +871,7 @@ export function ServerSettingsForm({
<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."
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",
@ -880,7 +880,7 @@ export function ServerSettingsForm({
/>
<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."
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",

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,
@ -834,6 +837,79 @@ describe("verifySkills (#2248)", () => {
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("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 = {

View file

@ -832,7 +832,7 @@ describe("server.ts supplemental coverage", () => {
expect((await res.json()).error).toMatch(/maxFetchRequests/);
});
it("rejects a non-positive skills catalog limit (#2294)", async () => {
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 });