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>
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
/**
|
|
* Skills extension (SEP-2640) detection, conformance checking, and digest
|
|
* verification — the part of the extension that makes the Inspector more than a
|
|
* viewer.
|
|
*
|
|
* SEP-2640 puts real obligations on whoever consumes a skill: verify each
|
|
* fetched file against the digest its manifest advertised, treat a mismatch as
|
|
* a failure, and honor the per-skill limits. Every one of those is a check a
|
|
* server author wants run against their implementation, which is the same
|
|
* argument the CLI's `--strict` tool-schema lint makes. So the checks live here,
|
|
* shared by every client, and produce a structured finding list rather than a
|
|
* boolean — a report is useful, "invalid" is not.
|
|
*
|
|
* ⚠️ Skills is a **server-declared** extension, read off the connecting server's
|
|
* `capabilities.extensions`. It deliberately does NOT belong in
|
|
* `ADVERTISABLE_EXTENSIONS` (`core/mcp/extensions.ts`), which is the catalog of
|
|
* extensions the *Inspector* advertises and the user toggles in Server Settings.
|
|
* The precedent is `appElicitation.ts`, which reads the server side the same
|
|
* way; getting it backwards would put a meaningless toggle in Server Settings.
|
|
*
|
|
* The Inspector is an inspector, not a host: a `resources/read` of a `SKILL.md`
|
|
* is explicitly not a load and confers no standing, so none of the SEP's host
|
|
* machinery (activation, per-skill consent, content-bound approval) is
|
|
* implemented here. Surface and verify.
|
|
*/
|
|
|
|
import type { ServerCapabilities } from "@modelcontextprotocol/client";
|
|
import {
|
|
DYNAMIC_RESOURCES,
|
|
SKILLS_EXTENSION_KEY,
|
|
type SkillEntry,
|
|
type SkillResource,
|
|
} from "./skillsSchemas.js";
|
|
|
|
/** Maximum resource entries a single skill may declare (SEP-2640). */
|
|
export const SKILL_MAX_RESOURCE_ENTRIES = 512;
|
|
|
|
/** Maximum total size, in bytes, of a single skill's resources (16 MiB). */
|
|
export const SKILL_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
|
|
/** The suffix every skill URI ends with; the segment before it is the name. */
|
|
export const SKILL_FILE_SUFFIX = "/SKILL.md";
|
|
|
|
/** `sha256:` followed by exactly 64 lowercase hex characters. */
|
|
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
|
|
/**
|
|
* What the server declared under `io.modelcontextprotocol/skills`. The only
|
|
* sub-option SEP-2640 defines is `directoryRead`, which gates
|
|
* `resources/directory/read`.
|
|
*/
|
|
export interface SkillsExtensionSupport {
|
|
/** True when the server declared `directoryRead: true`. */
|
|
directoryRead: boolean;
|
|
}
|
|
|
|
/**
|
|
* Read the Skills extension off a server's advertised capabilities, or
|
|
* `undefined` when the server did not declare it.
|
|
*
|
|
* Not era-gated, unlike `isTasksExtensionNegotiated()`: `skills/*` are not spec
|
|
* method names in either codec, so nothing about the negotiated era changes
|
|
* whether the extension can be served or called. A legacy-era server that
|
|
* declares it is serving it.
|
|
*/
|
|
export function getSkillsExtension(
|
|
capabilities: ServerCapabilities | undefined,
|
|
): SkillsExtensionSupport | undefined {
|
|
const declared = capabilities?.extensions?.[SKILLS_EXTENSION_KEY];
|
|
if (declared === undefined || declared === null) return undefined;
|
|
const directoryRead =
|
|
typeof declared === "object" &&
|
|
(declared as { directoryRead?: unknown }).directoryRead === true;
|
|
return { directoryRead };
|
|
}
|
|
|
|
/** True when the connected server declared the Skills extension. */
|
|
export function isSkillsExtensionSupported(
|
|
capabilities: ServerCapabilities | undefined,
|
|
): boolean {
|
|
return getSkillsExtension(capabilities) !== undefined;
|
|
}
|
|
|
|
/**
|
|
* The final `<skill-path>` segment of a skill URI — the segment *before*
|
|
* `/SKILL.md`, not the filename. SEP-2640 requires it to equal
|
|
* `frontmatter.name`, which is what makes a skill's name recoverable from its
|
|
* URI alone. Returns `undefined` when the URI does not have that shape, which
|
|
* is itself a conformance finding.
|
|
*/
|
|
export function skillNameFromUri(uri: string): string | undefined {
|
|
if (!uri.endsWith(SKILL_FILE_SUFFIX)) return undefined;
|
|
const path = uri.slice(0, -SKILL_FILE_SUFFIX.length);
|
|
const segment = path.slice(path.lastIndexOf("/") + 1);
|
|
return segment.length > 0 ? segment : undefined;
|
|
}
|
|
|
|
/**
|
|
* The label a UI shows for a skill: the declared name, falling back to the URI
|
|
* path segment, falling back to the raw URI. Never empty, so a list row is
|
|
* always addressable even for a badly non-conforming entry.
|
|
*/
|
|
export function skillDisplayName(entry: SkillEntry): string {
|
|
const declared = entry.frontmatter.name?.trim();
|
|
if (declared) return declared;
|
|
return skillNameFromUri(entry.uri) ?? entry.uri;
|
|
}
|
|
|
|
/** Machine-readable identity of a conformance finding. */
|
|
export type SkillIssueCode =
|
|
| "dynamic-resources"
|
|
| "missing-name"
|
|
| "missing-description"
|
|
| "malformed-uri"
|
|
| "name-path-mismatch"
|
|
| "missing-digest"
|
|
| "malformed-digest"
|
|
| "resource-limit-exceeded"
|
|
| "size-limit-exceeded";
|
|
|
|
/**
|
|
* `error` marks a stated requirement of SEP-2640 that the server broke.
|
|
* `warning` marks something that is legal but leaves the Inspector unable to
|
|
* verify integrity — `"dynamic"` resources above all, which is the case most
|
|
* worth surfacing and the one most easily buried.
|
|
*/
|
|
export type SkillIssueSeverity = "error" | "warning";
|
|
|
|
export interface SkillIssue {
|
|
code: SkillIssueCode;
|
|
severity: SkillIssueSeverity;
|
|
/** Human-readable statement of what is wrong. */
|
|
message: string;
|
|
/** The manifest entry the finding is about, when it is a per-file finding. */
|
|
resourceUri?: string;
|
|
}
|
|
|
|
/**
|
|
* Run every structural check SEP-2640 states against one skill entry, returning
|
|
* the findings in a stable order (skill-level first, then per-resource in
|
|
* manifest order). An empty array means the entry conforms.
|
|
*
|
|
* This is the static half. Digest *verification* needs the file's bytes and so
|
|
* lives in {@link verifySkillResource}, which the UI runs on demand.
|
|
*/
|
|
export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|
const issues: SkillIssue[] = [];
|
|
const declaredName = entry.frontmatter.name?.trim();
|
|
const uriName = skillNameFromUri(entry.uri);
|
|
|
|
if (!declaredName) {
|
|
issues.push({
|
|
code: "missing-name",
|
|
severity: "error",
|
|
message: "frontmatter.name is required but missing or empty.",
|
|
});
|
|
}
|
|
if (!entry.frontmatter.description?.trim()) {
|
|
issues.push({
|
|
code: "missing-description",
|
|
severity: "warning",
|
|
message: "frontmatter.description is missing or empty.",
|
|
});
|
|
}
|
|
if (uriName === undefined) {
|
|
issues.push({
|
|
code: "malformed-uri",
|
|
severity: "error",
|
|
message: `Skill URI must end with "${SKILL_FILE_SUFFIX}" and carry a non-empty path segment before it.`,
|
|
});
|
|
} else if (declaredName && uriName !== declaredName) {
|
|
// The one structural invariant the spec states outright: the segment before
|
|
// /SKILL.md must equal frontmatter.name, so the name is recoverable from
|
|
// the URI alone. Only checked when both halves exist — a missing name is
|
|
// already reported above, and reporting it twice reads as two defects.
|
|
issues.push({
|
|
code: "name-path-mismatch",
|
|
severity: "error",
|
|
message: `URI path segment "${uriName}" does not match frontmatter.name "${declaredName}".`,
|
|
});
|
|
}
|
|
|
|
if (entry.resources === DYNAMIC_RESOURCES) {
|
|
issues.push({
|
|
code: "dynamic-resources",
|
|
severity: "warning",
|
|
message:
|
|
'resources is "dynamic": the file set is generated, so no digest is advertised and integrity cannot be verified.',
|
|
});
|
|
return issues;
|
|
}
|
|
|
|
if (entry.resources.length > SKILL_MAX_RESOURCE_ENTRIES) {
|
|
issues.push({
|
|
code: "resource-limit-exceeded",
|
|
severity: "error",
|
|
message: `Skill declares ${entry.resources.length} resource entries, above the ${SKILL_MAX_RESOURCE_ENTRIES}-entry limit.`,
|
|
});
|
|
}
|
|
const totalBytes = totalSkillBytes(entry.resources);
|
|
if (totalBytes > SKILL_MAX_TOTAL_BYTES) {
|
|
issues.push({
|
|
code: "size-limit-exceeded",
|
|
severity: "error",
|
|
message: `Skill resources total ${totalBytes} bytes, above the ${SKILL_MAX_TOTAL_BYTES}-byte (16 MiB) limit.`,
|
|
});
|
|
}
|
|
|
|
for (const resource of entry.resources) {
|
|
if (resource.digest === undefined) {
|
|
issues.push({
|
|
code: "missing-digest",
|
|
severity: "warning",
|
|
message: "Manifest entry declares no digest, so it cannot be verified.",
|
|
resourceUri: resource.uri,
|
|
});
|
|
} else if (!DIGEST_PATTERN.test(resource.digest)) {
|
|
issues.push({
|
|
code: "malformed-digest",
|
|
severity: "error",
|
|
message: `Digest "${resource.digest}" is not "sha256:" followed by 64 lowercase hex characters.`,
|
|
resourceUri: resource.uri,
|
|
});
|
|
}
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
/**
|
|
* Sum of the manifest's declared `size` fields. An entry that omits `size`
|
|
* contributes nothing rather than failing the sum — the limit check is about
|
|
* catching a server that is demonstrably over, and an incomplete manifest can
|
|
* only ever understate the total, so this never produces a false positive.
|
|
*/
|
|
export function totalSkillBytes(resources: readonly SkillResource[]): number {
|
|
return resources.reduce((sum, r) => sum + (r.size ?? 0), 0);
|
|
}
|
|
|
|
/** Outcome of comparing a fetched file against its advertised digest. */
|
|
export type SkillVerificationStatus =
|
|
| "verified"
|
|
| "mismatch"
|
|
| "unverifiable"
|
|
| "error";
|
|
|
|
export interface SkillVerification {
|
|
status: SkillVerificationStatus;
|
|
/** The digest computed over the fetched bytes, when one was computed. */
|
|
actualDigest?: string;
|
|
/** The manifest's digest, echoed so a mismatch renders both halves. */
|
|
expectedDigest?: string;
|
|
/** Why the file could not be verified or fetched. */
|
|
reason?: string;
|
|
}
|
|
|
|
/** Lowercase hex of a byte array — the form SEP-2640 digests are written in. */
|
|
function toHex(bytes: Uint8Array): string {
|
|
let out = "";
|
|
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* `sha256:<64 hex>` over the given bytes, in the exact form a manifest digest
|
|
* takes, so a caller can compare strings rather than re-deriving the prefix.
|
|
*
|
|
* Uses WebCrypto (`crypto.subtle`), which both Node ≥22 and the browser provide
|
|
* — no dependency, and per [Dependency placement] this module adds nothing to
|
|
* any manifest. The Inspector's web client is served over localhost, a secure
|
|
* context, so `subtle` is present there too.
|
|
*/
|
|
export async function sha256Digest(bytes: Uint8Array): Promise<string> {
|
|
// `BufferSource` wants a plain ArrayBuffer; a Uint8Array over a SharedArrayBuffer
|
|
// (or a view into a larger buffer) would otherwise hash the wrong range.
|
|
const buffer = bytes.buffer.slice(
|
|
bytes.byteOffset,
|
|
bytes.byteOffset + bytes.byteLength,
|
|
) as ArrayBuffer;
|
|
const hash = await crypto.subtle.digest("SHA-256", buffer);
|
|
return `sha256:${toHex(new Uint8Array(hash))}`;
|
|
}
|
|
|
|
/** UTF-8 bytes of a `resources/read` text content block. */
|
|
export function textToBytes(text: string): Uint8Array {
|
|
return new TextEncoder().encode(text);
|
|
}
|
|
|
|
/**
|
|
* Raw bytes of a `resources/read` blob content block (standard base64).
|
|
* Uses `atob`, which Node ≥22 and every browser provide, so this stays
|
|
* dependency-free and works unchanged in both.
|
|
*/
|
|
export function base64ToBytes(blob: string): Uint8Array {
|
|
const binary = atob(blob);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
/**
|
|
* Verify one fetched skill file against its manifest entry.
|
|
*
|
|
* A mismatch is reported as `"mismatch"` with both digests attached rather than
|
|
* thrown — the whole value proposition is showing a digest mismatch loudly, and
|
|
* a thrown error would collapse into whatever the caller's generic failure UI
|
|
* says. `"unverifiable"` means the manifest advertised no digest (or advertised
|
|
* a malformed one, already reported by {@link checkSkillConformance}); nothing
|
|
* about the file itself is wrong, we simply have nothing to compare against.
|
|
*/
|
|
export async function verifySkillResource(
|
|
resource: SkillResource,
|
|
bytes: Uint8Array,
|
|
): Promise<SkillVerification> {
|
|
const expectedDigest = resource.digest;
|
|
if (expectedDigest === undefined) {
|
|
return {
|
|
status: "unverifiable",
|
|
reason: "The manifest entry advertises no digest.",
|
|
};
|
|
}
|
|
if (!DIGEST_PATTERN.test(expectedDigest)) {
|
|
return {
|
|
status: "unverifiable",
|
|
expectedDigest,
|
|
reason:
|
|
'The advertised digest is not "sha256:" followed by 64 lowercase hex characters.',
|
|
};
|
|
}
|
|
const actualDigest = await sha256Digest(bytes);
|
|
return {
|
|
status: actualDigest === expectedDigest ? "verified" : "mismatch",
|
|
actualDigest,
|
|
expectedDigest,
|
|
};
|
|
}
|