` inside a `
` is invalid HTML that React reports
+// as a hydration error and the Storybook run fails on. Heading and badge sit
+// side by side in an `InlineRow` instead.
+const SectionHeading = Text.withProps({
+ fw: 600,
+ size: "sm",
+});
+
+const MonoCaption = Text.withProps({
+ size: "xs",
+ c: "dimmed",
+ ff: "monospace",
+});
+
+const DetailStack = Stack.withProps({
+ gap: "md",
+});
+
+const IssueStack = Stack.withProps({
+ gap: "xs",
+});
+
+const ManifestTable = Table.withProps({
+ striped: true,
+ withTableBorder: true,
+ fz: "xs",
+ verticalSpacing: "xs",
+});
+
+const CountBadge = Badge.withProps({
+ size: "xs",
+ variant: "light",
+});
+
+// A tight, non-wrapping row — used for the detail-pane action pair and for the
+// badge + Verify button inside a manifest cell.
+const InlineRow = Group.withProps({
+ gap: "xs",
+ wrap: "nowrap",
+});
+
+const RowVerifyButton = Button.withProps({
+ variant: "subtle",
+ size: "compact-xs",
+});
+
+const SkillTitle = Text.withProps({
+ fw: 600,
+ size: "lg",
+ truncate: true,
+});
+
+/** Colour token for a finding's severity — errors read as failures. */
+function issueColor(issue: SkillIssue): string {
+ return issue.severity === "error" ? "red" : "yellow";
+}
+
+/** Colour token for a per-file verification verdict. */
+function verificationColor(status: SkillVerification["status"]): string {
+ if (status === "verified") return "green";
+ if (status === "mismatch") return "red";
+ return "yellow";
+}
+
+/**
+ * The short label a manifest row shows for its verdict. `—` (not yet run) is
+ * deliberately distinct from `unverifiable` (run, but nothing to compare
+ * against): conflating them would hide the fact that a server advertised no
+ * digest.
+ */
+function verificationLabel(state: FileState | undefined): string {
+ if (!state) return "—";
+ if (state.status === "pending") return "checking…";
+ if (state.status === "error") return "read failed";
+ return state.verification.status;
+}
+
+/**
+ * Whether a settled request should be discarded: its manifest was invalidated
+ * (a different key), or a later click for the same manifest already wrote (a
+ * higher attempt). A `null` key is the un-adopted initial manifest, which the
+ * first write claims.
+ */
+function isStale(
+ held: { key: string | null; attempt?: number },
+ key: string,
+ attempt: number,
+): boolean {
+ if (held.key !== null && held.key !== key) return true;
+ return held.attempt !== undefined && held.attempt > attempt;
+}
+
+/** `sha256:abcd…wxyz`, so a long digest stays readable in a table cell. */
+function shortDigest(digest: string | undefined): string {
+ if (!digest) return "—";
+ return digest.length <= 24 ? digest : `${digest.slice(0, 16)}…`;
+}
+
+/**
+ * The Skills screen (SEP-2640) — a conformance view, not just a list.
+ *
+ * The sidebar lists the skills the server enumerated; the detail pane shows the
+ * entry's frontmatter, every conformance finding
+ * (`checkSkillConformance`), and the resource manifest with a per-file
+ * verification verdict. Verification is on demand: SEP-2640 says a
+ * `resources/read` of a skill file is not a load and confers no standing, so
+ * the Inspector fetches only what the user asks it to.
+ */
+export function SkillsScreen({
+ sessionKey,
+ skills,
+ pageCount,
+ loadError,
+ ui,
+ onUiChange,
+ onRefreshList,
+ onReadSkillFile,
+ onGetSkill,
+}: SkillsScreenProps) {
+ const { selectedSkillUri, search } = ui;
+ // Both slices carry the manifest key they belong to, and every async
+ // continuation writes through a functional update that compares it. That is
+ // what discards a read still in flight when the selection changes or a
+ // Refresh replaces the manifest — without it, a slow fetch lands afterwards
+ // and writes a verdict for content nobody is looking at, or one that was
+ // never checked. Storing the key IN the state (rather than bumping a ref
+ // during render) keeps the `useValueChange` callback to `setState` calls
+ // only, which is the purity that hook documents and requires.
+ const [verification, setVerification] = useState({
+ key: null,
+ files: {},
+ });
+ const [previewState, setPreviewState] = useState({ key: null });
+ const [fetchedEntry, setFetchedEntry] = useState({
+ key: null,
+ });
+ // Every "Verify all" batch in flight, keyed by the manifest it belongs to.
+ //
+ // A **map**, not one slot, and the reason is a bug a single slot really had:
+ // batches on different skills genuinely overlap, so a slot remembers only the
+ // most recent one. Start A, switch to B and start B, return to A — the slot
+ // now says B, A's button reads as free, and clicking it starts a SECOND pool
+ // of workers for A on top of the first, doubling the concurrency cap. Keyed
+ // by manifest, A stays disabled for exactly as long as A's batch runs.
+ //
+ // The value is the invocation's token, so a finalizer deletes only its own
+ // entry; and a per-manifest entry is what keeps a hung batch on one skill
+ // from disabling every other skill's button.
+ const [batches, setBatches] = useState>(
+ () => new Map(),
+ );
+ // Monotonic attempt token, shared by every on-demand action here: a manifest
+ // row's verification, the SKILL.md preview, and the `skills/get` fetch. One
+ // counter rather than three because it only has to be *increasing*, and each
+ // consumer compares it against its own slot. A ref because it is claimed
+ // inside an event handler, never during render.
+ const nextAttempt = useRef(0);
+
+ const filtered = useMemo(() => {
+ const needle = search.trim().toLowerCase();
+ if (!needle) return skills;
+ return skills.filter(
+ (skill) =>
+ skillDisplayName(skill).toLowerCase().includes(needle) ||
+ skill.uri.toLowerCase().includes(needle),
+ );
+ }, [skills, search]);
+
+ // Matched by IDENTITY, like every other URI comparison here: a refresh that
+ // canonicalizes `skill://demo/%53KILL.md` to `skill://demo/SKILL.md` names
+ // the same skill, and the detail pane must not empty out because the server
+ // changed its spelling.
+ const selected = useMemo(() => {
+ if (selectedSkillUri === undefined) return undefined;
+ const wanted = skillUriIdentity(selectedSkillUri);
+ return skills.find((skill) => skillUriIdentity(skill.uri) === wanted);
+ }, [skills, selectedSkillUri]);
+
+ const issues = useMemo(
+ () => (selected ? checkSkillConformance(selected) : []),
+ [selected],
+ );
+
+ const manifest: SkillResource[] = useMemo(
+ () =>
+ selected && selected.resources !== DYNAMIC_RESOURCES
+ ? selected.resources
+ : [],
+ [selected],
+ );
+
+ // What every result on screen is a result *about*: the selected skill entry,
+ // in full. Keying on the URI alone would leave a green `verified` badge
+ // attached to a digest a Refresh replaced; keying on the manifest alone would
+ // leave a stale "skills/get matches skills/list" verdict after a
+ // metadata-only change, since that comparison covers `frontmatter` too.
+ // Re-verifying after a metadata-only refresh is the cheap direction to be
+ // wrong in; showing a match that was computed against a different entry is
+ // not. A primitive string, because `useValueChange` compares with `Object.is`
+ // and a fresh object every render would loop.
+ const manifestKey = useMemo(
+ () =>
+ `${sessionKey}\n${selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")}`,
+ [selected, selectedSkillUri, sessionKey],
+ );
+
+ // Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
+ // new selection (or a refreshed manifest) never paints a frame carrying the
+ // previous one's verification results. `setState` calls only — the hook
+ // replays this callback whenever React replays the render.
+ useValueChange(manifestKey, (next) => {
+ setVerification({ key: next, files: {} });
+ setPreviewState({ key: next });
+ setFetchedEntry({ key: next });
+ });
+
+ const fileStates = verification.key === manifestKey ? verification.files : {};
+
+ /**
+ * Verify one manifest ROW. Keyed by row index, not by URI: the checker
+ * deliberately tolerates a duplicated URI so it can report
+ * `duplicate-resource`, and two rows sharing a key would share one verdict —
+ * verifying either would update both, and "Verify all" would race two
+ * different digest/size declarations into the same slot.
+ */
+ const verifyRow = useCallback(
+ async (index: number, resource: SkillResource, key: string) => {
+ // Claimed synchronously, so two verifications of this row are ordered
+ // before either read starts.
+ const attempt = (nextAttempt.current += 1);
+ const write = (state: FileState) =>
+ setVerification((prev) => {
+ // `null` is the un-adopted initial manifest; any other mismatch is a
+ // continuation from a manifest that has since been invalidated.
+ if (prev.key !== null && prev.key !== key) return prev;
+ const files = prev.key === key ? prev.files : {};
+ // A newer attempt for this row already wrote — an older read
+ // finishing last must not overwrite it.
+ const held = files[index];
+ if (held !== undefined && held.attempt > attempt) return prev;
+ return { key, files: { ...files, [index]: state } };
+ });
+ write({ attempt, status: "pending" });
+ try {
+ const contents = await onReadSkillFile(resource.uri);
+ const result = await verifySkillResource(
+ resource,
+ skillFileBytes(contents),
+ );
+ write({ attempt, status: "done", verification: result });
+ } catch (err) {
+ write({
+ attempt,
+ status: "error",
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
+ },
+ [onReadSkillFile],
+ );
+
+ const verifyAll = useCallback(() => {
+ // Bounded concurrency, not `Promise.all` over the whole manifest: a
+ // conforming skill may declare 512 files, and firing 512 simultaneous
+ // `resources/read` calls would bury the transport and the server for no
+ // gain. Workers pull from a shared cursor so each row still flips to
+ // `checking…` and then to its verdict as it lands, rather than all at once.
+ //
+ // Held rather than floated: each `verifyFile` owns its own failures (it
+ // records them as per-row state), and this handler cannot be async, so the
+ // settled promise is discarded explicitly at one place instead of per file.
+ let next = 0;
+ const key = manifestKey;
+ const worker = async (): Promise => {
+ for (let i = next++; i < manifest.length; i = next++) {
+ await verifyRow(i, manifest[i], key);
+ }
+ };
+ const workers = Math.min(VERIFY_CONCURRENCY, manifest.length);
+ const token = (nextAttempt.current += 1);
+ setBatches((prev) => new Map(prev).set(key, token));
+ // The concurrency cap is per invocation, so without the button being
+ // disabled below, a second click would start a second pool of four and a
+ // third would make it twelve — the flood the cap exists to prevent.
+ void Promise.all(Array.from({ length: workers }, () => worker())).finally(
+ // Clears only ITS OWN invocation: matched on the token as well as the
+ // key, so an earlier batch settling cannot free a button a later one is
+ // holding.
+ () =>
+ setBatches((prev) => {
+ if (prev.get(key) !== token) return prev;
+ const next = new Map(prev);
+ next.delete(key);
+ return next;
+ }),
+ );
+ }, [manifest, manifestKey, verifyRow]);
+
+ const showSkillMd = useCallback(() => {
+ if (!selected) return;
+ const key = manifestKey;
+ const attempt = (nextAttempt.current += 1);
+ // A click handler cannot await, and this chain terminates in its own
+ // `catch` that surfaces the message in the preview slot. Both arms go
+ // through `writePreview`, which drops a result whose manifest has been
+ // invalidated OR whose click has been superseded.
+ const writePreview = (next: Omit) =>
+ setPreviewState((prev) =>
+ isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
+ );
+ // Claimed BEFORE the request goes out, the way `verifyRow` claims its row.
+ // Recording the attempt only on settle leaves a window where an older
+ // request that happens to resolve first is still considered current, and
+ // publishes its contents while a newer one is in flight. Clearing the
+ // previous result at the same time also means the pane doesn't keep
+ // showing the old file while the new read is running.
+ writePreview({});
+ void onReadSkillFile(selected.uri)
+ .then((contents) => writePreview({ contents }))
+ .catch((err: unknown) => {
+ writePreview({
+ message: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }, [manifestKey, onReadSkillFile, selected]);
+
+ const fetchEntry = useCallback(() => {
+ if (!selected) return;
+ const key = manifestKey;
+ const attempt = (nextAttempt.current += 1);
+ // Same shape as the SKILL.md read: a click handler cannot await, the chain
+ // ends in its own `catch`, and both arms drop a result whose manifest has
+ // been invalidated or whose click has been superseded.
+ const writeFetched = (next: Omit) =>
+ setFetchedEntry((prev) =>
+ isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
+ );
+ // Claimed before the request goes out — see `showSkillMd` for why settling
+ // is too late.
+ writeFetched({});
+ void onGetSkill(selected.uri)
+ .then((entry) => {
+ // The fetched entry is checked ON ITS OWN before being compared. A
+ // snapshot is allowed to have moved on, but it is not allowed to be
+ // non-conforming: an entry missing a digest is invalid whether or not
+ // the skill changed, and an entry for a DIFFERENT uri is never a valid
+ // refresh of the one that was asked for. Only a conforming entry with
+ // the same identity gets the benign "the snapshot moved" reading.
+ writeFetched({
+ entry,
+ issues: checkSkillConformance(entry),
+ // Compared by identity, not raw string: a server that canonicalizes
+ // an escape has answered for the same resource, and calling that
+ // "a different URI" would be the tool disagreeing with the read
+ // path, which accepts exactly that equivalence.
+ wrongUri:
+ skillUriIdentity(entry.uri) !== skillUriIdentity(selected.uri),
+ // Compared semantically — see `skillEntriesMatch` for why a
+ // `JSON.stringify` comparison would report key order and manifest
+ // order as differences.
+ matches: skillEntriesMatch(entry, selected),
+ });
+ })
+ .catch((err: unknown) => {
+ writeFetched({
+ message: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }, [manifestKey, onGetSkill, selected]);
+
+ const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined;
+ // `invalid` outranks the snapshot comparison: an entry that breaks a
+ // requirement, or answers for a different URI, is wrong regardless of
+ // whether the skill it describes has changed since the listing.
+ const fetchedVerdict =
+ fetched?.wrongUri ||
+ (fetched?.issues ?? []).some((issue) => issue.severity === "error")
+ ? "invalid"
+ : fetched?.matches
+ ? "matches"
+ : "differs";
+ const batchRunning = batches.has(manifestKey);
+
+ const preview =
+ previewState.key === manifestKey ? previewState.contents : undefined;
+ const previewError =
+ previewState.key === manifestKey ? previewState.message : undefined;
+
+ const errorCount = issues.filter((i) => i.severity === "error").length;
+ const warningCount = issues.length - errorCount;
+
+ return (
+ // `data-*` readiness contract for the headless tab smoke (#2148); see
+ // clients/web/README.md#core-tab-automation-contract.
+
+
+
+
+
+
+ onUiChange({ ...ui, search: event.currentTarget.value })
+ }
+ />
+ Refresh
+
+ {loadError && (
+
+ {loadError.message}
+
+ )}
+ {filtered.length === 0 ? (
+ // Not "No skills": SEP-2640 lets a server return an empty or
+ // partial catalog, and says an empty result must not be read as
+ // proof it has none — an unlisted skill can still be fetched by
+ // URI with `skills/get`. Claiming otherwise would be the tool
+ // asserting something the protocol explicitly does not.
+ No skills listed
+ ) : (
+ filtered.map((skill) => {
+ const skillIssues = checkSkillConformance(skill);
+ const errors = skillIssues.filter(
+ (i) => i.severity === "error",
+ ).length;
+ return (
+
+ onUiChange({ ...ui, selectedSkillUri: skill.uri })
+ }
+ rightSection={
+ skillIssues.length > 0 ? (
+ 0 ? "red" : "yellow"}>
+ {skillIssues.length}
+
+ ) : undefined
+ }
+ />
+ );
+ })
+ )}
+
+ {skills.length} skill(s) over {pageCount} page(s)
+
+
+
+
+
+
+ {!selected ? (
+ Select a skill to view details
+ ) : (
+
+
+
+ {skillDisplayName(selected)}
+ {selected.uri}
+
+
+ {selected.frontmatter.description && (
+ {selected.frontmatter.description}
+ )}
+
+
+
+ Conformance
+ 0 ? "red" : "green"}>
+ {errorCount} error(s), {warningCount} warning(s)
+
+
+ {issues.length === 0 ? (
+
+ No structural issues found in this entry.
+
+ ) : (
+
+ {issues.map((issue, index) => (
+
+
+ {issue.message}
+ {issue.resourceUri && (
+ {issue.resourceUri}
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ Resources
+
+ {manifest.length} file(s), {totalSkillBytes(manifest)}{" "}
+ bytes
+
+
+
+
+ Fetch with skills/get
+
+
+ View SKILL.md
+
+
+ Verify all
+
+
+
+ {selected.resources === DYNAMIC_RESOURCES ? (
+
+ This skill declares{" "}
+ resources: "dynamic" — its files are
+ generated, so no manifest is advertised and integrity cannot
+ be verified.
+
+ ) : (
+
+
+
+ URI
+ Size
+ Digest
+ Verification
+
+
+
+ {manifest.map((resource, index) => {
+ const state = fileStates[index];
+ const color =
+ state?.status === "done"
+ ? verificationColor(state.verification.status)
+ : state?.status === "error"
+ ? "red"
+ : "gray";
+ return (
+ // Index-keyed for the same reason the verdicts are:
+ // a duplicated URI is a case this screen reports, so
+ // it must not also collide two rows into one.
+
+ {resource.uri}
+ {resource.size ?? "—"}
+ {shortDigest(resource.digest)}
+
+
+
+ {verificationLabel(state)}
+
+
+ void verifyRow(index, resource, manifestKey)
+ }
+ >
+ Verify
+
+
+
+
+ );
+ })}
+
+
+ )}
+ {manifest.map((resource, index) => {
+ const state = fileStates[index];
+ if (state?.status === "done") {
+ const result = state.verification;
+ if (result.status !== "mismatch") return null;
+ // A size disagreement is caught BEFORE hashing, so it has
+ // no `actualDigest` — titling it "Digest mismatch" and
+ // rendering "actual undefined" would hide the real failure.
+ const sizeFailure = result.actualDigest === undefined;
+ return (
+
+
+ {resource.uri}
+ {sizeFailure ? (
+ {result.reason}
+ ) : (
+ <>
+
+ expected {result.expectedDigest}
+
+
+ actual {result.actualDigest}
+
+ >
+ )}
+
+
+ );
+ }
+ if (state?.status === "error") {
+ return (
+
+
+ {resource.uri}
+ {state.message}
+
+
+ );
+ }
+ return null;
+ })}
+
+
+ {fetched?.message !== undefined && (
+
+ {fetched.message}
+
+ )}
+ {fetched?.entry !== undefined && (
+
+
+
+ {fetchedVerdict === "invalid"
+ ? fetched.wrongUri
+ ? "This entry is for a different URI than the one requested, which is never a valid refresh of it."
+ : "This entry breaks a requirement of its own, so the difference is not simply a newer snapshot."
+ : fetchedVerdict === "matches"
+ ? "The entry this server returns for this URI describes the same skill it listed (compared ignoring key and manifest order)."
+ : "The entry this server returns for this URI differs from the one it listed. `skills/get` is a fresh snapshot, so this is expected if the skill changed since the list was fetched — and a server inconsistency if it did not."}
+
+ {(fetched.issues ?? [])
+ .filter((issue) => issue.severity === "error")
+ .map((issue, index) => (
+
+ {issue.code}: {issue.message}
+
+ ))}
+ {fetchedVerdict !== "matches" && (
+
+ )}
+
+
+ )}
+
+ {previewError && (
+
+ {previewError}
+
+ )}
+ {preview && (
+
+ SKILL.md
+ {/* `contents`, not a text `block`: a server may serve
+ SKILL.md as a base64 `blob`, and the block form would
+ substitute an empty string and paint a blank preview for
+ a file verification just read correctly. */}
+
+
+ )}
+
+
+ Frontmatter
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/clients/web/src/components/screens/screenUiState.ts b/clients/web/src/components/screens/screenUiState.ts
index 1da027cf8a..2e52459f5d 100644
--- a/clients/web/src/components/screens/screenUiState.ts
+++ b/clients/web/src/components/screens/screenUiState.ts
@@ -7,6 +7,7 @@ import type { ToolsUiState } from "./ToolsScreen/ToolsScreen";
import type { PromptsUiState } from "./PromptsScreen/PromptsScreen";
import type { ResourcesUiState } from "./ResourcesScreen/ResourcesScreen";
import type { AppsUiState } from "./AppsScreen/AppsScreen";
+import type { SkillsUiState } from "./SkillsScreen/SkillsScreen";
import type { TasksUiState } from "./TasksScreen/TasksScreen";
import type { LogsUiState } from "./LoggingScreen/LoggingScreen";
import type { ProtocolUiState } from "./ProtocolScreen/ProtocolScreen";
@@ -43,6 +44,11 @@ export const EMPTY_APPS_UI: AppsUiState = {
search: "",
};
+export const EMPTY_SKILLS_UI: SkillsUiState = {
+ selectedSkillUri: undefined,
+ search: "",
+};
+
export const EMPTY_TASKS_UI: TasksUiState = {
search: "",
statusFilter: undefined,
@@ -74,6 +80,7 @@ export const TAB_UI_REGISTRY = {
Tools: { empty: EMPTY_TOOLS_UI },
Prompts: { empty: EMPTY_PROMPTS_UI },
Resources: { empty: EMPTY_RESOURCES_UI },
+ Skills: { empty: EMPTY_SKILLS_UI },
Tasks: { empty: EMPTY_TASKS_UI },
Logs: { empty: EMPTY_LOGS_UI },
Protocol: { empty: EMPTY_PROTOCOL_UI },
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
index 24054fdaab..f8b2445a17 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
@@ -29,6 +29,7 @@ import type {
ResourcesPanelProps,
ServerListProps,
ShellProps,
+ SkillsPanelProps,
TasksPanelProps,
ToolsPanelProps,
} from "./types";
@@ -38,6 +39,7 @@ import {
EMPTY_APPS_UI,
EMPTY_PROMPTS_UI,
EMPTY_RESOURCES_UI,
+ EMPTY_SKILLS_UI,
EMPTY_TASKS_UI,
EMPTY_LOGS_UI,
EMPTY_PROTOCOL_UI,
@@ -424,6 +426,21 @@ const appsArgs: AppsPanelProps = {
onRefreshApps: fn(),
};
+const skillsArgs: SkillsPanelProps = {
+ skillsSessionKey: "story-session",
+ skills: [],
+ skillsPageCount: 0,
+ skillsUi: EMPTY_SKILLS_UI,
+ onSkillsUiChange: fn(),
+ onRefreshSkills: fn(),
+ onReadSkillFile: fn(async () => ({ text: "" })),
+ onGetSkill: fn(async (uri: string) => ({
+ uri,
+ frontmatter: {},
+ resources: [],
+ })),
+};
+
const tasksArgs: TasksPanelProps = {
tasks: demoTasks,
progressByTaskId: demoProgressByTaskId,
@@ -484,6 +501,7 @@ const meta: Meta = {
prompts: promptsArgs,
resources: resourcesArgs,
apps: appsArgs,
+ skills: skillsArgs,
tasks: tasksArgs,
logs: logsArgs,
protocol: protocolArgs,
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
index 20fd24db3d..d717dcfb1b 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
@@ -39,6 +39,7 @@ import {
EMPTY_APPS_UI,
EMPTY_PROMPTS_UI,
EMPTY_RESOURCES_UI,
+ EMPTY_SKILLS_UI,
EMPTY_TASKS_UI,
EMPTY_LOGS_UI,
EMPTY_PROTOCOL_UI,
@@ -170,6 +171,17 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps {
onRefreshApps: vi.fn(),
...mergeBundle("apps", overrides),
},
+ skills: {
+ skillsSessionKey: "test-session",
+ skills: [],
+ skillsPageCount: 0,
+ skillsUi: EMPTY_SKILLS_UI,
+ onSkillsUiChange: vi.fn(),
+ onRefreshSkills: vi.fn(),
+ onReadSkillFile: vi.fn().mockResolvedValue({ text: "" }),
+ onGetSkill: vi.fn(),
+ ...mergeBundle("skills", overrides),
+ },
tasks: {
tasks: [],
tasksUi: EMPTY_TASKS_UI,
@@ -1369,6 +1381,50 @@ describe("InspectorView", () => {
expect(labels).not.toContain("Tasks");
});
+ it("hides the Skills tab when the server declares no skills extension (#2234)", async () => {
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ const labels = radios.map((r) => r.getAttribute("value"));
+ expect(labels).toContain("Tools");
+ expect(labels).not.toContain("Skills");
+ });
+
+ it("shows the Skills tab on a LEGACY connection that declares the extension (#2234)", async () => {
+ // Unlike Tasks, Skills is not era-gated: `skills/*` are not spec method
+ // names in either codec, so a legacy-era server that declares the
+ // extension is serving it and must get the tab.
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ const labels = radios.map((r) => r.getAttribute("value"));
+ expect(labels).toContain("Skills");
+ });
+
it("shows the Tasks tab when the server advertises tasks even with no tasks yet", async () => {
renderWithMantine(
{
if (t === NETWORK_TAB && isStdio) return false;
// Console is the stdio process's stderr stream — shown only for stdio
@@ -663,6 +685,7 @@ export function InspectorView({
if (t === "Apps" && !hasApps) return false;
if (t === "Prompts" && !hasPrompts) return false;
if (t === "Resources" && !hasResources) return false;
+ if (t === SKILLS_TAB && !hasSkills) return false;
if (t === "Tasks" && !hasTasks) return false;
if (t === "Logs" && !hasLogging) return false;
return true;
@@ -1032,6 +1055,17 @@ export function InspectorView({
sortDirection: consoleSort,
onSortChange: setConsoleSort,
};
+ const skillsScreenProps = {
+ sessionKey: skillsSessionKey,
+ skills,
+ pageCount: skillsPageCount,
+ loadError: skillsLoadError,
+ ui: skillsUi,
+ onUiChange: onSkillsUiChange,
+ onRefreshList: onRefreshSkills,
+ onReadSkillFile,
+ onGetSkill,
+ };
const tasksScreenProps = {
tasks,
progressByTaskId,
@@ -1240,6 +1274,9 @@ export function InspectorView({
onCompactChange={setResourcesCompact}
/>
+
+
+
diff --git a/clients/web/src/components/views/InspectorView/types.ts b/clients/web/src/components/views/InspectorView/types.ts
index e4b7ce7287..1eff14681a 100644
--- a/clients/web/src/components/views/InspectorView/types.ts
+++ b/clients/web/src/components/views/InspectorView/types.ts
@@ -59,6 +59,9 @@ import type {
ResourcesUiState,
} from "../../screens/ResourcesScreen/ResourcesScreen";
import type { LogsUiState } from "../../screens/LoggingScreen/LoggingScreen";
+import type { SkillsUiState } from "../../screens/SkillsScreen/SkillsScreen";
+import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
+import type { SkillFileContents } from "../../../utils/skillFileBytes";
import type { TasksUiState } from "../../screens/TasksScreen/TasksScreen";
import type { ProtocolUiState } from "../../screens/ProtocolScreen/ProtocolScreen";
import type { NetworkUiState } from "../../screens/NetworkScreen/NetworkScreen";
@@ -308,6 +311,27 @@ export interface AppsPanelProps {
onRefreshApps: () => void;
}
+/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
+export interface SkillsPanelProps {
+ /**
+ * Identity of the connected session. Part of the screen's invalidation key,
+ * so async verification state can never cross a connection — see
+ * `UseInspectorStoresResult.sessionNonce`.
+ */
+ skillsSessionKey: string;
+ skills: SkillEntry[];
+ /** Pages the last `skills/list` walk took. */
+ skillsPageCount: number;
+ skillsLoadError?: Error | null;
+ skillsUi: SkillsUiState;
+ onSkillsUiChange: (next: SkillsUiState) => void;
+ onRefreshSkills: () => void;
+ /** Read one skill file (`resources/read`) so its digest can be checked. */
+ onReadSkillFile: (uri: string) => Promise;
+ /** Re-fetch the selected entry through `skills/get`. */
+ onGetSkill: (uri: string) => Promise;
+}
+
/** The Tasks monitor: the task list, its progress map, and actions. */
export interface TasksPanelProps {
tasks: Task[];
diff --git a/clients/web/src/hooks/useInspectorStores.test.tsx b/clients/web/src/hooks/useInspectorStores.test.tsx
index 6e7c29553e..d13713cc55 100644
--- a/clients/web/src/hooks/useInspectorStores.test.tsx
+++ b/clients/web/src/hooks/useInspectorStores.test.tsx
@@ -63,6 +63,9 @@ vi.mock("@inspector/core/mcp/state/managedResourceTemplatesState.js", () => ({
vi.mock("@inspector/core/mcp/state/managedRequestorTasksState.js", () => ({
ManagedRequestorTasksState: fakeState("managedRequestorTasksState"),
}));
+vi.mock("@inspector/core/mcp/state/managedSkillsState.js", () => ({
+ ManagedSkillsState: fakeState("managedSkillsState"),
+}));
vi.mock("@inspector/core/mcp/state/resourceSubscriptionsState.js", () => ({
ResourceSubscriptionsState: fakeState("resourceSubscriptionsState"),
}));
@@ -111,6 +114,14 @@ vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
clearCompleted: vi.fn(),
}),
}));
+vi.mock("@inspector/core/react/useManagedSkills.js", () => ({
+ useManagedSkills: () => ({
+ skills: [{ uri: "skill://s/SKILL.md" }],
+ pageCount: 1,
+ error: null,
+ refresh: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({
useResourceSubscriptions: () => ({
subscriptions: [{ uri: "sub://r" }],
@@ -181,6 +192,7 @@ const STORE_NAMES = [
"pagedResourcesState",
"managedResourceTemplatesState",
"managedRequestorTasksState",
+ "managedSkillsState",
"resourceSubscriptionsState",
"messageLogState",
"fetchRequestLogState",
@@ -201,7 +213,7 @@ describe("useInspectorStores", () => {
expect(built).toHaveLength(0);
});
- it("builds all twelve stores against the client", () => {
+ it("builds all thirteen stores against the client", () => {
const h = harness();
const c = client();
h.run((api) => api.createStores(c, fetchLogOptions));
@@ -245,6 +257,21 @@ describe("useInspectorStores", () => {
expect(h.api().stores).not.toBe(first);
});
+ it("advances the session nonce on both create and destroy", () => {
+ // It names one connected session and must never repeat across a
+ // reconnect — `SkillsScreen` keys async verification state on it, and a
+ // repeated value would let one session's result land in another.
+ const h = harness();
+ const seen = new Set([h.api().sessionNonce]);
+ h.run((api) => api.createStores(client(), fetchLogOptions));
+ seen.add(h.api().sessionNonce);
+ h.run((api) => api.destroyStores());
+ seen.add(h.api().sessionNonce);
+ h.run((api) => api.createStores(client(), fetchLogOptions));
+ seen.add(h.api().sessionNonce);
+ expect(seen.size).toBe(4);
+ });
+
it("destroys and clears on destroyStores", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
diff --git a/clients/web/src/hooks/useInspectorStores.ts b/clients/web/src/hooks/useInspectorStores.ts
index ae98d8bec6..bff13c9f4f 100644
--- a/clients/web/src/hooks/useInspectorStores.ts
+++ b/clients/web/src/hooks/useInspectorStores.ts
@@ -13,6 +13,7 @@ import type {
ResourceSubscriptionStreamState,
StderrLogEntry,
} from "@inspector/core/mcp/types.js";
+import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js";
@@ -22,6 +23,7 @@ import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState.j
import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState.js";
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
+import { ManagedSkillsState } from "@inspector/core/mcp/state/managedSkillsState.js";
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import {
@@ -37,6 +39,7 @@ import { usePagedPrompts } from "@inspector/core/react/usePagedPrompts.js";
import { usePagedResources } from "@inspector/core/react/usePagedResources.js";
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
+import { useManagedSkills } from "@inspector/core/react/useManagedSkills.js";
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js";
@@ -44,9 +47,9 @@ import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
import { usePaginatedList, type PaginatedListModel } from "./usePaginatedList";
/**
- * The twelve per-session state managers. They are created together (one
+ * The thirteen per-session state managers. They are created together (one
* `InspectorClient`, one set of stores) and torn down together, so they are
- * held as one slot rather than twelve — a partially-replaced set would leave
+ * held as one slot rather than thirteen — a partially-replaced set would leave
* some stores listening to a client the others had already left.
*/
export interface InspectorStores {
@@ -58,6 +61,7 @@ export interface InspectorStores {
pagedResourcesState: PagedResourcesState;
managedResourceTemplatesState: ManagedResourceTemplatesState;
managedRequestorTasksState: ManagedRequestorTasksState;
+ managedSkillsState: ManagedSkillsState;
resourceSubscriptionsState: ResourceSubscriptionsState;
messageLogState: MessageLogState;
fetchRequestLogState: FetchRequestLogState;
@@ -82,6 +86,17 @@ export type FetchLogOptions = Pick<
export interface UseInspectorStoresResult {
/** The live stores, or `null` before the first connect / after teardown. */
stores: InspectorStores | null;
+ /**
+ * Bumped on every `createStores` **and** every `destroyStores`, so it names
+ * one connected session and never repeats across a reconnect.
+ *
+ * Screens that hold async state keyed by *content* need this in the key:
+ * `SkillsScreen` stays mounted across a disconnect, so a verification still
+ * in flight for server A could otherwise land after a switch to server B and
+ * report a verdict for an identical-looking entry that was never read from B
+ * (#2234).
+ */
+ sessionNonce: number;
/**
* Build a fresh set of stores for `client`, tearing down whatever set is
* live first. Stable, so callers need no dependency on the current stores.
@@ -131,6 +146,12 @@ export interface UseInspectorStoresResult {
tasks: Task[];
refreshTasks: () => Promise;
clearCompletedTasks: () => void;
+ /** The server's skills (SEP-2640); empty when it declared no extension. */
+ skills: SkillEntry[];
+ /** Pages the last `skills/list` walk took. */
+ skillsPageCount: number;
+ skillsLoadError: Error | null;
+ refreshSkills: () => Promise;
subscriptions: InspectorResourceSubscription[];
subscriptionStreamState: ResourceSubscriptionStreamState;
messages: MessageEntry[];
@@ -152,6 +173,9 @@ export function useInspectorStores({
paginatedLists,
}: UseInspectorStoresParams): UseInspectorStoresResult {
const [stores, setStores] = useState(null);
+ // See `sessionNonce` above. A counter rather than the store object's identity
+ // because it has to be usable as part of a string key.
+ const [sessionNonce, setSessionNonce] = useState(0);
// Mirrors `stores` so `destroyStores` can read the live set without taking a
// dependency on it — which is what keeps every caller's callback stable.
const storesRef = useRef(null);
@@ -167,6 +191,7 @@ export function useInspectorStores({
storesRef.current = null;
fetchLogRef.current = null;
setStores(null);
+ setSessionNonce((n) => n + 1);
}, []);
const createStores = useCallback(
@@ -192,6 +217,7 @@ export function useInspectorStores({
client,
),
managedRequestorTasksState: new ManagedRequestorTasksState(client),
+ managedSkillsState: new ManagedSkillsState(client),
resourceSubscriptionsState: new ResourceSubscriptionsState(
client,
managedResourcesState,
@@ -203,6 +229,7 @@ export function useInspectorStores({
storesRef.current = next;
fetchLogRef.current = fetchRequestLogState;
setStores(next);
+ setSessionNonce((n) => n + 1);
},
[destroyStores],
);
@@ -310,6 +337,15 @@ export function useInspectorStores({
inspectorClient,
stores?.managedRequestorTasksState ?? null,
);
+ // Skills (SEP-2640). The store no-ops when the server declared no extension,
+ // so this hook is unconditional like the rest — the Skills *tab* is what is
+ // gated, in `InspectorView`.
+ const {
+ skills,
+ pageCount: skillsPageCount,
+ error: skillsLoadError,
+ refresh: refreshSkills,
+ } = useManagedSkills(inspectorClient, stores?.managedSkillsState ?? null);
const { subscriptions, streamState: subscriptionStreamState } =
useResourceSubscriptions(stores?.resourceSubscriptionsState ?? null);
const { messages } = useMessageLog(stores?.messageLogState ?? null);
@@ -320,6 +356,7 @@ export function useInspectorStores({
return {
stores,
+ sessionNonce,
createStores,
destroyStores,
fetchLogRef,
@@ -344,6 +381,10 @@ export function useInspectorStores({
tasks,
refreshTasks,
clearCompletedTasks,
+ skills,
+ skillsPageCount,
+ skillsLoadError,
+ refreshSkills,
subscriptions,
subscriptionStreamState,
messages,
diff --git a/clients/web/src/hooks/useServerCommands.test.tsx b/clients/web/src/hooks/useServerCommands.test.tsx
index b8de9e2dd9..004f0062fa 100644
--- a/clients/web/src/hooks/useServerCommands.test.tsx
+++ b/clients/web/src/hooks/useServerCommands.test.tsx
@@ -162,6 +162,7 @@ function spies() {
setModernLogLevel: vi.fn(),
clearCompletedTasks: vi.fn(),
refreshTasks: vi.fn().mockResolvedValue(undefined),
+ refreshSkills: vi.fn().mockResolvedValue(undefined),
refreshTools: vi.fn().mockResolvedValue(undefined),
refreshPrompts: vi.fn().mockResolvedValue(undefined),
refreshResources: vi.fn().mockResolvedValue(undefined),
@@ -271,6 +272,7 @@ function harness(initial: HarnessProps = {}): Harness {
activeToolCallTaskIdRef,
clearCompletedTasks: s.clearCompletedTasks,
refreshTasks: s.refreshTasks,
+ refreshSkills: s.refreshSkills,
paginatedLists: p.paginatedLists ?? false,
paginatedListsOverride: { record: s.record, valueFor: s.valueFor },
toolsPagination,
@@ -840,6 +842,191 @@ describe("onReadResourceContents", () => {
});
});
+describe("onReadSkillFile (#2234)", () => {
+ const skillUri = "skill://demo/reference.md";
+
+ it("returns the block whose uri matches exactly", async () => {
+ const c = client({
+ readResource: vi.fn().mockResolvedValue({
+ result: {
+ contents: [
+ { uri: "skill://demo/other.md", text: "wrong" },
+ { uri: skillUri, text: "right", mimeType: "text/markdown" },
+ ],
+ },
+ }),
+ });
+ const h = harness({ client: c });
+ await expect(h.api().onReadSkillFile(skillUri)).resolves.toEqual({
+ text: "right",
+ mimeType: "text/markdown",
+ });
+ });
+
+ it("accepts a block whose uri the server echoed back in an equivalent form", async () => {
+ // A resolved `..` is the same resource; `normalizeSkillUri` is what says so.
+ const c = client({
+ readResource: vi.fn().mockResolvedValue({
+ result: {
+ contents: [{ uri: "skill://demo/x/../reference.md", text: "x" }],
+ },
+ }),
+ });
+ const h = harness({ client: c });
+ await expect(h.api().onReadSkillFile(skillUri)).resolves.toEqual({
+ text: "x",
+ });
+ });
+
+ it("refuses a sole block for a DIFFERENT uri rather than verifying it", async () => {
+ // The dangerous fallback: treating "the only block" as "the block we asked
+ // for" would hash `other.md`'s bytes against `reference.md`'s advertised
+ // digest — and could report that as `verified`.
+ const c = client({
+ readResource: vi.fn().mockResolvedValue({
+ result: { contents: [{ uri: "skill://demo/other.md", text: "wrong" }] },
+ }),
+ });
+ const h = harness({ client: c });
+ await expect(h.api().onReadSkillFile(skillUri)).rejects.toThrow(
+ /returned no content .*skill:\/\/demo\/other\.md/,
+ );
+ });
+
+ it("refuses an unparseable echoed uri rather than matching it to another", async () => {
+ // Two unparseable URIs must not compare equal just because both normalize
+ // to `undefined`.
+ const c = client({
+ readResource: vi.fn().mockResolvedValue({
+ result: { contents: [{ uri: "not a uri", text: "wrong" }] },
+ }),
+ });
+ const h = harness({ client: c });
+ await expect(h.api().onReadSkillFile("also not a uri")).rejects.toThrow(
+ /returned no content/,
+ );
+ });
+
+ it("passes a blob block through as a blob", async () => {
+ const c = client({
+ readResource: vi.fn().mockResolvedValue({
+ result: { contents: [{ uri: skillUri, blob: "aGk=" }] },
+ }),
+ });
+ const h = harness({ client: c });
+ await expect(h.api().onReadSkillFile(skillUri)).resolves.toEqual({
+ blob: "aGk=",
+ });
+ });
+
+ it("throws when no block answers the uri", async () => {
+ // Returning an empty payload here would let the caller hash zero bytes and
+ // report a confident digest *mismatch* for a response that carried nothing.
+ const c = client({
+ readResource: vi.fn().mockResolvedValue({
+ result: {
+ contents: [
+ { uri: "skill://demo/a.md", text: "a" },
+ { uri: "skill://demo/b.md", text: "b" },
+ ],
+ },
+ }),
+ });
+ const h = harness({ client: c });
+ await expect(h.api().onReadSkillFile(skillUri)).rejects.toThrow(
+ /returned no content/,
+ );
+ });
+
+ it("throws when there is no client", async () => {
+ const h = harness();
+ await expect(h.api().onReadSkillFile(skillUri)).rejects.toThrow(
+ "Client is not connected",
+ );
+ });
+});
+
+describe("onGetSkill (#2234)", () => {
+ it("routes the uri through the client's skills/get", async () => {
+ const getSkill = vi.fn().mockResolvedValue({
+ uri: "skill://demo/SKILL.md",
+ frontmatter: {},
+ resources: [],
+ });
+ const h = harness({ client: client({ getSkill }) });
+ await expect(
+ h.api().onGetSkill("skill://demo/SKILL.md"),
+ ).resolves.toMatchObject({ uri: "skill://demo/SKILL.md" });
+ expect(getSkill).toHaveBeenCalledWith("skill://demo/SKILL.md");
+ });
+
+ it("throws when there is no client", async () => {
+ const h = harness();
+ await expect(h.api().onGetSkill("skill://demo/SKILL.md")).rejects.toThrow(
+ "Client is not connected",
+ );
+ });
+
+ it("retries once after a satisfied recovery", async () => {
+ // Every server command routes through the shared recovery; without it an
+ // expired authorization would render an error and stop there, with no
+ // reauthorization and no retry.
+ const recover = vi.fn().mockResolvedValue(true);
+ const getSkill = vi
+ .fn()
+ .mockRejectedValueOnce(authError())
+ .mockResolvedValue({
+ uri: "skill://demo/SKILL.md",
+ frontmatter: {},
+ resources: [],
+ });
+ const h = harness({
+ client: client({ getSkill }),
+ activeServerId: "a",
+ recovery: { handleCommandScopedAuthRecovery: recover },
+ });
+ await expect(
+ h.api().onGetSkill("skill://demo/SKILL.md"),
+ ).resolves.toMatchObject({ uri: "skill://demo/SKILL.md" });
+ expect(getSkill).toHaveBeenCalledTimes(2);
+ });
+
+ it("rethrows when the recovery was not satisfied", async () => {
+ const recover = vi.fn().mockResolvedValue(false);
+ const h = harness({
+ client: client({ getSkill: vi.fn().mockRejectedValue(authError()) }),
+ activeServerId: "a",
+ recovery: { handleCommandScopedAuthRecovery: recover },
+ });
+ await expect(
+ h.api().onGetSkill("skill://demo/SKILL.md"),
+ ).rejects.toBeInstanceOf(AuthRecoveryRequiredError);
+ });
+
+ it("rethrows a non-auth failure untouched", async () => {
+ const recover = vi.fn();
+ const h = harness({
+ client: client({
+ getSkill: vi.fn().mockRejectedValue(new Error("-32602")),
+ }),
+ activeServerId: "a",
+ recovery: { handleCommandScopedAuthRecovery: recover },
+ });
+ await expect(h.api().onGetSkill("skill://demo/SKILL.md")).rejects.toThrow(
+ "-32602",
+ );
+ expect(recover).not.toHaveBeenCalled();
+ });
+});
+
+describe("onRefreshSkills (#2234)", () => {
+ it("drives the store refresh in the background", () => {
+ const h = harness();
+ h.api().onRefreshSkills();
+ expect(h.spies.refreshSkills).toHaveBeenCalled();
+ });
+});
+
describe("subscriptions and completion", () => {
it("subscribes and unsubscribes through the client", async () => {
const c = client();
diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx
index 61aabae879..90457e7806 100644
--- a/clients/web/src/hooks/useServerCommands.tsx
+++ b/clients/web/src/hooks/useServerCommands.tsx
@@ -34,6 +34,9 @@ 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 type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
+import { normalizeSkillUri } from "@inspector/core/mcp/skills.js";
import { UrlElicitationErrorToastMessage } from "../components/elements/Toasts/UrlElicitationErrorToastMessage";
import {
errorCodeOf,
@@ -161,6 +164,8 @@ export interface UseServerCommandsOptions {
activeToolCallTaskIdRef: { current: string | undefined };
clearCompletedTasks: () => void;
refreshTasks: () => Promise;
+ /** Re-walk `skills/list` (SEP-2640). */
+ refreshSkills: () => Promise;
// --- The list stores and their two fetch modes (#1721). ---
paginatedLists: boolean;
@@ -210,6 +215,14 @@ export interface ServerCommands {
onReadResourceContents: (
uri: string,
) => Promise>["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;
+ /** Re-fetch one skill entry through `skills/get` (SEP-2640). */
+ onGetSkill: (uri: string) => Promise;
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
onCompleteArgument: (
@@ -228,6 +241,7 @@ export interface ServerCommands {
onRefreshTools: () => void;
onRefreshPrompts: () => void;
onRefreshResources: () => void;
+ onRefreshSkills: () => void;
onRefreshTasks: () => void;
onTogglePaginatedLists: (value: boolean) => void;
onLoadMoreTools: () => void;
@@ -269,6 +283,7 @@ export function useServerCommands({
activeToolCallTaskIdRef,
clearCompletedTasks,
refreshTasks,
+ refreshSkills,
paginatedLists,
paginatedListsOverride,
toolsPagination,
@@ -932,6 +947,88 @@ 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 => {
+ const result = await onReadResourceContents(uri);
+ // The returned URI must be the one asked for. A tempting fallback —
+ // "a single-block response must be the block we asked for" — is right
+ // for a viewer and WRONG here: these bytes are about to be hashed
+ // against `uri`'s advertised digest, so accepting a block the server
+ // labelled `b.md` would verify one file's content against another
+ // file's digest and could report that as `verified`. Knowing which
+ // bytes were hashed is the whole point.
+ //
+ // A normalized match is still accepted, because a server may echo the
+ // URI back in a different but equivalent form (a resolved `..`, a
+ // percent-encoding difference); `normalizeSkillUri` returns `undefined`
+ // for anything unparseable, and two `undefined`s must not compare equal.
+ const wanted = normalizeSkillUri(uri);
+ const block = result.contents.find((c) => {
+ if (c.uri === uri) return true;
+ const got = normalizeSkillUri(c.uri);
+ return got !== undefined && got === wanted;
+ });
+ if (!block) {
+ throw new Error(
+ `resources/read returned no content for ${uri}` +
+ (result.contents.length > 0
+ ? ` (got ${result.contents.map((c) => c.uri).join(", ")})`
+ : ""),
+ );
+ }
+ // `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],
+ );
+
+ // `skills/get` is the extension's second required method, and the Skills tab
+ // calls it on demand so a server author can see their own handler answer —
+ // and see whether it agrees with what their `skills/list` advertised.
+ const onGetSkill = useCallback(
+ async (uri: string): Promise => {
+ if (!inspectorClient) throw new Error("Client is not connected");
+ // Routed through the shared recovery like every other server command
+ // (#2174). Without it an expired authorization renders an error in the
+ // panel and stops there — no reauthorization, no retry — which is the
+ // one thing this hook exists to make impossible.
+ const get = () => inspectorClient.getSkill(uri);
+ try {
+ return await get();
+ } catch (err) {
+ if (err instanceof AuthRecoveryRequiredError && activeServerId) {
+ const satisfied = await handleCommandScopedAuthRecovery(err, {
+ serverId: activeServerId,
+ source: "resource",
+ });
+ if (satisfied) return get();
+ }
+ throw err;
+ }
+ },
+ [inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
+ );
+
+ const onRefreshSkills = useCallback(() => {
+ runCommandInBackground(
+ () => refreshSkills(),
+ "ambient",
+ "Failed to refresh skills",
+ );
+ }, [refreshSkills, runCommandInBackground]);
+
const onRefreshTasks = useCallback(() => {
runCommandInBackground(
() => refreshTasks(),
@@ -947,6 +1044,8 @@ export function useServerCommands({
onGetPrompt,
onReadResource,
onReadResourceContents,
+ onReadSkillFile,
+ onGetSkill,
onSubscribeResource,
onUnsubscribeResource,
onCompleteArgument,
@@ -958,6 +1057,7 @@ export function useServerCommands({
onRefreshTools,
onRefreshPrompts,
onRefreshResources,
+ onRefreshSkills,
onRefreshTasks,
onTogglePaginatedLists,
onLoadMoreTools,
diff --git a/clients/web/src/hooks/useTabUiState.ts b/clients/web/src/hooks/useTabUiState.ts
index 88bd5cdd1f..dab9af51dd 100644
--- a/clients/web/src/hooks/useTabUiState.ts
+++ b/clients/web/src/hooks/useTabUiState.ts
@@ -8,6 +8,7 @@ import {
EMPTY_PROMPTS_UI,
EMPTY_PROTOCOL_UI,
EMPTY_RESOURCES_UI,
+ EMPTY_SKILLS_UI,
EMPTY_TASKS_UI,
EMPTY_TOOLS_UI,
} from "../components/screens/screenUiState";
@@ -67,6 +68,7 @@ export function useTabUiState(): TabUiStateResult {
const [promptsUi, setPromptsUi] = useState(EMPTY_PROMPTS_UI);
const [resourcesUi, setResourcesUi] = useState(EMPTY_RESOURCES_UI);
const [appsUi, setAppsUi] = useState(EMPTY_APPS_UI);
+ const [skillsUi, setSkillsUi] = useState(EMPTY_SKILLS_UI);
const [tasksUi, setTasksUi] = useState(EMPTY_TASKS_UI);
const [logsUi, setLogsUi] = useState(EMPTY_LOGS_UI);
const [protocolUi, setProtocolUi] = useState(EMPTY_PROTOCOL_UI);
@@ -85,6 +87,7 @@ export function useTabUiState(): TabUiStateResult {
promptsUi,
resourcesUi,
appsUi,
+ skillsUi,
tasksUi,
logsUi,
protocolUi,
@@ -96,6 +99,7 @@ export function useTabUiState(): TabUiStateResult {
promptsUi,
resourcesUi,
appsUi,
+ skillsUi,
tasksUi,
logsUi,
protocolUi,
@@ -112,6 +116,7 @@ export function useTabUiState(): TabUiStateResult {
setPromptsUi,
setResourcesUi,
setAppsUi,
+ setSkillsUi,
setTasksUi,
setLogsUi,
setProtocolUi,
@@ -139,6 +144,7 @@ export function useTabUiState(): TabUiStateResult {
setPromptsUi(EMPTY_PROMPTS_UI);
setResourcesUi(EMPTY_RESOURCES_UI);
setAppsUi(EMPTY_APPS_UI);
+ setSkillsUi(EMPTY_SKILLS_UI);
setTasksUi(EMPTY_TASKS_UI);
setLogsUi(EMPTY_LOGS_UI);
setProtocolUi(EMPTY_PROTOCOL_UI);
diff --git a/clients/web/src/lib/oauthResume.test.ts b/clients/web/src/lib/oauthResume.test.ts
index fb8d1bd9d0..51b11c9f77 100644
--- a/clients/web/src/lib/oauthResume.test.ts
+++ b/clients/web/src/lib/oauthResume.test.ts
@@ -19,6 +19,7 @@ import {
EMPTY_PROMPTS_UI,
EMPTY_RESOURCES_UI,
EMPTY_APPS_UI,
+ EMPTY_SKILLS_UI,
EMPTY_TASKS_UI,
EMPTY_LOGS_UI,
EMPTY_PROTOCOL_UI,
@@ -107,6 +108,7 @@ describe("oauthResume", () => {
promptsUi: EMPTY_PROMPTS_UI,
resourcesUi: EMPTY_RESOURCES_UI,
appsUi: EMPTY_APPS_UI,
+ skillsUi: EMPTY_SKILLS_UI,
tasksUi: EMPTY_TASKS_UI,
logsUi: EMPTY_LOGS_UI,
protocolUi: EMPTY_PROTOCOL_UI,
@@ -118,6 +120,7 @@ describe("oauthResume", () => {
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
+ setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),
@@ -147,6 +150,7 @@ describe("oauthResume", () => {
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
+ setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),
@@ -244,6 +248,7 @@ describe("oauthResume", () => {
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
+ setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),
@@ -553,6 +558,7 @@ describe("oauthResume", () => {
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
+ setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),
@@ -567,6 +573,7 @@ describe("oauthResume", () => {
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
+ setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),
@@ -587,6 +594,7 @@ describe("oauthResume", () => {
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
+ setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),
@@ -598,6 +606,7 @@ describe("oauthResume", () => {
Prompts: EMPTY_PROMPTS_UI,
Resources: EMPTY_RESOURCES_UI,
Apps: EMPTY_APPS_UI,
+ Skills: EMPTY_SKILLS_UI,
Tasks: EMPTY_TASKS_UI,
Logs: EMPTY_LOGS_UI,
Protocol: EMPTY_PROTOCOL_UI,
@@ -609,18 +618,46 @@ describe("oauthResume", () => {
expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI);
expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI);
expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI);
+ expect(setters.setSkillsUi).toHaveBeenCalledWith(EMPTY_SKILLS_UI);
expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI);
expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI);
expect(setters.setProtocolUi).toHaveBeenCalledWith(EMPTY_PROTOCOL_UI);
expect(setters.setNetworkUi).toHaveBeenCalledWith(EMPTY_NETWORK_UI);
});
+ it("restoreTabUiFromSnapshot restores a SAVED Skills selection, not the default", () => {
+ // The other cases restore each tab's EMPTY value, so a bug that always
+ // wrote the default would pass them. This one saves a real selection.
+ const saved = {
+ ...EMPTY_SKILLS_UI,
+ selectedSkillUri: "skill://data-analysis/SKILL.md",
+ search: "analysis",
+ };
+ const setSkillsUi = vi.fn();
+ restoreTabUiFromSnapshot(
+ { Skills: saved },
+ {
+ setToolsUi: vi.fn(),
+ setPromptsUi: vi.fn(),
+ setResourcesUi: vi.fn(),
+ setAppsUi: vi.fn(),
+ setSkillsUi,
+ setTasksUi: vi.fn(),
+ setLogsUi: vi.fn(),
+ setProtocolUi: vi.fn(),
+ setNetworkUi: vi.fn(),
+ },
+ );
+ expect(setSkillsUi).toHaveBeenCalledWith(saved);
+ });
+
it("restoreTabUiFromSnapshot falls back to EMPTY state for undefined tab values", () => {
const setters = {
setToolsUi: vi.fn(),
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
+ setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),
@@ -632,6 +669,7 @@ describe("oauthResume", () => {
Prompts: undefined,
Resources: undefined,
Apps: undefined,
+ Skills: undefined,
Tasks: undefined,
Logs: undefined,
Protocol: undefined,
@@ -643,6 +681,7 @@ describe("oauthResume", () => {
expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI);
expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI);
expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI);
+ expect(setters.setSkillsUi).toHaveBeenCalledWith(EMPTY_SKILLS_UI);
expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI);
expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI);
expect(setters.setProtocolUi).toHaveBeenCalledWith(EMPTY_PROTOCOL_UI);
diff --git a/clients/web/src/lib/oauthResume.ts b/clients/web/src/lib/oauthResume.ts
index ee2bebf308..145171080d 100644
--- a/clients/web/src/lib/oauthResume.ts
+++ b/clients/web/src/lib/oauthResume.ts
@@ -11,6 +11,7 @@ import {
EMPTY_NETWORK_UI,
EMPTY_PROMPTS_UI,
EMPTY_RESOURCES_UI,
+ EMPTY_SKILLS_UI,
EMPTY_TASKS_UI,
EMPTY_TOOLS_UI,
} from "../components/screens/screenUiState.js";
@@ -20,6 +21,7 @@ import type { LogsUiState } from "../components/screens/LoggingScreen/LoggingScr
import type { NetworkUiState } from "../components/screens/NetworkScreen/NetworkScreen.js";
import type { PromptsUiState } from "../components/screens/PromptsScreen/PromptsScreen.js";
import type { ResourcesUiState } from "../components/screens/ResourcesScreen/ResourcesScreen.js";
+import type { SkillsUiState } from "../components/screens/SkillsScreen/SkillsScreen.js";
import type { TasksUiState } from "../components/screens/TasksScreen/TasksScreen.js";
import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen.js";
import {
@@ -77,6 +79,7 @@ export interface LiftedTabUiState {
promptsUi: PromptsUiState;
resourcesUi: ResourcesUiState;
appsUi: AppsUiState;
+ skillsUi: SkillsUiState;
tasksUi: TasksUiState;
logsUi: LogsUiState;
protocolUi: ProtocolUiState;
@@ -88,6 +91,7 @@ export interface TabUiSetters {
setPromptsUi: (next: PromptsUiState) => void;
setResourcesUi: (next: ResourcesUiState) => void;
setAppsUi: (next: AppsUiState) => void;
+ setSkillsUi: (next: SkillsUiState) => void;
setTasksUi: (next: TasksUiState) => void;
setLogsUi: (next: LogsUiState) => void;
setProtocolUi: (next: ProtocolUiState) => void;
@@ -102,6 +106,7 @@ export function buildTabUiSnapshot(
Tools: state.toolsUi,
Prompts: state.promptsUi,
Resources: state.resourcesUi,
+ Skills: state.skillsUi,
Tasks: state.tasksUi,
Logs: state.logsUi,
Protocol: state.protocolUi,
@@ -155,6 +160,11 @@ export function restoreTabUiFromSnapshot(
case "Apps":
setters.setAppsUi((value as AppsUiState | undefined) ?? EMPTY_APPS_UI);
break;
+ case "Skills":
+ setters.setSkillsUi(
+ (value as SkillsUiState | undefined) ?? EMPTY_SKILLS_UI,
+ );
+ break;
case "Tasks":
setters.setTasksUi(
(value as TasksUiState | undefined) ?? EMPTY_TASKS_UI,
diff --git a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts
new file mode 100644
index 0000000000..989a144850
--- /dev/null
+++ b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts
@@ -0,0 +1,239 @@
+import { describe, it, expect, vi } from "vitest";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import type { ServerCapabilities } from "@modelcontextprotocol/client";
+import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
+import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
+
+/**
+ * Unit coverage for the Skills extension methods (#2234, SEP-2640).
+ *
+ * The SDK client is stubbed rather than connected: what these assert is the
+ * shape of the outbound request and the normalization of the result, both of
+ * which are decided entirely in `InspectorClient` — and the point worth pinning
+ * is that `skills/*` go out through the ordinary `client.request` path with an
+ * explicit result schema, NOT through the raw-wire channel modern `tasks/*`
+ * needs.
+ */
+describe("InspectorClient skills methods (#2234)", () => {
+ const ENTRY = {
+ uri: "skill://demo/SKILL.md",
+ frontmatter: { name: "demo", description: "A demo skill" },
+ resources: [
+ {
+ uri: "skill://demo/ref.md",
+ digest: `sha256:${"a".repeat(64)}`,
+ size: 3,
+ },
+ ],
+ };
+
+ interface SkillsInternals {
+ protocolEra: string | undefined;
+ client: {
+ request: (
+ req: { method: string; params: Record },
+ schema: { parse: (value: unknown) => unknown },
+ ) => Promise;
+ } | null;
+ capabilities: ServerCapabilities | undefined;
+ }
+
+ function makeClient(): InspectorClient {
+ return new InspectorClient(
+ { type: "stdio", command: "noop", args: [] },
+ // `environment.transport` is only used on connect(); these tests never
+ // connect, they stub the SDK client directly.
+ { environment: { transport: () => ({}) as never } },
+ );
+ }
+
+ /**
+ * A structural view onto two private fields, so the tests can stub the SDK
+ * client and set `capabilities` without connecting.
+ *
+ * The double cast is justified rather than incidental: `InspectorClient`
+ * declares both members `private`, so no single `as` relates it to a type
+ * that exposes them, and there is no public setter for either — the public
+ * path is `connect()`, which needs a transport, a live server and a
+ * handshake to reach the same state. It is safe because the shape asserted
+ * here is exactly the shape the class declares (`client` is the SDK client;
+ * `capabilities` is `ServerCapabilities | undefined`), so a rename or a type
+ * change on either field breaks these tests at the first use rather than
+ * silently passing. The same seam is used by
+ * `inspectorClient-raw-wire.test.ts`.
+ */
+ function internals(client: InspectorClient): SkillsInternals {
+ return client as unknown as SkillsInternals;
+ }
+
+ /** Stub the SDK client so `request` parses through the supplied schema. */
+ function stubRequest(client: InspectorClient, result: unknown) {
+ const request = vi.fn(
+ async (
+ _req: { method: string; params: Record },
+ schema: { parse: (value: unknown) => unknown },
+ ) => schema.parse(result),
+ );
+ internals(client).client = { request };
+ return request;
+ }
+
+ it("getSkillsExtension reads the server's declaration", () => {
+ const client = makeClient();
+ expect(client.getSkillsExtension()).toBeUndefined();
+ internals(client).capabilities = {
+ extensions: { [SKILLS_EXTENSION_KEY]: { directoryRead: true } },
+ } as ServerCapabilities;
+ expect(client.getSkillsExtension()).toEqual({ directoryRead: true });
+ });
+
+ it("listSkills throws when not connected", async () => {
+ await expect(makeClient().listSkills()).rejects.toThrow(/not connected/i);
+ });
+
+ it("getSkill throws when not connected", async () => {
+ await expect(makeClient().getSkill("skill://x/SKILL.md")).rejects.toThrow(
+ /not connected/i,
+ );
+ });
+
+ it("sends skills/list with no cursor on the first page", async () => {
+ const client = makeClient();
+ const request = stubRequest(client, { skills: [ENTRY] });
+ const page = await client.listSkills();
+ expect(request.mock.calls[0][0].method).toBe("skills/list");
+ expect(request.mock.calls[0][0].params).not.toHaveProperty("cursor");
+ expect(page.skills).toEqual([ENTRY]);
+ expect(page.nextCursor).toBeUndefined();
+ });
+
+ it("forwards a cursor and returns the server's nextCursor", async () => {
+ const client = makeClient();
+ const request = stubRequest(client, { skills: [], nextCursor: "4" });
+ const page = await client.listSkills("2");
+ expect(request.mock.calls[0][0].params.cursor).toBe("2");
+ expect(page.nextCursor).toBe("4");
+ });
+
+ it("stamps call metadata onto skills/list as _meta", async () => {
+ const client = makeClient();
+ const request = stubRequest(client, { skills: [] });
+ await client.listSkills(undefined, { trace: "abc" });
+ expect(request.mock.calls[0][0].params._meta).toMatchObject({
+ trace: "abc",
+ });
+ });
+
+ it("sends skills/get with the requested uri", async () => {
+ const client = makeClient();
+ const request = stubRequest(client, { skill: ENTRY });
+ await client.getSkill("skill://demo/SKILL.md");
+ expect(request.mock.calls[0][0].method).toBe("skills/get");
+ expect(request.mock.calls[0][0].params.uri).toBe("skill://demo/SKILL.md");
+ });
+
+ it("unwraps the skills/get envelope to the entry", async () => {
+ const client = makeClient();
+ stubRequest(client, { skill: ENTRY });
+ expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY);
+ });
+
+ it("rejects a skills/get result returned without its envelope", async () => {
+ // Normalizing it would let a non-conforming server through the one place
+ // that could have reported it.
+ const client = makeClient();
+ stubRequest(client, ENTRY);
+ await expect(
+ client.getSkill("skill://demo/SKILL.md"),
+ ).rejects.toBeDefined();
+ });
+
+ it("requires the modern list envelope on a modern connection", async () => {
+ // SEP-2640: "In protocol versions 2026-07-28 and later, the result also
+ // carries … `ttlMs` and `cacheScope`." Nothing else validates it —
+ // `skills/*` is consumer-owned, so the SDK codec never sees it.
+ const client = makeClient();
+ internals(client).protocolEra = "modern";
+ stubRequest(client, { skills: [] });
+ await expect(client.listSkills()).rejects.toBeDefined();
+ });
+
+ it("accepts a modern result that carries the envelope", async () => {
+ const client = makeClient();
+ internals(client).protocolEra = "modern";
+ stubRequest(client, {
+ resultType: "complete",
+ ttlMs: 0,
+ cacheScope: "public",
+ skills: [ENTRY],
+ });
+ await expect(client.listSkills()).resolves.toMatchObject({
+ skills: [ENTRY],
+ });
+ });
+
+ it("does NOT require the envelope on a legacy connection", async () => {
+ // Those are 2026-era attributes; failing a legacy server for their absence
+ // would reject a conforming server.
+ const client = makeClient();
+ stubRequest(client, { skills: [ENTRY] });
+ await expect(client.listSkills()).resolves.toMatchObject({
+ skills: [ENTRY],
+ });
+ });
+
+ it("attributes a rejected skills/get envelope to its Protocol entry", async () => {
+ // Without this the Skills screen shows an error while the Protocol tab
+ // renders the same exchange as a clean success. Done in the client rather
+ // than a store because `skills/get` has none — the screen calls it.
+ const client = makeClient();
+ const marked: [string, string][] = [];
+ (
+ client as unknown as {
+ markResponseRejected: (m: string, r: string) => void;
+ }
+ ).markResponseRejected = (method, reason) => {
+ marked.push([method, reason]);
+ };
+ internals(client).client = {
+ request: async () => {
+ throw new SdkError(
+ SdkErrorCode.InvalidResult,
+ "Invalid result for skills/get",
+ );
+ },
+ };
+ await expect(client.getSkill("skill://demo/SKILL.md")).rejects.toThrow();
+ expect(marked).toEqual([["skills/get", "Invalid result for skills/get"]]);
+ });
+
+ it("does NOT attribute a transport failure on skills/get", async () => {
+ // No response frame arrived, so the last-answered id still points at an
+ // earlier, successful exchange; marking it would stamp that one.
+ const client = makeClient();
+ const marked: string[] = [];
+ (
+ client as unknown as {
+ markResponseRejected: (m: string, r: string) => void;
+ }
+ ).markResponseRejected = (method) => {
+ marked.push(method);
+ };
+ internals(client).client = {
+ request: async () => {
+ throw new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed");
+ },
+ };
+ await expect(client.getSkill("skill://demo/SKILL.md")).rejects.toThrow();
+ expect(marked).toEqual([]);
+ });
+
+ it("rejects a skills/list result that is not a skills page", async () => {
+ // The explicit result schema is the whole client-side mechanism for a
+ // consumer-owned extension method, so a nonconforming result must fail
+ // here rather than reaching the UI as a half-parsed shape.
+ const client = makeClient();
+ stubRequest(client, { notSkills: true });
+ await expect(client.listSkills()).rejects.toBeDefined();
+ });
+});
diff --git a/clients/web/src/test/core/mcp/sha256.test.ts b/clients/web/src/test/core/mcp/sha256.test.ts
new file mode 100644
index 0000000000..a0c66be31e
--- /dev/null
+++ b/clients/web/src/test/core/mcp/sha256.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import { sha256Bytes } from "@inspector/core/mcp/sha256";
+import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills";
+
+const hex = (bytes: Uint8Array) =>
+ Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+
+/**
+ * The fallback exists because `crypto.subtle` is absent in a non-secure
+ * context, and the web client is documented as servable over plain HTTP on a
+ * LAN IP. So it is checked two ways: against the published FIPS 180-4 vectors,
+ * and differentially against WebCrypto — "it agrees with the real thing" is the
+ * property that matters, and it is asserted rather than assumed.
+ */
+describe("sha256Bytes (the non-secure-context fallback)", () => {
+ it("matches the FIPS 180-4 vector for the empty message", () => {
+ expect(hex(sha256Bytes(new Uint8Array()))).toBe(
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ );
+ });
+
+ it("matches the FIPS 180-4 vector for 'abc'", () => {
+ expect(hex(sha256Bytes(textToBytes("abc")))).toBe(
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ );
+ });
+
+ it("matches the FIPS 180-4 two-block vector", () => {
+ expect(
+ hex(
+ sha256Bytes(
+ textToBytes(
+ "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
+ ),
+ ),
+ ),
+ ).toBe("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
+ });
+
+ it("agrees with WebCrypto across the block boundaries", async () => {
+ // 55/56/63/64/65 bracket the padding cases: the last block that still fits
+ // its length field, the one that forces an extra block, and the exact
+ // multiple of 64.
+ for (const length of [0, 1, 55, 56, 63, 64, 65, 200, 1000]) {
+ const bytes = new Uint8Array(length);
+ for (let i = 0; i < length; i += 1) bytes[i] = (i * 7 + 13) % 256;
+ const reference = new Uint8Array(
+ await crypto.subtle.digest("SHA-256", bytes),
+ );
+ expect(hex(sha256Bytes(bytes))).toBe(hex(reference));
+ }
+ });
+
+ it("hashes only the view, not its whole backing buffer", () => {
+ const backing = new Uint8Array([0xff, ...textToBytes("abc"), 0xff]);
+ expect(hex(sha256Bytes(backing.subarray(1, 4)))).toBe(
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ );
+ });
+});
+
+describe("sha256Digest without crypto.subtle", () => {
+ it("falls back rather than throwing, and returns the same digest", async () => {
+ // Exactly the shape a plain-HTTP LAN page presents: `crypto` exists,
+ // `crypto.subtle` does not. Before the fallback this threw for every file
+ // and the UI reported a read failure for a file it had fetched fine.
+ const withSubtle = await sha256Digest(textToBytes("abc"));
+ const real = globalThis.crypto;
+ try {
+ Object.defineProperty(globalThis, "crypto", {
+ configurable: true,
+ value: { getRandomValues: real.getRandomValues.bind(real) },
+ });
+ expect(globalThis.crypto.subtle).toBeUndefined();
+ await expect(sha256Digest(textToBytes("abc"))).resolves.toBe(withSubtle);
+ } finally {
+ Object.defineProperty(globalThis, "crypto", {
+ configurable: true,
+ value: real,
+ });
+ }
+ });
+});
diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts
new file mode 100644
index 0000000000..f4ea62e26c
--- /dev/null
+++ b/clients/web/src/test/core/mcp/skills.test.ts
@@ -0,0 +1,845 @@
+import { describe, it, expect } from "vitest";
+import type { ServerCapabilities } from "@modelcontextprotocol/client";
+import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
+import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas";
+import {
+ SKILL_MAX_RESOURCE_ENTRIES,
+ SKILL_MAX_TOTAL_BYTES,
+ base64ToBytes,
+ checkSkillConformance,
+ getSkillsExtension,
+ isSkillsExtensionSupported,
+ normalizeSkillUri,
+ skillEntriesMatch,
+ skillUriIdentity,
+ sha256Digest,
+ skillDisplayName,
+ skillNameFromUri,
+ textToBytes,
+ totalSkillBytes,
+ verifySkillResource,
+} from "@inspector/core/mcp/skills";
+
+/** The digest of the string "hello", precomputed so the assertion is a fact
+ * about SHA-256 rather than a restatement of what the code just did. */
+const HELLO_SHA256 =
+ "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
+
+const DIGEST = `sha256:${"a".repeat(64)}`;
+
+/**
+ * A conforming entry: the manifest is complete (it lists the skill's own
+ * SKILL.md), unique, inside the skill root, and every row carries a digest and
+ * a size. Overrides make exactly one of those false, one test at a time.
+ */
+function entry(overrides: Partial = {}): SkillEntry {
+ return {
+ uri: "skill://demo/SKILL.md",
+ frontmatter: { name: "demo", description: "A demo skill" },
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/ref.md", digest: DIGEST, size: 10 },
+ ],
+ ...overrides,
+ };
+}
+
+function caps(extensions?: Record): ServerCapabilities {
+ return { ...(extensions ? { extensions } : {}) } as ServerCapabilities;
+}
+
+describe("getSkillsExtension", () => {
+ it("returns undefined when the server declared no extensions at all", () => {
+ expect(getSkillsExtension(undefined)).toBeUndefined();
+ expect(getSkillsExtension(caps())).toBeUndefined();
+ });
+
+ it("returns undefined when other extensions are declared but not skills", () => {
+ expect(
+ getSkillsExtension(caps({ "io.modelcontextprotocol/tasks": {} })),
+ ).toBeUndefined();
+ });
+
+ it("reports directoryRead false for a bare declaration", () => {
+ expect(getSkillsExtension(caps({ [SKILLS_EXTENSION_KEY]: {} }))).toEqual({
+ directoryRead: false,
+ });
+ });
+
+ it("reports directoryRead only for a literal true", () => {
+ expect(
+ getSkillsExtension(
+ caps({ [SKILLS_EXTENSION_KEY]: { directoryRead: true } }),
+ ),
+ ).toEqual({ directoryRead: true });
+ // A truthy non-`true` value is a non-conforming advertisement; treating it
+ // as support would make the Inspector call a method the server may not
+ // serve, so it reads as unsupported.
+ expect(
+ getSkillsExtension(
+ caps({ [SKILLS_EXTENSION_KEY]: { directoryRead: "yes" } }),
+ ),
+ ).toEqual({ directoryRead: false });
+ });
+
+ it("treats a declared-but-null value as no declaration", () => {
+ expect(
+ getSkillsExtension(caps({ [SKILLS_EXTENSION_KEY]: null })),
+ ).toBeUndefined();
+ });
+
+ it("rejects a non-object declaration", () => {
+ // SEP-2133 declares an extension as an object of sub-options, so a
+ // primitive is not a declaration — and treating one as support would show
+ // the Skills tab and send `skills/list` to a server that never claimed to
+ // serve it. Same parsing as the UI extension in `appElicitation.ts`.
+ for (const declared of [true, false, "skills", 1]) {
+ expect(
+ getSkillsExtension(caps({ [SKILLS_EXTENSION_KEY]: declared })),
+ ).toBeUndefined();
+ }
+ });
+
+ it("isSkillsExtensionSupported mirrors presence", () => {
+ expect(isSkillsExtensionSupported(caps())).toBe(false);
+ expect(
+ isSkillsExtensionSupported(caps({ [SKILLS_EXTENSION_KEY]: {} })),
+ ).toBe(true);
+ });
+});
+
+describe("skillNameFromUri", () => {
+ it("returns the segment before /SKILL.md, not the filename", () => {
+ expect(skillNameFromUri("skill://a/b/data-analysis/SKILL.md")).toBe(
+ "data-analysis",
+ );
+ });
+
+ it("reads the name off a domain-native scheme too", () => {
+ expect(skillNameFromUri("github://acme/repo/data-analysis/SKILL.md")).toBe(
+ "data-analysis",
+ );
+ });
+
+ it("returns undefined for a URI that does not end in /SKILL.md", () => {
+ expect(skillNameFromUri("skill://demo/other.md")).toBeUndefined();
+ // The suffix must include the separator: a bare "SKILL.md" has no segment.
+ expect(skillNameFromUri("SKILL.md")).toBeUndefined();
+ });
+
+ it("returns undefined for a relative string", () => {
+ // SEP-2640 requires a full resource URI; treating `demo/SKILL.md` as one
+ // would let a non-conforming entry report a name and pass the path check.
+ expect(skillNameFromUri("demo/SKILL.md")).toBeUndefined();
+ });
+
+ it("reads the name off the RESOLVED path, not the raw string", () => {
+ expect(skillNameFromUri("skill://acme/wrong/../demo/SKILL.md")).toBe(
+ "demo",
+ );
+ });
+
+ it("returns undefined when the segment before the suffix is empty", () => {
+ expect(skillNameFromUri("skill:///SKILL.md")).toBeUndefined();
+ });
+});
+
+describe("normalizeSkillUri", () => {
+ it("resolves traversal segments", () => {
+ expect(normalizeSkillUri("skill://acme/billing/refunds/../other.md")).toBe(
+ "skill://acme/billing/other.md",
+ );
+ });
+
+ it("rejects a relative string, which is not a resource URI", () => {
+ expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined();
+ });
+
+ it("does not privilege the skill: scheme", () => {
+ // SEP-2640 only says a server SHOULD use `skill://`, and explicitly allows
+ // a domain-native scheme — so rejecting one would hand a conforming server
+ // a false `malformed-uri` and skip its name and root checks.
+ expect(normalizeSkillUri("github://acme/repo/SKILL.md")).toBe(
+ "github://acme/repo/SKILL.md",
+ );
+ expect(normalizeSkillUri("https://demo/a/../SKILL.md")).toBe(
+ "https://demo/SKILL.md",
+ );
+ });
+
+ it("rejects an opaque-path URI, which the parser does not normalize", () => {
+ // `skill:demo/../x.md` parses but keeps its `..` verbatim, so containment
+ // could not be decided on it — accepting it would reopen the hole.
+ expect(normalizeSkillUri("skill:demo/SKILL.md")).toBeUndefined();
+ });
+
+ it("decodes escapes that stand for unreserved characters", () => {
+ // `URL.href` leaves these encoded, so without canonicalizing them a server
+ // echoing an RFC-equivalent form would be treated as a different resource
+ // and an encoded name segment would produce a false name/path mismatch.
+ expect(normalizeSkillUri("skill://demo/%72eference.md")).toBe(
+ "skill://demo/reference.md",
+ );
+ expect(skillNameFromUri("skill://%64emo/SKILL.md")).toBe("demo");
+ });
+
+ it("upper-cases the hex of escapes that must stay encoded", () => {
+ // A space is not unreserved, so it stays escaped — but in one spelling, so
+ // two RFC-equivalent URIs compare equal.
+ expect(normalizeSkillUri("skill://demo/a%2fb.md")).toBe(
+ normalizeSkillUri("skill://demo/a%2Fb.md"),
+ );
+ });
+
+ it("leaves an already-normal URI alone", () => {
+ expect(normalizeSkillUri("skill://demo/SKILL.md")).toBe(
+ "skill://demo/SKILL.md",
+ );
+ });
+});
+
+describe("skillUriIdentity", () => {
+ it("is the normalized form when the URI parses", () => {
+ expect(skillUriIdentity("skill://demo/a/../SKILL.md")).toBe(
+ "skill://demo/SKILL.md",
+ );
+ });
+
+ it("falls back to the raw string, keeping two bad URIs distinct", () => {
+ expect(skillUriIdentity("not a uri")).toBe("not a uri");
+ expect(skillUriIdentity("not a uri")).not.toBe(
+ skillUriIdentity("also not a uri"),
+ );
+ });
+});
+
+describe("skillEntriesMatch", () => {
+ const base = (): SkillEntry => ({
+ uri: "skill://demo/SKILL.md",
+ frontmatter: { name: "demo", description: "d" },
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/ref.md", digest: DIGEST, size: 10 },
+ ],
+ });
+
+ it("ignores object key order", () => {
+ const reordered = {
+ resources: base().resources,
+ uri: base().uri,
+ frontmatter: { description: "d", name: "demo" },
+ };
+ expect(skillEntriesMatch(base(), reordered)).toBe(true);
+ });
+
+ it("ignores manifest order, because the manifest is a set", () => {
+ const reversed = {
+ ...base(),
+ resources: [...(base().resources as object[])].reverse(),
+ } as SkillEntry;
+ expect(skillEntriesMatch(base(), reversed)).toBe(true);
+ });
+
+ it("does NOT reorder an array nested in frontmatter", () => {
+ // `frontmatter` is verbatim arbitrary JSON from the skill author, so a
+ // custom `resources` array inside it is an ordinary list. A recursive
+ // sort would make these two genuinely different entries compare equal.
+ const withNested = (order: string[]): SkillEntry => ({
+ ...base(),
+ frontmatter: {
+ ...base().frontmatter,
+ metadata: { resources: order.map((uri) => ({ uri })) },
+ },
+ });
+ expect(
+ skillEntriesMatch(withNested(["a", "b"]), withNested(["b", "a"])),
+ ).toBe(false);
+ expect(
+ skillEntriesMatch(withNested(["a", "b"]), withNested(["a", "b"])),
+ ).toBe(true);
+ });
+
+ it("treats RFC-equivalent URI spellings as the same entry", () => {
+ // A server that canonicalizes an escape between the listing and the fetch
+ // has not changed the skill, so it must not read as a new snapshot.
+ const encoded: SkillEntry = {
+ ...base(),
+ uri: "skill://demo/%53KILL.md",
+ resources: [
+ { uri: "skill://demo/%53KILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/x/../ref.md", digest: DIGEST, size: 10 },
+ ],
+ };
+ expect(skillEntriesMatch(base(), encoded)).toBe(true);
+ });
+
+ it("still sees a real difference", () => {
+ expect(
+ skillEntriesMatch(base(), {
+ ...base(),
+ frontmatter: { name: "demo", description: "changed" },
+ }),
+ ).toBe(false);
+ });
+
+ it("compares the dynamic form without sorting it", () => {
+ const dynamic = { ...base(), resources: "dynamic" } as SkillEntry;
+ expect(skillEntriesMatch(dynamic, { ...dynamic })).toBe(true);
+ expect(skillEntriesMatch(dynamic, base())).toBe(false);
+ });
+});
+
+describe("skillDisplayName", () => {
+ it("prefers the declared frontmatter name", () => {
+ expect(skillDisplayName(entry())).toBe("demo");
+ });
+
+ it("falls back to the URI segment when the name is blank", () => {
+ expect(skillDisplayName(entry({ frontmatter: { name: " " } }))).toBe(
+ "demo",
+ );
+ });
+
+ it("falls back to the raw URI when neither is available", () => {
+ expect(skillDisplayName(entry({ uri: "skill://x", frontmatter: {} }))).toBe(
+ "skill://x",
+ );
+ });
+});
+
+describe("checkSkillConformance", () => {
+ it("reports nothing for a conforming entry", () => {
+ expect(checkSkillConformance(entry())).toEqual([]);
+ });
+
+ it("reports a missing name as an error", () => {
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { description: "d" } }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["missing-name"]);
+ expect(issues[0].severity).toBe("error");
+ });
+
+ it("reports a name that is not a valid Agent Skills name", () => {
+ // The name is not decorative: it must equal the URI path segment, so a
+ // name that cannot appear in a URI is a contradiction the entry cannot
+ // satisfy. Checking only for non-emptiness let these read as "Conforms".
+ for (const name of [
+ "Bad Name",
+ "UPPER",
+ "-leading",
+ "trailing-",
+ "double--hyphen",
+ "under_score",
+ "a".repeat(65),
+ ]) {
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { name, description: "d" } }),
+ );
+ expect(issues.map((i) => i.code)).toContain("malformed-name");
+ expect(issues.find((i) => i.code === "malformed-name")?.severity).toBe(
+ "error",
+ );
+ }
+ });
+
+ it("applies the name grammar to the RAW value, not a trimmed copy", () => {
+ // Trimming first would let `" demo "` through — and whitespace is not in
+ // the grammar, so the entry would report "Conforms" with a name that can
+ // never equal its URI path segment.
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { name: " demo ", description: "d" } }),
+ );
+ expect(issues.map((i) => i.code)).toContain("malformed-name");
+ });
+
+ it("accepts the names the Agent Skills format allows", () => {
+ for (const name of ["a", "demo", "data-analysis", "a1-b2-c3"]) {
+ const issues = checkSkillConformance(
+ entry({
+ uri: `skill://${name}/SKILL.md`,
+ frontmatter: { name, description: "d" },
+ resources: [
+ { uri: `skill://${name}/SKILL.md`, digest: DIGEST, size: 1 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).not.toContain("malformed-name");
+ }
+ });
+
+ it("does not report a malformed name when there is no name at all", () => {
+ // `missing-name` already says it; two findings would read as two defects.
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { description: "d" } }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["missing-name"]);
+ });
+
+ it("reports a description above the 1024-character limit", () => {
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { name: "demo", description: "d".repeat(1025) } }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["malformed-description"]);
+ expect(issues[0].severity).toBe("error");
+ });
+
+ it("counts description length in code points, not UTF-16 code units", () => {
+ // 600 non-BMP characters are 1200 code units. Measuring those would report
+ // a perfectly valid description as over the 1024-character limit — a
+ // conforming server failed by an off-by-encoding.
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { name: "demo", description: "𝄞".repeat(600) } }),
+ );
+ expect(issues).toEqual([]);
+ });
+
+ it("still reports a description over the limit in code points", () => {
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { name: "demo", description: "𝄞".repeat(1025) } }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["malformed-description"]);
+ expect(issues[0].message).toContain("1025");
+ });
+
+ it("accepts a description exactly at the limit", () => {
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { name: "demo", description: "d".repeat(1024) } }),
+ );
+ expect(issues).toEqual([]);
+ });
+
+ it("reports a missing description as an error", () => {
+ // SEP-2640 requires `description`, so an absent one is a format violation
+ // and must not read as "0 errors" in the conformance summary.
+ const issues = checkSkillConformance(
+ entry({ frontmatter: { name: "demo" } }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["missing-description"]);
+ expect(issues[0].severity).toBe("error");
+ });
+
+ it("reports a URI that does not carry a skill path", () => {
+ const issues = checkSkillConformance(entry({ uri: "skill://demo/x.md" }));
+ expect(issues.map((i) => i.code)).toContain("malformed-uri");
+ // The name/path check is suppressed: there is no path segment to compare,
+ // and reporting both would present one defect as two.
+ expect(issues.map((i) => i.code)).not.toContain("name-path-mismatch");
+ });
+
+ it("reports a path segment that disagrees with frontmatter.name", () => {
+ const issues = checkSkillConformance(
+ entry({ uri: "skill://wrong-folder/SKILL.md" }),
+ );
+ const mismatch = issues.find((i) => i.code === "name-path-mismatch");
+ expect(mismatch?.severity).toBe("error");
+ expect(mismatch?.message).toContain("wrong-folder");
+ expect(mismatch?.message).toContain("demo");
+ });
+
+ it("does not report a mismatch when the name is missing entirely", () => {
+ // The missing name is already an error of its own; a second finding
+ // comparing against an absent value would be noise.
+ const issues = checkSkillConformance(
+ entry({ uri: "skill://other/SKILL.md", frontmatter: {} }),
+ );
+ expect(issues.map((i) => i.code)).not.toContain("name-path-mismatch");
+ expect(issues.map((i) => i.code)).toContain("missing-name");
+ });
+
+ it("reports dynamic resources as a warning and checks nothing further", () => {
+ const issues = checkSkillConformance(entry({ resources: "dynamic" }));
+ expect(issues.map((i) => i.code)).toEqual(["dynamic-resources"]);
+ expect(issues[0].severity).toBe("warning");
+ });
+
+ it("reports a manifest entry with no digest as an error", () => {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/ref.md", size: 1 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["missing-digest"]);
+ expect(issues[0].severity).toBe("error");
+ expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
+ });
+
+ it("accepts a self-entry listed in an RFC-equivalent spelling", () => {
+ // The manifest names the same file the entry does, and it is fetchable as
+ // that file — reporting it missing would be the tool disagreeing with
+ // itself about which URIs are the same resource.
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/%53KILL.md", digest: DIGEST, size: 20 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).not.toContain("manifest-missing-self");
+ });
+
+ it("reports a manifest that omits the skill's own SKILL.md", () => {
+ // A manifest is the complete file set, so one without the entry file is
+ // not "a skill with no extras" — it cannot be checked against the skill.
+ const issues = checkSkillConformance(
+ entry({
+ resources: [{ uri: "skill://demo/ref.md", digest: DIGEST, size: 1 }],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["manifest-missing-self"]);
+ expect(issues[0].severity).toBe("error");
+ });
+
+ it("reports an empty manifest through the same finding", () => {
+ const issues = checkSkillConformance(entry({ resources: [] }));
+ expect(issues.map((i) => i.code)).toEqual(["manifest-missing-self"]);
+ });
+
+ it("reports a duplicated manifest URI", () => {
+ const dup = { uri: "skill://demo/ref.md", digest: DIGEST, size: 1 };
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ dup,
+ dup,
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["duplicate-resource"]);
+ expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
+ });
+
+ it("reports an entry that traverses out of the skill root", () => {
+ // The raw string starts with the root; the resolved path does not. A
+ // prefix check alone would miss this and report `Conforms`.
+ const issues = checkSkillConformance(
+ entry({
+ uri: "skill://demo/refunds/SKILL.md",
+ frontmatter: { name: "refunds", description: "d" },
+ resources: [
+ { uri: "skill://demo/refunds/SKILL.md", digest: DIGEST, size: 1 },
+ { uri: "skill://demo/refunds/../other.md", digest: DIGEST, size: 1 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
+ });
+
+ it("reports an unparseable manifest entry as outside the root", () => {
+ // Nothing can establish that a non-URI is inside a root.
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "not a uri", digest: DIGEST, size: 1 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
+ });
+
+ it("detects a duplicate that differs only before normalization", () => {
+ // Containment and the read that fetches the bytes both treat these as one
+ // resource, so the uniqueness check must too — otherwise a manifest naming
+ // one file twice passes as two distinct files.
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/x/../SKILL.md", digest: DIGEST, size: 20 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["duplicate-resource"]);
+ // Reported against the raw URI, so the diagnostic points at what the
+ // server actually sent.
+ expect(issues[0].resourceUri).toBe("skill://demo/x/../SKILL.md");
+ });
+
+ it("does not fold two different unparseable URIs into one duplicate", () => {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "not a uri", digest: DIGEST, size: 1 },
+ { uri: "also not a uri", digest: DIGEST, size: 1 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).not.toContain("duplicate-resource");
+ });
+
+ it("reports a manifest entry outside the skill root", () => {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://other/ref.md", digest: DIGEST, size: 1 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
+ expect(issues[0].resourceUri).toBe("skill://other/ref.md");
+ });
+
+ it("does not check the skill root when the entry URI is malformed", () => {
+ // There is no root to measure against, and `malformed-uri` already says so;
+ // a second finding per resource would present one defect as many.
+ const issues = checkSkillConformance(
+ entry({
+ uri: "skill://demo/other.md",
+ resources: [{ uri: "skill://elsewhere/a.md", digest: DIGEST, size: 1 }],
+ }),
+ );
+ expect(issues.map((i) => i.code)).not.toContain(
+ "resource-outside-skill-root",
+ );
+ expect(issues.map((i) => i.code)).toContain("malformed-uri");
+ });
+
+ it("reports a manifest entry with no size as an error", () => {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/ref.md", digest: DIGEST },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["missing-size"]);
+ expect(issues[0].severity).toBe("error");
+ });
+
+ it("reports a digest that is not sha256 + 64 lowercase hex", () => {
+ for (const digest of [
+ "sha256:XYZ",
+ `sha256:${"A".repeat(64)}`,
+ `sha512:${"a".repeat(64)}`,
+ `sha256:${"a".repeat(63)}`,
+ ]) {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/ref.md", digest, size: 1 },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["malformed-digest"]);
+ }
+ });
+
+ it("reports a size that is not a non-negative integer byte length", () => {
+ for (const size of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ { uri: "skill://demo/ref.md", digest: DIGEST, size },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toEqual(["malformed-size"]);
+ expect(issues[0].severity).toBe("error");
+ }
+ });
+
+ it("a negative size cannot pull the total back under the 16 MiB limit", () => {
+ // The reason `malformed-size` is an error and not just noise: summing a
+ // negative would hide a genuine `size-limit-exceeded`.
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ {
+ uri: "skill://demo/SKILL.md",
+ digest: DIGEST,
+ size: SKILL_MAX_TOTAL_BYTES + 1,
+ },
+ { uri: "skill://demo/ref.md", digest: DIGEST, size: -1000 },
+ ],
+ }),
+ );
+ const finding = issues.find((i) => i.code === "size-limit-exceeded");
+ expect(finding?.severity).toBe("warning");
+ });
+
+ // Both limits are SHOULD NOTs for a server and MAYs for a host, so exceeding
+ // one makes a skill less portable rather than invalid.
+ it("reports a manifest over the 512-entry limit as a warning", () => {
+ const resources = [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ ...Array.from({ length: SKILL_MAX_RESOURCE_ENTRIES }, (_unused, i) => ({
+ uri: `skill://demo/f${i}.md`,
+ digest: DIGEST,
+ size: 1,
+ })),
+ ];
+ const issues = checkSkillConformance(entry({ resources }));
+ const finding = issues.find((i) => i.code === "resource-limit-exceeded");
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("reports a manifest over the 16 MiB limit as a warning", () => {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
+ {
+ uri: "skill://demo/big.bin",
+ digest: DIGEST,
+ size: SKILL_MAX_TOTAL_BYTES,
+ },
+ ],
+ }),
+ );
+ expect(issues.map((i) => i.code)).toContain("size-limit-exceeded");
+ });
+
+ it("does not report the size limit at exactly the boundary", () => {
+ const issues = checkSkillConformance(
+ entry({
+ resources: [
+ {
+ uri: "skill://demo/SKILL.md",
+ digest: DIGEST,
+ size: SKILL_MAX_TOTAL_BYTES,
+ },
+ ],
+ }),
+ );
+ expect(issues).toEqual([]);
+ });
+});
+
+describe("totalSkillBytes", () => {
+ it("sums declared sizes and treats a missing size as zero", () => {
+ expect(
+ totalSkillBytes([
+ { uri: "a", size: 10 },
+ { uri: "b" },
+ { uri: "c", size: 5 },
+ ]),
+ ).toBe(15);
+ });
+
+ it("excludes an unusable size rather than summing it", () => {
+ // An incomplete manifest may only ever *understate* the total, which is
+ // what keeps the limit check free of false positives. A negative or
+ // fractional value would break that.
+ expect(
+ totalSkillBytes([
+ { uri: "a", size: 10 },
+ { uri: "b", size: -100 },
+ { uri: "c", size: 2.5 },
+ { uri: "d", size: Number.NaN },
+ ]),
+ ).toBe(10);
+ });
+});
+
+describe("byte helpers", () => {
+ it("textToBytes produces UTF-8, not code units", () => {
+ // "é" is two bytes in UTF-8 and one JS code unit — the digest is over the
+ // former, so a naive per-char encoding would verify the wrong thing.
+ expect(Array.from(textToBytes("é"))).toEqual([0xc3, 0xa9]);
+ });
+
+ it("base64ToBytes decodes standard base64", () => {
+ expect(Array.from(base64ToBytes("aGVsbG8="))).toEqual([
+ 104, 101, 108, 108, 111,
+ ]);
+ });
+
+ it("sha256Digest matches the known digest of 'hello'", async () => {
+ expect(await sha256Digest(textToBytes("hello"))).toBe(HELLO_SHA256);
+ });
+
+ it("sha256Digest hashes only the view, not the whole backing buffer", async () => {
+ // A Uint8Array can be a window into a larger ArrayBuffer. Hashing the
+ // buffer instead of the view would silently digest neighbouring bytes.
+ const backing = new Uint8Array([0xff, ...textToBytes("hello"), 0xff]);
+ const view = backing.subarray(1, 6);
+ expect(await sha256Digest(view)).toBe(HELLO_SHA256);
+ });
+});
+
+describe("verifySkillResource", () => {
+ it("verifies matching bytes", async () => {
+ const result = await verifySkillResource(
+ { uri: "skill://demo/a.md", digest: HELLO_SHA256 },
+ textToBytes("hello"),
+ );
+ expect(result.status).toBe("verified");
+ expect(result.actualDigest).toBe(HELLO_SHA256);
+ });
+
+ it("reports a mismatch with both digests instead of throwing", async () => {
+ const expected = `sha256:${"b".repeat(64)}`;
+ const result = await verifySkillResource(
+ { uri: "skill://demo/a.md", digest: expected },
+ textToBytes("hello"),
+ );
+ expect(result.status).toBe("mismatch");
+ expect(result.expectedDigest).toBe(expected);
+ expect(result.actualDigest).toBe(HELLO_SHA256);
+ });
+
+ it("fails on a declared size that disagrees with the fetched bytes", async () => {
+ // A size disagreement is a real inconsistency even when the digest would
+ // match: the digest is taken over the bytes the server served, so agreeing
+ // with it says nothing about whether the manifest describes those bytes.
+ const result = await verifySkillResource(
+ { uri: "skill://demo/a.md", digest: HELLO_SHA256, size: 999 },
+ textToBytes("hello"),
+ );
+ expect(result.status).toBe("mismatch");
+ expect(result.expectedSize).toBe(999);
+ expect(result.actualSize).toBe(5);
+ expect(result.reason).toMatch(/999 bytes/);
+ });
+
+ it("checks the size before hashing, so a bad size never reports verified", async () => {
+ const result = await verifySkillResource(
+ { uri: "skill://demo/a.md", size: 1 },
+ textToBytes("hello"),
+ );
+ // No digest at all, and still a mismatch — the length alone settles it.
+ expect(result.status).toBe("mismatch");
+ expect(result.actualDigest).toBeUndefined();
+ });
+
+ it("echoes both sizes on a verified result when one was declared", async () => {
+ const result = await verifySkillResource(
+ { uri: "skill://demo/a.md", digest: HELLO_SHA256, size: 5 },
+ textToBytes("hello"),
+ );
+ expect(result.status).toBe("verified");
+ expect(result.expectedSize).toBe(5);
+ expect(result.actualSize).toBe(5);
+ });
+
+ it("reports unverifiable when no digest is advertised", async () => {
+ const result = await verifySkillResource(
+ { uri: "skill://demo/a.md" },
+ textToBytes("hello"),
+ );
+ expect(result.status).toBe("unverifiable");
+ expect(result.actualDigest).toBeUndefined();
+ });
+
+ it("reports unverifiable — not a mismatch — for a malformed digest", async () => {
+ // A malformed digest is already a conformance finding; calling it a
+ // mismatch would accuse the file's bytes of being wrong when the manifest
+ // is what is broken.
+ const result = await verifySkillResource(
+ { uri: "skill://demo/a.md", digest: "sha256:nope" },
+ textToBytes("hello"),
+ );
+ expect(result.status).toBe("unverifiable");
+ expect(result.expectedDigest).toBe("sha256:nope");
+ });
+});
diff --git a/clients/web/src/test/core/mcp/skillsSchemas.test.ts b/clients/web/src/test/core/mcp/skillsSchemas.test.ts
new file mode 100644
index 0000000000..085e60b893
--- /dev/null
+++ b/clients/web/src/test/core/mcp/skillsSchemas.test.ts
@@ -0,0 +1,162 @@
+import { describe, it, expect } from "vitest";
+import {
+ DYNAMIC_RESOURCES,
+ GetSkillResultSchema,
+ ListSkillsResultSchema,
+ ModernListSkillsResultSchema,
+ SKILLS_EXTENSION_KEY,
+ SKILLS_GET_METHOD,
+ SKILLS_LIST_METHOD,
+ SkillEntrySchema,
+} from "@inspector/core/mcp/skillsSchemas";
+
+const ENTRY = {
+ uri: "skill://demo/SKILL.md",
+ frontmatter: { name: "demo", description: "A demo skill" },
+ resources: [
+ { uri: "skill://demo/ref.md", digest: `sha256:${"a".repeat(64)}`, size: 3 },
+ ],
+};
+
+describe("skills wire constants", () => {
+ it("names the extension and its two required methods", () => {
+ expect(SKILLS_EXTENSION_KEY).toBe("io.modelcontextprotocol/skills");
+ expect(SKILLS_LIST_METHOD).toBe("skills/list");
+ expect(SKILLS_GET_METHOD).toBe("skills/get");
+ });
+});
+
+describe("SkillEntrySchema", () => {
+ it("parses a full manifest entry", () => {
+ expect(SkillEntrySchema.parse(ENTRY)).toEqual(ENTRY);
+ });
+
+ it("parses the dynamic form", () => {
+ const dynamic = { ...ENTRY, resources: DYNAMIC_RESOURCES };
+ expect(SkillEntrySchema.parse(dynamic).resources).toBe("dynamic");
+ });
+
+ it("passes unknown frontmatter fields through untouched", () => {
+ // The skill *format* versions independently of this extension, so an
+ // unrecognized frontmatter field is a future Agent Skills field, not junk.
+ const parsed = SkillEntrySchema.parse({
+ ...ENTRY,
+ frontmatter: { ...ENTRY.frontmatter, license: "MIT" },
+ });
+ expect(parsed.frontmatter.license).toBe("MIT");
+ });
+
+ it("accepts a malformed digest rather than rejecting the entry", () => {
+ // Rejecting here would turn a reportable server bug into a parse failure,
+ // and the Inspector exists to report it. See `checkSkillConformance`.
+ const parsed = SkillEntrySchema.parse({
+ ...ENTRY,
+ resources: [{ uri: "skill://demo/ref.md", digest: "nope" }],
+ });
+ expect(parsed.resources).toEqual([
+ { uri: "skill://demo/ref.md", digest: "nope" },
+ ]);
+ });
+
+ it("rejects an entry with no uri", () => {
+ expect(() =>
+ SkillEntrySchema.parse({ frontmatter: {}, resources: [] }),
+ ).toThrow();
+ });
+
+ it("rejects a resources value that is neither a list nor 'dynamic'", () => {
+ expect(() =>
+ SkillEntrySchema.parse({ ...ENTRY, resources: "static" }),
+ ).toThrow();
+ });
+});
+
+describe("ListSkillsResultSchema", () => {
+ it("parses a page with a cursor", () => {
+ const parsed = ListSkillsResultSchema.parse({
+ skills: [ENTRY],
+ nextCursor: "2",
+ });
+ expect(parsed.skills).toHaveLength(1);
+ expect(parsed.nextCursor).toBe("2");
+ });
+
+ it("parses a final page with no cursor", () => {
+ expect(
+ ListSkillsResultSchema.parse({ skills: [] }).nextCursor,
+ ).toBeUndefined();
+ });
+});
+
+describe("ModernListSkillsResultSchema", () => {
+ const envelope = { resultType: "complete", ttlMs: 0, cacheScope: "public" };
+
+ it("accepts a modern page carrying the base list envelope", () => {
+ const parsed = ModernListSkillsResultSchema.parse({
+ ...envelope,
+ skills: [ENTRY],
+ });
+ expect(parsed.skills).toHaveLength(1);
+ });
+
+ it("rejects a modern page that omits the caching attributes", () => {
+ // The whole reason for the era split: `skills/*` is consumer-owned, so the
+ // SDK codec validates none of it, and `{ skills: [] }` would otherwise
+ // reach the conformance UI as a clean list.
+ expect(() => ModernListSkillsResultSchema.parse({ skills: [] })).toThrow();
+ expect(() =>
+ ModernListSkillsResultSchema.parse({
+ ...envelope,
+ ttlMs: undefined,
+ skills: [],
+ }),
+ ).toThrow();
+ });
+
+ it("rejects a malformed ttlMs rather than accepting the envelope loosely", () => {
+ for (const ttlMs of [-1, 0.5]) {
+ expect(() =>
+ ModernListSkillsResultSchema.parse({ ...envelope, ttlMs, skills: [] }),
+ ).toThrow();
+ }
+ });
+
+ it("rejects an unknown cacheScope", () => {
+ expect(() =>
+ ModernListSkillsResultSchema.parse({
+ ...envelope,
+ cacheScope: "shared",
+ skills: [],
+ }),
+ ).toThrow();
+ });
+
+ it("the LEGACY schema still accepts a page without the envelope", () => {
+ // Those are 2026-era attributes; a legacy server has no business sending
+ // them and must not be failed for their absence.
+ expect(ListSkillsResultSchema.parse({ skills: [] }).skills).toEqual([]);
+ });
+});
+
+describe("GetSkillResultSchema", () => {
+ it("unwraps the envelope to the entry", () => {
+ expect(GetSkillResultSchema.parse({ skill: ENTRY })).toEqual(ENTRY);
+ });
+
+ it("rejects an entry returned inline rather than normalizing it", () => {
+ // The envelope is required. Accepting the inline form would silently
+ // normalize a non-conforming response, which is the failure this
+ // extension's support exists to report.
+ expect(() => GetSkillResultSchema.parse(ENTRY)).toThrow();
+ });
+
+ it("rejects a result that is neither shape", () => {
+ expect(() => GetSkillResultSchema.parse({ nothing: true })).toThrow();
+ });
+
+ it("rejects an envelope whose skill is not an entry", () => {
+ expect(() =>
+ GetSkillResultSchema.parse({ skill: { frontmatter: {} } }),
+ ).toThrow();
+ });
+});
diff --git a/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts
new file mode 100644
index 0000000000..df22fae75d
--- /dev/null
+++ b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts
@@ -0,0 +1,310 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
+import {
+ ManagedSkillsState,
+ REPEATED_CURSOR_MESSAGE,
+ SKILLS_MAX_PAGES,
+ SKILLS_PAGE_LIMIT_MESSAGE,
+} from "@inspector/core/mcp/state/managedSkillsState";
+import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
+import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
+
+function skill(name: string): SkillEntry {
+ return {
+ uri: `skill://${name}/SKILL.md`,
+ frontmatter: { name, description: `${name} skill` },
+ resources: [],
+ };
+}
+
+function waitFor(
+ state: ManagedSkillsState,
+ event: "skillsChange" | "errorChange" | "paginationChange",
+): Promise {
+ return new Promise((resolve) => {
+ state.addEventListener(
+ event,
+ // The union of detail types across the three events is wider than any one
+ // caller wants, so the resolve is typed at the call site.
+ (e) => resolve(e.detail as T),
+ { once: true },
+ );
+ });
+}
+
+describe("ManagedSkillsState", () => {
+ let client: FakeInspectorClient;
+ let state: ManagedSkillsState;
+
+ beforeEach(() => {
+ client = new FakeInspectorClient();
+ client.skillsExtension = { directoryRead: false };
+ state = new ManagedSkillsState(client);
+ });
+
+ it("starts empty and returns defensive copies", () => {
+ expect(state.getSkills()).toEqual([]);
+ expect(state.getSkills()).not.toBe(state.getSkills());
+ expect(state.getPagination()).toEqual({ pageCount: 0 });
+ expect(state.getError()).toBeNull();
+ });
+
+ it("refresh no-ops while disconnected", async () => {
+ await state.refresh();
+ expect(client.listSkills).not.toHaveBeenCalled();
+ });
+
+ it("returns an empty list without calling the server when the extension is absent", async () => {
+ // Calling `skills/list` against a server that never declared the extension
+ // gets -32601 and spams the console for a question already answered.
+ client.setStatus("connected");
+ client.skillsExtension = undefined;
+ await state.refresh();
+ expect(client.listSkills).not.toHaveBeenCalled();
+ expect(state.getSkills()).toEqual([]);
+ });
+
+ it("walks every page and reports how many it took", async () => {
+ client.setStatus("connected");
+ client.skillPages = [
+ { skills: [skill("a"), skill("b")], nextCursor: "2" },
+ { skills: [skill("c")], nextCursor: undefined },
+ ];
+ const skills = await state.refresh();
+ expect(skills.map((s) => s.frontmatter.name)).toEqual(["a", "b", "c"]);
+ expect(state.getPagination()).toEqual({ pageCount: 2 });
+ expect(client.listSkills).toHaveBeenCalledTimes(2);
+ });
+
+ it("dispatches skillsChange and paginationChange on a successful walk", async () => {
+ client.setStatus("connected");
+ client.skillPages = [{ skills: [skill("a")] }];
+ const skillsEvent = waitFor(state, "skillsChange");
+ const paginationEvent = waitFor<{ pageCount: number }>(
+ state,
+ "paginationChange",
+ );
+ await state.refresh();
+ expect(await skillsEvent).toHaveLength(1);
+ expect(await paginationEvent).toEqual({ pageCount: 1 });
+ });
+
+ it("loads on connect", async () => {
+ client.skillPages = [{ skills: [skill("a")] }];
+ const skillsEvent = waitFor(state, "skillsChange");
+ await client.connect();
+ expect(await skillsEvent).toHaveLength(1);
+ });
+
+ it("stops and reports when the server repeats a cursor", async () => {
+ client.setStatus("connected");
+ // A server stuck on one cursor would otherwise walk forever, so the guard
+ // is what keeps a server bug from becoming a hang.
+ client.listSkills.mockResolvedValue({
+ skills: [skill("a")],
+ nextCursor: "same",
+ });
+ await expect(state.refresh()).rejects.toThrow(REPEATED_CURSOR_MESSAGE);
+ expect(state.getError()?.message).toBe(REPEATED_CURSOR_MESSAGE);
+ });
+
+ it("stops and reports when a server hands back endlessly unique cursors", async () => {
+ client.setStatus("connected");
+ // The repeated-cursor guard cannot see this shape: every cursor is new, so
+ // the walk would grow without bound. The cap raises rather than truncating
+ // — returning what we have would present a partial list as a complete one.
+ let n = 0;
+ client.listSkills.mockImplementation(async () => ({
+ skills: [skill(`s${n}`)],
+ nextCursor: String(++n),
+ }));
+ await expect(state.refresh()).rejects.toThrow(SKILLS_PAGE_LIMIT_MESSAGE);
+ expect(client.listSkills).toHaveBeenCalledTimes(SKILLS_MAX_PAGES);
+ // The truncated list is NOT committed.
+ expect(state.getSkills()).toEqual([]);
+ });
+
+ it("abandons a walk whose session ended mid-flight", async () => {
+ client.setStatus("connected");
+ let release: ((value: { skills: SkillEntry[] }) => void) | undefined;
+ client.listSkills.mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+ const walk = state.refresh();
+ // A disconnect lands while the request is still out.
+ await client.disconnect();
+ release?.({ skills: [skill("stale")] });
+ await walk;
+ // The continuation must not repopulate a store the disconnect cleared.
+ expect(state.getSkills()).toEqual([]);
+ expect(state.getPagination()).toEqual({ pageCount: 0 });
+ });
+
+ it("lets a reconnect load its skills while a stale walk is still hanging", async () => {
+ client.setStatus("connected");
+ // The first walk never settles. A boolean overlap guard would stay set,
+ // so the reconnect's own load would no-op and never be retried — the
+ // reconnect would show an empty Skills tab forever.
+ client.listSkills.mockImplementationOnce(() => new Promise(() => {}));
+ void state.refresh().catch(() => {});
+ await client.disconnect();
+
+ client.skillPages = [{ skills: [skill("fresh")] }];
+ await client.connect();
+ expect(state.getSkills().map((s) => s.frontmatter.name)).toEqual(["fresh"]);
+ });
+
+ it("a stale walk settling later cannot release the live session's guard", async () => {
+ client.setStatus("connected");
+ let release: ((value: { skills: SkillEntry[] }) => void) | undefined;
+ client.listSkills.mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+ const stale = state.refresh();
+ await client.disconnect();
+ client.setStatus("connected");
+
+ // A live walk is now in flight under the new generation.
+ let releaseLive: ((value: { skills: SkillEntry[] }) => void) | undefined;
+ client.listSkills.mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ releaseLive = resolve;
+ }),
+ );
+ const live = state.refresh();
+ // The stale one settles first; its `finally` must not free the guard.
+ release?.({ skills: [] });
+ await stale;
+ const blocked = await state.refresh();
+ expect(blocked).toEqual([]);
+ releaseLive?.({ skills: [skill("live")] });
+ await live;
+ expect(state.getSkills().map((s) => s.frontmatter.name)).toEqual(["live"]);
+ });
+
+ it("does not surface a dead session's failure in the live one", async () => {
+ client.setStatus("connected");
+ let fail: ((err: Error) => void) | undefined;
+ client.listSkills.mockImplementationOnce(
+ async () =>
+ new Promise((_resolve, reject) => {
+ fail = reject;
+ }),
+ );
+ const walk = state.refresh();
+ await client.disconnect();
+ fail?.(new Error("from the old session"));
+ // Still rejected — the caller's auth-recovery wrapper keys off that — but
+ // the store's observable error is left alone.
+ await expect(walk).rejects.toThrow("from the old session");
+ expect(state.getError()).toBeNull();
+ });
+
+ it("records a failure as observable state and re-throws it", async () => {
+ client.setStatus("connected");
+ const failure = new Error("boom");
+ client.listSkills.mockRejectedValueOnce(failure);
+ const errorEvent = waitFor(state, "errorChange");
+ await expect(state.refresh()).rejects.toThrow("boom");
+ expect(await errorEvent).toBe(failure);
+ expect(state.getError()).toBe(failure);
+ });
+
+ describe("Protocol-entry attribution", () => {
+ it("attributes a decode rejection to its skills/list Protocol entry", async () => {
+ // Without this the Protocol tab renders an invalid `skills/list` result
+ // as a clean success — the same gap every managed list closed in #1953.
+ client.setStatus("connected");
+ const rejection = new SdkError(
+ SdkErrorCode.InvalidResult,
+ "Invalid result for skills/list: skills required",
+ );
+ client.listSkills.mockRejectedValueOnce(rejection);
+ await expect(state.refresh()).rejects.toThrow(rejection);
+ expect(client.markResponseRejected).toHaveBeenCalledWith(
+ "skills/list",
+ rejection.message,
+ );
+ });
+
+ it("does NOT attribute a transport failure", async () => {
+ // No response frame arrived, so the last-answered id still points at an
+ // EARLIER successful call; marking it would stamp "Rejected by the
+ // Inspector" onto an exchange that worked.
+ client.setStatus("connected");
+ client.listSkills.mockRejectedValueOnce(
+ new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed"),
+ );
+ await expect(state.refresh()).rejects.toThrow();
+ expect(client.markResponseRejected).not.toHaveBeenCalled();
+ });
+ });
+
+ it("wraps a non-Error rejection", async () => {
+ client.setStatus("connected");
+ client.listSkills.mockRejectedValueOnce("just a string");
+ await expect(state.refresh()).rejects.toBeDefined();
+ expect(state.getError()?.message).toBe("just a string");
+ });
+
+ it("clears the error once a later walk succeeds", async () => {
+ client.setStatus("connected");
+ client.listSkills.mockRejectedValueOnce(new Error("boom"));
+ await expect(state.refresh()).rejects.toThrow();
+ client.skillPages = [{ skills: [skill("a")] }];
+ await state.refresh();
+ expect(state.getError()).toBeNull();
+ });
+
+ it("swallows the connect-time load's rejection rather than leaking it", async () => {
+ // Nobody awaits the connect-time load, so an unhandled rejection would
+ // fail an unrelated test file. The failure still lands on `getError`.
+ client.listSkills.mockRejectedValueOnce(new Error("connect boom"));
+ const errorEvent = waitFor(state, "errorChange");
+ await client.connect();
+ expect((await errorEvent)?.message).toBe("connect boom");
+ });
+
+ it("makes a second refresh a no-op while one is in flight", async () => {
+ client.setStatus("connected");
+ let release: (() => void) | undefined;
+ client.listSkills.mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ release = () => resolve({ skills: [skill("a")] });
+ }),
+ );
+ const first = state.refresh();
+ const second = await state.refresh();
+ expect(second).toEqual([]);
+ expect(client.listSkills).toHaveBeenCalledTimes(1);
+ release?.();
+ await first;
+ expect(state.getSkills()).toHaveLength(1);
+ });
+
+ it("clears the list and the error on disconnect", async () => {
+ client.setStatus("connected");
+ client.skillPages = [{ skills: [skill("a")] }];
+ await state.refresh();
+ await client.disconnect();
+ expect(state.getSkills()).toEqual([]);
+ expect(state.getPagination()).toEqual({ pageCount: 0 });
+ expect(state.getError()).toBeNull();
+ });
+
+ it("destroy unsubscribes so a later connect does not refetch", async () => {
+ state.destroy();
+ await client.connect();
+ expect(client.listSkills).not.toHaveBeenCalled();
+ // Idempotent.
+ state.destroy();
+ });
+});
diff --git a/clients/web/src/test/core/react/useManagedSkills.test.tsx b/clients/web/src/test/core/react/useManagedSkills.test.tsx
new file mode 100644
index 0000000000..a9c744e48c
--- /dev/null
+++ b/clients/web/src/test/core/react/useManagedSkills.test.tsx
@@ -0,0 +1,114 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
+import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
+import { ManagedSkillsState } from "@inspector/core/mcp/state/managedSkillsState";
+import { useManagedSkills } from "@inspector/core/react/useManagedSkills";
+
+function skill(name: string): SkillEntry {
+ return {
+ uri: `skill://${name}/SKILL.md`,
+ frontmatter: { name, description: `${name} skill` },
+ resources: [],
+ };
+}
+
+describe("useManagedSkills", () => {
+ let client: FakeInspectorClient;
+ let state: ManagedSkillsState;
+
+ beforeEach(() => {
+ client = new FakeInspectorClient({ status: "connected" });
+ client.skillsExtension = { directoryRead: false };
+ state = new ManagedSkillsState(client);
+ });
+
+ it("reports the store's current snapshot on first render", async () => {
+ client.skillPages = [{ skills: [skill("a"), skill("b")] }];
+ await state.refresh();
+
+ const { result } = renderHook(() => useManagedSkills(client, state));
+ expect(result.current.skills.map((s) => s.frontmatter.name)).toEqual([
+ "a",
+ "b",
+ ]);
+ expect(result.current.pageCount).toBe(1);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("degrades to empty values when no store is attached", async () => {
+ const { result } = renderHook(() => useManagedSkills(client, null));
+ expect(result.current.skills).toEqual([]);
+ expect(result.current.pageCount).toBe(0);
+ expect(result.current.error).toBeNull();
+ // The refresh is still callable and simply resolves to the empty list.
+ await expect(result.current.refresh()).resolves.toEqual([]);
+ });
+
+ it("updates when the store dispatches", async () => {
+ const { result } = renderHook(() => useManagedSkills(client, state));
+ expect(result.current.skills).toEqual([]);
+
+ client.skillPages = [
+ { skills: [skill("a")], nextCursor: "1" },
+ { skills: [skill("b")] },
+ ];
+ await act(async () => {
+ await state.refresh();
+ });
+ expect(result.current.skills).toHaveLength(2);
+ expect(result.current.pageCount).toBe(2);
+ });
+
+ it("holds the snapshot identity stable across renders with no dispatch", async () => {
+ client.skillPages = [{ skills: [skill("a")] }];
+ await state.refresh();
+ const { result, rerender } = renderHook(() =>
+ useManagedSkills(client, state),
+ );
+ const first = result.current.skills;
+ rerender();
+ // `getSkills()` returns a fresh copy per call, so an uncached snapshot
+ // would hand back a new array every render and defeat every downstream memo.
+ expect(result.current.skills).toBe(first);
+ });
+
+ it("surfaces the store's error", async () => {
+ client.listSkills.mockRejectedValueOnce(new Error("boom"));
+ const { result } = renderHook(() => useManagedSkills(client, state));
+ await act(async () => {
+ await state.refresh().catch(() => {});
+ });
+ expect(result.current.error?.message).toBe("boom");
+ });
+
+ it("refresh drives the store", async () => {
+ const { result } = renderHook(() => useManagedSkills(client, state));
+ client.skillPages = [{ skills: [skill("a")] }];
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.skills).toHaveLength(1);
+ });
+
+ it("swaps to another server's store in the same render", async () => {
+ const other = new FakeInspectorClient({ status: "connected" });
+ other.skillsExtension = { directoryRead: false };
+ const otherState = new ManagedSkillsState(other);
+ other.skillPages = [{ skills: [skill("z")] }];
+ await otherState.refresh();
+
+ client.skillPages = [{ skills: [skill("a")] }];
+ await state.refresh();
+
+ const { result, rerender } = renderHook(
+ ({ s }: { s: ManagedSkillsState }) => useManagedSkills(client, s),
+ { initialProps: { s: state } },
+ );
+ expect(result.current.skills[0].frontmatter.name).toBe("a");
+ rerender({ s: otherState });
+ // Read during render, so the swap lands in the same frame — no frame of
+ // the previous server's skills.
+ expect(result.current.skills[0].frontmatter.name).toBe("z");
+ });
+});
diff --git a/clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts
new file mode 100644
index 0000000000..3b53d6a3eb
--- /dev/null
+++ b/clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
+import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
+import { ManagedSkillsState } from "@inspector/core/mcp/state/managedSkillsState.js";
+import {
+ createTestServerHttp,
+ type TestServerHttp,
+ createTestServerInfo,
+} from "@modelcontextprotocol/inspector-test-server";
+
+/**
+ * Live coverage of the Skills extension (SEP-2640, #2234) over a real
+ * transport against the real fixture.
+ *
+ * Everything else that covers this feature stubs the seam it is about: the
+ * client unit tests replace `client.request`, the screen tests mock the
+ * callbacks, and the store tests use a fake client. That leaves precisely the
+ * integration-sensitive claims unguarded — that `skills/list` and `skills/get`
+ * can be served through the SDK's **public** `setRequestHandler` for a
+ * consumer-owned method, that the fixture's `resources/read` wrapper answers
+ * `skill://` URIs while leaving other URIs to the SDK, that the cursor walk
+ * actually pages, and that all of it works on **both** protocol eras. Each of
+ * those is an assertion about the SDK's behavior, so only a real connection
+ * can check it.
+ *
+ * The era coverage is the point of the parameterization: `skills/*` are in
+ * neither era codec, which is *why* one fixture is expected to serve both
+ * legs — and that expectation had no test until this one.
+ */
+describe("Skills extension over a real transport (#2234)", () => {
+ let client: InspectorClient | null = null;
+ const servers: TestServerHttp[] = [];
+
+ afterEach(async () => {
+ if (client) {
+ try {
+ await client.disconnect();
+ } catch {
+ // ignore
+ }
+ client = null;
+ }
+ while (servers.length) {
+ const s = servers.pop();
+ try {
+ await s?.stop();
+ } catch {
+ // ignore
+ }
+ }
+ });
+
+ async function startSkillsServer(modern: boolean): Promise {
+ const started = createTestServerHttp({
+ serverInfo: createTestServerInfo("skills-integration", "1.0.0"),
+ // An ordinary resource alongside the skills, so the fixture's
+ // `resources/read` wrapper is proven to DELEGATE rather than swallow.
+ resources: [
+ {
+ name: "plain",
+ uri: "foobar://plain",
+ mimeType: "text/plain",
+ text: "plain",
+ },
+ ],
+ skills: true,
+ ...(modern && { modern: {} }),
+ });
+ await started.start();
+ servers.push(started);
+ return started;
+ }
+
+ async function connect(
+ url: string,
+ modern: boolean,
+ ): Promise {
+ const connected = new InspectorClient(
+ {
+ type: "streamable-http",
+ url,
+ ...(modern && { protocolEra: "modern" as const }),
+ },
+ { environment: { transport: createTransportNode } },
+ );
+ await connected.connect();
+ client = connected;
+ return connected;
+ }
+
+ for (const modern of [false, true]) {
+ const era = modern ? "modern" : "legacy";
+
+ describe(`on the ${era} era`, () => {
+ it("advertises the extension in its capabilities", async () => {
+ const started = await startSkillsServer(modern);
+ const connected = await connect(started.url, modern);
+ // Bare, per the fixture: no `directoryRead` until phase 3 serves it.
+ expect(getSkillsExtension(connected.getCapabilities())).toEqual({
+ directoryRead: false,
+ });
+ });
+
+ it("serves skills/list as a paged walk", async () => {
+ const started = await startSkillsServer(modern);
+ const connected = await connect(started.url, modern);
+
+ const first = await connected.listSkills();
+ // On the modern leg this call resolving is itself the envelope
+ // assertion: `listSkills` selects `ModernListSkillsResultSchema` from
+ // the negotiated era, and that schema rejects a page without
+ // `resultType` / `ttlMs` / `cacheScope`. It cannot be asserted on the
+ // returned value — `listSkills` narrows its result to the two fields
+ // below — so a modern page missing the envelope surfaces here as a
+ // rejection rather than as a missing property.
+ //
+ // The fixture pages at two, so a client that stops here sees half.
+ expect(first.skills).toHaveLength(2);
+ expect(first.nextCursor).toBeDefined();
+
+ const second = await connected.listSkills(first.nextCursor);
+ expect(second.skills).toHaveLength(2);
+ expect(second.nextCursor).toBeUndefined();
+ });
+
+ it("walks every page through the managed store", async () => {
+ const started = await startSkillsServer(modern);
+ const connected = await connect(started.url, modern);
+ const store = new ManagedSkillsState(connected);
+ try {
+ const skills = await store.refresh();
+ expect(skills.map((s) => s.frontmatter.name)).toEqual([
+ "data-analysis",
+ "tampered-notes",
+ "dynamic-report",
+ "right-name",
+ ]);
+ expect(store.getPagination()).toEqual({ pageCount: 2 });
+ } finally {
+ store.destroy();
+ }
+ });
+
+ it("serves skills/get for one entry", async () => {
+ const started = await startSkillsServer(modern);
+ const connected = await connect(started.url, modern);
+ const entry = await connected.getSkill(
+ "skill://data-analysis/SKILL.md",
+ );
+ expect(entry.frontmatter.name).toBe("data-analysis");
+ expect(Array.isArray(entry.resources)).toBe(true);
+ });
+
+ it("answers -32602 for an unknown skill uri", async () => {
+ const started = await startSkillsServer(modern);
+ const connected = await connect(started.url, modern);
+ await expect(
+ connected.getSkill("skill://nope/SKILL.md"),
+ ).rejects.toThrow(/Unknown skill uri/);
+ });
+
+ it("reads a skill file through resources/read", async () => {
+ const started = await startSkillsServer(modern);
+ const connected = await connect(started.url, modern);
+ const read = await connected.readResource(
+ "skill://data-analysis/reference.md",
+ );
+ const block = read.result.contents[0];
+ expect(block.uri).toBe("skill://data-analysis/reference.md");
+ expect("text" in block && block.text).toContain("Column rules");
+ });
+
+ it("still serves an ordinary resource — the wrapper delegates", async () => {
+ // The one thing the `resources/read` wrap must not break.
+ const started = await startSkillsServer(modern);
+ const connected = await connect(started.url, modern);
+ const read = await connected.readResource("foobar://plain");
+ expect(read.result.contents[0].uri).toBe("foobar://plain");
+ });
+ });
+ }
+});
diff --git a/clients/web/src/utils/inspectorTabs.test.ts b/clients/web/src/utils/inspectorTabs.test.ts
index db6ab06435..181b088ed5 100644
--- a/clients/web/src/utils/inspectorTabs.test.ts
+++ b/clients/web/src/utils/inspectorTabs.test.ts
@@ -17,6 +17,7 @@ describe("inspectorTabs", () => {
"Tools",
"Prompts",
"Resources",
+ "Skills",
"Tasks",
"Logs",
"Protocol",
diff --git a/clients/web/src/utils/inspectorTabs.ts b/clients/web/src/utils/inspectorTabs.ts
index be69af737f..a9d69afe9f 100644
--- a/clients/web/src/utils/inspectorTabs.ts
+++ b/clients/web/src/utils/inspectorTabs.ts
@@ -11,6 +11,7 @@ export const INSPECTOR_TAB_IDS = [
"Tools",
"Prompts",
"Resources",
+ "Skills",
"Tasks",
"Logs",
"Protocol",
diff --git a/clients/web/src/utils/skillFileBytes.test.ts b/clients/web/src/utils/skillFileBytes.test.ts
new file mode 100644
index 0000000000..be27497d64
--- /dev/null
+++ b/clients/web/src/utils/skillFileBytes.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+import { skillFileBytes } from "./skillFileBytes";
+
+describe("skillFileBytes", () => {
+ it("encodes a text content block as UTF-8", () => {
+ expect(Array.from(skillFileBytes({ text: "hé" }))).toEqual([
+ 0x68, 0xc3, 0xa9,
+ ]);
+ });
+
+ it("decodes a blob content block from base64", () => {
+ expect(Array.from(skillFileBytes({ blob: "aGVsbG8=" }))).toEqual([
+ 104, 101, 108, 108, 111,
+ ]);
+ });
+
+ it("prefers text when a server sends both", () => {
+ expect(Array.from(skillFileBytes({ text: "a", blob: "Yg==" }))).toEqual([
+ 97,
+ ]);
+ });
+
+ it("treats an empty string as content, not as absence", () => {
+ // A zero-byte file is legal and has a perfectly good digest; falling
+ // through to the throw here would report a real file as unreadable.
+ expect(skillFileBytes({ text: "" })).toHaveLength(0);
+ });
+
+ it("throws for a response carrying neither field", () => {
+ // Not an empty array: an empty array hashes to the digest of nothing, so a
+ // silent fallback would report a confident digest *mismatch* rather than
+ // the truth, which is that the server returned no content.
+ expect(() => skillFileBytes({ mimeType: "text/markdown" })).toThrow(
+ /neither text nor blob/,
+ );
+ });
+});
diff --git a/clients/web/src/utils/skillFileBytes.ts b/clients/web/src/utils/skillFileBytes.ts
new file mode 100644
index 0000000000..a1fd0e141d
--- /dev/null
+++ b/clients/web/src/utils/skillFileBytes.ts
@@ -0,0 +1,35 @@
+/**
+ * Decoding a `resources/read` payload back to the bytes its digest was taken
+ * over (SEP-2640, #2234).
+ *
+ * A pure transform with no I/O and no subsystem ownership, so it belongs in
+ * `utils/` rather than `lib/` — the screen that verifies a skill file does the
+ * fetching; this only turns what came back into bytes.
+ */
+
+import { base64ToBytes, textToBytes } from "@inspector/core/mcp/skills.js";
+
+/**
+ * The content a `resources/read` returned for one skill file. Either `text` (a
+ * `TextResourceContents`) or `blob` (base64, a `BlobResourceContents`).
+ */
+export interface SkillFileContents {
+ text?: string;
+ blob?: string;
+ mimeType?: string;
+}
+
+/**
+ * The raw bytes of a skill file, as fetched.
+ *
+ * Throws for a result carrying neither `text` nor `blob`. That is a server bug,
+ * and it must not be quietly treated as empty content: an empty `Uint8Array`
+ * has a perfectly good SHA-256, so a silent fallback would report a *digest
+ * mismatch* — a confident, wrong diagnosis — instead of "this response carried
+ * no content at all". Callers surface the throw as a per-file read failure.
+ */
+export function skillFileBytes(contents: SkillFileContents): Uint8Array {
+ if (typeof contents.text === "string") return textToBytes(contents.text);
+ if (typeof contents.blob === "string") return base64ToBytes(contents.blob);
+ throw new Error("resources/read returned neither text nor blob content.");
+}
diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts
index 8e3519e95c..49232ad696 100644
--- a/core/mcp/__tests__/fakeInspectorClient.ts
+++ b/core/mcp/__tests__/fakeInspectorClient.ts
@@ -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 = {
@@ -104,6 +106,7 @@ export class FakeInspectorClient
ListResult<"resourceTemplates", ResourceTemplate>
> = [];
taskPages: Array> = [];
+ skillPages: Array> = [];
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) => {});
diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts
index 8fdaebe942..7b2964f281 100644
--- a/core/mcp/inspectorClient.ts
+++ b/core/mcp/inspectorClient.ts
@@ -144,6 +144,15 @@ import {
type ModernDetailedTask,
} from "./modernTaskSchemas.js";
import { buildClientExtensions } from "./extensions.js";
+import {
+ GetSkillResultSchema,
+ ListSkillsResultSchema,
+ ModernListSkillsResultSchema,
+ SKILLS_GET_METHOD,
+ SKILLS_LIST_METHOD,
+ type SkillEntry,
+} from "./skillsSchemas.js";
+import { getSkillsExtension, type SkillsExtensionSupport } from "./skills.js";
import {
getElicitationUiResourceUri,
isFormElicitation,
@@ -194,6 +203,7 @@ import {
LIST_MAX_PAGES,
ModernResultEnvelopeSchema,
isSalvageableRejection,
+ isClientDecodeRejection,
listPaginationExceeded,
toolItemSchemaForEra,
nextCursorOf,
@@ -5516,6 +5526,113 @@ 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 = {
+ ...(effectiveMeta ? { _meta: effectiveMeta } : {}),
+ // `!== undefined`, not truthiness: a cursor is opaque and the empty
+ // string is a legal value. Dropping `""` would silently re-request page
+ // one, which the store then reports as a repeated-cursor failure — a
+ // conforming server made to look broken.
+ ...(cursor !== undefined ? { cursor } : {}),
+ };
+ // Era-aware: a modern (2026-07-28+) `skills/list` result also carries the
+ // base list envelope (`resultType` / `ttlMs` / `cacheScope`). `skills/*` is
+ // consumer-owned, so the SDK codec validates none of it — without picking
+ // the schema here a modern server could answer `{ skills: [] }` and the
+ // conformance UI would show a clean list. Legacy stays permissive: those
+ // are 2026-era attributes.
+ const resultSchema = this.isModernEra()
+ ? ModernListSkillsResultSchema
+ : ListSkillsResultSchema;
+ const response = await this.invokeMcpClient(
+ () =>
+ this.client!.request(
+ { method: SKILLS_LIST_METHOD, params },
+ resultSchema,
+ 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 result envelope is
+ * required — `GetSkillResultSchema` unwraps `{ skill }` and rejects an entry
+ * returned inline, so a non-conforming shape fails here rather than being
+ * silently normalized past the conformance checks.
+ */
+ async getSkill(uri: string, metadata?: RequestMetadata): Promise {
+ if (!this.client) {
+ throw new Error("Client is not connected");
+ }
+ const effectiveMeta = this.mergeMeta(metadata);
+ const params: Record = {
+ uri,
+ ...(effectiveMeta ? { _meta: effectiveMeta } : {}),
+ };
+ // `GetSkillResultSchema` unwraps the envelope, so there is nothing to
+ // unwrap here.
+ try {
+ return await this.invokeMcpClient(
+ () =>
+ this.client!.request(
+ { method: SKILLS_GET_METHOD, params },
+ GetSkillResultSchema,
+ this.getRequestOptions(this.progressTokenOf(metadata)),
+ ),
+ { method: SKILLS_GET_METHOD },
+ );
+ } catch (err) {
+ // Attribute a rejected envelope to the exchange it came from, so the
+ // Protocol tab stops rendering it as a clean success while the Skills
+ // screen shows an error — the same handling every managed list gets
+ // (#1953). Done here rather than in a store because `skills/get` has
+ // none: the screen calls it directly. Must happen in this catch, while
+ // the correlation window is still current, and ONLY for a decode
+ // rejection — a request that never produced a response would otherwise
+ // stamp an earlier, successful exchange.
+ if (isClientDecodeRejection(err)) {
+ this.markResponseRejected(
+ SKILLS_GET_METHOD,
+ err instanceof Error ? err.message : String(err),
+ );
+ }
+ throw err;
+ }
+ }
+
/**
* Get a prompt by name
* @param name Prompt name
diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts
index ef290f9e17..675bf6776a 100644
--- a/core/mcp/inspectorClientProtocol.ts
+++ b/core/mcp/inspectorClientProtocol.ts
@@ -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;
+
/**
* 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
diff --git a/core/mcp/sha256.ts b/core/mcp/sha256.ts
new file mode 100644
index 0000000000..365bc94eed
--- /dev/null
+++ b/core/mcp/sha256.ts
@@ -0,0 +1,118 @@
+/**
+ * A dependency-free SHA-256, used when `crypto.subtle` is unavailable.
+ *
+ * ⚠️ **This is not an optimization — it is what makes digest verification work
+ * at all in a documented deployment.** `SubtleCrypto` is exposed only in a
+ * [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts),
+ * and `clients/web/README.md#hosting-on-a-network` documents serving the
+ * Inspector over plain HTTP on a LAN IP (`HOST=192.168.1.50`). A browser there
+ * has `globalThis.crypto` but **no** `crypto.subtle`, so every skill-file
+ * verification would throw and the UI would report a read failure for files
+ * that were fetched perfectly well (#2234).
+ *
+ * `crypto.subtle` is still preferred wherever it exists — see `sha256Digest` in
+ * `skills.ts`. This is the fallback, and it is exercised directly by its own
+ * tests against the published FIPS 180-4 vectors plus a differential check
+ * against WebCrypto, so "it agrees with the real thing" is asserted rather than
+ * assumed.
+ *
+ * The implementation is the standard FIPS 180-4 construction; it is short
+ * enough that adding a dependency for it would cost more than it saves, and
+ * per [Dependency placement] a new runtime dependency here would have to be
+ * declared at the repo root and threaded through three bundler `external`
+ * lists.
+ */
+
+/** SHA-256 round constants: the first 32 bits of the fractional parts of the
+ * cube roots of the first 64 primes (FIPS 180-4 §4.2.2). */
+const K = new Uint32Array([
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
+ 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
+ 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+]);
+
+/** Initial hash value: fractional parts of the square roots of the first eight
+ * primes (FIPS 180-4 §5.3.3). */
+const H0 = new Uint32Array([
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
+ 0x1f83d9ab, 0x5be0cd19,
+]);
+
+const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
+
+/**
+ * The raw 32-byte SHA-256 digest of `bytes`.
+ *
+ * Operates on a copy of the view's own range, so a `Uint8Array` that is a
+ * window into a larger buffer hashes only what it spans — the same guarantee
+ * the WebCrypto path makes.
+ */
+export function sha256Bytes(bytes: Uint8Array): Uint8Array {
+ const message = new Uint8Array(bytes);
+ const bitLength = message.length * 8;
+ // Padded length: message + the mandatory 0x80 byte + zeros + a 64-bit length,
+ // rounded up to a whole number of 64-byte blocks.
+ const withLength = message.length + 9;
+ const padded = new Uint8Array(Math.ceil(withLength / 64) * 64);
+ padded.set(message);
+ padded[message.length] = 0x80;
+
+ const view = new DataView(padded.buffer);
+ // The length field is 64 bits. A message long enough to overflow the low 32
+ // would be 512 MiB, well past the extension's 16 MiB per-skill limit, but the
+ // high word is written correctly rather than assumed zero.
+ view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000));
+ view.setUint32(padded.length - 4, bitLength >>> 0);
+
+ const h = new Uint32Array(H0);
+ const w = new Uint32Array(64);
+
+ for (let offset = 0; offset < padded.length; offset += 64) {
+ for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4);
+ for (let i = 16; i < 64; i += 1) {
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
+ }
+
+ let [a, b, c, d, e, f, g, hh] = h;
+ for (let i = 0; i < 64; i += 1) {
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
+ const ch = (e & f) ^ (~e & g);
+ const temp1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
+ const maj = (a & b) ^ (a & c) ^ (b & c);
+ const temp2 = (S0 + maj) >>> 0;
+ hh = g;
+ g = f;
+ f = e;
+ e = (d + temp1) >>> 0;
+ d = c;
+ c = b;
+ b = a;
+ a = (temp1 + temp2) >>> 0;
+ }
+
+ h[0] = (h[0] + a) >>> 0;
+ h[1] = (h[1] + b) >>> 0;
+ h[2] = (h[2] + c) >>> 0;
+ h[3] = (h[3] + d) >>> 0;
+ h[4] = (h[4] + e) >>> 0;
+ h[5] = (h[5] + f) >>> 0;
+ h[6] = (h[6] + g) >>> 0;
+ h[7] = (h[7] + hh) >>> 0;
+ }
+
+ const digest = new Uint8Array(32);
+ const out = new DataView(digest.buffer);
+ for (let i = 0; i < 8; i += 1) out.setUint32(i * 4, h[i]);
+ return digest;
+}
diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts
new file mode 100644
index 0000000000..5d4883744e
--- /dev/null
+++ b/core/mcp/skills.ts
@@ -0,0 +1,706 @@
+/**
+ * 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.
+ *
+ * ⚠️ **One SEP-2640 obligation is deliberately NOT checked here: that an entry's
+ * `frontmatter` matches the fetched `SKILL.md`'s frontmatter field by field.**
+ * The digest check does not cover it — a digest is taken over the bytes the
+ * server served, so it proves the file was not tampered with in transit and
+ * says nothing about whether the *listing* described that file honestly. A
+ * server can therefore advertise one description, serve a different one, and
+ * pass every check in this module. Closing it needs a YAML parser, which is a
+ * new runtime dependency and a placement decision of its own, so it is tracked
+ * on #2248 rather than half-done here.
+ */
+
+import type { ServerCapabilities } from "@modelcontextprotocol/client";
+import {
+ DYNAMIC_RESOURCES,
+ SKILLS_EXTENSION_KEY,
+ type SkillEntry,
+ type SkillResource,
+} from "./skillsSchemas.js";
+import { sha256Bytes } from "./sha256.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}$/;
+
+/**
+ * The Agent Skills name format SEP-2640 requires of `frontmatter.name`: 1–64
+ * characters of lowercase alphanumerics and hyphens, with no leading, trailing
+ * or consecutive hyphen.
+ *
+ * Checking only that the name is non-empty let `Bad Name` reach the UI as
+ * "Conforms" — and the name is not decorative here: it must equal the URI path
+ * segment, so a name that cannot appear in a URI is a contradiction the entry
+ * cannot satisfy.
+ */
+const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+const SKILL_NAME_MAX_LENGTH = 64;
+
+/** The Agent Skills limit on `frontmatter.description`. */
+const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
+
+/**
+ * Length in Unicode **code points**, not UTF-16 code units.
+ *
+ * `String.prototype.length` counts code units, so every non-BMP character
+ * (emoji, many CJK extension characters) counts twice — a perfectly valid
+ * 600-character description would be measured as 1200 and reported as
+ * `malformed-description`. The Agent Skills limit is in characters, and its
+ * reference validator uses Python's `len()`, which counts code points. Getting
+ * this wrong fails a conforming server, which is the direction this module
+ * works hardest to avoid.
+ */
+function codePointLength(value: string): number {
+ return [...value].length;
+}
+
+/**
+ * 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];
+ // Must be an OBJECT. SEP-2133 declares an extension as an object of
+ // sub-options, so a primitive (`false`, `"skills"`) is not a declaration —
+ // and treating one as support would show the Skills tab and send
+ // `skills/list` to a server that never claimed to serve it. Matches how
+ // `appElicitation.ts` parses the UI extension.
+ if (typeof declared !== "object" || declared === null) return undefined;
+ const directoryRead =
+ (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;
+}
+
+/**
+ * A skill URI in normalized form, or `undefined` when it is not one.
+ *
+ * Two things a raw string comparison gets wrong, and both matter:
+ *
+ * 1. **`..` segments.** `skill://acme/billing/refunds/../other.md` starts with
+ * the advertised root but resolves outside it. Containment has to be decided
+ * on the resolved path, so every check below goes through the parser.
+ * 2. **Relative strings.** SEP-2640 requires a full resource URI, and
+ * `demo/SKILL.md` is not one — it fails to parse and is reported as
+ * `malformed-uri` rather than quietly treated as a skill path.
+ *
+ * An **opaque-path** URI (`skill:demo/SKILL.md`, no authority) parses but is
+ * NOT normalized by the parser — its `..` segments survive verbatim — so it is
+ * rejected too: containment could not be decided on it, and silently accepting
+ * one would reintroduce exactly the hole this function closes.
+ *
+ * The scheme is deliberately **not** constrained. `skill://` is what SEP-2640
+ * recommends and what this repo's fixture serves, but the SEP only says servers
+ * SHOULD use it and explicitly allows a domain-native scheme (`github://…`), so
+ * requiring `skill:` would hand a conforming server a false `malformed-uri` and
+ * skip its name and root checks entirely. Containment is scheme-independent
+ * anyway: it compares a resource against *this entry's own* root, so an entry
+ * cannot escape its skill whatever scheme it uses.
+ */
+export function normalizeSkillUri(uri: string): string | undefined {
+ let parsed: URL;
+ try {
+ parsed = new URL(uri);
+ } catch {
+ return undefined;
+ }
+ if (!parsed.pathname.startsWith("/")) return undefined;
+ return canonicalizePercentEncoding(parsed.href);
+}
+
+/**
+ * The two percent-encoding normalizations RFC 3986 §6.2.2 calls for and the
+ * URL parser does **not** do: decode an escape that stands for an *unreserved*
+ * character, and upper-case the hex of every escape that remains.
+ *
+ * `URL.href` leaves `%72eference.md` encoded, so without this a server echoing
+ * an RFC-equivalent form of the URI we asked for would be rejected by
+ * `onReadSkillFile` as a different resource, and an encoded skill-name segment
+ * would produce a false `name-path-mismatch`. Both are the tool calling a
+ * conforming server wrong, which is the failure mode this module works hardest
+ * to avoid.
+ */
+function canonicalizePercentEncoding(value: string): string {
+ return value.replace(/%[0-9a-fA-F]{2}/g, (escape) => {
+ const char = String.fromCharCode(Number.parseInt(escape.slice(1), 16));
+ return /[A-Za-z0-9\-._~]/.test(char) ? char : escape.toUpperCase();
+ });
+}
+
+/**
+ * The final `` 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.
+ *
+ * Read off the **normalized** URI, so a traversal segment cannot produce a
+ * name the resolved path does not actually carry.
+ */
+export function skillNameFromUri(uri: string): string | undefined {
+ const normalized = normalizeSkillUri(uri);
+ if (normalized === undefined || !normalized.endsWith(SKILL_FILE_SUFFIX)) {
+ return undefined;
+ }
+ const path = normalized.slice(0, -SKILL_FILE_SUFFIX.length);
+ const segment = path.slice(path.lastIndexOf("/") + 1);
+ return segment.length > 0 ? segment : undefined;
+}
+
+/**
+ * The comparison identity of a resource URI: its normalized form, falling back
+ * to the raw string when it does not parse.
+ *
+ * Every URI comparison in this module and in the screen goes through this, so
+ * they cannot disagree about whether two spellings name the same file. The raw
+ * fallback is deliberate: two *different* unparseable URIs must stay distinct
+ * rather than both collapsing to one `undefined` identity.
+ */
+export function skillUriIdentity(uri: string): string {
+ return normalizeSkillUri(uri) ?? uri;
+}
+
+/**
+ * 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"
+ | "malformed-name"
+ | "missing-description"
+ | "malformed-description"
+ | "malformed-uri"
+ | "name-path-mismatch"
+ | "missing-digest"
+ | "malformed-digest"
+ | "missing-size"
+ | "malformed-size"
+ | "duplicate-resource"
+ | "resource-outside-skill-root"
+ | "manifest-missing-self"
+ | "resource-limit-exceeded"
+ | "size-limit-exceeded";
+
+/**
+ * `error` marks a **MUST** of SEP-2640 that the server broke, so a manifest
+ * reporting "0 errors" really is one the spec accepts. `warning` covers
+ * everything the spec permits but a consumer still wants told about: the
+ * `SHOULD NOT`-exceed interoperability limits, and — above all — `"dynamic"`
+ * resources, which are legal and leave integrity unverifiable, 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[] = [];
+ // The RAW value is what the grammar is applied to — trimming first would let
+ // `" demo "` pass, and whitespace is not in the Agent Skills name grammar.
+ // The trimmed copy exists only to tell "absent" from "present but invalid".
+ const rawName = entry.frontmatter.name;
+ const declaredName = rawName?.trim() ? rawName : undefined;
+ const uriName = skillNameFromUri(entry.uri);
+
+ if (!declaredName) {
+ issues.push({
+ code: "missing-name",
+ severity: "error",
+ message: "frontmatter.name is required but missing or empty.",
+ });
+ } else if (
+ codePointLength(declaredName) > SKILL_NAME_MAX_LENGTH ||
+ !SKILL_NAME_PATTERN.test(declaredName)
+ ) {
+ // Reaches here for `" demo "` too: the grammar sees the untrimmed value.
+ issues.push({
+ code: "malformed-name",
+ severity: "error",
+ message: `frontmatter.name "${declaredName}" is not a valid Agent Skills name: 1–${SKILL_NAME_MAX_LENGTH} lowercase alphanumerics and hyphens, with no leading, trailing or consecutive hyphen.`,
+ });
+ }
+ const rawDescription = entry.frontmatter.description;
+ if (!rawDescription?.trim()) {
+ // An error, not a warning: SEP-2640 requires `description` on every skill,
+ // so an absent one is a format violation and must not read as "0 errors".
+ issues.push({
+ code: "missing-description",
+ severity: "error",
+ message: "frontmatter.description is required but missing or empty.",
+ });
+ } else if (codePointLength(rawDescription) > SKILL_DESCRIPTION_MAX_LENGTH) {
+ issues.push({
+ code: "malformed-description",
+ severity: "error",
+ message: `frontmatter.description is ${codePointLength(rawDescription)} characters, above the ${SKILL_DESCRIPTION_MAX_LENGTH}-character limit.`,
+ });
+ }
+ if (uriName === undefined) {
+ issues.push({
+ code: "malformed-uri",
+ severity: "error",
+ message: `Skill URI must be a hierarchical URI ending with "${SKILL_FILE_SUFFIX}" and carrying 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;
+ }
+
+ // Both limits are *interoperability* bounds, not MUSTs: SEP-2640 says a
+ // server SHOULD NOT exceed them and a host MAY support more. So they are
+ // warnings — calling a permitted oversized skill an error would contradict
+ // what `error` means here and tell a server author their skill is invalid
+ // when it is merely less portable.
+ if (entry.resources.length > SKILL_MAX_RESOURCE_ENTRIES) {
+ issues.push({
+ code: "resource-limit-exceeded",
+ severity: "warning",
+ message: `Skill declares ${entry.resources.length} resource entries, above the ${SKILL_MAX_RESOURCE_ENTRIES}-entry interoperability limit; a host is only required to support up to it.`,
+ });
+ }
+ const totalBytes = totalSkillBytes(entry.resources);
+ if (totalBytes > SKILL_MAX_TOTAL_BYTES) {
+ issues.push({
+ code: "size-limit-exceeded",
+ severity: "warning",
+ message: `Skill resources total ${totalBytes} bytes, above the ${SKILL_MAX_TOTAL_BYTES}-byte (16 MiB) interoperability limit; a host is only required to support up to it.`,
+ });
+ }
+
+ // A manifest is the *complete* file set, and the skill's own SKILL.md is one
+ // of those files. An empty list, or one that omits the entry's own URI, is
+ // therefore not "a skill with no extra files" — it is a manifest that cannot
+ // be checked against what the skill actually is, and reporting `Conforms`
+ // for it would be a wrong answer rather than a missing one.
+ // Compared on the normalized identity, like every other URI comparison here:
+ // a manifest listing the RFC-equivalent `skill://demo/%53KILL.md` names the
+ // same file the entry does, and is fetchable as that file, so calling it a
+ // missing self-entry would be the tool disagreeing with itself.
+ const entryIdentity = skillUriIdentity(entry.uri);
+ if (
+ !entry.resources.some(
+ (resource) => skillUriIdentity(resource.uri) === entryIdentity,
+ )
+ ) {
+ issues.push({
+ code: "manifest-missing-self",
+ severity: "error",
+ message: `Manifest does not list the skill's own entry file (${entry.uri}); a manifest must be the complete file set.`,
+ });
+ }
+
+ const seenUris = new Set();
+ // Relative references resolve against the skill root, so every manifest entry
+ // must live under it. A URI outside that prefix is either a typo or a server
+ // claiming integrity over a file that is not part of this skill. Computed
+ // from the NORMALIZED entry URI, and compared against normalized resource
+ // URIs, so a `..` segment cannot walk out of the root while still matching it
+ // as a string. Left `undefined` for a malformed entry URI — there is no root
+ // to measure against, and `malformed-uri` already reports that.
+ const normalizedEntryUri = normalizeSkillUri(entry.uri);
+ const root =
+ normalizedEntryUri !== undefined &&
+ normalizedEntryUri.endsWith(SKILL_FILE_SUFFIX)
+ ? `${normalizedEntryUri.slice(0, -SKILL_FILE_SUFFIX.length)}/`
+ : undefined;
+
+ for (const resource of entry.resources) {
+ // Compared on the NORMALIZED identity, because everything else here treats
+ // normalized-equivalents as the same resource — containment does, and so
+ // does the read that fetches the bytes. On the raw string,
+ // `skill://demo/SKILL.md` and `skill://demo/x/../SKILL.md` would pass as
+ // two distinct files while naming one. The raw URI is still what the
+ // finding reports, so the diagnostic points at what the server actually
+ // sent. Unparseable URIs fall back to the raw string: they are already
+ // reported by the root check, and normalizing them all to `undefined`
+ // would make two different bad URIs look like one duplicate.
+ const identity = skillUriIdentity(resource.uri);
+ if (seenUris.has(identity)) {
+ issues.push({
+ code: "duplicate-resource",
+ severity: "error",
+ message:
+ "Manifest lists this URI more than once; entries must be unique.",
+ resourceUri: resource.uri,
+ });
+ }
+ seenUris.add(identity);
+ if (root !== undefined) {
+ const normalized = normalizeSkillUri(resource.uri);
+ // An unparseable entry URI is outside the root by construction: nothing
+ // can establish that it is inside one.
+ if (normalized === undefined || !normalized.startsWith(root)) {
+ issues.push({
+ code: "resource-outside-skill-root",
+ severity: "error",
+ message: `Manifest entry does not resolve inside the skill root "${root}".`,
+ resourceUri: resource.uri,
+ });
+ }
+ }
+ if (resource.digest === undefined) {
+ // An error, not a warning: SEP-2640 requires `digest` on every manifest
+ // entry, so an entry without one is invalid — and reporting it as a
+ // warning would let such a manifest show "0 errors", which is the
+ // affirmative pass this checker must never give.
+ issues.push({
+ code: "missing-digest",
+ severity: "error",
+ message:
+ "Manifest entry declares no digest, which is required — and without it the file 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,
+ });
+ }
+ if (resource.size === undefined) {
+ // Also an error: `size` is a required field, not an integrity hint, and
+ // an omitted one is what lets a server slip past the 16 MiB pre-fetch
+ // limit — the entry is excluded from the total — while the UI reports no
+ // conformance errors at all.
+ issues.push({
+ code: "missing-size",
+ severity: "error",
+ message:
+ "Manifest entry declares no size, which is required — and without it the entry is excluded from the 16 MiB total and its length cannot be cross-checked.",
+ resourceUri: resource.uri,
+ });
+ } else if (!isUsableSize(resource.size)) {
+ issues.push({
+ code: "malformed-size",
+ severity: "error",
+ message: `Size ${resource.size} is not a non-negative integer byte length.`,
+ resourceUri: resource.uri,
+ });
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * Whether a declared `size` is a usable byte length. SEP-2640 defines it as the
+ * raw byte count, so anything that is not a non-negative safe integer is
+ * nonsense — and a *negative* one is worse than nonsense, because summing it
+ * would pull the manifest total back under the 16 MiB limit and hide a
+ * violation. Reported as `malformed-size` and excluded from the sum.
+ */
+function isUsableSize(size: number | undefined): size is number {
+ return size !== undefined && Number.isSafeInteger(size) && size >= 0;
+}
+
+/**
+ * Sum of the manifest's declared `size` fields. An entry that omits `size` — or
+ * declares an unusable one — 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 + (isUsableSize(r.size) ? r.size : 0),
+ 0,
+ );
+}
+
+/**
+ * Whether a `skills/get` entry describes the same skill as the `skills/list`
+ * entry alongside it, compared **semantically** rather than byte-for-byte.
+ *
+ * Two things a `JSON.stringify` comparison gets wrong here, and both would
+ * report a conforming server as broken: object key order is not meaningful in
+ * JSON, and the resource manifest is a *set*, so a server free to enumerate it
+ * in any order would look inconsistent for reordering it. Both sides are
+ * canonicalized — keys sorted recursively, manifest entries sorted by URI —
+ * before they are compared.
+ *
+ * A difference is still worth showing, but it is NOT by itself an error:
+ * SEP-2640 defines `skills/get` as a fresh point-in-time snapshot, so a skill
+ * that genuinely changed since the listing legitimately differs. The caller
+ * presents it as "the snapshot moved" and leaves the judgement to the reader.
+ */
+export function skillEntriesMatch(a: SkillEntry, b: SkillEntry): boolean {
+ return canonicalEntry(a) === canonicalEntry(b);
+}
+
+/**
+ * Deterministic JSON for one entry: object keys sorted recursively, and **the
+ * entry's own manifest** — nothing else — sorted by URI.
+ *
+ * The manifest sort is deliberately not recursive. `frontmatter` is verbatim
+ * arbitrary JSON from the skill author, so a custom `frontmatter.metadata.
+ * resources` array would be caught by a recursive rule and two genuinely
+ * different frontmatters would compare equal. Only `SkillEntry.resources` is
+ * a set; every other array keeps its order.
+ */
+function canonicalEntry(entry: SkillEntry): string {
+ const { resources, uri, ...rest } = entry;
+ // URIs are compared by IDENTITY, so a server that canonicalizes an escape
+ // between the listing and the fetch is not reported as a changed snapshot.
+ const manifest = Array.isArray(resources)
+ ? [...resources]
+ .map((resource) => ({
+ ...resource,
+ uri: skillUriIdentity(String(resource?.uri)),
+ }))
+ .sort((x, y) => x.uri.localeCompare(y.uri))
+ .map(canonicalize)
+ : resources;
+ return JSON.stringify({
+ ...sortKeys(rest),
+ uri: skillUriIdentity(uri),
+ resources: manifest,
+ });
+}
+
+/** Object keys sorted recursively; array ORDER is preserved throughout. */
+function canonicalize(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(canonicalize);
+ if (value === null || typeof value !== "object") return value;
+ return sortKeys(value as Record);
+}
+
+function sortKeys(value: Record): Record {
+ return Object.fromEntries(
+ Object.entries(value)
+ .map(([key, member]): [string, unknown] => [key, canonicalize(member)])
+ .sort(([x], [y]) => x.localeCompare(y)),
+ );
+}
+
+/** 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;
+ /** The manifest's declared byte length, when it declared one. */
+ expectedSize?: number;
+ /** The fetched file's actual byte length, when it was measured. */
+ actualSize?: number;
+ /** 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 {
+ // `crypto.subtle` is exposed only in a SECURE CONTEXT, and this app is
+ // documented as servable over plain HTTP on a LAN IP
+ // (`clients/web/README.md#hosting-on-a-network`). There, `crypto` exists but
+ // `crypto.subtle` does not — so without this fallback every verification
+ // would throw and the UI would report a read failure for a file it fetched
+ // perfectly well. `sha256Bytes` is checked against the published FIPS 180-4
+ // vectors and differentially against WebCrypto, so the two paths agree.
+ const subtle = globalThis.crypto?.subtle;
+ if (!subtle) return `sha256:${toHex(sha256Bytes(bytes))}`;
+ // Copy the VIEW into a fresh typed array rather than slicing its backing
+ // store. Two things depend on that: a `Uint8Array` can be a window into a
+ // larger buffer, so hashing the buffer would digest neighbouring bytes; and
+ // `SharedArrayBuffer.prototype.slice()` returns another `SharedArrayBuffer`,
+ // which `crypto.subtle.digest` rejects — so slicing-and-casting would have
+ // failed at runtime for the exact input a cast claimed to handle.
+ // `new Uint8Array(view)` always allocates a plain `ArrayBuffer`, which is
+ // also why no cast is needed here.
+ const copy = new Uint8Array(bytes);
+ const hash = await subtle.digest("SHA-256", copy.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.
+ *
+ * The declared `size` is cross-checked **before** the digest and fails
+ * verification on its own. A length that disagrees with the manifest is a real
+ * inconsistency even when the digest matches — the digest is taken over the
+ * bytes the server served, so agreeing with it says nothing about whether the
+ * manifest describes those bytes — and it is the cheaper check, so a
+ * 16 MiB file that was never going to verify is not hashed first.
+ */
+export async function verifySkillResource(
+ resource: SkillResource,
+ bytes: Uint8Array,
+): Promise {
+ const expectedSize = resource.size;
+ if (expectedSize !== undefined && expectedSize !== bytes.byteLength) {
+ return {
+ status: "mismatch",
+ expectedSize,
+ actualSize: bytes.byteLength,
+ ...(resource.digest !== undefined
+ ? { expectedDigest: resource.digest }
+ : {}),
+ reason: `Manifest declares ${expectedSize} bytes but the fetched file is ${bytes.byteLength}.`,
+ };
+ }
+ 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,
+ ...(expectedSize !== undefined
+ ? { expectedSize, actualSize: bytes.byteLength }
+ : {}),
+ };
+}
diff --git a/core/mcp/skillsSchemas.ts b/core/mcp/skillsSchemas.ts
new file mode 100644
index 0000000000..bb3a1160dd
--- /dev/null
+++ b/core/mcp/skillsSchemas.ts
@@ -0,0 +1,177 @@
+/**
+ * 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 wire surface for the two methods the Inspector calls.**
+ * SEP-2640 is Accepted, so the method names and the entry shape are settled;
+ * the skill *format* is delegated to the independently-versioned Agent Skills
+ * specification. Keeping every wire type here makes a spec revision a
+ * single-file edit (#2234).
+ *
+ * **Permissive where a defect should be reported; strict where a shape is
+ * settled.** `looseObject`, and a `digest` typed as a plain string rather than
+ * a hex-constrained one, so a non-conforming server is *surfaced* by
+ * `checkSkillConformance` rather than rejected at the parse — a malformed
+ * digest is a finding, not a parse error to swallow. But a settled shape is
+ * enforced, because silently normalizing one past the checks would defeat the
+ * point: `skills/get` requires its `{ skill }` envelope, and a modern
+ * `skills/list` result requires the base list envelope (see
+ * `ModernListSkillsResultSchema`).
+ */
+
+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;
+
+/**
+ * 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;
+
+/**
+ * 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;
+
+/**
+ * `skills/list` result on a **legacy** connection: a page of entries plus the
+ * opaque cursor, and nothing required beyond that.
+ */
+export const ListSkillsResultSchema = z.looseObject({
+ skills: z.array(SkillEntrySchema),
+ nextCursor: z.string().optional(),
+});
+
+export type ListSkillsResult = z.infer;
+
+/**
+ * `skills/list` result on a **modern** (2026-07-28+) connection: the page plus
+ * the base list envelope.
+ *
+ * SEP-2640's `skills/list` section states: *"In protocol versions 2026-07-28
+ * and later, the result also carries … `ttlMs` and `cacheScope`."* The field
+ * shapes mirror `ModernResultEnvelopeSchema` in `listSalvage.ts`, which is this
+ * repo's existing statement of a modern result envelope and is already applied
+ * to modern list results on the salvage path — so the two cannot drift.
+ *
+ * The era split is load-bearing rather than defensive. `skills/*` is a
+ * consumer-owned method, so it is absent from the SDK's cacheable-method
+ * registry and **nothing else validates this**: without an era-aware schema a
+ * modern server could answer `{ skills: [] }` and the conformance UI would
+ * present it as a clean list. The legacy shape stays permissive because these
+ * are 2026-era attributes a legacy server has no business sending.
+ *
+ * ⚠️ Picked by `InspectorClient.listSkills` from the negotiated era — a schema
+ * cannot know it on its own.
+ */
+export const ModernListSkillsResultSchema = ListSkillsResultSchema.extend({
+ resultType: z.literal("complete"),
+ // A non-negative INTEGER, matching the codec: `ttlMs: -1` or `0.5` is an
+ // envelope violation, and accepting it here would hide exactly the kind of
+ // defect this schema exists to surface.
+ ttlMs: z.int().min(0),
+ cacheScope: z.enum(["public", "private"]),
+});
+
+/**
+ * The `skills/get` result envelope: the entry wrapped under `skill`.
+ *
+ * Required, not one of two accepted shapes. An earlier revision of this module
+ * also accepted a bare entry at the top level, on the reading that the SEP
+ * settled the entry but not its wrapper. It does settle the wrapper, and
+ * accepting the inline form would silently normalize a non-conforming response
+ * — which is exactly the failure this extension's support exists to *report*.
+ * A server that returns the entry inline now fails the parse, loudly.
+ */
+const GetSkillEnvelopeSchema = z.looseObject({ skill: SkillEntrySchema });
+
+/**
+ * `skills/get` result, unwrapped to the entry it carries.
+ *
+ * The transform means every caller receives a `SkillEntry` and none of them
+ * reaches into the envelope; the strictness lives in the schema.
+ */
+export const GetSkillResultSchema = GetSkillEnvelopeSchema.transform(
+ (result) => result.skill,
+);
+
+export type GetSkillResult = SkillEntry;
+
+/**
+ * ⚠️ **No `resources/directory/read` result schema here yet, on purpose.**
+ *
+ * The method name and the directory MIME type above are stated in SEP-2640;
+ * the shape of the result it returns is not something this PR verified against
+ * the normative text, and the Inspector does not call the method (phase 3,
+ * #2248). Declaring a guessed schema would put an unverified claim in the one
+ * module that is supposed to be the authority on the wire format — and one
+ * nothing exercises, so it could be wrong indefinitely without failing
+ * anything. Phase 3 adds it against the spec, alongside the call that uses it.
+ */
diff --git a/core/mcp/state/index.ts b/core/mcp/state/index.ts
index af1e9f65e1..ab66e34e98 100644
--- a/core/mcp/state/index.ts
+++ b/core/mcp/state/index.ts
@@ -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";
diff --git a/core/mcp/state/managedSkillsState.ts b/core/mcp/state/managedSkillsState.ts
new file mode 100644
index 0000000000..0429e821d5
--- /dev/null
+++ b/core/mcp/state/managedSkillsState.ts
@@ -0,0 +1,241 @@
+/**
+ * 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 { SKILLS_LIST_METHOD, type SkillEntry } from "../skillsSchemas.js";
+import { LIST_MAX_PAGES, isClientDecodeRejection } from "../listSalvage.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.";
+
+/**
+ * Page cap for the `skills/list` walk. Mirrors `LIST_MAX_PAGES`, the bound the
+ * SDK's aggregate verbs and the #1909 salvage re-walks already use, so the two
+ * pagination paths in this repo cannot drift to different limits.
+ */
+export const SKILLS_MAX_PAGES = LIST_MAX_PAGES;
+
+/** The error a walk raises when it hits {@link SKILLS_MAX_PAGES}. */
+export const SKILLS_PAGE_LIMIT_MESSAGE = `skills/list exceeded ${SKILLS_MAX_PAGES} pages without the server's pagination converging`;
+
+export class ManagedSkillsState extends TypedEventTarget {
+ private skills: SkillEntry[] = [];
+ private pageCount = 0;
+ private error: Error | null = null;
+ private client: InspectorClientProtocol | null = null;
+ private unsubscribe: (() => void) | null = null;
+ // Overlap guard, held as the generation whose walk is in flight (or `null`).
+ // See `refresh` for why this is not a boolean.
+ private runningGeneration: number | null = null;
+ // Session counter, advanced by `reset` (disconnect) and by `destroy`. A walk
+ // captures it and abandons its writes when it no longer matches.
+ private generation = 0;
+
+ 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 {
+ // Advance the session first, so an in-flight walk's continuation sees a
+ // stale generation and leaves the cleared state alone.
+ this.generation += 1;
+ 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 {
+ 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();
+ }
+ // The session this walk belongs to. A disconnect, a `destroy()`, or a
+ // reconnect on the same client advances it, and every write below is
+ // gated on it still being current — otherwise a slow walk's continuation
+ // repopulates a store that has already been cleared, or delivers the
+ // previous session's skills into the next one.
+ const generation = this.generation;
+ const isCurrent = () => this.generation === generation;
+ // The overlap guard is per SESSION, not a bare boolean. A boolean would
+ // stay set while a walk from a dead session was still awaiting the server,
+ // so the reconnect's own load would return here as a no-op and never be
+ // retried — permanently, if that stale request never settles. Keying it on
+ // the generation lets a new session start immediately, and the `finally`
+ // below only clears the guard it actually set.
+ if (this.runningGeneration === generation) return this.getSkills();
+ this.runningGeneration = generation;
+ try {
+ const collected: SkillEntry[] = [];
+ const seen = new Set();
+ let cursor: string | undefined;
+ let pages = 0;
+ for (;;) {
+ const page = await client.listSkills(cursor, metadata);
+ if (!isCurrent()) return this.getSkills();
+ collected.push(...page.skills);
+ pages += 1;
+ if (page.nextCursor === undefined) break;
+ // Two distinct runaway shapes, and the repeated-cursor guard only
+ // catches one: a server stuck on a single cursor. A server that hands
+ // back an endlessly *unique* cursor walks forever and grows
+ // `collected` without bound, so the page cap is what stops it. The cap
+ // raises rather than truncating, for the reason `listPaginationExceeded`
+ // states: returning what we have would present a partial list as a
+ // complete one.
+ if (pages >= SKILLS_MAX_PAGES) {
+ throw new Error(SKILLS_PAGE_LIMIT_MESSAGE);
+ }
+ 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) {
+ // Still re-thrown even when the session moved on — the caller's
+ // auth-recovery wrapper keys off the rejection — but the *state* is left
+ // alone, so a dead session's failure cannot surface in the live one.
+ if (isCurrent()) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ this.setError(error);
+ // Attribute the failure to the response it came from, so the Protocol
+ // entry stops rendering a rejected result as a clean success (#1953).
+ // Must happen in this catch, while the correlation window the client
+ // documents is still valid — and ONLY for a decode rejection, which is
+ // what `isClientDecodeRejection` separates from a request that never
+ // produced a response at all. Same handling every managed list gets.
+ if (isClientDecodeRejection(err)) {
+ client.markResponseRejected?.(SKILLS_LIST_METHOD, error.message);
+ }
+ }
+ throw err;
+ } finally {
+ // Only the walk that set the guard may clear it: a stale session's
+ // `finally` must not release the guard a live session is holding.
+ if (this.runningGeneration === generation) {
+ this.runningGeneration = null;
+ }
+ }
+ }
+
+ 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.generation += 1;
+ this.unsubscribe?.();
+ this.unsubscribe = null;
+ this.skills = [];
+ this.pageCount = 0;
+ this.error = null;
+ }
+}
diff --git a/core/react/useManagedSkills.ts b/core/react/useManagedSkills.ts
new file mode 100644
index 0000000000..e02e121940
--- /dev/null
+++ b/core/react/useManagedSkills.ts
@@ -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;
+}
+
+/**
+ * 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 => {
+ 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 };
+}
diff --git a/docs/test-servers.md b/docs/test-servers.md
index 93e699c1fc..4e214f344b 100644
--- a/docs/test-servers.md
+++ b/docs/test-servers.md
@@ -56,6 +56,47 @@ as a missing capability rather than an error.
| `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
| `tasks-{legacy,modern}-http.json` **(era per file)** | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) |
| `cancellation-modern-http.json` **(modern era)** | Cancelling a call by closing its response stream | [#2140](https://github.com/modelcontextprotocol/inspector/issues/2140) |
+| `skills-http.json` **(either era)** | Skills tab: `skills/list`, digest verification, and the non-conforming cases | [#2234](https://github.com/modelcontextprotocol/inspector/issues/2234) |
+
+## Skills (SEP-2640)
+
+`skills-http.json` sets `"skills": true` and serves four skills over two
+`skills/list` pages. The extension is advertised **bare**: there is deliberately
+no `directoryRead` option to turn on, because nothing here serves
+`resources/directory/read` and a config that advertised it would produce exactly
+the false capability this fixture helps catch — Connection Info reporting a
+sub-option supported while the method answers `-32601`. Both come back in
+phase 3 ([#2248](https://github.com/modelcontextprotocol/inspector/issues/2248)).
+
+Every result carries the modern base envelope (`resultType` / `ttlMs` /
+`cacheScope`). `skills/*` are consumer-owned, so the SDK stamps nothing for
+them; without it a 2026-era connection would receive a result missing the
+envelope. It is stamped unconditionally rather than per era — the modern leg
+builds a fresh server per request, so there is no era to branch on when the
+handlers are registered, and on the legacy leg they are three extra members no
+codec inspects.
+It works on **either era**: `skills/list`, `skills/get` and
+`resources/directory/read` are consumer-owned extension methods that neither
+era codec defines, so the SDK's era gate skips them entirely — which is why
+this fixture, unlike the tasks ones, needs no per-era variant.
+
+Three of the four skills are deliberately awkward, because the checks the Skills
+tab runs are untestable without them. Only two are actual violations — the
+`"dynamic"` form is **conforming**, and is here because "legal but unverifiable"
+is the case most easily buried:
+
+| Skill | What it exercises |
+| --- | --- |
+| `data-analysis` | The clean case — **Verify all** reports `verified` for every file. |
+| `tampered-notes` | An advertised digest that does not match the bytes served, so verification reports a **digest mismatch** with both digests shown. |
+| `dynamic-report` | `resources: "dynamic"` — a **legal** form for generated content. No manifest is advertised, so integrity cannot be verified at all; reported as a warning, not an error. |
+| `wrong-folder` | A URI path segment (`wrong-folder`) that disagrees with `frontmatter.name` (`right-name`), the one structural invariant SEP-2640 states outright. |
+
+Connection Info's **Skills Extension Options** section shows the `directoryRead`
+sub-flag — against this fixture, a red ✗. The Inspector surfaces the flag but
+does not call `resources/directory/read` yet, and the wire schema for that
+result is deliberately absent from `core/mcp/skillsSchemas.ts` too: phase 3 adds
+it against the normative text rather than shipping a guess nothing exercises.
## Cancelling a call
diff --git a/test-servers/configs/skills-http.json b/test-servers/configs/skills-http.json
new file mode 100644
index 0000000000..2b10585145
--- /dev/null
+++ b/test-servers/configs/skills-http.json
@@ -0,0 +1,17 @@
+{
+ "serverInfo": {
+ "name": "skills",
+ "version": "1.0.0"
+ },
+ "tools": [
+ {
+ "preset": "echo"
+ }
+ ],
+ "resources": [],
+ "skills": true,
+ "transport": {
+ "type": "streamable-http",
+ "port": 3230
+ }
+}
diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts
index eddb56d5f9..eadc45e35d 100644
--- a/test-servers/src/composable-test-server.ts
+++ b/test-servers/src/composable-test-server.ts
@@ -49,6 +49,7 @@ import {
createModernTaskTools,
wireModernTaskHandlers,
} from "./modern-tasks.js";
+import { SKILLS_EXTENSION_KEY, wireSkillsHandlers } from "./skills.js";
/**
* MCP Apps extension id. Hardcoded for the same reason the Inspector's
@@ -556,6 +557,18 @@ export interface ServerConfig {
* `modern: true`. See `modern-tasks.ts`.
*/
tasksExtension?: boolean;
+ /**
+ * Advertise the Skills extension (SEP-2640) and serve `skills/list` /
+ * `skills/get` plus the `skill://` files those entries name. The fixture set
+ * deliberately includes non-conforming skills — see `skills.ts`.
+ *
+ * There is deliberately **no `directoryRead` option**. The flag would
+ * advertise `resources/directory/read`, which nothing here serves, so a
+ * config could produce exactly the false capability this fixture exists to
+ * help catch — Connection Info reporting "supported" for a method that
+ * answers `-32601`. It comes back in phase 3 (#2248) with the handler.
+ */
+ skills?: boolean;
/**
* Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the
* nested `elicitation` setting — the server-side half of the app-rendered
@@ -824,6 +837,20 @@ export function createMcpServer(config: ServerConfig): McpServer {
};
}
+ // Skills extension (SEP-2640): a server-declared extension, advertised bare.
+ // See `ServerConfig.skills` for why there is no `directoryRead` sub-option
+ // to turn on.
+ if (config.skills) {
+ capabilities.extensions = {
+ ...(capabilities.extensions ?? {}),
+ [SKILLS_EXTENSION_KEY]: {},
+ };
+ // Skill files are fetched through ordinary `resources/read`, so the
+ // resources capability has to be advertised even when the config registers
+ // no ordinary resources of its own.
+ capabilities.resources = capabilities.resources ?? {};
+ }
+
// MCP Apps app-rendered elicitation (#1854): the server-side half of the
// negotiation, on the same extension the Apps work already uses.
if (config.appElicitation) {
@@ -1503,6 +1530,13 @@ export function createMcpServer(config: ServerConfig): McpServer {
wireModernTaskHandlers(mcpServer, modernTaskRuntime);
}
+ // Skills extension (SEP-2640): raw skills/list + skills/get, and the
+ // `skill://` half of resources/read. Wired after the SDK's own handlers so
+ // the resources/read wrapper can delegate non-skill URIs to them.
+ if (config.skills) {
+ wireSkillsHandlers(mcpServer);
+ }
+
// Extension-gated tools (#1739): start each gated tool disabled, then enable
// it on `initialized` iff the connected client declared its extension.
if (config.extensionGatedTools) {
diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts
index dc68bb8dad..ffb4dcab20 100644
--- a/test-servers/src/load-config.ts
+++ b/test-servers/src/load-config.ts
@@ -67,6 +67,9 @@ export interface ConfigFile {
* and wire its handlers + `modern_task` / `modern_input_task` tools. Pair with
* `transport.modern`. */
tasksExtension?: boolean;
+ /** Advertise the Skills extension (SEP-2640) and serve its fixture skills.
+ * No `directoryRead` sub-option — see {@link ServerConfig.skills}. */
+ skills?: boolean;
/** Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the nested
* `elicitation` setting — the server half of app-rendered form elicitation
* (#1854). Pair with the `app_choose_option` tool + `choose_option_app` resource. */
diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts
index 693c0bc37b..1448da033d 100644
--- a/test-servers/src/resolve-config.ts
+++ b/test-servers/src/resolve-config.ts
@@ -90,6 +90,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
subscriptions: config.subscriptions,
tasks: config.tasks,
tasksExtension: config.tasksExtension,
+ skills: config.skills,
appElicitation: config.appElicitation,
maxPageSize: config.maxPageSize,
duplicateToolNames: config.duplicateToolNames,
diff --git a/test-servers/src/skills.ts b/test-servers/src/skills.ts
new file mode 100644
index 0000000000..18d2edca64
--- /dev/null
+++ b/test-servers/src/skills.ts
@@ -0,0 +1,408 @@
+/**
+ * Skills extension test fixture — SEP-2640 (`io.modelcontextprotocol/skills`).
+ *
+ * Serves `skills/list` (paginated) and `skills/get`, plus `resources/read` for
+ * the `skill://` URIs those entries name, so an Inspector connected here can
+ * exercise the whole flow: enumerate, fetch a file, and verify its digest.
+ *
+ * **The awkward skills are the point.** A fixture that only served a clean
+ * skill would leave every verification and conformance path in the Inspector
+ * untestable, so the set below deliberately includes three edge cases — each
+ * the exact shape one of the checks in `core/mcp/skills.ts` exists to catch:
+ *
+ * - `dynamic-report` declares `resources: "dynamic"`. That is a **conforming**
+ * wire form for generated content, not a violation; what it costs is that
+ * integrity cannot be verified at all, which the Inspector reports as a
+ * warning. It is here because "legal but unverifiable" is the case most
+ * easily buried.
+ * - `tampered-notes` advertises a digest that does not match the bytes it
+ * serves — a genuine violation.
+ * - `wrong-folder` has a URI path segment that disagrees with
+ * `frontmatter.name` — the other genuine violation.
+ *
+ * `skills/list` and `skills/get` are registered through the **public**
+ * `setRequestHandler`, which accepts a consumer-owned method name as long as
+ * explicit schemas are supplied — no private-field escape hatch, and the params
+ * are validated on the way in. That is the difference from `modern-tasks.ts`:
+ * `tasks/*` are spec names the 2026 codec deleted, so they need the raw seam;
+ * `skills/*` are in neither codec, which makes them era-blind in both
+ * directions and lets one fixture serve both the legacy and modern legs.
+ *
+ * `resources/read` is the one exception, and it is a *wrap* rather than a
+ * registration: the fixture must answer `skill://` URIs while leaving every
+ * other URI to the SDK's own handler, and `setRequestHandler` replaces a
+ * handler instead of chaining onto it. There is no public "extend this method"
+ * API, so the existing handler is captured and delegated to.
+ */
+
+import { createHash } from "node:crypto";
+import * as z from "zod/v4";
+import {
+ ProtocolError,
+ ProtocolErrorCode,
+ type McpServer,
+} from "@modelcontextprotocol/server";
+
+/** SEP-2133 extension identifier for the Skills extension (SEP-2640). */
+export const SKILLS_EXTENSION_KEY = "io.modelcontextprotocol/skills";
+
+/**
+ * Entries per `skills/list` page. Two, deliberately: the fixture serves four
+ * skills, so a client that stops after page one sees half the set — which is
+ * what makes a broken cursor walk visible rather than merely slower.
+ */
+export const SKILLS_PAGE_SIZE = 2;
+
+/**
+ * The modern (2026-07-28) base result envelope, stamped on every skills result.
+ *
+ * The SDK stamps this for methods in its own codec, and `skills/*` are
+ * consumer-owned — so nothing adds it here and a modern connection would
+ * otherwise receive a result missing `resultType` / `ttlMs` / `cacheScope`.
+ * Stamped **unconditionally** rather than per era: one `McpServer` config
+ * serves both legs, the modern leg builds a fresh server per request so there
+ * is no era to branch on at handler-registration time, and on the legacy leg
+ * these are three unknown members that a consumer-owned method has no codec to
+ * reject. Values match `ModernResultEnvelopeSchema` in `core/mcp/listSalvage.ts`.
+ */
+const MODERN_RESULT_ENVELOPE = {
+ resultType: "complete",
+ ttlMs: 0,
+ cacheScope: "public",
+} as const;
+
+/** `sha256:<64 lowercase hex>` over a UTF-8 string, the SEP's digest form. */
+function digestOf(text: string): string {
+ return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
+}
+
+/** Byte length of a UTF-8 string, for the manifest's `size`. */
+function sizeOf(text: string): number {
+ return Buffer.byteLength(text, "utf8");
+}
+
+interface FixtureFile {
+ uri: string;
+ text: string;
+ mimeType: string;
+ /**
+ * Digest to *advertise*, when it should differ from the real one. The
+ * tampered skill sets this; everywhere else the advertised digest is
+ * computed from the very bytes served, so a clean skill verifies.
+ */
+ advertisedDigest?: string;
+}
+
+interface FixtureSkill {
+ /** The `` segment; `skill:///SKILL.md` is the entry URI. */
+ path: string;
+ /** The SAME object the served `SKILL.md` was built from — see `skillMd`. */
+ frontmatter: Frontmatter;
+ /** `"dynamic"` for a generated skill with no enumerable manifest. */
+ files: FixtureFile[] | "dynamic";
+}
+
+interface Frontmatter {
+ name: string;
+ description: string;
+}
+
+/**
+ * The `SKILL.md` for one skill, built FROM its frontmatter object.
+ *
+ * SEP-2640 requires the frontmatter a server lists to match the frontmatter in
+ * the file it serves, field for field. Writing the two out separately let them
+ * drift — and did: three fixtures listed one description and served another,
+ * which is an undocumented extra violation that would have made phase 3's
+ * frontmatter check report a finding these fixtures were not built to
+ * demonstrate. Deriving one from the other makes that class of drift
+ * impossible rather than merely fixed.
+ */
+function skillMd(frontmatter: Frontmatter, body: string): string {
+ return `---\nname: ${frontmatter.name}\ndescription: ${frontmatter.description}\n---\n\n${body}\n`;
+}
+
+const DATA_ANALYSIS_FM: Frontmatter = {
+ name: "data-analysis",
+ description: "Analyze a CSV and summarize its columns",
+};
+const DATA_ANALYSIS_MD = skillMd(
+ DATA_ANALYSIS_FM,
+ "# Data analysis\n\nLoad the CSV, then follow `reference.md` for the column rules.",
+);
+const DATA_ANALYSIS_REF =
+ "# Column rules\n\nNumeric columns get min/max/mean; text columns get a value count.\n";
+
+const TAMPERED_FM: Frontmatter = {
+ name: "tampered-notes",
+ description: "A skill whose manifest digest does not match its served bytes",
+};
+const TAMPERED_MD = skillMd(
+ TAMPERED_FM,
+ "# Tampered notes\n\nThe digest advertised for `notes.md` is wrong on purpose.",
+);
+const TAMPERED_NOTES =
+ "# Notes\n\nThese bytes hash to something other than what the manifest claims.\n";
+
+const DYNAMIC_FM: Frontmatter = {
+ name: "dynamic-report",
+ description: "A skill whose files are generated per request",
+};
+const DYNAMIC_MD = skillMd(
+ DYNAMIC_FM,
+ "# Dynamic report\n\nThis skill's file set is generated, so it advertises no manifest.",
+);
+
+// The frontmatter says `right-name` while the URI segment says `wrong-folder`,
+// breaking the one structural invariant SEP-2640 states outright: the segment
+// before /SKILL.md must equal frontmatter.name. That is this fixture's ONLY
+// violation — its listed and served frontmatter agree, as the SEP requires.
+const MISMATCHED_FM: Frontmatter = {
+ name: "right-name",
+ description:
+ "A skill whose URI path segment disagrees with its frontmatter name",
+};
+const MISMATCHED_MD = skillMd(
+ MISMATCHED_FM,
+ "# Mismatched name\n\nServed from `wrong-folder/` while claiming the name `right-name`.",
+);
+
+const FIXTURE_SKILLS: FixtureSkill[] = [
+ {
+ path: "data-analysis",
+ frontmatter: DATA_ANALYSIS_FM,
+ files: [
+ {
+ uri: "skill://data-analysis/SKILL.md",
+ text: DATA_ANALYSIS_MD,
+ mimeType: "text/markdown",
+ },
+ {
+ uri: "skill://data-analysis/reference.md",
+ text: DATA_ANALYSIS_REF,
+ mimeType: "text/markdown",
+ },
+ ],
+ },
+ {
+ path: "tampered-notes",
+ frontmatter: TAMPERED_FM,
+ files: [
+ {
+ uri: "skill://tampered-notes/SKILL.md",
+ text: TAMPERED_MD,
+ mimeType: "text/markdown",
+ },
+ {
+ uri: "skill://tampered-notes/notes.md",
+ text: TAMPERED_NOTES,
+ mimeType: "text/markdown",
+ // A syntactically valid digest of the *wrong* bytes, so the failure the
+ // Inspector reports is a mismatch rather than a malformed-digest
+ // finding — those are different checks and must stay distinguishable.
+ advertisedDigest: digestOf("not the bytes this server serves"),
+ },
+ ],
+ },
+ {
+ path: "dynamic-report",
+ frontmatter: DYNAMIC_FM,
+ files: "dynamic",
+ },
+ {
+ path: "wrong-folder",
+ frontmatter: MISMATCHED_FM,
+ files: [
+ {
+ uri: "skill://wrong-folder/SKILL.md",
+ text: MISMATCHED_MD,
+ mimeType: "text/markdown",
+ },
+ ],
+ },
+];
+
+/** Every servable `skill://` file, by URI. `dynamic` skills contribute their
+ * `SKILL.md` too, so the screen's "View SKILL.md" works there as well. */
+const FILES_BY_URI = new Map();
+for (const skill of FIXTURE_SKILLS) {
+ if (skill.files === "dynamic") {
+ FILES_BY_URI.set(`skill://${skill.path}/SKILL.md`, {
+ uri: `skill://${skill.path}/SKILL.md`,
+ text: DYNAMIC_MD,
+ mimeType: "text/markdown",
+ });
+ continue;
+ }
+ for (const file of skill.files) FILES_BY_URI.set(file.uri, file);
+}
+
+/** The wire entry for one fixture skill. */
+function toEntry(skill: FixtureSkill): z.infer {
+ return {
+ uri: `skill://${skill.path}/SKILL.md`,
+ frontmatter: skill.frontmatter,
+ resources:
+ skill.files === "dynamic"
+ ? "dynamic"
+ : skill.files.map((file) => ({
+ uri: file.uri,
+ digest: file.advertisedDigest ?? digestOf(file.text),
+ size: sizeOf(file.text),
+ })),
+ };
+}
+
+/** One `skills/list` page starting at `cursor` (an index, as a string). */
+export function listSkillsPage(
+ cursor?: string,
+): z.infer {
+ const start = cursor ? Number.parseInt(cursor, 10) : 0;
+ // A cursor the fixture never issued is answered as an empty final page
+ // rather than an error: the Inspector's walk should terminate, and a thrown
+ // error here would read as a transport failure instead.
+ const from = Number.isFinite(start) && start > 0 ? start : 0;
+ const page = FIXTURE_SKILLS.slice(from, from + SKILLS_PAGE_SIZE);
+ const next = from + SKILLS_PAGE_SIZE;
+ return {
+ ...MODERN_RESULT_ENVELOPE,
+ skills: page.map(toEntry),
+ ...(next < FIXTURE_SKILLS.length ? { nextCursor: String(next) } : {}),
+ };
+}
+
+/** The `skills/get` result for one entry URI. */
+export function getSkillEntry(
+ uri: string,
+): z.infer {
+ const skill = FIXTURE_SKILLS.find(
+ (candidate) => `skill://${candidate.path}/SKILL.md` === uri,
+ );
+ // `-32602`, not a plain `Error`: the method contract says an unknown skill
+ // URI is invalid params, and a generic throw would be mapped to a server
+ // failure — making the fixture non-conforming outside its three documented
+ // bad cases, which is the opposite of what it is for.
+ if (!skill) {
+ throw new ProtocolError(
+ ProtocolErrorCode.InvalidParams,
+ `Unknown skill uri: ${uri}`,
+ );
+ }
+ // The `{ skill }` wrapper is the conforming shape, and the only one the
+ // Inspector accepts — see `GetSkillResultSchema`.
+ return { ...MODERN_RESULT_ENVELOPE, skill: toEntry(skill) };
+}
+
+/** The `resources/read` result for a `skill://` file, or `undefined`. */
+export function readSkillFile(
+ uri: string,
+): Record | undefined {
+ const file = FILES_BY_URI.get(uri);
+ if (!file) return undefined;
+ return {
+ ...MODERN_RESULT_ENVELOPE,
+ contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }],
+ };
+}
+
+/**
+ * The private handler registry the SDK dispatches through. Reached ONLY to wrap
+ * `resources/read` — see the module header for why that one has no public
+ * equivalent.
+ */
+interface RawHandlerHost {
+ _requestHandlers: Map<
+ string,
+ (request: unknown, ctx: unknown) => Promise
+ >;
+}
+
+interface UriRequest {
+ params?: { uri?: string };
+}
+
+const ListSkillsParamsSchema = z.object({ cursor: z.string().optional() });
+const GetSkillParamsSchema = z.object({ uri: z.string() });
+
+/**
+ * Result schemas for the two custom methods.
+ *
+ * ⚠️ The SDK does **not** runtime-validate a handler's result — its own doc on
+ * `RequestHandlerSchemas` says `result` is optional and "no runtime validation
+ * is performed on the result". So these do not make the fixture's output
+ * checked at the server boundary; what they buy is that the handler's return
+ * type is inferred from them, so a shape change in `toEntry` or
+ * `listSkillsPage` fails `tsc` instead of silently shipping a fixture that
+ * claims to be conforming. That is the whole benefit, and it is worth having
+ * for a fixture whose job is to be wrong only in documented ways.
+ *
+ * Deliberately declared here rather than imported from
+ * `core/mcp/skillsSchemas.ts`: a fixture that validated itself against the
+ * client's own schema could never catch the client being wrong.
+ */
+const ModernEnvelopeShape = {
+ resultType: z.literal("complete"),
+ ttlMs: z.int().min(0),
+ cacheScope: z.enum(["public", "private"]),
+};
+
+const SkillResourceShape = z.object({
+ uri: z.string(),
+ digest: z.string(),
+ size: z.number(),
+});
+
+const SkillEntryShape = z.object({
+ uri: z.string(),
+ frontmatter: z.object({ name: z.string(), description: z.string() }),
+ resources: z.union([z.literal("dynamic"), z.array(SkillResourceShape)]),
+});
+
+const ListSkillsResultShape = z.object({
+ ...ModernEnvelopeShape,
+ skills: z.array(SkillEntryShape),
+ nextCursor: z.string().optional(),
+});
+
+const GetSkillResultShape = z.object({
+ ...ModernEnvelopeShape,
+ skill: SkillEntryShape,
+});
+
+/**
+ * Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read`
+ * onto an `McpServer`.
+ */
+export function wireSkillsHandlers(mcpServer: McpServer): void {
+ const lowLevel = mcpServer.server;
+
+ lowLevel.setRequestHandler(
+ "skills/list",
+ { params: ListSkillsParamsSchema, result: ListSkillsResultShape },
+ async (params) => listSkillsPage(params.cursor),
+ );
+
+ lowLevel.setRequestHandler(
+ "skills/get",
+ { params: GetSkillParamsSchema, result: GetSkillResultShape },
+ async (params) => getSkillEntry(params.uri),
+ );
+
+ // Wrapped, not registered: a `skill://` URI is answered here and everything
+ // else falls through to whatever the SDK registered, so a config can serve
+ // ordinary resources alongside its skills.
+ const registry = (lowLevel as unknown as RawHandlerHost)._requestHandlers;
+ const sdkResourcesRead = registry.get("resources/read");
+ registry.set("resources/read", async (request, ctx) => {
+ const req = request as UriRequest;
+ const skillFile = readSkillFile(req.params?.uri ?? "");
+ if (skillFile) return skillFile;
+ if (!sdkResourcesRead) {
+ throw new ProtocolError(
+ ProtocolErrorCode.InvalidParams,
+ `Unknown resource: ${req.params?.uri}`,
+ );
+ }
+ return sdkResourcesRead(request, ctx);
+ });
+}