inspector/core/mcp/skillsSchemas.ts
cliffhall 63623b754b 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>
2026-09-04 20:45:15 -04:00

164 lines
6.5 KiB
TypeScript

/**
* Skills extension wire schemas — SEP-2640 (`io.modelcontextprotocol/skills`).
*
* There is no `@modelcontextprotocol/ext-skills` package (404 on npm) and the
* SDK's era codecs define none of these methods, so the Inspector drives them
* as ordinary `client.request(…, ResultSchema)` calls with the explicit result
* schemas below. That is exactly what the SDK prescribes for a consumer-owned
* extension method: `Protocol._assertOutboundRequestInEra` only fires for names
* one of the era codecs knows, so `skills/list` and `skills/get` are era-blind
* and go out unchanged on both the 2025- and 2026-era legs. The raw-wire escape
* hatch that modern `tasks/*` needs is deliberately NOT used here — `tasks/*`
* are spec names the 2026 codec deleted, which is a different problem.
*
* **This module is the whole wire surface.** SEP-2640 is Accepted, so the method
* names and the entry shape are settled, but the skill *format* is delegated to
* the independently-versioned Agent Skills specification and the SEP leaves the
* `skills/get` caching attributes (SEP-2549 `ttlMs` / `cacheScope`) open. Keeping
* every wire type here makes a spec revision a single-file edit (#2234).
*
* Schemas are deliberately permissive (`looseObject`, and a `digest` typed as a
* plain string rather than a hex-constrained one) so a non-conforming server is
* *surfaced* rather than rejected — the Inspector is a conformance tool, and a
* malformed digest is a finding to report, not a parse error to swallow. The
* structural checks live in `skills.ts`.
*/
import { z } from "zod/v4";
/** SEP-2133 extension identifier for the Skills extension (SEP-2640). */
export const SKILLS_EXTENSION_KEY = "io.modelcontextprotocol/skills";
/** The `skills/list` JSON-RPC method name. */
export const SKILLS_LIST_METHOD = "skills/list";
/** The `skills/get` JSON-RPC method name. */
export const SKILLS_GET_METHOD = "skills/get";
/**
* The `resources/directory/read` method name, gated on the server declaring
* `directoryRead: true` in its extension advertisement. Declared here so the
* one wire-surface module names every method the extension defines, even though
* the Inspector does not call it yet (phase 3 of #2234).
*/
export const RESOURCES_DIRECTORY_READ_METHOD = "resources/directory/read";
/** `mimeType` marking a resource as a directory rather than a file. */
export const DIRECTORY_MIME_TYPE = "inode/directory";
/**
* The literal `resources` value meaning "this skill's file set is generated and
* cannot be enumerated". Integrity verification is impossible for such a skill,
* which is why it is a reported finding rather than a silent absence.
*/
export const DYNAMIC_RESOURCES = "dynamic";
/**
* The verbatim YAML frontmatter of a `SKILL.md`, expressed as JSON. `name` and
* `description` are the two fields SEP-2640 requires; everything else the Agent
* Skills format defines passes through untouched, since that format versions
* independently of this extension.
*/
export const SkillFrontmatterSchema = z.looseObject({
name: z.string().optional(),
description: z.string().optional(),
});
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>;
/**
* One file in a skill's manifest. `digest` is `sha256:` + 64 lowercase hex per
* the SEP, but it is typed as a bare string so a server that gets the format
* wrong still parses and can be *reported* — see `checkSkillConformance`.
*/
export const SkillResourceSchema = z.looseObject({
uri: z.string(),
digest: z.string().optional(),
size: z.number().optional(),
});
export type SkillResource = z.infer<typeof SkillResourceSchema>;
/**
* A skill entry as returned by `skills/list` and `skills/get`. `resources` is
* either the full file manifest or the literal `"dynamic"`; the union is
* preserved on the type rather than normalized away, because which one a server
* sent is itself the finding.
*/
export const SkillEntrySchema = z.looseObject({
uri: z.string(),
frontmatter: SkillFrontmatterSchema,
resources: z.union([
z.literal(DYNAMIC_RESOURCES),
z.array(SkillResourceSchema),
]),
});
export type SkillEntry = z.infer<typeof SkillEntrySchema>;
/** `skills/list` result: a page of entries plus the opaque cursor. */
export const ListSkillsResultSchema = z.looseObject({
skills: z.array(SkillEntrySchema),
nextCursor: z.string().optional(),
});
export type ListSkillsResult = z.infer<typeof ListSkillsResultSchema>;
/**
* The envelope form of a `skills/get` result: the entry wrapped under `skill`.
*/
const GetSkillEnvelopeSchema = z.looseObject({ skill: SkillEntrySchema });
/**
* Collapse either accepted `skills/get` shape to the entry it carries.
*
* Written as a parse rather than an `in` check because both accepted shapes are
* loose objects — they carry an index signature, so `"skill" in result` narrows
* nothing and would leave the extracted value `unknown`. Parsing the envelope
* is what proves its `skill` really is an entry, with no cast anywhere.
*/
export function normalizeGetSkillResult(result: unknown): SkillEntry {
const enveloped = GetSkillEnvelopeSchema.safeParse(result);
return enveloped.success
? enveloped.data.skill
: SkillEntrySchema.parse(result);
}
/**
* `skills/get` result, normalized to the entry.
*
* ⚠️ The SEP settles the *entry* shape but not the envelope this result wraps it
* in, so both plausible forms are accepted: `{ skill: <entry> }` and the entry
* returned inline at the top level. Being permissive here costs nothing (the
* two are structurally distinguishable — an inline entry carries `uri` and
* `frontmatter`, the envelope carries neither) and spares a server author a
* failure whose cause is a spec ambiguity rather than their code. The transform
* means every caller receives the entry and none of them branches.
*/
export const GetSkillResultSchema = z
.union([GetSkillEnvelopeSchema, SkillEntrySchema])
.transform(normalizeGetSkillResult);
export type GetSkillResult = SkillEntry;
/**
* `resources/directory/read` result — the direct (non-recursive) children of a
* directory resource. Present for completeness of the wire surface; the
* Inspector surfaces the `directoryRead` sub-flag today and calls the method in
* phase 3.
*/
export const ReadResourceDirectoryResultSchema = z.looseObject({
contents: z.array(
z.looseObject({
uri: z.string(),
name: z.string().optional(),
mimeType: z.string().optional(),
size: z.number().optional(),
}),
),
nextCursor: z.string().optional(),
});
export type ReadResourceDirectoryResult = z.infer<
typeof ReadResourceDirectoryResultSchema
>;