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
8 changed files with 3684 additions and 4 deletions
|
|
@ -40,6 +40,8 @@ import type {
|
|||
} from "../types.js";
|
||||
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
|
||||
import type { MalformedListItem } from "../listSalvage.js";
|
||||
import type { SkillEntry, SkillResource } from "../skillsSchemas.js";
|
||||
import type { SkillsExtensionSupport } from "../skills.js";
|
||||
import type { JsonValue } from "../../json/jsonUtils.js";
|
||||
|
||||
type ListResult<TKey extends string, TItem> = {
|
||||
|
|
@ -104,6 +106,7 @@ export class FakeInspectorClient
|
|||
ListResult<"resourceTemplates", ResourceTemplate>
|
||||
> = [];
|
||||
taskPages: Array<ListResult<"tasks", Task>> = [];
|
||||
skillPages: Array<ListResult<"skills", SkillEntry>> = [];
|
||||
|
||||
listTools = vi.fn(async () => this.toolPages.shift() ?? { tools: [] });
|
||||
listPrompts = vi.fn(async () => this.promptPages.shift() ?? { prompts: [] });
|
||||
|
|
@ -116,6 +119,13 @@ export class FakeInspectorClient
|
|||
listRequestorTasks = vi.fn(
|
||||
async () => this.taskPages.shift() ?? { tasks: [] },
|
||||
);
|
||||
listSkills = vi.fn(async () => this.skillPages.shift() ?? { skills: [] });
|
||||
// `skills/get` echoes a minimal entry; tests that care override the mock.
|
||||
getSkill = vi.fn(async (uri: string) => ({
|
||||
uri,
|
||||
frontmatter: {},
|
||||
resources: [] as SkillResource[],
|
||||
}));
|
||||
// Modern task poll (#1631): defaults to echoing back a minimal task; tests
|
||||
// override the mock to drive status transitions. Dispatches nothing by
|
||||
// default — tests that exercise the merge path dispatch requestorTaskUpdated
|
||||
|
|
@ -135,6 +145,14 @@ export class FakeInspectorClient
|
|||
return this.tasksExtensionNegotiated;
|
||||
}
|
||||
|
||||
// The Skills extension (SEP-2640) this fake presents. `undefined` means the
|
||||
// server declared none, which is what `getSkillsExtension` returns then —
|
||||
// tests assign a support object to exercise the skills paths.
|
||||
skillsExtension: SkillsExtensionSupport | undefined = undefined;
|
||||
getSkillsExtension(): SkillsExtensionSupport | undefined {
|
||||
return this.skillsExtension;
|
||||
}
|
||||
|
||||
// Attributes a failed load back to its Protocol entry (#1953). A `vi.fn` so
|
||||
// tests can assert the method name and reason a failing refresh reported.
|
||||
markResponseRejected = vi.fn((_method: string, _reason: string) => {});
|
||||
|
|
|
|||
|
|
@ -144,6 +144,14 @@ import {
|
|||
type ModernDetailedTask,
|
||||
} from "./modernTaskSchemas.js";
|
||||
import { buildClientExtensions } from "./extensions.js";
|
||||
import {
|
||||
GetSkillResultSchema,
|
||||
ListSkillsResultSchema,
|
||||
SKILLS_GET_METHOD,
|
||||
SKILLS_LIST_METHOD,
|
||||
type SkillEntry,
|
||||
} from "./skillsSchemas.js";
|
||||
import { getSkillsExtension, type SkillsExtensionSupport } from "./skills.js";
|
||||
import {
|
||||
getElicitationUiResourceUri,
|
||||
isFormElicitation,
|
||||
|
|
@ -5516,6 +5524,81 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return { prompts };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Skills extension (SEP-2640) the server declared, or `undefined` when it
|
||||
* declared none. Gates the Skills tab and the skills store the same way
|
||||
* {@link isTasksExtensionNegotiated} gates Tasks — but deliberately without an
|
||||
* era check: `skills/*` are not spec method names in either codec, so a
|
||||
* legacy-era server that declares the extension is serving it (#2234).
|
||||
*/
|
||||
getSkillsExtension(): SkillsExtensionSupport | undefined {
|
||||
return getSkillsExtension(this.capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of `skills/list` (SEP-2640).
|
||||
*
|
||||
* An ordinary `client.request` with an explicit result schema — the SDK's era
|
||||
* gate skips methods neither codec defines, and `assertCapabilityForMethod`
|
||||
* falls through to a no-op for them, so this is all the extension needs. The
|
||||
* raw-wire path modern `tasks/*` uses would be wrong here: it exists for spec
|
||||
* names the 2026 codec deleted, and taking it would bypass the SDK's response
|
||||
* correlation for nothing (#2234).
|
||||
*/
|
||||
async listSkills(
|
||||
cursor?: string,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ skills: SkillEntry[]; nextCursor?: string }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const effectiveMeta = this.mergeMeta(metadata);
|
||||
const params: Record<string, unknown> = {
|
||||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
};
|
||||
const response = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.request(
|
||||
{ method: SKILLS_LIST_METHOD, params },
|
||||
ListSkillsResultSchema,
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: SKILLS_LIST_METHOD },
|
||||
);
|
||||
return {
|
||||
skills: response.skills,
|
||||
nextCursor: response.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One skill entry by URI (`skills/get`, SEP-2640). The SEP settles the entry
|
||||
* shape but not the envelope around it, so the result is normalized through
|
||||
* {@link normalizeGetSkillResult} rather than assuming one form.
|
||||
*/
|
||||
async getSkill(uri: string, metadata?: RequestMetadata): Promise<SkillEntry> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const effectiveMeta = this.mergeMeta(metadata);
|
||||
const params: Record<string, unknown> = {
|
||||
uri,
|
||||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
};
|
||||
// `GetSkillResultSchema` normalizes both accepted envelopes to the entry,
|
||||
// so there is nothing to unwrap here.
|
||||
return this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.request(
|
||||
{ method: SKILLS_GET_METHOD, params },
|
||||
GetSkillResultSchema,
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: SKILLS_GET_METHOD },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prompt by name
|
||||
* @param name Prompt name
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ import type {
|
|||
import type { JsonValue } from "../json/jsonUtils.js";
|
||||
import type { MalformedListItem } from "./listSalvage.js";
|
||||
import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js";
|
||||
import type { SkillEntry } from "./skillsSchemas.js";
|
||||
import type { SkillsExtensionSupport } from "./skills.js";
|
||||
import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
|
||||
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
|
||||
|
||||
|
|
@ -110,6 +112,17 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
* and the modern task store's poll-based refresh. */
|
||||
isTasksExtensionNegotiated(): boolean;
|
||||
|
||||
/** The Skills extension (SEP-2640) the server declared, or `undefined`.
|
||||
* Gates the Skills tab and the managed skills store (#2234). */
|
||||
getSkillsExtension(): SkillsExtensionSupport | undefined;
|
||||
/** One page of `skills/list`; the managed skills store walks the cursor. */
|
||||
listSkills(
|
||||
cursor?: string,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ skills: SkillEntry[]; nextCursor?: string }>;
|
||||
/** One skill entry by URI (`skills/get`). */
|
||||
getSkill(uri: string, metadata?: RequestMetadata): Promise<SkillEntry>;
|
||||
|
||||
/**
|
||||
* Mark the response that most recently answered `method` as rejected by the
|
||||
* client, so its Protocol entry shows the reason instead of rendering as a
|
||||
|
|
|
|||
336
core/mcp/skills.ts
Normal file
336
core/mcp/skills.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
164
core/mcp/skillsSchemas.ts
Normal file
164
core/mcp/skillsSchemas.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* 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
|
||||
>;
|
||||
|
|
@ -50,3 +50,8 @@ export type {
|
|||
} from "./pagedRequestorTasksState.js";
|
||||
export { ResourceSubscriptionsState } from "./resourceSubscriptionsState.js";
|
||||
export type { ResourceSubscriptionsStateEventMap } from "./resourceSubscriptionsState.js";
|
||||
export { ManagedSkillsState } from "./managedSkillsState.js";
|
||||
export type {
|
||||
ManagedSkillsStateEventMap,
|
||||
SkillsPaginationState,
|
||||
} from "./managedSkillsState.js";
|
||||
|
|
|
|||
181
core/mcp/state/managedSkillsState.ts
Normal file
181
core/mcp/state/managedSkillsState.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* ManagedSkillsState: holds the full skill list (SEP-2640), in sync with the
|
||||
* server. Loaded on connect, cleared on disconnect, re-walked on refresh.
|
||||
*
|
||||
* Deliberately NOT a `ManagedListState` subclass, despite the family
|
||||
* resemblance. That base is built around two things the Skills extension does
|
||||
* not have: a top-level `ServerCapabilities` key to gate on (skills is a
|
||||
* *server-declared extension*, read from `capabilities.extensions`), and a
|
||||
* per-list `list_changed` notification to debounce and turn into a sidebar
|
||||
* indicator
|
||||
* (SEP-2640 defines none). Subclassing would mean widening the base's
|
||||
* capability gate and inventing a list-changed event nothing dispatches — two
|
||||
* changes to shared machinery to serve one caller. The cursor walk below is the
|
||||
* only behavior actually shared, and it is nine lines.
|
||||
*
|
||||
* The walk is done here rather than through an SDK `listAll*` verb for the same
|
||||
* reason the request is a plain `client.request`: the SDK has no high-level verb
|
||||
* for a consumer-owned extension method, so there is no cache-aware wrapper to
|
||||
* delegate to and no `cacheMode` to honor.
|
||||
*/
|
||||
|
||||
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
||||
import type { SkillEntry } from "../skillsSchemas.js";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import type { RequestMetadata } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
|
||||
export interface SkillsPaginationState {
|
||||
/** Pages walked on the last completed refresh (a one-page list is 1). */
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface ManagedSkillsStateEventMap {
|
||||
skillsChange: SkillEntry[];
|
||||
paginationChange: SkillsPaginationState;
|
||||
/** The last walk's failure, or `null` once one succeeds. */
|
||||
errorChange: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a server hands back a cursor it already handed back, which would
|
||||
* otherwise walk forever. Surfaced as the store's error so the panel reports the
|
||||
* server bug instead of hanging — the same "report, don't swallow" posture the
|
||||
* conformance checks take.
|
||||
*/
|
||||
export const REPEATED_CURSOR_MESSAGE =
|
||||
"Server repeated a pagination cursor in skills/list; stopped to avoid an infinite walk.";
|
||||
|
||||
export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEventMap> {
|
||||
private skills: SkillEntry[] = [];
|
||||
private pageCount = 0;
|
||||
private error: Error | null = null;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
// Overlap guard: a walk in flight makes a second one a no-op so a slow older
|
||||
// walk can't clobber a newer list via last-write-wins.
|
||||
private running = false;
|
||||
|
||||
constructor(client: InspectorClientProtocol) {
|
||||
super();
|
||||
this.client = client;
|
||||
const onConnect = (): void => {
|
||||
// No caller to await the connect-time load, so its rejection is caught
|
||||
// here rather than left to become an unhandled rejection. Not a swallow:
|
||||
// `refresh` has already recorded the failure via `setError`, and the
|
||||
// panel renders it.
|
||||
void this.refresh().catch(() => {});
|
||||
};
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.reset();
|
||||
}
|
||||
};
|
||||
this.client.addEventListener("connect", onConnect);
|
||||
this.client.addEventListener("statusChange", onStatusChange);
|
||||
this.unsubscribe = () => {
|
||||
if (this.client) {
|
||||
this.client.removeEventListener("connect", onConnect);
|
||||
this.client.removeEventListener("statusChange", onStatusChange);
|
||||
}
|
||||
this.client = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Defensive copy of the current list. */
|
||||
getSkills(): SkillEntry[] {
|
||||
return [...this.skills];
|
||||
}
|
||||
|
||||
getPagination(): SkillsPaginationState {
|
||||
return { pageCount: this.pageCount };
|
||||
}
|
||||
|
||||
/** The last walk's failure, or `null` when it succeeded. */
|
||||
getError(): Error | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
// Compared by identity rather than message: two distinct failures with the
|
||||
// same text are still two events, and a re-render on a repeat failure is
|
||||
// cheap next to silently coalescing them.
|
||||
private setError(value: Error | null): void {
|
||||
if (this.error === value) return;
|
||||
this.error = value;
|
||||
this.dispatchTypedEvent("errorChange", value);
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.skills = [];
|
||||
this.pageCount = 0;
|
||||
this.dispatchTypedEvent("skillsChange", this.getSkills());
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
// A disconnect ends the session the error belonged to — a stale
|
||||
// "couldn't load skills" must not outlive it into the next connect.
|
||||
this.setError(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every page of `skills/list` and commit the result.
|
||||
*
|
||||
* A failure is recorded as observable state (`getError`) AND re-thrown: the
|
||||
* state drives the panel's error rendering, while the rejection is what a
|
||||
* caller's auth-recovery wrapper keys off to detect a 401 and start a
|
||||
* re-authorization. The connect-time load, which has no such caller, catches
|
||||
* it above.
|
||||
*/
|
||||
async refresh(metadata?: RequestMetadata): Promise<SkillEntry[]> {
|
||||
const client = this.client;
|
||||
if (!client || client.getStatus() !== "connected") return this.getSkills();
|
||||
// A server that never declared the extension answers `skills/list` with
|
||||
// -32601, which would spam the console for a question we already know the
|
||||
// answer to. An empty list is the right semantics.
|
||||
if (!client.getSkillsExtension()) {
|
||||
this.applyPages([], 0);
|
||||
return this.getSkills();
|
||||
}
|
||||
if (this.running) return this.getSkills();
|
||||
this.running = true;
|
||||
try {
|
||||
const collected: SkillEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const page = await client.listSkills(cursor, metadata);
|
||||
collected.push(...page.skills);
|
||||
pages += 1;
|
||||
if (page.nextCursor === undefined) break;
|
||||
if (seen.has(page.nextCursor)) {
|
||||
throw new Error(REPEATED_CURSOR_MESSAGE);
|
||||
}
|
||||
seen.add(page.nextCursor);
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
this.setError(null);
|
||||
this.applyPages(collected, pages);
|
||||
return this.getSkills();
|
||||
} catch (err) {
|
||||
this.setError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applyPages(skills: SkillEntry[], pageCount: number): void {
|
||||
this.skills = skills;
|
||||
this.pageCount = pageCount;
|
||||
this.dispatchTypedEvent("skillsChange", this.getSkills());
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
}
|
||||
|
||||
/** Unsubscribe from the client and drop the list; idempotent. */
|
||||
destroy(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.skills = [];
|
||||
this.pageCount = 0;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
76
core/react/useManagedSkills.ts
Normal file
76
core/react/useManagedSkills.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useCallback } from "react";
|
||||
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
|
||||
import type {
|
||||
ManagedSkillsState,
|
||||
SkillsPaginationState,
|
||||
} from "../mcp/state/managedSkillsState.js";
|
||||
import type { SkillEntry } from "../mcp/skillsSchemas.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
import { useStoreSnapshot } from "./useStoreSnapshot.js";
|
||||
|
||||
/**
|
||||
* Shared stable empty values for the no-server case. Module scope so the
|
||||
* snapshots don't change identity every render — see `useStoreSnapshot`.
|
||||
* Read-only by contract: nothing mutates a value this hook returns.
|
||||
*/
|
||||
const NO_SKILLS: SkillEntry[] = [];
|
||||
const NO_SKILLS_PAGINATION: SkillsPaginationState = Object.freeze({
|
||||
pageCount: 0,
|
||||
});
|
||||
|
||||
const readSkills = (state: ManagedSkillsState): SkillEntry[] =>
|
||||
state.getSkills();
|
||||
const readPagination = (state: ManagedSkillsState): SkillsPaginationState =>
|
||||
state.getPagination();
|
||||
|
||||
export interface UseManagedSkillsResult {
|
||||
skills: SkillEntry[];
|
||||
/** Pages walked on the last completed refresh (a one-page list is 1). */
|
||||
pageCount: number;
|
||||
/**
|
||||
* The last walk's failure, or `null` when it succeeded. Includes the
|
||||
* connect-time load, whose failure has no caller to surface it.
|
||||
*/
|
||||
error: Error | null;
|
||||
refresh: () => Promise<SkillEntry[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook over `ManagedSkillsState` (SEP-2640): the full skill list, the
|
||||
* page count the walk took, the last failure, and a refresh.
|
||||
*
|
||||
* Read during render via `useStoreSnapshot`, never `useState` + a subscribing
|
||||
* effect — that shape re-seeds local state from the store prop, so switching
|
||||
* servers would paint one frame of the previous server's skills, and an event
|
||||
* dispatched between render and subscribe would be lost outright.
|
||||
*/
|
||||
export function useManagedSkills(
|
||||
client: InspectorClientProtocol | null,
|
||||
managedSkillsState: ManagedSkillsState | null,
|
||||
): UseManagedSkillsResult {
|
||||
const skills = useStoreSnapshot(
|
||||
managedSkillsState,
|
||||
"skillsChange",
|
||||
readSkills,
|
||||
NO_SKILLS,
|
||||
);
|
||||
const { pageCount } = useStoreSnapshot(
|
||||
managedSkillsState,
|
||||
"paginationChange",
|
||||
readPagination,
|
||||
NO_SKILLS_PAGINATION,
|
||||
);
|
||||
|
||||
const error = useListError(managedSkillsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<SkillEntry[]> => {
|
||||
if (!managedSkillsState || !client) return NO_SKILLS;
|
||||
// The store dispatches `skillsChange` as it commits, so the snapshot above
|
||||
// updates on its own. No `cacheMode`: `skills/list` is a consumer-owned
|
||||
// extension method with no SDK cache-aware verb behind it, so every walk is
|
||||
// already a real round trip.
|
||||
return managedSkillsState.refresh();
|
||||
}, [client, managedSkillsState]);
|
||||
|
||||
return { skills, pageCount, error, refresh };
|
||||
}
|
||||
Loading…
Reference in a new issue