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>
181 lines
6.7 KiB
TypeScript
181 lines
6.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|