feat: support the Skills extension (SEP-2640) with digest verification

Adds detection, enumeration, and conformance checking for the Skills
extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234,
which is everything its Acceptance list names.

Skills is a *server*-declared extension, read off the connecting server's
`capabilities.extensions`, so it is deliberately absent from
`ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises
and the user toggles, and an entry there would be a meaningless toggle.

`skills/list` and `skills/get` are consumer-owned extension methods that
neither era codec defines, so the SDK's era gate skips them and they go out
as ordinary `client.request` calls with explicit result schemas. The
raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is
not era-gated: a legacy-era server that declares the extension is serving it.

The checks are the point, not the list view. `core/mcp/skills.ts` reports
each obligation SEP-2640 states — the name/path invariant, the digest
format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which
means integrity cannot be verified at all. Digest verification hashes the
fetched bytes with WebCrypto and returns a mismatch with both digests
attached rather than throwing, because showing a mismatch loudly is the
whole value proposition.

Files are fetched on demand: SEP-2640 is explicit that a `resources/read`
of a `SKILL.md` is not a load and confers no standing, so none of the SEP's
host machinery is implemented.

The `skills-http` fixture serves four skills over two pages, three of them
deliberately non-conforming — without those the verification code is
untestable.

Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248.

Closes #2234

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-04 20:01:13 -04:00
commit 63623b754b
42 changed files with 3684 additions and 4 deletions

View file

@ -93,7 +93,7 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage
## Core tab automation contract
The Tools, Resources, and Prompts screens each expose a `data-testid` plus a
The Tools, Resources, Prompts and Skills screens each expose a `data-testid` plus a
small set of `data-*` attributes, so a headless driver can `waitForSelector` on
a deterministic signal rather than on visible copy. `scripts/smoke-web-tabs.mjs`
drives all three against `test-servers/configs/web-tabs-http.json` ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)).
@ -114,6 +114,10 @@ Treat them as a public contract, for the same reason as the Apps ones below:
| `data-prompt-count` | on `prompts-screen`| Entries from `prompts/list`. |
| `data-get-status` | on `prompts-screen`| `idle` → `pending` → `ok` / `error` for the current `prompts/get`. |
| `data-testid="prompt-messages"` | messages panel | The fetched prompt's **rendered** messages — the `prompts/get` counterpart of `resource-preview`, and asserted alongside `data-get-status` for the same reason. |
| `data-testid="skills-screen"` | Skills root | The element carrying the two attributes below ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234)). Not driven by `smoke-web-tabs.mjs` yet — the attributes exist so it can be, and so a rename fails in the screen's unit test rather than later. |
| `data-skill-count` | on `skills-screen` | Entries accumulated from `skills/list`. |
| `data-skill-page-count` | on `skills-screen` | Pages the last `skills/list` walk took — a **separate** fact from the count, and the one that shows pagination actually happened. |
| `data-testid="skill-manifest"` | detail pane | The selected skill's resource manifest. Absent for a `resources: "dynamic"` skill, which has no manifest to render. |
Why attributes rather than text: a smoke that waited on a label fails the next
time the label is reworded, which is noise rather than signal — and it fails as

View file

@ -75,6 +75,7 @@ import type {
ResourcesPanelProps,
ServerListProps,
ShellProps,
SkillsPanelProps,
TasksPanelProps,
ToolsPanelProps,
} from "./components/views/InspectorView/types";
@ -458,6 +459,10 @@ function App() {
tasks,
refreshTasks,
clearCompletedTasks,
skills,
skillsPageCount,
skillsLoadError,
refreshSkills,
subscriptions,
subscriptionStreamState,
messages,
@ -828,6 +833,8 @@ function App() {
onRefreshTools,
onRefreshPrompts,
onRefreshResources,
onRefreshSkills,
onReadSkillFile,
onRefreshTasks,
onTogglePaginatedLists,
onLoadMoreTools,
@ -851,6 +858,7 @@ function App() {
activeToolCallTaskIdRef,
clearCompletedTasks,
refreshTasks,
refreshSkills,
paginatedLists,
paginatedListsOverride,
toolsPagination,
@ -1816,6 +1824,16 @@ function App() {
onRefreshApps: onRefreshTools,
};
const skillsPanelProps: SkillsPanelProps = {
skills,
skillsPageCount,
skillsLoadError,
skillsUi: ui.skillsUi,
onSkillsUiChange: setUi.setSkillsUi,
onRefreshSkills,
onReadSkillFile,
};
const tasksPanelProps: TasksPanelProps = {
tasks,
progressByTaskId,
@ -1889,6 +1907,7 @@ function App() {
prompts={promptsPanelProps}
resources={resourcesPanelProps}
apps={appsPanelProps}
skills={skillsPanelProps}
tasks={tasksPanelProps}
logs={logsPanelProps}
protocol={protocolPanelProps}

View file

@ -452,6 +452,64 @@ describe("ConnectionInfoContent", () => {
expect(screen.getAllByText("—")).toHaveLength(2);
});
it("hides the Skills section when the server declares no skills extension (#2234)", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="legacy"
/>,
);
expect(screen.queryByText("Skills Extension")).not.toBeInTheDocument();
});
it("shows the Skills extension and its directoryRead sub-flag (#2234)", () => {
// The generic "Server Extensions" row lists the identifier; the sub-flag
// that gates `resources/directory/read` is what this section adds, and it
// is the fact a server author opens the modal to confirm.
renderWithMantine(
<ConnectionInfoContent
initializeResult={{
...fullResult,
capabilities: {
...fullResult.capabilities,
extensions: {
"io.modelcontextprotocol/skills": { directoryRead: true },
},
},
}}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="legacy"
/>,
);
expect(screen.getByText("Skills Extension")).toBeInTheDocument();
expect(screen.getByTestId("skills-directory-read")).toHaveTextContent(
"Supported",
);
});
it("reports directory read as unsupported for a bare skills declaration (#2234)", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={{
...fullResult,
capabilities: {
...fullResult.capabilities,
extensions: { "io.modelcontextprotocol/skills": {} },
},
}}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="legacy"
/>,
);
expect(screen.getByTestId("skills-directory-read")).toHaveTextContent(
"Not supported",
);
});
it("renders client registration kind when provided", () => {
renderWithMantine(
<ConnectionInfoContent

View file

@ -18,6 +18,8 @@ import type {
} from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js";
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
import {
CapabilityItem,
@ -259,6 +261,9 @@ export function ConnectionInfoContent({
}: ConnectionInfoContentProps) {
const { serverInfo, protocolVersion, capabilities, instructions } =
initializeResult;
// `undefined` when the server declared no Skills extension, which is what
// hides the section below — an absent extension has no sub-flags to report.
const skillsExtension = getSkillsExtension(capabilities);
// Only trust `serverInfo` when the server actually reported it; otherwise the
// name is a catalog fallback. Both rows `?.trim()` before the `||` (not `??`)
@ -362,6 +367,26 @@ export function ConnectionInfoContent({
</Stack>
</SimpleGrid>
{/* Skills (SEP-2640). The generic "Server Extensions" row above lists the
identifier, but not the one sub-option the extension defines —
`directoryRead`, which gates `resources/directory/read`. That flag is
exactly what a server author opens this modal to confirm, so it gets a
row of its own rather than being flattened into a key list (#2234). */}
{skillsExtension && (
<SimpleGrid cols={2}>
<Stack gap="xs">
<SectionHeading>Skills Extension</SectionHeading>
<ValueText>{SKILLS_EXTENSION_KEY}</ValueText>
</Stack>
<Stack gap="xs">
<SectionHeading>Directory Read</SectionHeading>
<ValueText data-testid="skills-directory-read">
{skillsExtension.directoryRead ? "Supported" : "Not supported"}
</ValueText>
</Stack>
</SimpleGrid>
)}
{instructions && (
<Stack gap="xs">
<SectionHeading>Server Instructions</SectionHeading>

View file

@ -0,0 +1,136 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
import { SkillsScreen } from "./SkillsScreen";
import type { SkillsUiState } from "./SkillsScreen";
import { EMPTY_SKILLS_UI } from "../screenUiState";
// SkillsScreen is controlled (selection and search live in the parent as one
// `ui` object — see #1417). This wrapper holds that state so the play-driven
// clicks drive the detail pane, mirroring how App owns it in the real app.
function StatefulSkillsScreen(args: ComponentProps<typeof SkillsScreen>) {
const [ui, setUi] = useState<SkillsUiState>(args.ui ?? EMPTY_SKILLS_UI);
return <SkillsScreen {...args} ui={ui} onUiChange={setUi} />;
}
const REF_TEXT = "# Column rules\n";
// The digest of REF_TEXT, so the clean skill really does verify when the
// "Verify all" story runs — a placeholder here would demo a false green.
const REF_DIGEST =
"sha256:e201429aa2684958ca1a0537ab4eb4b7eb3a81c71e7cc7a11397eb500738e015";
const sampleSkills: SkillEntry[] = [
{
uri: "skill://data-analysis/SKILL.md",
frontmatter: {
name: "data-analysis",
description: "Analyze a CSV and summarize its columns",
},
resources: [
{
uri: "skill://data-analysis/reference.md",
digest: REF_DIGEST,
size: REF_TEXT.length,
},
],
},
{
uri: "skill://tampered-notes/SKILL.md",
frontmatter: {
name: "tampered-notes",
description: "Advertises a digest its bytes do not match",
},
resources: [
{
uri: "skill://tampered-notes/notes.md",
digest: `sha256:${"b".repeat(64)}`,
size: 12,
},
],
},
{
uri: "skill://dynamic-report/SKILL.md",
frontmatter: {
name: "dynamic-report",
description: "Generated files, so integrity cannot be verified",
},
resources: "dynamic",
},
{
uri: "skill://wrong-folder/SKILL.md",
frontmatter: {
name: "right-name",
description: "URI path segment disagrees with frontmatter.name",
},
resources: [],
},
];
const meta: Meta<typeof SkillsScreen> = {
title: "Screens/SkillsScreen",
component: SkillsScreen,
parameters: { layout: "fullscreen" },
args: {
skills: sampleSkills,
pageCount: 2,
ui: EMPTY_SKILLS_UI,
onUiChange: fn(),
onRefreshList: fn(),
onReadSkillFile: fn(async (uri: string) =>
uri.endsWith("reference.md")
? { text: REF_TEXT }
: { text: `# ${uri}\n`, mimeType: "text/markdown" },
),
},
render: (args) => <StatefulSkillsScreen {...args} />,
};
export default meta;
type Story = StoryObj<typeof SkillsScreen>;
export const Default: Story = {};
export const Empty: Story = {
args: { skills: [], pageCount: 0 },
};
export const LoadFailed: Story = {
args: { loadError: new Error("skills/list failed: -32601 Method not found") },
};
export const ConformingSkill: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("data-analysis"));
await expect(canvas.getByText("Conforms")).toBeInTheDocument();
},
};
export const NameMismatch: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("right-name"));
await expect(canvas.getByText("name-path-mismatch")).toBeInTheDocument();
},
};
export const DynamicResources: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("dynamic-report"));
await expect(canvas.getByText("Dynamic resources")).toBeInTheDocument();
},
};
export const DigestMismatch: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("tampered-notes"));
await userEvent.click(canvas.getByRole("button", { name: /Verify all/ }));
await expect(
await canvas.findByText("Digest mismatch"),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,343 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills";
import {
renderWithMantine,
screen,
within,
} from "../../../test/renderWithMantine";
import {
SkillsScreen,
type SkillsScreenProps,
type SkillsUiState,
} from "./SkillsScreen";
import { EMPTY_SKILLS_UI } from "../screenUiState";
const REF_TEXT = "# Column rules\n";
// Computed once at module load so the fixture's advertised digest really is the
// digest of the bytes the fake read returns — a hard-coded constant here would
// make the "verified" test pass for the wrong reason if the encoder changed.
const REF_DIGEST = await sha256Digest(textToBytes(REF_TEXT));
const CLEAN_SKILL: SkillEntry = {
uri: "skill://data-analysis/SKILL.md",
frontmatter: {
name: "data-analysis",
description: "Analyze a CSV and summarize its columns",
},
resources: [
{
uri: "skill://data-analysis/reference.md",
digest: REF_DIGEST,
size: REF_TEXT.length,
},
],
};
const TAMPERED_SKILL: SkillEntry = {
uri: "skill://tampered/SKILL.md",
frontmatter: { name: "tampered", description: "Bad digest" },
resources: [
{
uri: "skill://tampered/notes.md",
digest: `sha256:${"b".repeat(64)}`,
size: 4,
},
],
};
const DYNAMIC_SKILL: SkillEntry = {
uri: "skill://dynamic-report/SKILL.md",
frontmatter: { name: "dynamic-report", description: "Generated files" },
resources: "dynamic",
};
const MISMATCHED_SKILL: SkillEntry = {
uri: "skill://wrong-folder/SKILL.md",
frontmatter: { name: "right-name", description: "Name disagreement" },
resources: [],
};
const ALL_SKILLS = [
CLEAN_SKILL,
TAMPERED_SKILL,
DYNAMIC_SKILL,
MISMATCHED_SKILL,
];
/** A `resources/read` that serves the fixture bytes for any known URI. */
const readFixtureFile = vi.fn(async (uri: string) => {
if (uri === "skill://data-analysis/reference.md") return { text: REF_TEXT };
if (uri === "skill://tampered/notes.md") return { text: "different\n" };
return { text: `# ${uri}\n`, mimeType: "text/markdown" };
});
const baseProps: SkillsScreenProps = {
skills: ALL_SKILLS,
pageCount: 2,
ui: EMPTY_SKILLS_UI,
onUiChange: vi.fn(),
onRefreshList: vi.fn(),
onReadSkillFile: readFixtureFile,
};
// SkillsScreen is controlled: the selection and the sidebar search live in the
// parent (App) as one `ui` object so they persist across tab navigation
// (#1417). This host holds that state so clicking a skill actually selects it.
function ControlledSkillsScreen(props: Partial<SkillsScreenProps> = {}) {
const [ui, setUi] = useState<SkillsUiState>({
...EMPTY_SKILLS_UI,
...props.ui,
});
return (
<SkillsScreen
{...baseProps}
{...props}
ui={ui}
onUiChange={(next) => {
setUi(next);
props.onUiChange?.(next);
}}
/>
);
}
describe("SkillsScreen", () => {
it("renders the empty state until a skill is selected", () => {
renderWithMantine(<SkillsScreen {...baseProps} />);
expect(
screen.getByText("Select a skill to view details"),
).toBeInTheDocument();
});
it("exposes the readiness contract the headless tab smoke keys off", () => {
renderWithMantine(<SkillsScreen {...baseProps} />);
const root = screen.getByTestId("skills-screen");
expect(root).toHaveAttribute("data-skill-count", "4");
expect(root).toHaveAttribute("data-skill-page-count", "2");
});
it("renders 'No skills' when the list is empty", () => {
renderWithMantine(<SkillsScreen {...baseProps} skills={[]} />);
expect(screen.getByText("No skills")).toBeInTheDocument();
});
it("renders a load failure above the list", () => {
renderWithMantine(
<SkillsScreen {...baseProps} loadError={new Error("nope")} />,
);
expect(screen.getByText("Could not load skills")).toBeInTheDocument();
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("filters the sidebar by name and by URI", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.type(screen.getByLabelText("Search skills"), "wrong-folder");
// The matching skill's *name* is `right-name`, so a hit here proves the URI
// is searched too and not just the display name.
expect(screen.getByText("right-name")).toBeInTheDocument();
expect(screen.queryByText("data-analysis")).not.toBeInTheDocument();
});
it("calls onRefreshList when Refresh is clicked", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<SkillsScreen {...baseProps} onRefreshList={onRefreshList} />,
);
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefreshList).toHaveBeenCalled();
});
it("reports a conforming skill as conforming", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
expect(screen.getByText("Conforms")).toBeInTheDocument();
expect(screen.queryByTestId("skill-issues")).not.toBeInTheDocument();
});
it("shows the name/path mismatch as a distinct, named finding", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("right-name"));
const issues = screen.getByTestId("skill-issues");
expect(within(issues).getByText("name-path-mismatch")).toBeInTheDocument();
});
it("shows the dynamic warning and no manifest table", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("dynamic-report"));
expect(screen.getByText("Dynamic resources")).toBeInTheDocument();
expect(screen.queryByTestId("skill-manifest")).not.toBeInTheDocument();
// "Verify all" has nothing to verify, so it is disabled rather than a
// button that silently does nothing.
expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled();
});
it("verifies a file whose bytes match its digest", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("verified")).toBeInTheDocument();
});
it("reports a digest mismatch loudly, with both digests", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("tampered"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("Digest mismatch")).toBeInTheDocument();
expect(
screen.getByText(`expected sha256:${"b".repeat(64)}`),
).toBeInTheDocument();
});
it("verifies a single file from its own row button", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest");
await user.click(within(manifest).getByRole("button", { name: "Verify" }));
expect(await screen.findByText("verified")).toBeInTheDocument();
});
it("reports a failed read as a read failure, not a mismatch", async () => {
const user = userEvent.setup();
const onReadSkillFile = vi.fn().mockRejectedValue(new Error("403"));
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("Could not read file")).toBeInTheDocument();
expect(screen.getByText("403")).toBeInTheDocument();
});
it("wraps a non-Error read rejection", async () => {
const user = userEvent.setup();
const onReadSkillFile = vi.fn().mockRejectedValue("plain string");
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("plain string")).toBeInTheDocument();
});
it("shows the SKILL.md preview on demand", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
});
it("reports a failed SKILL.md read", async () => {
const user = userEvent.setup();
const onReadSkillFile = vi.fn().mockRejectedValue(new Error("gone"));
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(
await screen.findByText("Could not read SKILL.md"),
).toBeInTheDocument();
});
it("wraps a non-Error SKILL.md rejection", async () => {
const user = userEvent.setup();
const onReadSkillFile = vi.fn().mockRejectedValue("bare");
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByText("bare")).toBeInTheDocument();
});
it("drops verification results when the selection changes", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("verified")).toBeInTheDocument();
// A verdict belongs to the skill it was computed for; carrying it across a
// selection change would attribute one skill's result to another.
await user.click(screen.getByText("tampered"));
expect(screen.queryByText("verified")).not.toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
});
it("drops the SKILL.md preview when the selection changes", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
await user.click(screen.getByText("tampered"));
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
});
it("renders an em dash for a manifest entry with no size", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledSkillsScreen
skills={[
{
...CLEAN_SKILL,
resources: [{ uri: "skill://data-analysis/reference.md" }],
},
]}
/>,
);
await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest");
// Three em dashes in the row: the size cell, the digest cell, and the
// not-yet-run verification badge — which stays distinct from
// "unverifiable" so an absent digest is never mistaken for an unrun check.
expect(within(manifest).getAllByText("—")).toHaveLength(3);
});
it("truncates a long digest but shows a short one whole", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledSkillsScreen
skills={[
{
...CLEAN_SKILL,
resources: [
{ uri: "skill://data-analysis/a.md", digest: "sha256:short" },
],
},
]}
/>,
);
await user.click(screen.getByText("data-analysis"));
expect(screen.getByText("sha256:short")).toBeInTheDocument();
});
it("reports a file with no advertised digest as unverifiable, not verified", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledSkillsScreen
skills={[
{
...CLEAN_SKILL,
resources: [{ uri: "skill://data-analysis/reference.md" }],
},
]}
/>,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("unverifiable")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,588 @@
import { useCallback, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Code,
Flex,
Group,
NavLink,
ScrollArea,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import { MdRefresh, MdSearch, MdVerifiedUser } from "react-icons/md";
import type {
SkillEntry,
SkillResource,
} from "@inspector/core/mcp/skillsSchemas.js";
import { DYNAMIC_RESOURCES } from "@inspector/core/mcp/skillsSchemas.js";
import {
checkSkillConformance,
skillDisplayName,
totalSkillBytes,
verifySkillResource,
type SkillIssue,
type SkillVerification,
} from "@inspector/core/mcp/skills.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { useValueChange } from "../../../hooks/useValueChange";
import {
skillFileBytes,
type SkillFileContents,
} from "../../../utils/skillFileBytes";
/** Per-file verification progress, keyed by the manifest entry's URI. */
type FileState =
| { status: "pending" }
| { status: "done"; verification: SkillVerification }
| { status: "error"; message: string };
export interface SkillsScreenProps {
skills: SkillEntry[];
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
pageCount: number;
/** A failed list walk, rendered above the sidebar list. */
loadError?: Error | null;
ui: SkillsUiState;
onUiChange: (next: SkillsUiState) => void;
onRefreshList: () => void;
/** Fetch one skill file's contents via `resources/read`, on demand. */
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
}
/**
* Selection and the sidebar search — controlled by the parent (App) as one
* object so they persist across tab navigation within a live session (#1417).
* Verification results stay local to the screen: they are derived from a live
* `resources/read` round trip that is torn down with the screen, so persisting
* them would restore a verdict without the fetch that produced it.
*/
export interface SkillsUiState {
selectedSkillUri?: string;
search: string;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))",
gap: "md",
p: "xl",
align: "flex-start",
});
const Sidebar = Stack.withProps({
w: 340,
flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
});
const DetailCard = Card.withProps({
withBorder: true,
padding: "lg",
flex: 1,
h: "100%",
});
const DetailScroll = ScrollArea.withProps({
type: "auto",
scrollbars: "y",
offsetScrollbars: true,
h: "100%",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
const ControlsRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "sm",
});
const SearchInput = TextInput.withProps({
size: "xs",
flex: 1,
leftSection: <MdSearch aria-hidden size={14} />,
});
const RefreshButton = Button.withProps({
variant: "subtle",
size: "compact-xs",
leftSection: <MdRefresh aria-hidden size={14} />,
});
const VerifyButton = Button.withProps({
variant: "light",
size: "compact-sm",
leftSection: <MdVerifiedUser aria-hidden size={14} />,
});
// A `Text` renders a `<p>`, so a section heading must never *wrap* the count
// badge beside it — a `<div>` inside a `<p>` 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;
}
/** `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({
skills,
pageCount,
loadError,
ui,
onUiChange,
onRefreshList,
onReadSkillFile,
}: SkillsScreenProps) {
const { selectedSkillUri, search } = ui;
const [fileStates, setFileStates] = useState<Record<string, FileState>>({});
const [preview, setPreview] = useState<SkillFileContents | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
// Changing the selection invalidates every verdict and the SKILL.md preview:
// they belong to the skill that was selected. Adjusted DURING RENDER via
// `useValueChange` rather than in an effect, so the new skill never paints
// for a frame carrying the previous one's verification results.
useValueChange(selectedSkillUri, () => {
setFileStates({});
setPreview(null);
setPreviewError(null);
});
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]);
const selected = useMemo(
() => skills.find((skill) => skill.uri === selectedSkillUri),
[skills, selectedSkillUri],
);
const issues = useMemo(
() => (selected ? checkSkillConformance(selected) : []),
[selected],
);
const manifest: SkillResource[] = useMemo(
() =>
selected && selected.resources !== DYNAMIC_RESOURCES
? selected.resources
: [],
[selected],
);
const verifyFile = useCallback(
async (resource: SkillResource) => {
setFileStates((prev) => ({
...prev,
[resource.uri]: { status: "pending" },
}));
try {
const contents = await onReadSkillFile(resource.uri);
const verification = await verifySkillResource(
resource,
skillFileBytes(contents),
);
setFileStates((prev) => ({
...prev,
[resource.uri]: { status: "done", verification },
}));
} catch (err) {
setFileStates((prev) => ({
...prev,
[resource.uri]: {
status: "error",
message: err instanceof Error ? err.message : String(err),
},
}));
}
},
[onReadSkillFile],
);
const verifyAll = useCallback(() => {
// Held rather than floated: each `verifyFile` owns its own failures (it
// records them as per-file state), and this handler cannot be async, so the
// settled promise is discarded explicitly at one place instead of per file.
void Promise.all(manifest.map((resource) => verifyFile(resource)));
}, [manifest, verifyFile]);
const showSkillMd = useCallback(() => {
if (!selected) return;
// A click handler cannot await, and this chain terminates in its own
// `catch` that surfaces the message in the preview slot.
void onReadSkillFile(selected.uri)
.then((contents) => {
setPreview(contents);
setPreviewError(null);
})
.catch((err: unknown) => {
setPreview(null);
setPreviewError(err instanceof Error ? err.message : String(err));
});
}, [onReadSkillFile, selected]);
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.
<ScreenLayout
data-testid="skills-screen"
data-skill-count={skills.length}
data-skill-page-count={pageCount}
>
<Sidebar>
<SidebarCard>
<Stack gap="sm">
<ControlsRow>
<SearchInput
aria-label="Search skills"
placeholder="Search skills"
value={search}
onChange={(event) =>
onUiChange({ ...ui, search: event.currentTarget.value })
}
/>
<RefreshButton onClick={onRefreshList}>Refresh</RefreshButton>
</ControlsRow>
{loadError && (
<Alert color="red" title="Could not load skills">
{loadError.message}
</Alert>
)}
{filtered.length === 0 ? (
<EmptyState>No skills</EmptyState>
) : (
filtered.map((skill) => {
const skillIssues = checkSkillConformance(skill);
const errors = skillIssues.filter(
(i) => i.severity === "error",
).length;
return (
<NavLink
key={skill.uri}
active={skill.uri === selectedSkillUri}
label={skillDisplayName(skill)}
description={skill.uri}
onClick={() =>
onUiChange({ ...ui, selectedSkillUri: skill.uri })
}
rightSection={
skillIssues.length > 0 ? (
<CountBadge color={errors > 0 ? "red" : "yellow"}>
{skillIssues.length}
</CountBadge>
) : undefined
}
/>
);
})
)}
<MonoCaption>
{skills.length} skill(s) over {pageCount} page(s)
</MonoCaption>
</Stack>
</SidebarCard>
</Sidebar>
<DetailCard>
{!selected ? (
<EmptyState>Select a skill to view details</EmptyState>
) : (
<DetailScroll>
<DetailStack data-testid="skill-detail">
<Stack gap={4}>
<SkillTitle>{skillDisplayName(selected)}</SkillTitle>
<MonoCaption>{selected.uri}</MonoCaption>
</Stack>
{selected.frontmatter.description && (
<Text size="sm">{selected.frontmatter.description}</Text>
)}
<Stack gap="xs">
<InlineRow>
<SectionHeading>Conformance</SectionHeading>
<CountBadge color={errorCount > 0 ? "red" : "green"}>
{errorCount} error(s), {warningCount} warning(s)
</CountBadge>
</InlineRow>
{issues.length === 0 ? (
<Alert color="green" title="Conforms">
No structural issues found in this entry.
</Alert>
) : (
<IssueStack data-testid="skill-issues">
{issues.map((issue) => (
<Alert
key={`${issue.code}:${issue.resourceUri ?? ""}`}
color={issueColor(issue)}
title={issue.code}
>
<Stack gap={2}>
<Text size="sm">{issue.message}</Text>
{issue.resourceUri && (
<MonoCaption>{issue.resourceUri}</MonoCaption>
)}
</Stack>
</Alert>
))}
</IssueStack>
)}
</Stack>
<Stack gap="xs">
<ControlsRow>
<InlineRow>
<SectionHeading>Resources</SectionHeading>
<CountBadge>
{manifest.length} file(s), {totalSkillBytes(manifest)}{" "}
bytes
</CountBadge>
</InlineRow>
<InlineRow>
<VerifyButton onClick={showSkillMd}>
View SKILL.md
</VerifyButton>
<VerifyButton
onClick={verifyAll}
disabled={manifest.length === 0}
>
Verify all
</VerifyButton>
</InlineRow>
</ControlsRow>
{selected.resources === DYNAMIC_RESOURCES ? (
<Alert color="yellow" title="Dynamic resources">
This skill declares{" "}
<Code>resources: &quot;dynamic&quot;</Code> — its files are
generated, so no manifest is advertised and integrity cannot
be verified.
</Alert>
) : (
<ManifestTable data-testid="skill-manifest">
<Table.Thead>
<Table.Tr>
<Table.Th>URI</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Digest</Table.Th>
<Table.Th>Verification</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{manifest.map((resource) => {
const state = fileStates[resource.uri];
const color =
state?.status === "done"
? verificationColor(state.verification.status)
: state?.status === "error"
? "red"
: "gray";
return (
<Table.Tr key={resource.uri}>
<Table.Td>{resource.uri}</Table.Td>
<Table.Td>{resource.size ?? "—"}</Table.Td>
<Table.Td>{shortDigest(resource.digest)}</Table.Td>
<Table.Td>
<InlineRow>
<CountBadge color={color}>
{verificationLabel(state)}
</CountBadge>
<RowVerifyButton
// A click handler cannot await, and
// `verifyFile` owns its own failures — it
// records them as this row's state.
onClick={() => void verifyFile(resource)}
>
Verify
</RowVerifyButton>
</InlineRow>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</ManifestTable>
)}
{manifest.map((resource) => {
const state = fileStates[resource.uri];
if (state?.status === "done") {
const { verification } = state;
if (verification.status === "mismatch") {
return (
<Alert
key={`mismatch:${resource.uri}`}
color="red"
title="Digest mismatch"
>
<Stack gap={2}>
<MonoCaption>{resource.uri}</MonoCaption>
<MonoCaption>
expected {verification.expectedDigest}
</MonoCaption>
<MonoCaption>
actual {verification.actualDigest}
</MonoCaption>
</Stack>
</Alert>
);
}
return null;
}
if (state?.status === "error") {
return (
<Alert
key={`read:${resource.uri}`}
color="red"
title="Could not read file"
>
<Stack gap={2}>
<MonoCaption>{resource.uri}</MonoCaption>
<Text size="sm">{state.message}</Text>
</Stack>
</Alert>
);
}
return null;
})}
</Stack>
{previewError && (
<Alert color="red" title="Could not read SKILL.md">
{previewError}
</Alert>
)}
{preview && (
<Stack gap="xs" data-testid="skill-md-preview">
<SectionHeading>SKILL.md</SectionHeading>
<ContentViewer
block={{ type: "text", text: preview.text ?? "" }}
mimeType={preview.mimeType ?? "text/markdown"}
copyable
/>
</Stack>
)}
<Stack gap="xs">
<SectionHeading>Frontmatter</SectionHeading>
<ContentViewer
block={{
type: "text",
text: JSON.stringify(selected.frontmatter, null, 2),
}}
mimeType="application/json"
copyable
/>
</Stack>
</DetailStack>
</DetailScroll>
)}
</DetailCard>
</ScreenLayout>
);
}

View file

@ -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 },

View file

@ -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,15 @@ const appsArgs: AppsPanelProps = {
onRefreshApps: fn(),
};
const skillsArgs: SkillsPanelProps = {
skills: [],
skillsPageCount: 0,
skillsUi: EMPTY_SKILLS_UI,
onSkillsUiChange: fn(),
onRefreshSkills: fn(),
onReadSkillFile: fn(async () => ({ text: "" })),
};
const tasksArgs: TasksPanelProps = {
tasks: demoTasks,
progressByTaskId: demoProgressByTaskId,
@ -484,6 +495,7 @@ const meta: Meta<typeof InspectorView> = {
prompts: promptsArgs,
resources: resourcesArgs,
apps: appsArgs,
skills: skillsArgs,
tasks: tasksArgs,
logs: logsArgs,
protocol: protocolArgs,

View file

@ -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,15 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps {
onRefreshApps: vi.fn(),
...mergeBundle("apps", overrides),
},
skills: {
skills: [],
skillsPageCount: 0,
skillsUi: EMPTY_SKILLS_UI,
onSkillsUiChange: vi.fn(),
onRefreshSkills: vi.fn(),
onReadSkillFile: vi.fn().mockResolvedValue({ text: "" }),
...mergeBundle("skills", overrides),
},
tasks: {
tasks: [],
tasksUi: EMPTY_TASKS_UI,
@ -1369,6 +1379,50 @@ describe("InspectorView", () => {
expect(labels).not.toContain("Tasks");
});
it("hides the Skills tab when the server declares no skills extension (#2234)", async () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
connection: {
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: initWithCapabilities({ tools: {} }),
},
servers: { servers: [sampleServer] },
})}
/>,
);
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(
<StatefulInspectorViewHost
{...makeProps({
connection: {
activeServer: "alpha",
connectionStatus: "connected",
protocolEra: "legacy",
initializeResult: initWithCapabilities({
tools: {},
extensions: { "io.modelcontextprotocol/skills": {} },
}),
},
servers: { servers: [sampleServer] },
})}
/>,
);
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(
<StatefulInspectorViewHost

View file

@ -13,6 +13,7 @@ import type { ServerEntry } from "@inspector/core/mcp/types.js";
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
import { isAppTool } from "@inspector/core/mcp/apps.js";
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { isSkillsExtensionSupported } from "@inspector/core/mcp/skills.js";
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
@ -22,6 +23,7 @@ import { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
import { SkillsScreen } from "../../screens/SkillsScreen/SkillsScreen";
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
import { ProtocolScreen } from "../../screens/ProtocolScreen/ProtocolScreen";
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
@ -37,6 +39,7 @@ import type {
ResourcesPanelProps,
ServerListProps,
ShellProps,
SkillsPanelProps,
TasksPanelProps,
ToolsPanelProps,
} from "./types";
@ -118,6 +121,7 @@ function useListCompact(
}
const SERVERS_TAB = INSPECTOR_SERVERS_TAB;
const SKILLS_TAB = "Skills";
const TASKS_TAB = "Tasks";
const LOGS_TAB = "Logs";
const PROTOCOL_TAB = "Protocol";
@ -151,6 +155,7 @@ const ALL_TABS: string[] = [
"Tools",
"Prompts",
"Resources",
SKILLS_TAB,
TASKS_TAB,
LOGS_TAB,
PROTOCOL_TAB,
@ -328,6 +333,7 @@ export interface InspectorViewProps {
prompts: PromptsPanelProps;
resources: ResourcesPanelProps;
apps: AppsPanelProps;
skills: SkillsPanelProps;
tasks: TasksPanelProps;
logs: LogsPanelProps;
protocol: ProtocolPanelProps;
@ -343,6 +349,7 @@ export function InspectorView({
prompts: promptsPanel,
resources: resourcesPanel,
apps: appsPanel,
skills: skillsPanel,
tasks: tasksPanel,
logs: logsPanel,
protocol: protocolPanel,
@ -448,6 +455,15 @@ export function InspectorView({
onAppError,
onRefreshApps,
} = appsPanel;
const {
skills,
skillsPageCount,
skillsLoadError,
skillsUi,
onSkillsUiChange,
onRefreshSkills,
onReadSkillFile,
} = skillsPanel;
const {
tasks,
progressByTaskId,
@ -654,6 +670,10 @@ export function InspectorView({
capabilities?.tasks !== undefined ||
capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined;
const hasLogging = capabilities?.logging !== undefined;
// Skills (SEP-2640) is a server-declared *extension*, and unlike Tasks it
// 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.
const hasSkills = isSkillsExtensionSupported(capabilities);
return ALL_TABS.filter((t) => {
if (t === NETWORK_TAB && isStdio) return false;
// Console is the stdio process's stderr stream — shown only for stdio
@ -663,6 +683,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 +1053,15 @@ export function InspectorView({
sortDirection: consoleSort,
onSortChange: setConsoleSort,
};
const skillsScreenProps = {
skills,
pageCount: skillsPageCount,
loadError: skillsLoadError,
ui: skillsUi,
onUiChange: onSkillsUiChange,
onRefreshList: onRefreshSkills,
onReadSkillFile,
};
const tasksScreenProps = {
tasks,
progressByTaskId,
@ -1240,6 +1270,9 @@ export function InspectorView({
onCompactChange={setResourcesCompact}
/>
</ScreenStage>
<ScreenStage active={activeTab === SKILLS_TAB}>
<SkillsScreen {...skillsScreenProps} />
</ScreenStage>
<ScreenStage active={activeTab === TASKS_TAB}>
<TasksScreen {...tasksScreenProps} />
</ScreenStage>

View file

@ -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";
@ -309,6 +312,19 @@ export interface AppsPanelProps {
}
/** The Tasks monitor: the task list, its progress map, and actions. */
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
export interface SkillsPanelProps {
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<SkillFileContents>;
}
export interface TasksPanelProps {
tasks: Task[];
progressByTaskId?: Record<string, TaskProgress>;

View file

@ -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));

