feat: support the Skills extension (SEP-2640) with digest verification
Adds detection, enumeration, and conformance checking for the Skills extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234, which is everything its Acceptance list names. Skills is a *server*-declared extension, read off the connecting server's `capabilities.extensions`, so it is deliberately absent from `ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises and the user toggles, and an entry there would be a meaningless toggle. `skills/list` and `skills/get` are consumer-owned extension methods that neither era codec defines, so the SDK's era gate skips them and they go out as ordinary `client.request` calls with explicit result schemas. The raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is not era-gated: a legacy-era server that declares the extension is serving it. The checks are the point, not the list view. `core/mcp/skills.ts` reports each obligation SEP-2640 states — the name/path invariant, the digest format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which means integrity cannot be verified at all. Digest verification hashes the fetched bytes with WebCrypto and returns a mismatch with both digests attached rather than throwing, because showing a mismatch loudly is the whole value proposition. Files are fetched on demand: SEP-2640 is explicit that a `resources/read` of a `SKILL.md` is not a load and confers no standing, so none of the SEP's host machinery is implemented. The `skills-http` fixture serves four skills over two pages, three of them deliberately non-conforming — without those the verification code is untestable. Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248. Closes #2234 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
1c1e70820b
commit
63623b754b
1 changed files with 3684 additions and 4 deletions
|
|
@ -40,6 +40,8 @@ import type {
|
|||
} from "../types.js";
|
||||
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
|
||||
import type { MalformedListItem } from "../listSalvage.js";
|
||||
import type { SkillEntry, SkillResource } from "../skillsSchemas.js";
|
||||
import type { SkillsExtensionSupport } from "../skills.js";
|
||||
import type { JsonValue } from "../../json/jsonUtils.js";
|
||||
|
||||
type ListResult<TKey extends string, TItem> = {
|
||||
|
|
@ -104,6 +106,7 @@ export class FakeInspectorClient
|
|||
ListResult<"resourceTemplates", ResourceTemplate>
|
||||
> = [];
|
||||
taskPages: Array<ListResult<"tasks", Task>> = [];
|
||||
skillPages: Array<ListResult<"skills", SkillEntry>> = [];
|
||||
|
||||
listTools = vi.fn(async () => this.toolPages.shift() ?? { tools: [] });
|
||||
listPrompts = vi.fn(async () => this.promptPages.shift() ?? { prompts: [] });
|
||||
|
|
@ -116,6 +119,13 @@ export class FakeInspectorClient
|
|||
listRequestorTasks = vi.fn(
|
||||
async () => this.taskPages.shift() ?? { tasks: [] },
|
||||
);
|
||||
listSkills = vi.fn(async () => this.skillPages.shift() ?? { skills: [] });
|
||||
// `skills/get` echoes a minimal entry; tests that care override the mock.
|
||||
getSkill = vi.fn(async (uri: string) => ({
|
||||
uri,
|
||||
frontmatter: {},
|
||||
resources: [] as SkillResource[],
|
||||
}));
|
||||
// Modern task poll (#1631): defaults to echoing back a minimal task; tests
|
||||
// override the mock to drive status transitions. Dispatches nothing by
|
||||
// default — tests that exercise the merge path dispatch requestorTaskUpdated
|
||||
|
|
@ -135,6 +145,14 @@ export class FakeInspectorClient
|
|||
return this.tasksExtensionNegotiated;
|
||||
}
|
||||
|
||||
// The Skills extension (SEP-2640) this fake presents. `undefined` means the
|
||||
// server declared none, which is what `getSkillsExtension` returns then —
|
||||
// tests assign a support object to exercise the skills paths.
|
||||
skillsExtension: SkillsExtensionSupport | undefined = undefined;
|
||||
getSkillsExtension(): SkillsExtensionSupport | undefined {
|
||||
return this.skillsExtension;
|
||||
}
|
||||
|
||||
// Attributes a failed load back to its Protocol entry (#1953). A `vi.fn` so
|
||||
// tests can assert the method name and reason a failing refresh reported.
|
||||
markResponseRejected = vi.fn((_method: string, _reason: string) => {});
|
||||
|
|
|
|||
Loading…
Reference in a new issue