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:
cliffhall 2026-09-04 20:01:13 -04:00
commit 63623b754b

View file

@ -34,6 +34,7 @@ import type {
} from "../components/screens/ToolsScreen/ToolsScreen";
import type { GetPromptState } from "../components/screens/PromptsScreen/PromptsScreen";
import type { ReadResourceState } from "../components/screens/ResourcesScreen/ResourcesScreen";
import type { SkillFileContents } from "../utils/skillFileBytes";
import { UrlElicitationErrorToastMessage } from "../components/elements/Toasts/UrlElicitationErrorToastMessage";
import {
errorCodeOf,
@ -161,6 +162,8 @@ export interface UseServerCommandsOptions {
activeToolCallTaskIdRef: { current: string | undefined };
clearCompletedTasks: () => void;
refreshTasks: () => Promise<unknown>;
/** Re-walk `skills/list` (SEP-2640). */
refreshSkills: () => Promise<unknown>;
// --- The list stores and their two fetch modes (#1721). ---
paginatedLists: boolean;
@ -210,6 +213,12 @@ export interface ServerCommands {
onReadResourceContents: (
uri: string,
) => Promise<Awaited<ReturnType<InspectorClient["readResource"]>>["result"]>;
/**
* Read one skill file's contents (SEP-2640), for digest verification. Returns
* the single content block that answers the URI, narrowed to the two fields
* the digest is taken over.
*/
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
onCompleteArgument: (
@ -228,6 +237,7 @@ export interface ServerCommands {
onRefreshTools: () => void;
onRefreshPrompts: () => void;
onRefreshResources: () => void;
onRefreshSkills: () => void;
onRefreshTasks: () => void;
onTogglePaginatedLists: (value: boolean) => void;
onLoadMoreTools: () => void;
@ -269,6 +279,7 @@ export function useServerCommands({
activeToolCallTaskIdRef,
clearCompletedTasks,
refreshTasks,
refreshSkills,
paginatedLists,
paginatedListsOverride,
toolsPagination,
@ -932,6 +943,44 @@ export function useServerCommands({
runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"),
[resourcesPagination, runCommandInBackground],
);
// Skill files are fetched on demand, never pre-fetched: SEP-2640 is explicit
// that a `resources/read` of a skill file is not a load and confers no
// standing, so the Inspector reads only what the user asks it to verify.
// Routed through `onReadResourceContents` so a skill read gets the same
// auth-recovery retry every other read does.
const onReadSkillFile = useCallback(
async (uri: string): Promise<SkillFileContents> => {
const result = await onReadResourceContents(uri);
// `resources/read` answers the URI it was asked for, so a single-block
// response is that block even when the server echoes the URI back in a
// slightly different form; an exact match wins when there are several.
const block =
result.contents.find((c) => c.uri === uri) ??
(result.contents.length === 1 ? result.contents[0] : undefined);
if (!block) {
throw new Error(`resources/read returned no content for ${uri}`);
}
// `contents` is a union of the text and blob shapes, each with its own
// payload field required — so `in` is what narrows it, not a `typeof` on
// a property one arm does not declare.
return {
...("text" in block ? { text: block.text } : { blob: block.blob }),
...(typeof block.mimeType === "string"
? { mimeType: block.mimeType }
: {}),
};
},
[onReadResourceContents],
);
const onRefreshSkills = useCallback(() => {
runCommandInBackground(
() => refreshSkills(),
"ambient",
"Failed to refresh skills",
);
}, [refreshSkills, runCommandInBackground]);
const onRefreshTasks = useCallback(() => {
runCommandInBackground(
() => refreshTasks(),
@ -947,6 +996,7 @@ export function useServerCommands({
onGetPrompt,
onReadResource,
onReadResourceContents,
onReadSkillFile,
onSubscribeResource,
onUnsubscribeResource,
onCompleteArgument,
@ -958,6 +1008,7 @@ export function useServerCommands({
onRefreshTools,
onRefreshPrompts,
onRefreshResources,
onRefreshSkills,
onRefreshTasks,
onTogglePaginatedLists,
onLoadMoreTools,