View file

@ -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;
@ -131,6 +135,12 @@ export interface UseInspectorStoresResult {
tasks: Task[];
refreshTasks: () => Promise<unknown>;
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<unknown>;
subscriptions: InspectorResourceSubscription[];
subscriptionStreamState: ResourceSubscriptionStreamState;
messages: MessageEntry[];
@ -192,6 +202,7 @@ export function useInspectorStores({
client,
),
managedRequestorTasksState: new ManagedRequestorTasksState(client),
managedSkillsState: new ManagedSkillsState(client),
resourceSubscriptionsState: new ResourceSubscriptionsState(
client,
managedResourcesState,
@ -310,6 +321,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);
@ -344,6 +364,10 @@ export function useInspectorStores({
tasks,
refreshTasks,
clearCompletedTasks,
skills,
skillsPageCount,
skillsLoadError,
refreshSkills,
subscriptions,
subscriptionStreamState,
messages,

View file

@ -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,88 @@ 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 sole block whose uri the server echoed back differently", async () => {
// `resources/read` answers the URI it was asked for, so a single-block
// response IS that block even when the echo differs in form.
const c = client({
readResource: vi.fn().mockResolvedValue({
result: { contents: [{ uri: "SKILL://DEMO/reference.md", text: "x" }] },
}),
});
const h = harness({ client: c });
await expect(h.api().onReadSkillFile(skillUri)).resolves.toEqual({
text: "x",
});
});
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("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();

View file

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

View file

@ -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);

View file

@ -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(),
@ -621,6 +629,7 @@ describe("oauthResume", () => {
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
setSkillsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setProtocolUi: vi.fn(),

View file

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

View file

@ -0,0 +1,140 @@
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";
/**
* 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 {
client: {
request: (
req: { method: string; params: Record<string, unknown> },
schema: { parse: (value: unknown) => unknown },
) => Promise<unknown>;
} | 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 } },
);
}
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<string, unknown> },
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("normalizes the enveloped skills/get result to the entry", async () => {
const client = makeClient();
stubRequest(client, { skill: ENTRY });
expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY);
});
it("normalizes the inline skills/get result to the entry", async () => {
// The SEP settles the entry shape but not the envelope; a server that
// returns the entry at the top level must not fail here.
const client = makeClient();
stubRequest(client, ENTRY);
expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY);
});
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();
});
});

Some files were not shown because too many files have changed in this diff Show more