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
4 changed files with 3684 additions and 4 deletions
|
|
@ -49,6 +49,7 @@ import {
|
|||
createModernTaskTools,
|
||||
wireModernTaskHandlers,
|
||||
} from "./modern-tasks.js";
|
||||
import { SKILLS_EXTENSION_KEY, wireSkillsHandlers } from "./skills.js";
|
||||
|
||||
/**
|
||||
* MCP Apps extension id. Hardcoded for the same reason the Inspector's
|
||||
|
|
@ -556,6 +557,12 @@ export interface ServerConfig {
|
|||
* `modern: true`. See `modern-tasks.ts`.
|
||||
*/
|
||||
tasksExtension?: boolean;
|
||||
/**
|
||||
* Advertise the Skills extension (SEP-2640) and serve `skills/list` /
|
||||
* `skills/get` plus the `skill://` files those entries name. The fixture set
|
||||
* deliberately includes non-conforming skills — see `skills.ts`.
|
||||
*/
|
||||
skills?: { directoryRead?: boolean };
|
||||
/**
|
||||
* Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the
|
||||
* nested `elicitation` setting — the server-side half of the app-rendered
|
||||
|
|
@ -824,6 +831,23 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
};
|
||||
}
|
||||
|
||||
// Skills extension (SEP-2640): a server-declared extension, advertised with
|
||||
// its one sub-option. `directoryRead` is opt-in per config so a client can be
|
||||
// exercised against both a server that offers `resources/directory/read` and
|
||||
// one that does not.
|
||||
if (config.skills) {
|
||||
capabilities.extensions = {
|
||||
...(capabilities.extensions ?? {}),
|
||||
[SKILLS_EXTENSION_KEY]: {
|
||||
...(config.skills.directoryRead ? { directoryRead: true } : {}),
|
||||
},
|
||||
};
|
||||
// Skill files are fetched through ordinary `resources/read`, so the
|
||||
// resources capability has to be advertised even when the config registers
|
||||
// no ordinary resources of its own.
|
||||
capabilities.resources = capabilities.resources ?? {};
|
||||
}
|
||||
|
||||
// MCP Apps app-rendered elicitation (#1854): the server-side half of the
|
||||
// negotiation, on the same extension the Apps work already uses.
|
||||
if (config.appElicitation) {
|
||||
|
|
@ -1503,6 +1527,13 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
wireModernTaskHandlers(mcpServer, modernTaskRuntime);
|
||||
}
|
||||
|
||||
// Skills extension (SEP-2640): raw skills/list + skills/get, and the
|
||||
// `skill://` half of resources/read. Wired after the SDK's own handlers so
|
||||
// the resources/read wrapper can delegate non-skill URIs to them.
|
||||
if (config.skills) {
|
||||
wireSkillsHandlers(mcpServer);
|
||||
}
|
||||
|
||||
// Extension-gated tools (#1739): start each gated tool disabled, then enable
|
||||
// it on `initialized` iff the connected client declared its extension.
|
||||
if (config.extensionGatedTools) {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export interface ConfigFile {
|
|||
* and wire its handlers + `modern_task` / `modern_input_task` tools. Pair with
|
||||
* `transport.modern`. */
|
||||
tasksExtension?: boolean;
|
||||
/** Advertise the Skills extension (SEP-2640) and serve its fixture skills.
|
||||
* `directoryRead` advertises the `resources/directory/read` sub-option. */
|
||||
skills?: { directoryRead?: boolean };
|
||||
/** Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the nested
|
||||
* `elicitation` setting — the server half of app-rendered form elicitation
|
||||
* (#1854). Pair with the `app_choose_option` tool + `choose_option_app` resource. */
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
|
|||
subscriptions: config.subscriptions,
|
||||
tasks: config.tasks,
|
||||
tasksExtension: config.tasksExtension,
|
||||
skills: config.skills,
|
||||
appElicitation: config.appElicitation,
|
||||
maxPageSize: config.maxPageSize,
|
||||
duplicateToolNames: config.duplicateToolNames,
|
||||
|
|
|
|||
280
test-servers/src/skills.ts
Normal file
280
test-servers/src/skills.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* Skills extension test fixture — SEP-2640 (`io.modelcontextprotocol/skills`).
|
||||
*
|
||||
* Serves `skills/list` (paginated) and `skills/get`, plus `resources/read` for
|
||||
* the `skill://` URIs those entries name, so an Inspector connected here can
|
||||
* exercise the whole flow: enumerate, fetch a file, and verify its digest.
|
||||
*
|
||||
* **The non-conforming skills are the point.** A fixture that only served a
|
||||
* clean skill would leave every verification and conformance path in the
|
||||
* Inspector untestable, so the set below deliberately includes one `"dynamic"`
|
||||
* skill, one whose advertised digest does not match the bytes served, and one
|
||||
* whose URI path segment disagrees with `frontmatter.name`. Each is the exact
|
||||
* shape one of the checks in `core/mcp/skills.ts` exists to catch.
|
||||
*
|
||||
* Handlers are installed straight into the low-level `_requestHandlers` map
|
||||
* rather than through `setRequestHandler`, the same seam `modern-tasks.ts`
|
||||
* uses. `skills/*` are consumer-owned extension methods that neither era codec
|
||||
* defines, so they need no schemas and are era-blind in both directions —
|
||||
* which is what lets one fixture serve both the legacy and modern legs.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
/** SEP-2133 extension identifier for the Skills extension (SEP-2640). */
|
||||
export const SKILLS_EXTENSION_KEY = "io.modelcontextprotocol/skills";
|
||||
|
||||
/**
|
||||
* Entries per `skills/list` page. Two, deliberately: the fixture serves four
|
||||
* skills, so a client that stops after page one sees half the set — which is
|
||||
* what makes a broken cursor walk visible rather than merely slower.
|
||||
*/
|
||||
export const SKILLS_PAGE_SIZE = 2;
|
||||
|
||||
/** `sha256:<64 lowercase hex>` over a UTF-8 string, the SEP's digest form. */
|
||||
function digestOf(text: string): string {
|
||||
return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
/** Byte length of a UTF-8 string, for the manifest's `size`. */
|
||||
function sizeOf(text: string): number {
|
||||
return Buffer.byteLength(text, "utf8");
|
||||
}
|
||||
|
||||
interface FixtureFile {
|
||||
uri: string;
|
||||
text: string;
|
||||
mimeType: string;
|
||||
/**
|
||||
* Digest to *advertise*, when it should differ from the real one. The
|
||||
* tampered skill sets this; everywhere else the advertised digest is
|
||||
* computed from the very bytes served, so a clean skill verifies.
|
||||
*/
|
||||
advertisedDigest?: string;
|
||||
}
|
||||
|
||||
interface FixtureSkill {
|
||||
/** The `<skill-path>` segment; `skill://<path>/SKILL.md` is the entry URI. */
|
||||
path: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
/** `"dynamic"` for a generated skill with no enumerable manifest. */
|
||||
files: FixtureFile[] | "dynamic";
|
||||
}
|
||||
|
||||
function skillMd(name: string, description: string, body: string): string {
|
||||
return `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`;
|
||||
}
|
||||
|
||||
const DATA_ANALYSIS_MD = skillMd(
|
||||
"data-analysis",
|
||||
"Analyze a CSV and summarize its columns",
|
||||
"# Data analysis\n\nLoad the CSV, then follow `reference.md` for the column rules.",
|
||||
);
|
||||
const DATA_ANALYSIS_REF =
|
||||
"# Column rules\n\nNumeric columns get min/max/mean; text columns get a value count.\n";
|
||||
|
||||
const TAMPERED_MD = skillMd(
|
||||
"tampered-notes",
|
||||
"A skill whose manifest digest does not match its served bytes",
|
||||
"# Tampered notes\n\nThe digest advertised for `notes.md` is wrong on purpose.",
|
||||
);
|
||||
const TAMPERED_NOTES =
|
||||
"# Notes\n\nThese bytes hash to something other than what the manifest claims.\n";
|
||||
|
||||
const DYNAMIC_MD = skillMd(
|
||||
"dynamic-report",
|
||||
"A skill whose files are generated per request",
|
||||
"# Dynamic report\n\nThis skill's file set is generated, so it advertises no manifest.",
|
||||
);
|
||||
|
||||
// The frontmatter says `right-name` while the URI segment says `wrong-folder`,
|
||||
// breaking the one structural invariant SEP-2640 states outright: the segment
|
||||
// before /SKILL.md must equal frontmatter.name.
|
||||
const MISMATCHED_MD = skillMd(
|
||||
"right-name",
|
||||
"A skill whose URI path segment disagrees with its frontmatter name",
|
||||
"# Mismatched name\n\nServed from `wrong-folder/` while claiming the name `right-name`.",
|
||||
);
|
||||
|
||||
const FIXTURE_SKILLS: FixtureSkill[] = [
|
||||
{
|
||||
path: "data-analysis",
|
||||
frontmatter: {
|
||||
name: "data-analysis",
|
||||
description: "Analyze a CSV and summarize its columns",
|
||||
},
|
||||
files: [
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
text: DATA_ANALYSIS_MD,
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
{
|
||||
uri: "skill://data-analysis/reference.md",
|
||||
text: DATA_ANALYSIS_REF,
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tampered-notes",
|
||||
frontmatter: {
|
||||
name: "tampered-notes",
|
||||
description: "A skill whose manifest digest does not match its bytes",
|
||||
},
|
||||
files: [
|
||||
{
|
||||
uri: "skill://tampered-notes/SKILL.md",
|
||||
text: TAMPERED_MD,
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
{
|
||||
uri: "skill://tampered-notes/notes.md",
|
||||
text: TAMPERED_NOTES,
|
||||
mimeType: "text/markdown",
|
||||
// A syntactically valid digest of the *wrong* bytes, so the failure the
|
||||
// Inspector reports is a mismatch rather than a malformed-digest
|
||||
// finding — those are different checks and must stay distinguishable.
|
||||
advertisedDigest: digestOf("not the bytes this server serves"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "dynamic-report",
|
||||
frontmatter: {
|
||||
name: "dynamic-report",
|
||||
description: "A skill whose files are generated per request",
|
||||
},
|
||||
files: "dynamic",
|
||||
},
|
||||
{
|
||||
path: "wrong-folder",
|
||||
frontmatter: {
|
||||
name: "right-name",
|
||||
description: "A skill whose URI segment disagrees with its name",
|
||||
},
|
||||
files: [
|
||||
{
|
||||
uri: "skill://wrong-folder/SKILL.md",
|
||||
text: MISMATCHED_MD,
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Every servable `skill://` file, by URI. `dynamic` skills contribute their
|
||||
* `SKILL.md` too, so the screen's "View SKILL.md" works there as well. */
|
||||
const FILES_BY_URI = new Map<string, FixtureFile>();
|
||||
for (const skill of FIXTURE_SKILLS) {
|
||||
if (skill.files === "dynamic") {
|
||||
FILES_BY_URI.set(`skill://${skill.path}/SKILL.md`, {
|
||||
uri: `skill://${skill.path}/SKILL.md`,
|
||||
text: DYNAMIC_MD,
|
||||
mimeType: "text/markdown",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const file of skill.files) FILES_BY_URI.set(file.uri, file);
|
||||
}
|
||||
|
||||
/** The wire entry for one fixture skill. */
|
||||
function toEntry(skill: FixtureSkill): Record<string, unknown> {
|
||||
return {
|
||||
uri: `skill://${skill.path}/SKILL.md`,
|
||||
frontmatter: skill.frontmatter,
|
||||
resources:
|
||||
skill.files === "dynamic"
|
||||
? "dynamic"
|
||||
: skill.files.map((file) => ({
|
||||
uri: file.uri,
|
||||
digest: file.advertisedDigest ?? digestOf(file.text),
|
||||
size: sizeOf(file.text),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** One `skills/list` page starting at `cursor` (an index, as a string). */
|
||||
export function listSkillsPage(cursor?: string): Record<string, unknown> {
|
||||
const start = cursor ? Number.parseInt(cursor, 10) : 0;
|
||||
// A cursor the fixture never issued is answered as an empty final page
|
||||
// rather than an error: the Inspector's walk should terminate, and a thrown
|
||||
// error here would read as a transport failure instead.
|
||||
const from = Number.isFinite(start) && start > 0 ? start : 0;
|
||||
const page = FIXTURE_SKILLS.slice(from, from + SKILLS_PAGE_SIZE);
|
||||
const next = from + SKILLS_PAGE_SIZE;
|
||||
return {
|
||||
skills: page.map(toEntry),
|
||||
...(next < FIXTURE_SKILLS.length ? { nextCursor: String(next) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The `skills/get` result for one entry URI. */
|
||||
export function getSkillEntry(uri: string): Record<string, unknown> {
|
||||
const skill = FIXTURE_SKILLS.find(
|
||||
(candidate) => `skill://${candidate.path}/SKILL.md` === uri,
|
||||
);
|
||||
if (!skill) throw new Error(`Unknown skill uri: ${uri}`);
|
||||
// The envelope form (`{ skill }`) is served deliberately: the SEP settles the
|
||||
// entry shape but not this wrapper, and the Inspector accepts both — serving
|
||||
// the wrapped one keeps that tolerance exercised.
|
||||
return { skill: toEntry(skill) };
|
||||
}
|
||||
|
||||
/** The `resources/read` result for a `skill://` file, or `undefined`. */
|
||||
export function readSkillFile(
|
||||
uri: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const file = FILES_BY_URI.get(uri);
|
||||
if (!file) return undefined;
|
||||
return {
|
||||
contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }],
|
||||
};
|
||||
}
|
||||
|
||||
/** The private handler registry the SDK dispatches through. */
|
||||
interface RawHandlerHost {
|
||||
_requestHandlers: Map<
|
||||
string,
|
||||
(request: unknown, ctx: unknown) => Promise<unknown>
|
||||
>;
|
||||
}
|
||||
|
||||
interface UriRequest {
|
||||
params?: { uri?: string; cursor?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read`
|
||||
* onto an `McpServer`.
|
||||
*
|
||||
* `resources/read` is wrapped rather than replaced: a `skill://` URI is
|
||||
* answered here and everything else falls through to whatever the SDK
|
||||
* registered, so a config can serve ordinary resources alongside its skills.
|
||||
*/
|
||||
export function wireSkillsHandlers(mcpServer: McpServer): void {
|
||||
const registry = (mcpServer.server as unknown as RawHandlerHost)
|
||||
._requestHandlers;
|
||||
|
||||
registry.set("skills/list", async (request) => {
|
||||
const req = request as UriRequest;
|
||||
return listSkillsPage(req.params?.cursor);
|
||||
});
|
||||
|
||||
registry.set("skills/get", async (request) => {
|
||||
const req = request as UriRequest;
|
||||
return getSkillEntry(req.params?.uri ?? "");
|
||||
});
|
||||
|
||||
const sdkResourcesRead = registry.get("resources/read");
|
||||
registry.set("resources/read", async (request, ctx) => {
|
||||
const req = request as UriRequest;
|
||||
const skillFile = readSkillFile(req.params?.uri ?? "");
|
||||
if (skillFile) return skillFile;
|
||||
if (!sdkResourcesRead) {
|
||||
throw new Error(`Unknown resource: ${req.params?.uri}`);
|
||||
}
|
||||
return sdkResourcesRead(request, ctx);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue