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
1 changed files with 3684 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue