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:
parent
eb41185c37
commit
467c97307e
7 changed files with 102 additions and 9 deletions
|
|
@ -130,6 +130,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
|
||||||
settings: { ...args.settings, maxFetchRequests: value },
|
settings: { ...args.settings, maxFetchRequests: value },
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onSkillCatalogLimitChange={(field, value) => {
|
||||||
|
args.onSkillCatalogLimitChange(field, value);
|
||||||
|
updateArgs({
|
||||||
|
settings: { ...args.settings, [field]: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
onProtocolEraChange={(value) => {
|
onProtocolEraChange={(value) => {
|
||||||
args.onProtocolEraChange(value);
|
args.onProtocolEraChange(value);
|
||||||
updateArgs({
|
updateArgs({
|
||||||
|
|
@ -178,6 +184,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
||||||
onPaginatedListsChange: fn(),
|
onPaginatedListsChange: fn(),
|
||||||
onAdvertisedExtensionChange: fn(),
|
onAdvertisedExtensionChange: fn(),
|
||||||
onMaxFetchRequestsChange: fn(),
|
onMaxFetchRequestsChange: fn(),
|
||||||
|
onSkillCatalogLimitChange: fn(),
|
||||||
onProtocolEraChange: fn(),
|
onProtocolEraChange: fn(),
|
||||||
onModernLogLevelChange: fn(),
|
onModernLogLevelChange: fn(),
|
||||||
onOAuthChange: fn(),
|
onOAuthChange: fn(),
|
||||||
|
|
|
||||||
|
|
@ -871,7 +871,7 @@ export function ServerSettingsForm({
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<SkillCountInput
|
<SkillCountInput
|
||||||
label="Maximum Number of Skills"
|
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}
|
value={skillCatalogBudget.maxSkills}
|
||||||
onChange={handleSkillCatalogLimitChange(
|
onChange={handleSkillCatalogLimitChange(
|
||||||
"skillCatalogMaxSkills",
|
"skillCatalogMaxSkills",
|
||||||
|
|
@ -880,7 +880,7 @@ export function ServerSettingsForm({
|
||||||
/>
|
/>
|
||||||
<SkillBytesInput
|
<SkillBytesInput
|
||||||
label="Maximum Catalog Size"
|
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}
|
value={skillCatalogBudget.maxBytes}
|
||||||
onChange={handleSkillCatalogLimitChange(
|
onChange={handleSkillCatalogLimitChange(
|
||||||
"skillCatalogMaxBytes",
|
"skillCatalogMaxBytes",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import type { InspectorClientProtocol } from "@inspector/core/mcp/inspectorClientProtocol.js";
|
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 {
|
import {
|
||||||
SKILL_MAX_CATALOG_SKILLS,
|
SKILL_MAX_CATALOG_SKILLS,
|
||||||
sha256Digest,
|
sha256Digest,
|
||||||
|
|
@ -834,6 +837,79 @@ describe("verifySkills (#2248)", () => {
|
||||||
expect(reports[2].incomplete).toMatch(/budget of 2 skills \/ 67108864/);
|
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 () => {
|
it("falls back to the default budget for an unusable configured limit", async () => {
|
||||||
const { skill, client } = await truncatable({ name: "fb", count: 2 });
|
const { skill, client } = await truncatable({ name: "fb", count: 2 });
|
||||||
const withSettings = {
|
const withSettings = {
|
||||||
|
|
|
||||||
|
|
@ -832,7 +832,7 @@ describe("server.ts supplemental coverage", () => {
|
||||||
expect((await res.json()).error).toMatch(/maxFetchRequests/);
|
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 });
|
const skills = await postSettings({ ...base, skillCatalogMaxSkills: 0 });
|
||||||
expect((await skills.json()).error).toMatch(/skillCatalogMaxSkills/);
|
expect((await skills.json()).error).toMatch(/skillCatalogMaxSkills/);
|
||||||
const bytes = await postSettings({ ...base, skillCatalogMaxBytes: 1.5 });
|
const bytes = await postSettings({ ...base, skillCatalogMaxBytes: 1.5 });
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,12 @@ export async function verifySkills(
|
||||||
files.push({ uri: entry.uri, status: "read-error", reason });
|
files.push({ uri: entry.uri, status: "read-error", reason });
|
||||||
try {
|
try {
|
||||||
const invocation = await client.readResource(entry.uri, metadata);
|
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);
|
const contents = contentsFor(invocation.result, entry.uri);
|
||||||
if (!contents) {
|
if (!contents) {
|
||||||
fail(
|
fail(
|
||||||
|
|
@ -512,10 +518,12 @@ export async function verifySkills(
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
entryBytes = skillFileBytes(contents);
|
entryBytes = skillFileBytes(contents);
|
||||||
|
charged = Math.max(charged, entryBytes.byteLength);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(reasonOf(err));
|
fail(reasonOf(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
receivedBytes += charged;
|
||||||
// If the DECLARED manifest lists this file but the read bounds
|
// If the DECLARED manifest lists this file but the read bounds
|
||||||
// excluded it, verify it here too. The fallback exists for the
|
// excluded it, verify it here too. The fallback exists for the
|
||||||
// frontmatter check, but reading a file and then not checking the
|
// frontmatter check, but reading a file and then not checking the
|
||||||
|
|
|
||||||
|
|
@ -172,14 +172,16 @@ export type StoredMCPServer = MCPServerConfig & {
|
||||||
*/
|
*/
|
||||||
maxFetchRequests?: number;
|
maxFetchRequests?: number;
|
||||||
/**
|
/**
|
||||||
* Most skills one skills verification run reads from (#2294). Omitted on
|
* The maximum number of skills whose files are read in one skills
|
||||||
* disk when it equals `SKILL_MAX_CATALOG_SKILLS`. Must be a positive integer
|
* 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`
|
* — there is no unlimited value, since the bound is what makes `--verify`
|
||||||
* terminate.
|
* terminate.
|
||||||
*/
|
*/
|
||||||
skillCatalogMaxSkills?: number;
|
skillCatalogMaxSkills?: number;
|
||||||
/**
|
/**
|
||||||
* Byte ceiling across one skills verification run (#2294). Omitted on disk
|
* 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.
|
* when it equals `SKILL_MAX_CATALOG_BYTES`. Positive integer, as above.
|
||||||
*/
|
*/
|
||||||
skillCatalogMaxBytes?: number;
|
skillCatalogMaxBytes?: number;
|
||||||
|
|
|
||||||
|
|
@ -188,8 +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 |
|
| `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` |
|
| `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 |
|
| `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 |
|
| `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` | Most bytes one skills verification run reads (`SKILL_MAX_CATALOG_BYTES`, 64 MiB). Positive integer |
|
| `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 }` |
|
| `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.
|
`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