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:
cliffhall 2026-09-04 20:01:13 -04:00
commit 63623b754b

View file

@ -162,6 +162,7 @@ function spies() {
setModernLogLevel: vi.fn(),
clearCompletedTasks: vi.fn(),
refreshTasks: vi.fn().mockResolvedValue(undefined),
refreshSkills: vi.fn().mockResolvedValue(undefined),
refreshTools: vi.fn().mockResolvedValue(undefined),
refreshPrompts: vi.fn().mockResolvedValue(undefined),
refreshResources: vi.fn().mockResolvedValue(undefined),
@ -271,6 +272,7 @@ function harness(initial: HarnessProps = {}): Harness {
activeToolCallTaskIdRef,
clearCompletedTasks: s.clearCompletedTasks,
refreshTasks: s.refreshTasks,
refreshSkills: s.refreshSkills,
paginatedLists: p.paginatedLists ?? false,
paginatedListsOverride: { record: s.record, valueFor: s.valueFor },
toolsPagination,
@ -840,6 +842,88 @@ describe("onReadResourceContents", () => {
});
});
describe("onReadSkillFile (#2234)", () => {
const skillUri = "skill://demo/reference.md";
it("returns the block whose uri matches exactly", async () => {
const c = client({
readResource: vi.fn().mockResolvedValue({
result: {
contents: [
{ uri: "skill://demo/other.md", text: "wrong" },
{ uri: skillUri, text: "right", mimeType: "text/markdown" },
],
},
}),
});
const h = harness({ client: c });
await expect(h.api().onReadSkillFile(skillUri)).resolves.toEqual({
text: "right",
mimeType: "text/markdown",
});
});
it("accepts a sole block whose uri the server echoed back differently", async () => {
// `resources/read` answers the URI it was asked for, so a single-block
// response IS that block even when the echo differs in form.
const c = client({
readResource: vi.fn().mockResolvedValue({
result: { contents: [{ uri: "SKILL://DEMO/reference.md", text: "x" }] },
}),
});
const h = harness({ client: c });
await expect(h.api().onReadSkillFile(skillUri)).resolves.toEqual({
text: "x",
});
});
it("passes a blob block through as a blob", async () => {
const c = client({
readResource: vi.fn().mockResolvedValue({
result: { contents: [{ uri: skillUri, blob: "aGk=" }] },
}),
});
const h = harness({ client: c });
await expect(h.api().onReadSkillFile(skillUri)).resolves.toEqual({
blob: "aGk=",
});
});
it("throws when no block answers the uri", async () => {
// Returning an empty payload here would let the caller hash zero bytes and
// report a confident digest *mismatch* for a response that carried nothing.
const c = client({
readResource: vi.fn().mockResolvedValue({
result: {
contents: [
{ uri: "skill://demo/a.md", text: "a" },
{ uri: "skill://demo/b.md", text: "b" },
],
},
}),
});
const h = harness({ client: c });
await expect(h.api().onReadSkillFile(skillUri)).rejects.toThrow(
/returned no content/,
);
});
it("throws when there is no client", async () => {
const h = harness();
await expect(h.api().onReadSkillFile(skillUri)).rejects.toThrow(
"Client is not connected",
);
});
});
describe("onRefreshSkills (#2234)", () => {
it("drives the store refresh in the background", () => {
const h = harness();
h.api().onRefreshSkills();
expect(h.spies.refreshSkills).toHaveBeenCalled();
});
});
describe("subscriptions and completion", () => {
it("subscribes and unsubscribes through the client", async () => {
const c = client();