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
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