- skills.ts: report a manifest that omits the skill's own SKILL.md (an empty list included), duplicate URIs, entries outside the skill root, and a missing size. `Conforms` was reachable for manifests that break invariants SEP-2640 states. - skills.ts: cross-check the declared byte length before hashing. A size that disagrees fails verification on its own — the digest is taken over the bytes the server served, so agreeing with it says nothing about whether the manifest describes them. - skills.ts: copy the view instead of slicing its backing store in `sha256Digest`. `SharedArrayBuffer.prototype.slice()` returns another SharedArrayBuffer, which `crypto.subtle.digest` rejects — the cast claimed to handle the exact input that would have thrown. No cast now. - skills.ts: state the one obligation NOT checked here — that an entry's frontmatter matches the fetched SKILL.md's. The digest cannot cover it, and closing it needs a YAML parser, so it is tracked on #2248. - managedSkillsState: cap the walk at LIST_MAX_PAGES. The repeated-cursor guard only catches a server stuck on one cursor; endlessly unique ones walked forever. Raises rather than truncating, like the salvage walk. - managedSkillsState: gate every write on a session generation, so a walk that resolves after a disconnect or destroy cannot repopulate a cleared store or deliver the previous session's skills into the next. - inspectorClient: send a cursor when it is `!== undefined`, not when it is truthy. An opaque cursor may be `""`, and dropping it re-requested page one — which the store then reported as a repeated-cursor failure. - SkillsScreen: key verdict invalidation on the manifest (URI + digests + sizes), not the URI alone. A Refresh that changed the manifest left a green badge attached to a digest nothing had checked. - SkillsScreen: epoch-guard every read continuation, so a fetch that resolves after the selection moved on cannot write into the new one. - SkillsScreen: bound "Verify all" to 4 concurrent reads. A conforming manifest may hold 512 files. - skillsSchemas: drop the guessed `resources/directory/read` result schema. Nothing calls it, so an unverified shape could sit wrong indefinitely; phase 3 adds it against the normative text. - skills-http.json: declare the extension bare. It advertised `directoryRead: true` with no handler, so Connection Info reported "Supported" for a method that answers -32601. - types.ts: restore the Tasks doc comment the Skills interface displaced. 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>
154 lines
6.5 KiB
TypeScript
154 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;
|
|
|
|
/**
|
|
* ⚠️ **No `resources/directory/read` result schema here yet, on purpose.**
|
|
*
|
|
* The method name and the directory MIME type above are stated in SEP-2640;
|
|
* the shape of the result it returns is not something this PR verified against
|
|
* the normative text, and the Inspector does not call the method (phase 3,
|
|
* #2248). Declaring a guessed schema would put an unverified claim in the one
|
|
* module that is supposed to be the authority on the wire format — and one
|
|
* nothing exercises, so it could be wrong indefinitely without failing
|
|
* anything. Phase 3 adds it against the spec, alongside the call that uses it.
|
|
*/
|