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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue