From 63623b754bc815845e08972d0beedb221175abe1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 20:01:13 -0400 Subject: [PATCH 01/18] feat: support the Skills extension (SEP-2640) with digest verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- clients/web/README.md | 6 +- clients/web/src/App.tsx | 19 + .../ConnectionInfoContent.test.tsx | 58 ++ .../ConnectionInfoContent.tsx | 25 + .../SkillsScreen/SkillsScreen.stories.tsx | 136 ++++ .../SkillsScreen/SkillsScreen.test.tsx | 343 ++++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 588 ++++++++++++++++++ .../src/components/screens/screenUiState.ts | 7 + .../InspectorView/InspectorView.stories.tsx | 12 + .../InspectorView/InspectorView.test.tsx | 54 ++ .../views/InspectorView/InspectorView.tsx | 33 + .../components/views/InspectorView/types.ts | 16 + .../web/src/hooks/useInspectorStores.test.tsx | 14 +- clients/web/src/hooks/useInspectorStores.ts | 28 +- .../web/src/hooks/useServerCommands.test.tsx | 84 +++ clients/web/src/hooks/useServerCommands.tsx | 51 ++ clients/web/src/hooks/useTabUiState.ts | 6 + clients/web/src/lib/oauthResume.test.ts | 9 + clients/web/src/lib/oauthResume.ts | 10 + .../core/mcp/inspectorClient-skills.test.ts | 140 +++++ clients/web/src/test/core/mcp/skills.test.ts | 333 ++++++++++ .../src/test/core/mcp/skillsSchemas.test.ts | 121 ++++ .../core/mcp/state/managedSkillsState.test.ts | 178 ++++++ .../test/core/react/useManagedSkills.test.tsx | 114 ++++ clients/web/src/utils/inspectorTabs.test.ts | 1 + clients/web/src/utils/inspectorTabs.ts | 1 + clients/web/src/utils/skillFileBytes.test.ts | 37 ++ clients/web/src/utils/skillFileBytes.ts | 35 ++ core/mcp/__tests__/fakeInspectorClient.ts | 18 + core/mcp/inspectorClient.ts | 83 +++ core/mcp/inspectorClientProtocol.ts | 13 + core/mcp/skills.ts | 336 ++++++++++ core/mcp/skillsSchemas.ts | 164 +++++ core/mcp/state/index.ts | 5 + core/mcp/state/managedSkillsState.ts | 181 ++++++ core/react/useManagedSkills.ts | 76 +++ docs/test-servers.md | 23 + test-servers/configs/skills-http.json | 15 + test-servers/src/composable-test-server.ts | 31 + test-servers/src/load-config.ts | 3 + test-servers/src/resolve-config.ts | 1 + test-servers/src/skills.ts | 280 +++++++++ 42 files changed, 3684 insertions(+), 4 deletions(-) create mode 100644 clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx create mode 100644 clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx create mode 100644 clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx create mode 100644 clients/web/src/test/core/mcp/inspectorClient-skills.test.ts create mode 100644 clients/web/src/test/core/mcp/skills.test.ts create mode 100644 clients/web/src/test/core/mcp/skillsSchemas.test.ts create mode 100644 clients/web/src/test/core/mcp/state/managedSkillsState.test.ts create mode 100644 clients/web/src/test/core/react/useManagedSkills.test.tsx create mode 100644 clients/web/src/utils/skillFileBytes.test.ts create mode 100644 clients/web/src/utils/skillFileBytes.ts create mode 100644 core/mcp/skills.ts create mode 100644 core/mcp/skillsSchemas.ts create mode 100644 core/mcp/state/managedSkillsState.ts create mode 100644 core/react/useManagedSkills.ts create mode 100644 test-servers/configs/skills-http.json create mode 100644 test-servers/src/skills.ts diff --git a/clients/web/README.md b/clients/web/README.md index a72ed8df84..8a427970d9 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -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 diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 03bcda5d48..930b52a1c2 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -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} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 0ebb740f11..d495f8f293 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByTestId("skills-directory-read")).toHaveTextContent( + "Not supported", + ); + }); + it("renders client registration kind when provided", () => { renderWithMantine( + {/* 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 && ( + + + Skills Extension + {SKILLS_EXTENSION_KEY} + + + Directory Read + + {skillsExtension.directoryRead ? "Supported" : "Not supported"} + + + + )} + {instructions && ( Server Instructions diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx new file mode 100644 index 0000000000..101d73838e --- /dev/null +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx @@ -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) { + const [ui, setUi] = useState(args.ui ?? EMPTY_SKILLS_UI); + return ; +} + +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 = { + 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) => , +}; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx new file mode 100644 index 0000000000..90de63c1a5 --- /dev/null +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -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 = {}) { + const [ui, setUi] = useState({ + ...EMPTY_SKILLS_UI, + ...props.ui, + }); + return ( + { + setUi(next); + props.onUiChange?.(next); + }} + /> + ); +} + +describe("SkillsScreen", () => { + it("renders the empty state until a skill is selected", () => { + renderWithMantine(); + expect( + screen.getByText("Select a skill to view details"), + ).toBeInTheDocument(); + }); + + it("exposes the readiness contract the headless tab smoke keys off", () => { + renderWithMantine(); + 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(); + expect(screen.getByText("No skills")).toBeInTheDocument(); + }); + + it("renders a load failure above the list", () => { + renderWithMantine( + , + ); + 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(); + 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( + , + ); + await user.click(screen.getByRole("button", { name: "Refresh" })); + expect(onRefreshList).toHaveBeenCalled(); + }); + + it("reports a conforming skill as conforming", async () => { + const user = userEvent.setup(); + renderWithMantine(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + 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( + , + ); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + expect(await screen.findByText("unverifiable")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx new file mode 100644 index 0000000000..d325c1a66b --- /dev/null +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -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; +} + +/** + * 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: , +}); + +const RefreshButton = Button.withProps({ + variant: "subtle", + size: "compact-xs", + leftSection: , +}); + +const VerifyButton = Button.withProps({ + variant: "light", + size: "compact-sm", + leftSection: , +}); + +// A `Text` renders a `

`, so a section heading must never *wrap* the count +// badge beside it — a `

` 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; +} + +/** `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>({}); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(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. + + + + + + + onUiChange({ ...ui, search: event.currentTarget.value }) + } + /> + Refresh + + {loadError && ( + + {loadError.message} + + )} + {filtered.length === 0 ? ( + No skills + ) : ( + 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) => ( + + + {issue.message} + {issue.resourceUri && ( + {issue.resourceUri} + )} + + + ))} + + )} + + + + + + Resources + + {manifest.length} file(s), {totalSkillBytes(manifest)}{" "} + bytes + + + + + 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) => { + const state = fileStates[resource.uri]; + const color = + state?.status === "done" + ? verificationColor(state.verification.status) + : state?.status === "error" + ? "red" + : "gray"; + return ( + + {resource.uri} + {resource.size ?? "—"} + {shortDigest(resource.digest)} + + + + {verificationLabel(state)} + + void verifyFile(resource)} + > + Verify + + + + + ); + })} + + + )} + {manifest.map((resource) => { + const state = fileStates[resource.uri]; + if (state?.status === "done") { + const { verification } = state; + if (verification.status === "mismatch") { + return ( + + + {resource.uri} + + expected {verification.expectedDigest} + + + actual {verification.actualDigest} + + + + ); + } + return null; + } + if (state?.status === "error") { + return ( + + + {resource.uri} + {state.message} + + + ); + } + return null; + })} + + + {previewError && ( + + {previewError} + + )} + {preview && ( + + SKILL.md + + + )} + + + 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..0c22737912 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,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 = { 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..d720d1e444 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,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( + , + ); + 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 +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} /> + + + diff --git a/clients/web/src/components/views/InspectorView/types.ts b/clients/web/src/components/views/InspectorView/types.ts index e4b7ce7287..02a9ee8622 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"; @@ -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; +} + export interface TasksPanelProps { tasks: Task[]; progressByTaskId?: Record; diff --git a/clients/web/src/hooks/useInspectorStores.test.tsx b/clients/web/src/hooks/useInspectorStores.test.tsx index 6e7c29553e..2ec027e2b8 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)); diff --git a/clients/web/src/hooks/useInspectorStores.ts b/clients/web/src/hooks/useInspectorStores.ts index ae98d8bec6..3ad32fad43 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; @@ -131,6 +135,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[]; @@ -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, diff --git a/clients/web/src/hooks/useServerCommands.test.tsx b/clients/web/src/hooks/useServerCommands.test.tsx index b8de9e2dd9..a675a284af 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,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(); diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx index 61aabae879..9488e352ef 100644 --- a/clients/web/src/hooks/useServerCommands.tsx +++ b/clients/web/src/hooks/useServerCommands.tsx @@ -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; + /** Re-walk `skills/list` (SEP-2640). */ + refreshSkills: () => Promise; // --- The list stores and their two fetch modes (#1721). --- paginatedLists: boolean; @@ -210,6 +213,12 @@ 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; 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 => { + 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, 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..6977bafa37 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(), @@ -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(), 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..d60b16db24 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts @@ -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 }, + 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 } }, + ); + } + + 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("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(); + }); +}); 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..46afa98d6b --- /dev/null +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -0,0 +1,333 @@ +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, + 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"; + +function entry(overrides: Partial = {}): SkillEntry { + return { + uri: "skill://demo/SKILL.md", + frontmatter: { name: "demo", description: "A demo skill" }, + resources: [ + { + uri: "skill://demo/ref.md", + digest: `sha256:${"a".repeat(64)}`, + 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("treats a non-object declaration as declared with no sub-options", () => { + expect(getSkillsExtension(caps({ [SKILLS_EXTENSION_KEY]: true }))).toEqual({ + directoryRead: false, + }); + }); + + 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("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 when the segment before the suffix is empty", () => { + expect(skillNameFromUri("skill:///SKILL.md")).toBeUndefined(); + }); +}); + +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 missing description as a warning", () => { + const issues = checkSkillConformance( + entry({ frontmatter: { name: "demo" } }), + ); + expect(issues.map((i) => i.code)).toEqual(["missing-description"]); + expect(issues[0].severity).toBe("warning"); + }); + + 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 unverifiable", () => { + const issues = checkSkillConformance( + entry({ resources: [{ uri: "skill://demo/ref.md", size: 1 }] }), + ); + expect(issues.map((i) => i.code)).toEqual(["missing-digest"]); + expect(issues[0].resourceUri).toBe("skill://demo/ref.md"); + }); + + 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/ref.md", digest }] }), + ); + expect(issues.map((i) => i.code)).toEqual(["malformed-digest"]); + } + }); + + it("reports a manifest over the 512-entry limit", () => { + const resources = Array.from( + { length: SKILL_MAX_RESOURCE_ENTRIES + 1 }, + (_unused, i) => ({ + uri: `skill://demo/f${i}.md`, + digest: `sha256:${"a".repeat(64)}`, + }), + ); + const issues = checkSkillConformance(entry({ resources })); + expect(issues.map((i) => i.code)).toContain("resource-limit-exceeded"); + }); + + it("reports a manifest over the 16 MiB limit", () => { + const issues = checkSkillConformance( + entry({ + resources: [ + { + uri: "skill://demo/big.bin", + digest: `sha256:${"a".repeat(64)}`, + size: SKILL_MAX_TOTAL_BYTES + 1, + }, + ], + }), + ); + 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/big.bin", + digest: `sha256:${"a".repeat(64)}`, + 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); + }); +}); + +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("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..1a19eb2a68 --- /dev/null +++ b/clients/web/src/test/core/mcp/skillsSchemas.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { + DYNAMIC_RESOURCES, + GetSkillResultSchema, + ListSkillsResultSchema, + ReadResourceDirectoryResultSchema, + SKILLS_EXTENSION_KEY, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD, + SkillEntrySchema, + normalizeGetSkillResult, +} 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("GetSkillResultSchema", () => { + it("normalizes the enveloped form to the entry", () => { + expect(GetSkillResultSchema.parse({ skill: ENTRY })).toEqual(ENTRY); + }); + + it("normalizes the inline form to the entry", () => { + expect(GetSkillResultSchema.parse(ENTRY)).toEqual(ENTRY); + }); + + it("normalizeGetSkillResult accepts either shape directly", () => { + expect(normalizeGetSkillResult({ skill: ENTRY })).toEqual(ENTRY); + expect(normalizeGetSkillResult(ENTRY)).toEqual(ENTRY); + }); + + it("rejects a result that is neither shape", () => { + expect(() => GetSkillResultSchema.parse({ nothing: true })).toThrow(); + }); +}); + +describe("ReadResourceDirectoryResultSchema", () => { + it("parses directory children including the directory mime type", () => { + const parsed = ReadResourceDirectoryResultSchema.parse({ + contents: [ + { uri: "skill://demo/sub", mimeType: "inode/directory" }, + { uri: "skill://demo/ref.md", mimeType: "text/markdown", size: 3 }, + ], + }); + expect(parsed.contents).toHaveLength(2); + }); +}); 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..79f0035144 --- /dev/null +++ b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas"; +import { + ManagedSkillsState, + REPEATED_CURSOR_MESSAGE, +} from "@inspector/core/mcp/state/managedSkillsState"; +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("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); + }); + + 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/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..763beac5c5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -144,6 +144,14 @@ import { type ModernDetailedTask, } from "./modernTaskSchemas.js"; import { buildClientExtensions } from "./extensions.js"; +import { + GetSkillResultSchema, + ListSkillsResultSchema, + SKILLS_GET_METHOD, + SKILLS_LIST_METHOD, + type SkillEntry, +} from "./skillsSchemas.js"; +import { getSkillsExtension, type SkillsExtensionSupport } from "./skills.js"; import { getElicitationUiResourceUri, isFormElicitation, @@ -5516,6 +5524,81 @@ 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 } : {}), + ...(cursor ? { cursor } : {}), + }; + const response = await this.invokeMcpClient( + () => + this.client!.request( + { method: SKILLS_LIST_METHOD, params }, + ListSkillsResultSchema, + 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 SEP settles the entry + * shape but not the envelope around it, so the result is normalized through + * {@link normalizeGetSkillResult} rather than assuming one form. + */ + 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` normalizes both accepted envelopes to the entry, + // so there is nothing to unwrap here. + return this.invokeMcpClient( + () => + this.client!.request( + { method: SKILLS_GET_METHOD, params }, + GetSkillResultSchema, + this.getRequestOptions(this.progressTokenOf(metadata)), + ), + { method: SKILLS_GET_METHOD }, + ); + } + /** * 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/skills.ts b/core/mcp/skills.ts new file mode 100644 index 0000000000..eab9770df2 --- /dev/null +++ b/core/mcp/skills.ts @@ -0,0 +1,336 @@ +/** + * 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. + */ + +import type { ServerCapabilities } from "@modelcontextprotocol/client"; +import { + DYNAMIC_RESOURCES, + SKILLS_EXTENSION_KEY, + type SkillEntry, + type SkillResource, +} from "./skillsSchemas.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}$/; + +/** + * 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]; + if (declared === undefined || declared === null) return undefined; + const directoryRead = + typeof declared === "object" && + (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; +} + +/** + * 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. + */ +export function skillNameFromUri(uri: string): string | undefined { + if (!uri.endsWith(SKILL_FILE_SUFFIX)) return undefined; + const path = uri.slice(0, -SKILL_FILE_SUFFIX.length); + const segment = path.slice(path.lastIndexOf("/") + 1); + return segment.length > 0 ? segment : undefined; +} + +/** + * 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" + | "missing-description" + | "malformed-uri" + | "name-path-mismatch" + | "missing-digest" + | "malformed-digest" + | "resource-limit-exceeded" + | "size-limit-exceeded"; + +/** + * `error` marks a stated requirement of SEP-2640 that the server broke. + * `warning` marks something that is legal but leaves the Inspector unable to + * verify integrity — `"dynamic"` resources above all, which is 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[] = []; + const declaredName = entry.frontmatter.name?.trim(); + const uriName = skillNameFromUri(entry.uri); + + if (!declaredName) { + issues.push({ + code: "missing-name", + severity: "error", + message: "frontmatter.name is required but missing or empty.", + }); + } + if (!entry.frontmatter.description?.trim()) { + issues.push({ + code: "missing-description", + severity: "warning", + message: "frontmatter.description is missing or empty.", + }); + } + if (uriName === undefined) { + issues.push({ + code: "malformed-uri", + severity: "error", + message: `Skill URI must end with "${SKILL_FILE_SUFFIX}" and carry 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; + } + + if (entry.resources.length > SKILL_MAX_RESOURCE_ENTRIES) { + issues.push({ + code: "resource-limit-exceeded", + severity: "error", + message: `Skill declares ${entry.resources.length} resource entries, above the ${SKILL_MAX_RESOURCE_ENTRIES}-entry limit.`, + }); + } + const totalBytes = totalSkillBytes(entry.resources); + if (totalBytes > SKILL_MAX_TOTAL_BYTES) { + issues.push({ + code: "size-limit-exceeded", + severity: "error", + message: `Skill resources total ${totalBytes} bytes, above the ${SKILL_MAX_TOTAL_BYTES}-byte (16 MiB) limit.`, + }); + } + + for (const resource of entry.resources) { + if (resource.digest === undefined) { + issues.push({ + code: "missing-digest", + severity: "warning", + message: "Manifest entry declares no digest, so it 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, + }); + } + } + + return issues; +} + +/** + * Sum of the manifest's declared `size` fields. An entry that omits `size` + * 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 + (r.size ?? 0), 0); +} + +/** 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; + /** 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 { + // `BufferSource` wants a plain ArrayBuffer; a Uint8Array over a SharedArrayBuffer + // (or a view into a larger buffer) would otherwise hash the wrong range. + const buffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const hash = await crypto.subtle.digest("SHA-256", 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. + */ +export async function verifySkillResource( + resource: SkillResource, + bytes: Uint8Array, +): Promise { + 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, + }; +} diff --git a/core/mcp/skillsSchemas.ts b/core/mcp/skillsSchemas.ts new file mode 100644 index 0000000000..8ee49cbcb2 --- /dev/null +++ b/core/mcp/skillsSchemas.ts @@ -0,0 +1,164 @@ +/** + * 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 whole wire surface.** SEP-2640 is Accepted, so the method + * names and the entry shape are settled, but the skill *format* is delegated to + * the independently-versioned Agent Skills specification and the SEP leaves the + * `skills/get` caching attributes (SEP-2549 `ttlMs` / `cacheScope`) open. Keeping + * every wire type here makes a spec revision a single-file edit (#2234). + * + * Schemas are deliberately permissive (`looseObject`, and a `digest` typed as a + * plain string rather than a hex-constrained one) so a non-conforming server is + * *surfaced* rather than rejected — the Inspector is a conformance tool, and a + * malformed digest is a finding to report, not a parse error to swallow. The + * structural checks live in `skills.ts`. + */ + +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: a page of entries plus the opaque cursor. */ +export const ListSkillsResultSchema = z.looseObject({ + skills: z.array(SkillEntrySchema), + nextCursor: z.string().optional(), +}); + +export type ListSkillsResult = z.infer; + +/** + * The envelope form of a `skills/get` result: the entry wrapped under `skill`. + */ +const GetSkillEnvelopeSchema = z.looseObject({ skill: SkillEntrySchema }); + +/** + * Collapse either accepted `skills/get` shape to the entry it carries. + * + * Written as a parse rather than an `in` check because both accepted shapes are + * loose objects — they carry an index signature, so `"skill" in result` narrows + * nothing and would leave the extracted value `unknown`. Parsing the envelope + * is what proves its `skill` really is an entry, with no cast anywhere. + */ +export function normalizeGetSkillResult(result: unknown): SkillEntry { + const enveloped = GetSkillEnvelopeSchema.safeParse(result); + return enveloped.success + ? enveloped.data.skill + : SkillEntrySchema.parse(result); +} + +/** + * `skills/get` result, normalized to the entry. + * + * ⚠️ The SEP settles the *entry* shape but not the envelope this result wraps it + * in, so both plausible forms are accepted: `{ skill: }` and the entry + * returned inline at the top level. Being permissive here costs nothing (the + * two are structurally distinguishable — an inline entry carries `uri` and + * `frontmatter`, the envelope carries neither) and spares a server author a + * failure whose cause is a spec ambiguity rather than their code. The transform + * means every caller receives the entry and none of them branches. + */ +export const GetSkillResultSchema = z + .union([GetSkillEnvelopeSchema, SkillEntrySchema]) + .transform(normalizeGetSkillResult); + +export type GetSkillResult = SkillEntry; + +/** + * `resources/directory/read` result — the direct (non-recursive) children of a + * directory resource. Present for completeness of the wire surface; the + * Inspector surfaces the `directoryRead` sub-flag today and calls the method in + * phase 3. + */ +export const ReadResourceDirectoryResultSchema = z.looseObject({ + contents: z.array( + z.looseObject({ + uri: z.string(), + name: z.string().optional(), + mimeType: z.string().optional(), + size: z.number().optional(), + }), + ), + nextCursor: z.string().optional(), +}); + +export type ReadResourceDirectoryResult = z.infer< + typeof ReadResourceDirectoryResultSchema +>; 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..b28a784dc9 --- /dev/null +++ b/core/mcp/state/managedSkillsState.ts @@ -0,0 +1,181 @@ +/** + * ManagedSkillsState: holds the full skill list (SEP-2640), in sync with the + * server. Loaded on connect, cleared on disconnect, re-walked on refresh. + * + * Deliberately NOT a `ManagedListState` subclass, despite the family + * resemblance. That base is built around two things the Skills extension does + * not have: a top-level `ServerCapabilities` key to gate on (skills is a + * *server-declared extension*, read from `capabilities.extensions`), and a + * per-list `list_changed` notification to debounce and turn into a sidebar + * indicator + * (SEP-2640 defines none). Subclassing would mean widening the base's + * capability gate and inventing a list-changed event nothing dispatches — two + * changes to shared machinery to serve one caller. The cursor walk below is the + * only behavior actually shared, and it is nine lines. + * + * The walk is done here rather than through an SDK `listAll*` verb for the same + * reason the request is a plain `client.request`: the SDK has no high-level verb + * for a consumer-owned extension method, so there is no cache-aware wrapper to + * delegate to and no `cacheMode` to honor. + */ + +import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; +import type { SkillEntry } from "../skillsSchemas.js"; +import { isTerminalStatus } from "../types.js"; +import type { RequestMetadata } from "../types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface SkillsPaginationState { + /** Pages walked on the last completed refresh (a one-page list is 1). */ + pageCount: number; +} + +export interface ManagedSkillsStateEventMap { + skillsChange: SkillEntry[]; + paginationChange: SkillsPaginationState; + /** The last walk's failure, or `null` once one succeeds. */ + errorChange: Error | null; +} + +/** + * Thrown when a server hands back a cursor it already handed back, which would + * otherwise walk forever. Surfaced as the store's error so the panel reports the + * server bug instead of hanging — the same "report, don't swallow" posture the + * conformance checks take. + */ +export const REPEATED_CURSOR_MESSAGE = + "Server repeated a pagination cursor in skills/list; stopped to avoid an infinite walk."; + +export class ManagedSkillsState extends TypedEventTarget { + private skills: SkillEntry[] = []; + private pageCount = 0; + private error: Error | null = null; + private client: InspectorClientProtocol | null = null; + private unsubscribe: (() => void) | null = null; + // Overlap guard: a walk in flight makes a second one a no-op so a slow older + // walk can't clobber a newer list via last-write-wins. + private running = false; + + constructor(client: InspectorClientProtocol) { + super(); + this.client = client; + const onConnect = (): void => { + // No caller to await the connect-time load, so its rejection is caught + // here rather than left to become an unhandled rejection. Not a swallow: + // `refresh` has already recorded the failure via `setError`, and the + // panel renders it. + void this.refresh().catch(() => {}); + }; + const onStatusChange = (): void => { + if (isTerminalStatus(this.client?.getStatus())) { + this.reset(); + } + }; + this.client.addEventListener("connect", onConnect); + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("connect", onConnect); + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + /** Defensive copy of the current list. */ + getSkills(): SkillEntry[] { + return [...this.skills]; + } + + getPagination(): SkillsPaginationState { + return { pageCount: this.pageCount }; + } + + /** The last walk's failure, or `null` when it succeeded. */ + getError(): Error | null { + return this.error; + } + + // Compared by identity rather than message: two distinct failures with the + // same text are still two events, and a re-render on a repeat failure is + // cheap next to silently coalescing them. + private setError(value: Error | null): void { + if (this.error === value) return; + this.error = value; + this.dispatchTypedEvent("errorChange", value); + } + + private reset(): void { + this.skills = []; + this.pageCount = 0; + this.dispatchTypedEvent("skillsChange", this.getSkills()); + this.dispatchTypedEvent("paginationChange", this.getPagination()); + // A disconnect ends the session the error belonged to — a stale + // "couldn't load skills" must not outlive it into the next connect. + this.setError(null); + } + + /** + * Walk every page of `skills/list` and commit the result. + * + * A failure is recorded as observable state (`getError`) AND re-thrown: the + * state drives the panel's error rendering, while the rejection is what a + * caller's auth-recovery wrapper keys off to detect a 401 and start a + * re-authorization. The connect-time load, which has no such caller, catches + * it above. + */ + async refresh(metadata?: RequestMetadata): Promise { + const client = this.client; + if (!client || client.getStatus() !== "connected") return this.getSkills(); + // A server that never declared the extension answers `skills/list` with + // -32601, which would spam the console for a question we already know the + // answer to. An empty list is the right semantics. + if (!client.getSkillsExtension()) { + this.applyPages([], 0); + return this.getSkills(); + } + if (this.running) return this.getSkills(); + this.running = true; + try { + const collected: SkillEntry[] = []; + const seen = new Set(); + let cursor: string | undefined; + let pages = 0; + for (;;) { + const page = await client.listSkills(cursor, metadata); + collected.push(...page.skills); + pages += 1; + if (page.nextCursor === undefined) break; + if (seen.has(page.nextCursor)) { + throw new Error(REPEATED_CURSOR_MESSAGE); + } + seen.add(page.nextCursor); + cursor = page.nextCursor; + } + this.setError(null); + this.applyPages(collected, pages); + return this.getSkills(); + } catch (err) { + this.setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + this.running = false; + } + } + + private applyPages(skills: SkillEntry[], pageCount: number): void { + this.skills = skills; + this.pageCount = pageCount; + this.dispatchTypedEvent("skillsChange", this.getSkills()); + this.dispatchTypedEvent("paginationChange", this.getPagination()); + } + + /** Unsubscribe from the client and drop the list; idempotent. */ + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.skills = []; + this.pageCount = 0; + this.error = null; + } +} 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..a0b4419c3f 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -56,6 +56,29 @@ 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` advertises the `io.modelcontextprotocol/skills` extension +with `directoryRead: true` and serves four skills over two `skills/list` pages. +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 non-conforming, because the checks +the Skills tab runs are untestable without them: + +| 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 generated file set, so integrity cannot be verified at all and the tab says so rather than staying silent. | +| `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 shows the extension and its `directoryRead` sub-flag; the +Inspector surfaces that flag but does not call `resources/directory/read` yet. ## 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..dab5ae2ce6 --- /dev/null +++ b/test-servers/configs/skills-http.json @@ -0,0 +1,15 @@ +{ + "serverInfo": { + "name": "skills", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }], + "resources": [], + "skills": { + "directoryRead": 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..3bbfb3a026 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,12 @@ 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`. + */ + skills?: { directoryRead?: boolean }; /** * Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the * nested `elicitation` setting — the server-side half of the app-rendered @@ -824,6 +831,23 @@ export function createMcpServer(config: ServerConfig): McpServer { }; } + // Skills extension (SEP-2640): a server-declared extension, advertised with + // its one sub-option. `directoryRead` is opt-in per config so a client can be + // exercised against both a server that offers `resources/directory/read` and + // one that does not. + if (config.skills) { + capabilities.extensions = { + ...(capabilities.extensions ?? {}), + [SKILLS_EXTENSION_KEY]: { + ...(config.skills.directoryRead ? { directoryRead: true } : {}), + }, + }; + // 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 +1527,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..1b136a8cad 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. + * `directoryRead` advertises the `resources/directory/read` sub-option. */ + skills?: { directoryRead?: 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..03f0810cdf --- /dev/null +++ b/test-servers/src/skills.ts @@ -0,0 +1,280 @@ +/** + * 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 non-conforming 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 one `"dynamic"` + * skill, one whose advertised digest does not match the bytes served, and one + * whose URI path segment disagrees with `frontmatter.name`. Each is the exact + * shape one of the checks in `core/mcp/skills.ts` exists to catch. + * + * Handlers are installed straight into the low-level `_requestHandlers` map + * rather than through `setRequestHandler`, the same seam `modern-tasks.ts` + * uses. `skills/*` are consumer-owned extension methods that neither era codec + * defines, so they need no schemas and are era-blind in both directions — + * which is what lets one fixture serve both the legacy and modern legs. + */ + +import { createHash } from "node:crypto"; +import 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; + +/** `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; + frontmatter: Record; + /** `"dynamic"` for a generated skill with no enumerable manifest. */ + files: FixtureFile[] | "dynamic"; +} + +function skillMd(name: string, description: string, body: string): string { + return `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`; +} + +const DATA_ANALYSIS_MD = skillMd( + "data-analysis", + "Analyze a CSV and summarize its columns", + "# 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_MD = skillMd( + "tampered-notes", + "A skill whose manifest digest does not match its served bytes", + "# 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_MD = skillMd( + "dynamic-report", + "A skill whose files are generated per request", + "# 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. +const MISMATCHED_MD = skillMd( + "right-name", + "A skill whose URI path segment disagrees with its frontmatter name", + "# Mismatched name\n\nServed from `wrong-folder/` while claiming the name `right-name`.", +); + +const FIXTURE_SKILLS: FixtureSkill[] = [ + { + path: "data-analysis", + frontmatter: { + name: "data-analysis", + description: "Analyze a CSV and summarize its columns", + }, + 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: { + name: "tampered-notes", + description: "A skill whose manifest digest does not match its bytes", + }, + 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: { + name: "dynamic-report", + description: "A skill whose files are generated per request", + }, + files: "dynamic", + }, + { + path: "wrong-folder", + frontmatter: { + name: "right-name", + description: "A skill whose URI segment disagrees with its name", + }, + 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): Record { + 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): Record { + 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 { + skills: page.map(toEntry), + ...(next < FIXTURE_SKILLS.length ? { nextCursor: String(next) } : {}), + }; +} + +/** The `skills/get` result for one entry URI. */ +export function getSkillEntry(uri: string): Record { + const skill = FIXTURE_SKILLS.find( + (candidate) => `skill://${candidate.path}/SKILL.md` === uri, + ); + if (!skill) throw new Error(`Unknown skill uri: ${uri}`); + // The envelope form (`{ skill }`) is served deliberately: the SEP settles the + // entry shape but not this wrapper, and the Inspector accepts both — serving + // the wrapped one keeps that tolerance exercised. + return { 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 { + contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }], + }; +} + +/** The private handler registry the SDK dispatches through. */ +interface RawHandlerHost { + _requestHandlers: Map< + string, + (request: unknown, ctx: unknown) => Promise + >; +} + +interface UriRequest { + params?: { uri?: string; cursor?: string }; +} + +/** + * Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read` + * onto an `McpServer`. + * + * `resources/read` is wrapped rather than replaced: 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. + */ +export function wireSkillsHandlers(mcpServer: McpServer): void { + const registry = (mcpServer.server as unknown as RawHandlerHost) + ._requestHandlers; + + registry.set("skills/list", async (request) => { + const req = request as UriRequest; + return listSkillsPage(req.params?.cursor); + }); + + registry.set("skills/get", async (request) => { + const req = request as UriRequest; + return getSkillEntry(req.params?.uri ?? ""); + }); + + 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 Error(`Unknown resource: ${req.params?.uri}`); + } + return sdkResourcesRead(request, ctx); + }); +} From da8cc166ceb553155f8122242cba2b667c03c839 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 21:23:58 -0400 Subject: [PATCH 02/18] fix: address Copilot review round 1 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills.ts: report a manifest that omits the skill's own SKILL.md (an empty list included), duplicate URIs, entries outside the skill root, and a missing size. `Conforms` was reachable for manifests that break invariants SEP-2640 states. - skills.ts: cross-check the declared byte length before hashing. A size that disagrees fails verification on its own — the digest is taken over the bytes the server served, so agreeing with it says nothing about whether the manifest describes them. - skills.ts: copy the view instead of slicing its backing store in `sha256Digest`. `SharedArrayBuffer.prototype.slice()` returns another SharedArrayBuffer, which `crypto.subtle.digest` rejects — the cast claimed to handle the exact input that would have thrown. No cast now. - skills.ts: state the one obligation NOT checked here — that an entry's frontmatter matches the fetched SKILL.md's. The digest cannot cover it, and closing it needs a YAML parser, so it is tracked on #2248. - managedSkillsState: cap the walk at LIST_MAX_PAGES. The repeated-cursor guard only catches a server stuck on one cursor; endlessly unique ones walked forever. Raises rather than truncating, like the salvage walk. - managedSkillsState: gate every write on a session generation, so a walk that resolves after a disconnect or destroy cannot repopulate a cleared store or deliver the previous session's skills into the next. - inspectorClient: send a cursor when it is `!== undefined`, not when it is truthy. An opaque cursor may be `""`, and dropping it re-requested page one — which the store then reported as a repeated-cursor failure. - SkillsScreen: key verdict invalidation on the manifest (URI + digests + sizes), not the URI alone. A Refresh that changed the manifest left a green badge attached to a digest nothing had checked. - SkillsScreen: epoch-guard every read continuation, so a fetch that resolves after the selection moved on cannot write into the new one. - SkillsScreen: bound "Verify all" to 4 concurrent reads. A conforming manifest may hold 512 files. - skillsSchemas: drop the guessed `resources/directory/read` result schema. Nothing calls it, so an unverified shape could sit wrong indefinitely; phase 3 adds it against the normative text. - skills-http.json: declare the extension bare. It advertised `directoryRead: true` with no handler, so Connection Info reported "Supported" for a method that answers -32601. - types.ts: restore the Tasks doc comment the Skills interface displaced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.stories.tsx | 28 ++- .../SkillsScreen/SkillsScreen.test.tsx | 166 +++++++++++++++--- .../screens/SkillsScreen/SkillsScreen.tsx | 80 +++++++-- .../components/views/InspectorView/types.ts | 2 +- clients/web/src/test/core/mcp/skills.test.ts | 157 +++++++++++++++-- .../src/test/core/mcp/skillsSchemas.test.ts | 13 -- .../core/mcp/state/managedSkillsState.test.ts | 55 ++++++ core/mcp/inspectorClient.ts | 6 +- core/mcp/skills.ts | 110 +++++++++++- core/mcp/skillsSchemas.ts | 28 +-- core/mcp/state/managedSkillsState.ts | 43 ++++- docs/test-servers.md | 10 +- test-servers/configs/skills-http.json | 4 +- test-servers/src/composable-test-server.ts | 9 +- 14 files changed, 604 insertions(+), 107 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx index 101d73838e..b1a9735596 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx @@ -16,10 +16,21 @@ function StatefulSkillsScreen(args: ComponentProps) { } 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 SELF_TEXT = "# skill\n"; +// The real digests of those two strings, so the clean skill actually verifies +// when the "Verify all" story runs — a placeholder would demo a false green. const REF_DIGEST = "sha256:e201429aa2684958ca1a0537ab4eb4b7eb3a81c71e7cc7a11397eb500738e015"; +const SELF_DIGEST = + "sha256:6504f2de0a1febf7492c3b98f93d9ab49558eb364607a706f02fe9a75aa7f75b"; + +/** Every manifest lists the skill's own SKILL.md — a manifest is the complete + * file set, so one that omits it is a `manifest-missing-self` error. */ +const selfEntry = (path: string) => ({ + uri: `skill://${path}/SKILL.md`, + digest: SELF_DIGEST, + size: 8, +}); const sampleSkills: SkillEntry[] = [ { @@ -29,10 +40,11 @@ const sampleSkills: SkillEntry[] = [ description: "Analyze a CSV and summarize its columns", }, resources: [ + selfEntry("data-analysis"), { uri: "skill://data-analysis/reference.md", digest: REF_DIGEST, - size: REF_TEXT.length, + size: 15, }, ], }, @@ -43,10 +55,14 @@ const sampleSkills: SkillEntry[] = [ description: "Advertises a digest its bytes do not match", }, resources: [ + selfEntry("tampered-notes"), { + // A well-formed digest of bytes the fake read does not return, with a + // size that agrees — so the reported failure is a *digest* mismatch + // rather than the cheaper size cross-check. uri: "skill://tampered-notes/notes.md", digest: `sha256:${"b".repeat(64)}`, - size: 12, + size: 8, }, ], }, @@ -64,7 +80,7 @@ const sampleSkills: SkillEntry[] = [ name: "right-name", description: "URI path segment disagrees with frontmatter.name", }, - resources: [], + resources: [selfEntry("wrong-folder")], }, ]; @@ -81,7 +97,7 @@ const meta: Meta = { onReadSkillFile: fn(async (uri: string) => uri.endsWith("reference.md") ? { text: REF_TEXT } - : { text: `# ${uri}\n`, mimeType: "text/markdown" }, + : { text: SELF_TEXT, mimeType: "text/markdown" }, ), }, render: (args) => , diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 90de63c1a5..9ee2532c21 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -16,11 +16,17 @@ import { 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 +const SELF_TEXT = "# data-analysis\n"; +const NOTES_TEXT = "different\n"; +// Computed once at module load so each fixture's advertised digest really is +// the digest of the bytes the fake read returns — a hard-coded constant would // make the "verified" test pass for the wrong reason if the encoder changed. const REF_DIGEST = await sha256Digest(textToBytes(REF_TEXT)); +const SELF_DIGEST = await sha256Digest(textToBytes(SELF_TEXT)); +// Every manifest lists the skill's own SKILL.md: a manifest is the complete +// file set, so one that omits it is a `manifest-missing-self` error and no +// fixture here would be "clean". const CLEAN_SKILL: SkillEntry = { uri: "skill://data-analysis/SKILL.md", frontmatter: { @@ -28,10 +34,15 @@ const CLEAN_SKILL: SkillEntry = { description: "Analyze a CSV and summarize its columns", }, resources: [ + { + uri: "skill://data-analysis/SKILL.md", + digest: SELF_DIGEST, + size: textToBytes(SELF_TEXT).byteLength, + }, { uri: "skill://data-analysis/reference.md", digest: REF_DIGEST, - size: REF_TEXT.length, + size: textToBytes(REF_TEXT).byteLength, }, ], }; @@ -41,9 +52,17 @@ const TAMPERED_SKILL: SkillEntry = { frontmatter: { name: "tampered", description: "Bad digest" }, resources: [ { + uri: "skill://tampered/SKILL.md", + digest: SELF_DIGEST, + size: textToBytes(SELF_TEXT).byteLength, + }, + { + // A well-formed digest of bytes the fake read does not return, and a + // size that agrees — so the failure reported is a *digest* mismatch and + // not the cheaper size cross-check. uri: "skill://tampered/notes.md", digest: `sha256:${"b".repeat(64)}`, - size: 4, + size: textToBytes(NOTES_TEXT).byteLength, }, ], }; @@ -57,7 +76,13 @@ const DYNAMIC_SKILL: SkillEntry = { const MISMATCHED_SKILL: SkillEntry = { uri: "skill://wrong-folder/SKILL.md", frontmatter: { name: "right-name", description: "Name disagreement" }, - resources: [], + resources: [ + { + uri: "skill://wrong-folder/SKILL.md", + digest: SELF_DIGEST, + size: textToBytes(SELF_TEXT).byteLength, + }, + ], }; const ALL_SKILLS = [ @@ -70,8 +95,8 @@ const ALL_SKILLS = [ /** 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" }; + if (uri === "skill://tampered/notes.md") return { text: NOTES_TEXT }; + return { text: SELF_TEXT, mimeType: "text/markdown" }; }); const baseProps: SkillsScreenProps = { @@ -184,7 +209,7 @@ describe("SkillsScreen", () => { renderWithMantine(); await user.click(screen.getByText("data-analysis")); await user.click(screen.getByRole("button", { name: /Verify all/ })); - expect(await screen.findByText("verified")).toBeInTheDocument(); + expect(await screen.findAllByText("verified")).toHaveLength(2); }); it("reports a digest mismatch loudly, with both digests", async () => { @@ -203,7 +228,10 @@ describe("SkillsScreen", () => { renderWithMantine(); await user.click(screen.getByText("data-analysis")); const manifest = screen.getByTestId("skill-manifest"); - await user.click(within(manifest).getByRole("button", { name: "Verify" })); + // One row at a time: the first row's own Verify button, not "Verify all". + await user.click( + within(manifest).getAllByRole("button", { name: "Verify" })[0], + ); expect(await screen.findByText("verified")).toBeInTheDocument(); }); @@ -215,8 +243,9 @@ describe("SkillsScreen", () => { ); 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(); + // One alert per file in the manifest — both reads failed. + expect(await screen.findAllByText("Could not read file")).toHaveLength(2); + expect(screen.getAllByText("403")).toHaveLength(2); }); it("wraps a non-Error read rejection", async () => { @@ -227,7 +256,7 @@ describe("SkillsScreen", () => { ); await user.click(screen.getByText("data-analysis")); await user.click(screen.getByRole("button", { name: /Verify all/ })); - expect(await screen.findByText("plain string")).toBeInTheDocument(); + expect(await screen.findAllByText("plain string")).toHaveLength(2); }); it("shows the SKILL.md preview on demand", async () => { @@ -267,13 +296,13 @@ describe("SkillsScreen", () => { renderWithMantine(); await user.click(screen.getByText("data-analysis")); await user.click(screen.getByRole("button", { name: /Verify all/ })); - expect(await screen.findByText("verified")).toBeInTheDocument(); + expect(await screen.findAllByText("verified")).toHaveLength(2); // 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(); + expect(screen.getAllByText("—")).toHaveLength(2); }); it("drops the SKILL.md preview when the selection changes", async () => { @@ -286,22 +315,22 @@ describe("SkillsScreen", () => { expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument(); }); - it("renders an em dash for a manifest entry with no size", async () => { + it("renders an em dash for a manifest entry with no size or digest", async () => { const user = userEvent.setup(); renderWithMantine( , ); 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 + // Three em dashes in the single 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); }); @@ -314,7 +343,7 @@ describe("SkillsScreen", () => { { ...CLEAN_SKILL, resources: [ - { uri: "skill://data-analysis/a.md", digest: "sha256:short" }, + { uri: "skill://data-analysis/SKILL.md", digest: "sha256:short" }, ], }, ]} @@ -331,7 +360,7 @@ describe("SkillsScreen", () => { skills={[ { ...CLEAN_SKILL, - resources: [{ uri: "skill://data-analysis/reference.md" }], + resources: [{ uri: "skill://data-analysis/SKILL.md" }], }, ]} />, @@ -340,4 +369,101 @@ describe("SkillsScreen", () => { await user.click(screen.getByRole("button", { name: /Verify all/ })); expect(await screen.findByText("unverifiable")).toBeInTheDocument(); }); + + it("drops verdicts when a refresh replaces the manifest for the same skill", async () => { + // The selection never changes, so keying invalidation on the URI alone + // would leave a green `verified` badge attached to a digest the refresh + // replaced — the UI vouching for content it has never checked. + const user = userEvent.setup(); + const { rerender } = renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + expect(await screen.findAllByText("verified")).toHaveLength(2); + + rerender( + , + ); + expect(screen.queryByText("verified")).not.toBeInTheDocument(); + }); + + it("discards a verification that resolves after the selection moved on", async () => { + // A read still in flight when the user switches skills must not write its + // verdict into the newly selected skill's rows. + const user = userEvent.setup(); + let release: ((value: { text: string }) => void) | undefined; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((resolve) => { + release = resolve; + }), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + await user.click(screen.getByText("tampered")); + release?.({ text: SELF_TEXT }); + // Nothing from the abandoned read reaches the new selection's rows. + expect(screen.queryByText("verified")).not.toBeInTheDocument(); + expect(screen.queryByText("mismatch")).not.toBeInTheDocument(); + }); + + it("discards a SKILL.md read that resolves after the selection moved on", async () => { + const user = userEvent.setup(); + let release: ((value: { text: string }) => void) | undefined; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((resolve) => { + release = resolve; + }), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /View SKILL.md/ })); + await user.click(screen.getByText("tampered")); + release?.({ text: SELF_TEXT }); + expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument(); + }); + + it("discards a failed SKILL.md read that resolves after the selection moved on", async () => { + const user = userEvent.setup(); + let fail: ((err: Error) => void) | undefined; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((_resolve, reject) => { + fail = reject; + }), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /View SKILL.md/ })); + await user.click(screen.getByText("tampered")); + fail?.(new Error("too late")); + expect(screen.queryByText("too late")).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index d325c1a66b..1f313cd013 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Badge, @@ -35,6 +35,13 @@ import { type SkillFileContents, } from "../../../utils/skillFileBytes"; +/** + * How many skill files are read at once by "Verify all". A conforming manifest + * may hold 512 entries, so this is what keeps one click from becoming 512 + * simultaneous `resources/read` calls. + */ +const VERIFY_CONCURRENCY = 4; + /** Per-file verification progress, keyed by the manifest entry's URI. */ type FileState = | { status: "pending" } @@ -235,16 +242,12 @@ export function SkillsScreen({ const [fileStates, setFileStates] = useState>({}); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(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); - }); + // Bumped every time the verdicts are invalidated. A read that was already in + // flight captures the value it started under and discards its result when + // this has moved on — otherwise a slow fetch for the previous selection (or + // the previous manifest) lands afterwards and writes a verdict for content + // nobody is looking at, or worse, one that was never checked. + const epoch = useRef(0); const filtered = useMemo(() => { const needle = search.trim().toLowerCase(); @@ -274,8 +277,37 @@ export function SkillsScreen({ [selected], ); + // What every verdict on screen is a verdict *about*: the selected skill AND + // the manifest it advertised. Keying invalidation on the URI alone would + // leave a green `verified` badge attached to a digest the Refresh replaced, + // so the UI would vouch for content it has never checked. A primitive string + // rather than the manifest object, because `useValueChange` compares with + // `Object.is` and a fresh array every render would loop. + const manifestKey = useMemo( + () => + [ + selectedSkillUri ?? "", + selected?.resources === DYNAMIC_RESOURCES ? "dynamic" : "", + ...manifest.map((r) => `${r.uri}|${r.digest ?? ""}|${r.size ?? ""}`), + ].join("\n"), + [manifest, selected, selectedSkillUri], + ); + + // 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. The epoch bump is a ref write, which + // is why it is done in the callback alongside the state resets rather than + // during the render body itself. + useValueChange(manifestKey, () => { + epoch.current += 1; + setFileStates({}); + setPreview(null); + setPreviewError(null); + }); + const verifyFile = useCallback( async (resource: SkillResource) => { + const started = epoch.current; setFileStates((prev) => ({ ...prev, [resource.uri]: { status: "pending" }, @@ -286,11 +318,13 @@ export function SkillsScreen({ resource, skillFileBytes(contents), ); + if (epoch.current !== started) return; setFileStates((prev) => ({ ...prev, [resource.uri]: { status: "done", verification }, })); } catch (err) { + if (epoch.current !== started) return; setFileStates((prev) => ({ ...prev, [resource.uri]: { @@ -304,22 +338,40 @@ export function SkillsScreen({ ); 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-file state), and this handler cannot be async, so the + // 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. - void Promise.all(manifest.map((resource) => verifyFile(resource))); + let next = 0; + const worker = async (): Promise => { + for (let i = next++; i < manifest.length; i = next++) { + await verifyFile(manifest[i]); + } + }; + const workers = Math.min(VERIFY_CONCURRENCY, manifest.length); + void Promise.all(Array.from({ length: workers }, () => worker())); }, [manifest, verifyFile]); const showSkillMd = useCallback(() => { if (!selected) return; + const started = epoch.current; // A click handler cannot await, and this chain terminates in its own - // `catch` that surfaces the message in the preview slot. + // `catch` that surfaces the message in the preview slot. Both arms are + // epoch-guarded: a read that resolves after the selection moved on would + // otherwise show one skill's SKILL.md under another's heading. void onReadSkillFile(selected.uri) .then((contents) => { + if (epoch.current !== started) return; setPreview(contents); setPreviewError(null); }) .catch((err: unknown) => { + if (epoch.current !== started) return; setPreview(null); setPreviewError(err instanceof Error ? err.message : String(err)); }); diff --git a/clients/web/src/components/views/InspectorView/types.ts b/clients/web/src/components/views/InspectorView/types.ts index 02a9ee8622..4950765c7b 100644 --- a/clients/web/src/components/views/InspectorView/types.ts +++ b/clients/web/src/components/views/InspectorView/types.ts @@ -311,7 +311,6 @@ export interface AppsPanelProps { onRefreshApps: () => void; } -/** 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[]; @@ -325,6 +324,7 @@ export interface SkillsPanelProps { onReadSkillFile: (uri: string) => Promise; } +/** The Tasks monitor: the task list, its progress map, and actions. */ export interface TasksPanelProps { tasks: Task[]; progressByTaskId?: Record; diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 46afa98d6b..ace626b190 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -22,16 +22,20 @@ import { 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/ref.md", - digest: `sha256:${"a".repeat(64)}`, - size: 10, - }, + { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 }, + { uri: "skill://demo/ref.md", digest: DIGEST, size: 10 }, ], ...overrides, }; @@ -188,12 +192,90 @@ describe("checkSkillConformance", () => { it("reports a manifest entry with no digest as unverifiable", () => { const issues = checkSkillConformance( - entry({ resources: [{ uri: "skill://demo/ref.md", size: 1 }] }), + 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].resourceUri).toBe("skill://demo/ref.md"); }); + 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 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 a warning", () => { + 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("warning"); + }); + it("reports a digest that is not sha256 + 64 lowercase hex", () => { for (const digest of [ "sha256:XYZ", @@ -202,20 +284,26 @@ describe("checkSkillConformance", () => { `sha256:${"a".repeat(63)}`, ]) { const issues = checkSkillConformance( - entry({ resources: [{ uri: "skill://demo/ref.md", digest }] }), + 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 manifest over the 512-entry limit", () => { - const resources = Array.from( - { length: SKILL_MAX_RESOURCE_ENTRIES + 1 }, - (_unused, i) => ({ + 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: `sha256:${"a".repeat(64)}`, - }), - ); + digest: DIGEST, + size: 1, + })), + ]; const issues = checkSkillConformance(entry({ resources })); expect(issues.map((i) => i.code)).toContain("resource-limit-exceeded"); }); @@ -224,10 +312,11 @@ describe("checkSkillConformance", () => { const issues = checkSkillConformance( entry({ resources: [ + { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 }, { uri: "skill://demo/big.bin", - digest: `sha256:${"a".repeat(64)}`, - size: SKILL_MAX_TOTAL_BYTES + 1, + digest: DIGEST, + size: SKILL_MAX_TOTAL_BYTES, }, ], }), @@ -240,8 +329,8 @@ describe("checkSkillConformance", () => { entry({ resources: [ { - uri: "skill://demo/big.bin", - digest: `sha256:${"a".repeat(64)}`, + uri: "skill://demo/SKILL.md", + digest: DIGEST, size: SKILL_MAX_TOTAL_BYTES, }, ], @@ -310,6 +399,40 @@ describe("verifySkillResource", () => { 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" }, diff --git a/clients/web/src/test/core/mcp/skillsSchemas.test.ts b/clients/web/src/test/core/mcp/skillsSchemas.test.ts index 1a19eb2a68..e0a09226d3 100644 --- a/clients/web/src/test/core/mcp/skillsSchemas.test.ts +++ b/clients/web/src/test/core/mcp/skillsSchemas.test.ts @@ -3,7 +3,6 @@ import { DYNAMIC_RESOURCES, GetSkillResultSchema, ListSkillsResultSchema, - ReadResourceDirectoryResultSchema, SKILLS_EXTENSION_KEY, SKILLS_GET_METHOD, SKILLS_LIST_METHOD, @@ -107,15 +106,3 @@ describe("GetSkillResultSchema", () => { expect(() => GetSkillResultSchema.parse({ nothing: true })).toThrow(); }); }); - -describe("ReadResourceDirectoryResultSchema", () => { - it("parses directory children including the directory mime type", () => { - const parsed = ReadResourceDirectoryResultSchema.parse({ - contents: [ - { uri: "skill://demo/sub", mimeType: "inode/directory" }, - { uri: "skill://demo/ref.md", mimeType: "text/markdown", size: 3 }, - ], - }); - expect(parsed.contents).toHaveLength(2); - }); -}); diff --git a/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts index 79f0035144..7a9ecfda35 100644 --- a/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts @@ -3,6 +3,8 @@ 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 { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; @@ -105,6 +107,59 @@ describe("ManagedSkillsState", () => { 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("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"); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 763beac5c5..ca59952d9f 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -5555,7 +5555,11 @@ export class InspectorClient extends InspectorClientEventTarget { const effectiveMeta = this.mergeMeta(metadata); const params: Record = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), - ...(cursor ? { cursor } : {}), + // `!== 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 } : {}), }; const response = await this.invokeMcpClient( () => diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index eab9770df2..0a1aeead9d 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -22,6 +22,16 @@ * 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"; @@ -115,6 +125,10 @@ export type SkillIssueCode = | "name-path-mismatch" | "missing-digest" | "malformed-digest" + | "missing-size" + | "duplicate-resource" + | "resource-outside-skill-root" + | "manifest-missing-self" | "resource-limit-exceeded" | "size-limit-exceeded"; @@ -206,7 +220,48 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { }); } + // 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. + if (!entry.resources.some((resource) => resource.uri === entry.uri)) { + 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. Left + // `undefined` for a malformed entry URI — there is no root to measure + // against, and `malformed-uri` already reports that. + const root = entry.uri.endsWith(SKILL_FILE_SUFFIX) + ? `${entry.uri.slice(0, -SKILL_FILE_SUFFIX.length)}/` + : undefined; + for (const resource of entry.resources) { + if (seenUris.has(resource.uri)) { + issues.push({ + code: "duplicate-resource", + severity: "error", + message: + "Manifest lists this URI more than once; entries must be unique.", + resourceUri: resource.uri, + }); + } + seenUris.add(resource.uri); + if (root !== undefined && !resource.uri.startsWith(root)) { + issues.push({ + code: "resource-outside-skill-root", + severity: "error", + message: `Manifest entry is outside the skill root "${root}".`, + resourceUri: resource.uri, + }); + } if (resource.digest === undefined) { issues.push({ code: "missing-digest", @@ -222,6 +277,18 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { resourceUri: resource.uri, }); } + if (resource.size === undefined) { + // A warning, not an error: an absent size costs the length cross-check in + // `verifySkillResource` and silently understates the 16 MiB total, but + // the digest still verifies the bytes. + issues.push({ + code: "missing-size", + severity: "warning", + message: + "Manifest entry declares no size, so it is excluded from the 16 MiB total and its length cannot be cross-checked.", + resourceUri: resource.uri, + }); + } } return issues; @@ -250,6 +317,10 @@ export interface SkillVerification { 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; } @@ -271,13 +342,16 @@ function toHex(bytes: Uint8Array): string { * context, so `subtle` is present there too. */ export async function sha256Digest(bytes: Uint8Array): Promise { - // `BufferSource` wants a plain ArrayBuffer; a Uint8Array over a SharedArrayBuffer - // (or a view into a larger buffer) would otherwise hash the wrong range. - const buffer = bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer; - const hash = await crypto.subtle.digest("SHA-256", buffer); + // 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 crypto.subtle.digest("SHA-256", copy.buffer); return `sha256:${toHex(new Uint8Array(hash))}`; } @@ -307,11 +381,30 @@ export function base64ToBytes(blob: string): Uint8Array { * 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 { @@ -332,5 +425,8 @@ export async function verifySkillResource( 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 index 8ee49cbcb2..bf2d36a5b9 100644 --- a/core/mcp/skillsSchemas.ts +++ b/core/mcp/skillsSchemas.ts @@ -142,23 +142,13 @@ export const GetSkillResultSchema = z export type GetSkillResult = SkillEntry; /** - * `resources/directory/read` result — the direct (non-recursive) children of a - * directory resource. Present for completeness of the wire surface; the - * Inspector surfaces the `directoryRead` sub-flag today and calls the method in - * phase 3. + * ⚠️ **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. */ -export const ReadResourceDirectoryResultSchema = z.looseObject({ - contents: z.array( - z.looseObject({ - uri: z.string(), - name: z.string().optional(), - mimeType: z.string().optional(), - size: z.number().optional(), - }), - ), - nextCursor: z.string().optional(), -}); - -export type ReadResourceDirectoryResult = z.infer< - typeof ReadResourceDirectoryResultSchema ->; diff --git a/core/mcp/state/managedSkillsState.ts b/core/mcp/state/managedSkillsState.ts index b28a784dc9..d45aa11585 100644 --- a/core/mcp/state/managedSkillsState.ts +++ b/core/mcp/state/managedSkillsState.ts @@ -21,6 +21,7 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; import type { SkillEntry } from "../skillsSchemas.js"; +import { LIST_MAX_PAGES } from "../listSalvage.js"; import { isTerminalStatus } from "../types.js"; import type { RequestMetadata } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; @@ -46,6 +47,16 @@ export interface ManagedSkillsStateEventMap { 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; @@ -55,6 +66,9 @@ export class ManagedSkillsState extends TypedEventTarget this.generation === generation; try { const collected: SkillEntry[] = []; const seen = new Set(); @@ -143,9 +167,20 @@ export class ManagedSkillsState extends TypedEventTarget= SKILLS_MAX_PAGES) { + throw new Error(SKILLS_PAGE_LIMIT_MESSAGE); + } if (seen.has(page.nextCursor)) { throw new Error(REPEATED_CURSOR_MESSAGE); } @@ -156,7 +191,12 @@ export class ManagedSkillsState extends TypedEventTarget Date: Fri, 4 Sep 2026 21:45:49 -0400 Subject: [PATCH 03/18] fix: address Copilot review round 2 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkillsScreen: key verdicts and React elements by manifest ROW INDEX, not URI. The checker deliberately tolerates a duplicated URI so it can report `duplicate-resource`; a URI key collided those rows into one verdict, so verifying either updated both and "Verify all" raced two different digest/size declarations into the same slot. - SkillsScreen: hold the invalidation generation in React state keyed by the manifest, not a ref bumped during render. `useValueChange` runs its callback during render and requires setState-only purity — an abandoned render left the ref incremented and silently discarded a live verification. - SkillsScreen: title a size disagreement "Size mismatch" and render its reason. `verifySkillResource` catches it before hashing, so the alert was showing "actual undefined" under "Digest mismatch". - SkillsScreen: pass `contents` to ContentViewer so a base64 SKILL.md renders. The text-block form substituted "" and painted a blank preview for a file verification had just read correctly. - skills.ts: add `malformed-size` for a size that is not a non-negative safe integer, and exclude such values from the 16 MiB total. A negative one could pull the sum back under the limit and hide a violation. - managedSkillsState: make the overlap guard per-session instead of a boolean. A disconnect during an in-flight walk left it set, so the reconnect's own load no-oped and was never retried — permanently, if the stale request never settled. A stale `finally` can no longer clear the live session's guard either. - skillsSchemas: require the `{ skill }` envelope for `skills/get`. The accepted SEP settles it, and normalizing an inline entry would let a non-conforming response past the one place that could report it. - #2248 and the PR description: corrected — they said `ReadResourceDirectoryResultSchema` was already declared, which round 1 removed. #2248 now owns defining it, and records the frontmatter cross-check gap too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 76 ++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 235 +++++++++++------- .../core/mcp/inspectorClient-skills.test.ts | 12 +- clients/web/src/test/core/mcp/skills.test.ts | 47 ++++ .../src/test/core/mcp/skillsSchemas.test.ts | 21 +- .../core/mcp/state/managedSkillsState.test.ts | 46 ++++ core/mcp/inspectorClient.ts | 11 +- core/mcp/skills.ts | 33 ++- core/mcp/skillsSchemas.ts | 41 ++- core/mcp/state/managedSkillsState.ts | 22 +- test-servers/src/skills.ts | 5 +- 11 files changed, 406 insertions(+), 143 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 9ee2532c21..1356b156b7 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -259,6 +259,82 @@ describe("SkillsScreen", () => { expect(await screen.findAllByText("plain string")).toHaveLength(2); }); + it("titles a size disagreement a size mismatch, not a digest one", async () => { + // `verifySkillResource` catches a size disagreement BEFORE hashing, so + // there is no `actualDigest` — labelling it "Digest mismatch" would render + // "actual undefined" and hide the real failure. + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + expect(await screen.findByText("Size mismatch")).toBeInTheDocument(); + expect(screen.queryByText("Digest mismatch")).not.toBeInTheDocument(); + // The alert states both lengths; the manifest row also shows the declared + // one, hence `getAllByText`. + expect(screen.getAllByText(/9999 bytes/).length).toBeGreaterThan(0); + }); + + it("gives duplicated manifest URIs their own row and their own verdict", async () => { + // The conformance checker reports `duplicate-resource` rather than + // collapsing the rows, so the verdicts must not collapse either: the two + // entries declare different digests and only one of them is right. + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + // One row verifies and the other does not — a shared key would have made + // both show whichever landed last. + expect(await screen.findByText("verified")).toBeInTheDocument(); + expect(screen.getByText("mismatch")).toBeInTheDocument(); + }); + + it("renders a base64 SKILL.md preview instead of a blank one", async () => { + // `onReadSkillFile` supports blob content, and verification reads it + // correctly; dropping it in the preview would paint an empty box for a + // file the screen had just checked. + const user = userEvent.setup(); + const onReadSkillFile = vi.fn().mockResolvedValue({ + blob: btoa("# from a blob\n"), + mimeType: "text/markdown", + }); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /View SKILL.md/ })); + const preview = await screen.findByTestId("skill-md-preview"); + expect(preview).toHaveTextContent("from a blob"); + }); + it("shows the SKILL.md preview on demand", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 1f313cd013..faab7e7b5d 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Alert, Badge, @@ -42,12 +42,37 @@ import { */ const VERIFY_CONCURRENCY = 4; -/** Per-file verification progress, keyed by the manifest entry's URI. */ +/** Per-row verification progress. */ type FileState = | { status: "pending" } | { status: "done"; verification: SkillVerification } | { status: "error"; message: string }; +/** + * Verification verdicts plus the manifest they belong to. Rows are keyed by + * their **index**, not their URI: the conformance checker deliberately tolerates + * a duplicated URI so it can report `duplicate-resource`, and a URI key would + * collide those two rows into one verdict. + */ +interface VerificationState { + /** + * The manifest these verdicts belong to, or `null` before anything has been + * verified. `useValueChange` deliberately does not fire on the first render, + * so `null` stands in for "the initial manifest, not yet adopted" — the first + * write claims it. Once set it is only ever replaced by an invalidation, so a + * stale continuation can never be mistaken for an initial one. + */ + key: string | null; + files: Record; +} + +/** The SKILL.md preview, plus the manifest it belongs to (`null` as above). */ +interface PreviewState { + key: string | null; + contents?: SkillFileContents; + message?: string; +} + export interface SkillsScreenProps { skills: SkillEntry[]; /** Pages the last `skills/list` walk took; shown so pagination is visible. */ @@ -239,15 +264,19 @@ export function SkillsScreen({ onReadSkillFile, }: SkillsScreenProps) { const { selectedSkillUri, search } = ui; - const [fileStates, setFileStates] = useState>({}); - const [preview, setPreview] = useState(null); - const [previewError, setPreviewError] = useState(null); - // Bumped every time the verdicts are invalidated. A read that was already in - // flight captures the value it started under and discards its result when - // this has moved on — otherwise a slow fetch for the previous selection (or - // the previous manifest) lands afterwards and writes a verdict for content - // nobody is looking at, or worse, one that was never checked. - const epoch = useRef(0); + // 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 filtered = useMemo(() => { const needle = search.trim().toLowerCase(); @@ -295,43 +324,45 @@ export function SkillsScreen({ // 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. The epoch bump is a ref write, which - // is why it is done in the callback alongside the state resets rather than - // during the render body itself. - useValueChange(manifestKey, () => { - epoch.current += 1; - setFileStates({}); - setPreview(null); - setPreviewError(null); + // 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 }); }); - const verifyFile = useCallback( - async (resource: SkillResource) => { - const started = epoch.current; - setFileStates((prev) => ({ - ...prev, - [resource.uri]: { status: "pending" }, - })); + 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) => { + 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 : {}; + return { key, files: { ...files, [index]: state } }; + }); + write({ status: "pending" }); try { const contents = await onReadSkillFile(resource.uri); - const verification = await verifySkillResource( + const result = await verifySkillResource( resource, skillFileBytes(contents), ); - if (epoch.current !== started) return; - setFileStates((prev) => ({ - ...prev, - [resource.uri]: { status: "done", verification }, - })); + write({ status: "done", verification: result }); } catch (err) { - if (epoch.current !== started) return; - setFileStates((prev) => ({ - ...prev, - [resource.uri]: { - status: "error", - message: err instanceof Error ? err.message : String(err), - }, - })); + write({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); } }, [onReadSkillFile], @@ -348,34 +379,42 @@ export function SkillsScreen({ // 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 verifyFile(manifest[i]); + await verifyRow(i, manifest[i], key); } }; const workers = Math.min(VERIFY_CONCURRENCY, manifest.length); void Promise.all(Array.from({ length: workers }, () => worker())); - }, [manifest, verifyFile]); + }, [manifest, manifestKey, verifyRow]); const showSkillMd = useCallback(() => { if (!selected) return; - const started = epoch.current; + const key = manifestKey; // A click handler cannot await, and this chain terminates in its own - // `catch` that surfaces the message in the preview slot. Both arms are - // epoch-guarded: a read that resolves after the selection moved on would - // otherwise show one skill's SKILL.md under another's heading. + // `catch` that surfaces the message in the preview slot. Both arms compare + // the manifest key they started under: a read that resolves after the + // selection moved on would otherwise show one skill's SKILL.md under + // another's heading. void onReadSkillFile(selected.uri) .then((contents) => { - if (epoch.current !== started) return; - setPreview(contents); - setPreviewError(null); + setPreviewState((prev) => + prev.key !== null && prev.key !== key ? prev : { key, contents }, + ); }) .catch((err: unknown) => { - if (epoch.current !== started) return; - setPreview(null); - setPreviewError(err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + setPreviewState((prev) => + prev.key !== null && prev.key !== key ? prev : { key, message }, + ); }); - }, [onReadSkillFile, selected]); + }, [manifestKey, onReadSkillFile, selected]); + + 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; @@ -527,8 +566,8 @@ export function SkillsScreen({ - {manifest.map((resource) => { - const state = fileStates[resource.uri]; + {manifest.map((resource, index) => { + const state = fileStates[index]; const color = state?.status === "done" ? verificationColor(state.verification.status) @@ -536,7 +575,10 @@ export function SkillsScreen({ ? "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)} @@ -549,7 +591,9 @@ export function SkillsScreen({ // A click handler cannot await, and // `verifyFile` owns its own failures — it // records them as this row's state. - onClick={() => void verifyFile(resource)} + onClick={() => + void verifyRow(index, resource, manifestKey) + } > Verify @@ -561,35 +605,45 @@ export function SkillsScreen({ )} - {manifest.map((resource) => { - const state = fileStates[resource.uri]; + {manifest.map((resource, index) => { + const state = fileStates[index]; if (state?.status === "done") { - const { verification } = state; - if (verification.status === "mismatch") { - return ( - - - {resource.uri} - - expected {verification.expectedDigest} - - - actual {verification.actualDigest} - - - - ); - } - return null; + 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 ( @@ -612,9 +666,24 @@ export function SkillsScreen({ {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. */} diff --git a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts index d60b16db24..1ad5250cde 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts @@ -115,18 +115,20 @@ describe("InspectorClient skills methods (#2234)", () => { expect(request.mock.calls[0][0].params.uri).toBe("skill://demo/SKILL.md"); }); - it("normalizes the enveloped skills/get result to the entry", async () => { + 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("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. + 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); - expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY); + await expect( + client.getSkill("skill://demo/SKILL.md"), + ).rejects.toBeDefined(); }); it("rejects a skills/list result that is not a skills page", async () => { diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index ace626b190..06e933d0ac 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -295,6 +295,39 @@ describe("checkSkillConformance", () => { } }); + 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 }, + ], + }), + ); + expect(issues.map((i) => i.code)).toContain("size-limit-exceeded"); + }); + it("reports a manifest over the 512-entry limit", () => { const resources = [ { uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 }, @@ -350,6 +383,20 @@ describe("totalSkillBytes", () => { ]), ).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", () => { diff --git a/clients/web/src/test/core/mcp/skillsSchemas.test.ts b/clients/web/src/test/core/mcp/skillsSchemas.test.ts index e0a09226d3..724c98cc38 100644 --- a/clients/web/src/test/core/mcp/skillsSchemas.test.ts +++ b/clients/web/src/test/core/mcp/skillsSchemas.test.ts @@ -7,7 +7,6 @@ import { SKILLS_GET_METHOD, SKILLS_LIST_METHOD, SkillEntrySchema, - normalizeGetSkillResult, } from "@inspector/core/mcp/skillsSchemas"; const ENTRY = { @@ -89,20 +88,24 @@ describe("ListSkillsResultSchema", () => { }); describe("GetSkillResultSchema", () => { - it("normalizes the enveloped form to the entry", () => { + it("unwraps the envelope to the entry", () => { expect(GetSkillResultSchema.parse({ skill: ENTRY })).toEqual(ENTRY); }); - it("normalizes the inline form to the entry", () => { - expect(GetSkillResultSchema.parse(ENTRY)).toEqual(ENTRY); - }); - - it("normalizeGetSkillResult accepts either shape directly", () => { - expect(normalizeGetSkillResult({ skill: ENTRY })).toEqual(ENTRY); - expect(normalizeGetSkillResult(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 index 7a9ecfda35..2cda153700 100644 --- a/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts @@ -142,6 +142,52 @@ describe("ManagedSkillsState", () => { 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; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index ca59952d9f..07a9cc6df7 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -5577,9 +5577,10 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * One skill entry by URI (`skills/get`, SEP-2640). The SEP settles the entry - * shape but not the envelope around it, so the result is normalized through - * {@link normalizeGetSkillResult} rather than assuming one form. + * 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) { @@ -5590,8 +5591,8 @@ export class InspectorClient extends InspectorClientEventTarget { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - // `GetSkillResultSchema` normalizes both accepted envelopes to the entry, - // so there is nothing to unwrap here. + // `GetSkillResultSchema` unwraps the envelope, so there is nothing to + // unwrap here. return this.invokeMcpClient( () => this.client!.request( diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 0a1aeead9d..f7c776d523 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -126,6 +126,7 @@ export type SkillIssueCode = | "missing-digest" | "malformed-digest" | "missing-size" + | "malformed-size" | "duplicate-resource" | "resource-outside-skill-root" | "manifest-missing-self" @@ -288,6 +289,13 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { "Manifest entry declares no size, so it 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, + }); } } @@ -295,13 +303,28 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { } /** - * Sum of the manifest's declared `size` fields. An entry that omits `size` - * 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. + * 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 + (r.size ?? 0), 0); + return resources.reduce( + (sum, r) => sum + (isUsableSize(r.size) ? r.size : 0), + 0, + ); } /** Outcome of comparing a fetched file against its advertised digest. */ diff --git a/core/mcp/skillsSchemas.ts b/core/mcp/skillsSchemas.ts index bf2d36a5b9..1441b3ea49 100644 --- a/core/mcp/skillsSchemas.ts +++ b/core/mcp/skillsSchemas.ts @@ -105,39 +105,26 @@ export const ListSkillsResultSchema = z.looseObject({ export type ListSkillsResult = z.infer; /** - * The envelope form of a `skills/get` result: the entry wrapped under `skill`. - */ -const GetSkillEnvelopeSchema = z.looseObject({ skill: SkillEntrySchema }); - -/** - * Collapse either accepted `skills/get` shape to the entry it carries. + * The `skills/get` result envelope: the entry wrapped under `skill`. * - * Written as a parse rather than an `in` check because both accepted shapes are - * loose objects — they carry an index signature, so `"skill" in result` narrows - * nothing and would leave the extracted value `unknown`. Parsing the envelope - * is what proves its `skill` really is an entry, with no cast anywhere. + * 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. */ -export function normalizeGetSkillResult(result: unknown): SkillEntry { - const enveloped = GetSkillEnvelopeSchema.safeParse(result); - return enveloped.success - ? enveloped.data.skill - : SkillEntrySchema.parse(result); -} +const GetSkillEnvelopeSchema = z.looseObject({ skill: SkillEntrySchema }); /** - * `skills/get` result, normalized to the entry. + * `skills/get` result, unwrapped to the entry it carries. * - * ⚠️ The SEP settles the *entry* shape but not the envelope this result wraps it - * in, so both plausible forms are accepted: `{ skill: }` and the entry - * returned inline at the top level. Being permissive here costs nothing (the - * two are structurally distinguishable — an inline entry carries `uri` and - * `frontmatter`, the envelope carries neither) and spares a server author a - * failure whose cause is a spec ambiguity rather than their code. The transform - * means every caller receives the entry and none of them branches. + * 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 = z - .union([GetSkillEnvelopeSchema, SkillEntrySchema]) - .transform(normalizeGetSkillResult); +export const GetSkillResultSchema = GetSkillEnvelopeSchema.transform( + (result) => result.skill, +); export type GetSkillResult = SkillEntry; diff --git a/core/mcp/state/managedSkillsState.ts b/core/mcp/state/managedSkillsState.ts index d45aa11585..808663332c 100644 --- a/core/mcp/state/managedSkillsState.ts +++ b/core/mcp/state/managedSkillsState.ts @@ -63,9 +63,9 @@ export class ManagedSkillsState extends TypedEventTarget void) | null = null; - // Overlap guard: a walk in flight makes a second one a no-op so a slow older - // walk can't clobber a newer list via last-write-wins. - private running = false; + // 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; @@ -151,8 +151,6 @@ export class ManagedSkillsState extends TypedEventTarget 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(); @@ -199,7 +205,11 @@ export class ManagedSkillsState extends TypedEventTarget { (candidate) => `skill://${candidate.path}/SKILL.md` === uri, ); if (!skill) throw new Error(`Unknown skill uri: ${uri}`); - // The envelope form (`{ skill }`) is served deliberately: the SEP settles the - // entry shape but not this wrapper, and the Inspector accepts both — serving - // the wrapped one keeps that tolerance exercised. + // The envelope (`{ skill }`) is the conforming shape, and the only one the + // Inspector accepts — see `GetSkillResultSchema`. return { skill: toEntry(skill) }; } From 6a08c77c9247b03792ba8c1692eb3a697152de20 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 22:06:36 -0400 Subject: [PATCH 04/18] fix: address Copilot review round 3 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills.ts: decide root containment on NORMALIZED URIs. A raw prefix check passed `skill://acme/billing/refunds/../other.md`, which starts with the advertised root but resolves outside it — a traversal the conformance report was reporting as clean. - skills.ts: `normalizeSkillUri` also rejects a relative string, so `demo/SKILL.md` is `malformed-uri` rather than a skill path, and an opaque-path URI (`skill:demo/..`), which the parser leaves un-normalized and on which containment cannot be decided. - skills.ts: `missing-description` is an error. SEP-2640 requires it, so an absent one must not read as "0 errors" in the conformance summary. - managedSkillsState: call `markResponseRejected` for a decode rejection, as every managed list does. An invalid `skills/list` result was showing in the Protocol tab as a clean success. - SkillsScreen: per-row attempt token. The manifest key cannot tell two verifications of the SAME row apart, so a double click (or a row button pressed during "Verify all") let an older read finish last and overwrite the newer verdict. - SkillsScreen: disable "Verify all" while a batch runs. The concurrency cap is per invocation, so repeated clicks stacked pools — 4, then 8, then 12. - SkillsScreen: include the finding index in each issue alert's key. Three identical URIs produce two `duplicate-resource` findings with the same code and URI, and React was free to drop the extras — hiding findings in exactly the malformed input this view exists to inspect. - test-servers/skills.ts: register `skills/list` and `skills/get` through the PUBLIC `setRequestHandler` with explicit param schemas. The private `_requestHandlers` map is now reached only to wrap `resources/read`, which has to chain onto the SDK's handler rather than replace it — the one thing the public API cannot express, and the comment now says so instead of citing the tasks fixture. - test-servers/skills.ts: raise `-32602` for an unknown `skills/get` URI. A plain Error mapped to a generic server failure, making the fixture non-conforming outside its three documented bad cases. - clients/web/README.md: the paragraph named four contracts while still saying the smoke "drives all three". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- clients/web/README.md | 4 +- .../SkillsScreen/SkillsScreen.test.tsx | 95 +++++++++++++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 54 +++++++++-- clients/web/src/test/core/mcp/skills.test.ts | 72 +++++++++++++- .../core/mcp/state/managedSkillsState.test.ts | 31 ++++++ core/mcp/skills.ts | 79 +++++++++++---- core/mcp/state/managedSkillsState.ts | 19 +++- test-servers/src/skills.ts | 81 +++++++++++----- 8 files changed, 376 insertions(+), 59 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 8a427970d9..619865abfb 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -96,7 +96,9 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage 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)). +drives the first three of those four against `test-servers/configs/web-tabs-http.json` +([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)); Skills +publishes the same contract but is not smoked yet ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234)). Treat them as a public contract, for the same reason as the Apps ones below: | Attribute | Where | Meaning | diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 1356b156b7..e3cd52db72 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -6,6 +6,7 @@ import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills"; import { renderWithMantine, screen, + waitFor, within, } from "../../../test/renderWithMantine"; import { @@ -335,6 +336,100 @@ describe("SkillsScreen", () => { expect(preview).toHaveTextContent("from a blob"); }); + it("keeps the newest verdict when two verifications of one row overlap", async () => { + // Same row, same manifest — so the manifest key cannot tell these apart. + // Without a per-row attempt token the older read finishing last would + // overwrite the newer verdict and leave the UI reporting stale bytes. + const user = userEvent.setup(); + const resolvers: ((value: { text: string }) => void)[] = []; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((resolve) => { + resolvers.push(resolve); + }), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + const manifest = screen.getByTestId("skill-manifest"); + const rowVerify = within(manifest).getAllByRole("button", { + name: "Verify", + })[0]; + await user.click(rowVerify); + await user.click(rowVerify); + expect(resolvers).toHaveLength(2); + + // The SECOND read answers first with the matching bytes, then the first + // read answers with bytes that would verify as a mismatch. + resolvers[1]({ text: SELF_TEXT }); + expect(await screen.findByText("verified")).toBeInTheDocument(); + resolvers[0]({ text: "stale bytes\n" }); + // Still the newer verdict. + expect(await screen.findByText("verified")).toBeInTheDocument(); + expect(screen.queryByText("mismatch")).not.toBeInTheDocument(); + }); + + it("disables Verify all while a batch is running", async () => { + // The concurrency cap is per invocation, so a second click would start a + // second pool of four rather than reusing the first. + const user = userEvent.setup(); + const pending: ((value: { text: string }) => void)[] = []; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((resolve) => { + pending.push(resolve); + }), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + const verifyAll = screen.getByRole("button", { name: /Verify all/ }); + await user.click(verifyAll); + expect(verifyAll).toBeDisabled(); + // Release every read the batch started; the button frees only once the + // whole batch settles, not once the first file does. + await waitFor(() => expect(pending.length).toBeGreaterThan(0)); + for (const resolve of pending) resolve({ text: SELF_TEXT }); + await waitFor(() => expect(verifyAll).not.toBeDisabled()); + }); + + it("renders every duplicate finding rather than collapsing them", async () => { + // Three identical URIs produce two `duplicate-resource` findings with the + // same code and URI. A key built from those alone would make React drop + // the extras — hiding findings in exactly the malformed input this view is + // for. + const user = userEvent.setup(); + const dup = { + uri: "skill://data-analysis/SKILL.md", + digest: SELF_DIGEST, + size: textToBytes(SELF_TEXT).byteLength, + }; + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + const issues = screen.getByTestId("skill-issues"); + expect(within(issues).getAllByText("duplicate-resource")).toHaveLength(2); + }); + it("shows the SKILL.md preview on demand", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index faab7e7b5d..6dc6fa9a12 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Badge, @@ -42,11 +42,18 @@ import { */ const VERIFY_CONCURRENCY = 4; -/** Per-row verification progress. */ -type FileState = +/** + * Per-row verification progress. `attempt` is the click that produced it: two + * verifications of the SAME row in the SAME manifest (a double click, or a row + * button pressed while "Verify all" is running) are not distinguished by the + * manifest key, so without it an older read finishing last would overwrite the + * newer verdict and leave the UI reporting bytes it no longer fetched. + */ +type FileState = { attempt: number } & ( | { status: "pending" } | { status: "done"; verification: SkillVerification } - | { status: "error"; message: string }; + | { status: "error"; message: string } +); /** * Verification verdicts plus the manifest they belong to. Rows are keyed by @@ -277,6 +284,12 @@ export function SkillsScreen({ files: {}, }); const [previewState, setPreviewState] = useState({ key: null }); + // True while a "Verify all" batch is in flight; disables the button so a + // second click cannot stack another pool of workers on top. + const [batchRunning, setBatchRunning] = useState(false); + // Monotonic per-row attempt token. 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(); @@ -342,24 +355,32 @@ export function SkillsScreen({ */ 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({ status: "pending" }); + write({ attempt, status: "pending" }); try { const contents = await onReadSkillFile(resource.uri); const result = await verifySkillResource( resource, skillFileBytes(contents), ); - write({ status: "done", verification: result }); + write({ attempt, status: "done", verification: result }); } catch (err) { write({ + attempt, status: "error", message: err instanceof Error ? err.message : String(err), }); @@ -386,7 +407,13 @@ export function SkillsScreen({ } }; const workers = Math.min(VERIFY_CONCURRENCY, manifest.length); - void Promise.all(Array.from({ length: workers }, () => worker())); + setBatchRunning(true); + // 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( + () => setBatchRunning(false), + ); }, [manifest, manifestKey, verifyRow]); const showSkillMd = useCallback(() => { @@ -509,9 +536,15 @@ export function SkillsScreen({ ) : ( - {issues.map((issue) => ( + {issues.map((issue, index) => ( @@ -542,7 +575,8 @@ export function SkillsScreen({ Verify all diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 06e933d0ac..f489c7feed 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -9,6 +9,7 @@ import { checkSkillConformance, getSkillsExtension, isSkillsExtensionSupported, + normalizeSkillUri, sha256Digest, skillDisplayName, skillNameFromUri, @@ -112,11 +113,47 @@ describe("skillNameFromUri", () => { 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("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("leaves an already-normal URI alone", () => { + expect(normalizeSkillUri("skill://demo/SKILL.md")).toBe( + "skill://demo/SKILL.md", + ); + }); +}); + describe("skillDisplayName", () => { it("prefers the declared frontmatter name", () => { expect(skillDisplayName(entry())).toBe("demo"); @@ -148,12 +185,14 @@ describe("checkSkillConformance", () => { expect(issues[0].severity).toBe("error"); }); - it("reports a missing description as a warning", () => { + 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("warning"); + expect(issues[0].severity).toBe("error"); }); it("reports a URI that does not carry a skill path", () => { @@ -235,6 +274,35 @@ describe("checkSkillConformance", () => { 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("reports a manifest entry outside the skill root", () => { const issues = checkSkillConformance( entry({ diff --git a/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts index 2cda153700..df22fae75d 100644 --- a/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedSkillsState.test.ts @@ -6,6 +6,7 @@ import { 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 { @@ -216,6 +217,36 @@ describe("ManagedSkillsState", () => { 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"); diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index f7c776d523..586b110ddd 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -91,16 +91,49 @@ export function isSkillsExtensionSupported( 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. + */ +export function normalizeSkillUri(uri: string): string | undefined { + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + return undefined; + } + return parsed.pathname.startsWith("/") ? parsed.href : undefined; +} + /** * 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 { - if (!uri.endsWith(SKILL_FILE_SUFFIX)) return undefined; - const path = uri.slice(0, -SKILL_FILE_SUFFIX.length); + 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; } @@ -171,10 +204,12 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { }); } if (!entry.frontmatter.description?.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: "warning", - message: "frontmatter.description is missing or empty.", + severity: "error", + message: "frontmatter.description is required but missing or empty.", }); } if (uriName === undefined) { @@ -237,12 +272,17 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { 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. Left - // `undefined` for a malformed entry URI — there is no root to measure - // against, and `malformed-uri` already reports that. - const root = entry.uri.endsWith(SKILL_FILE_SUFFIX) - ? `${entry.uri.slice(0, -SKILL_FILE_SUFFIX.length)}/` - : undefined; + // 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) { if (seenUris.has(resource.uri)) { @@ -255,13 +295,18 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { }); } seenUris.add(resource.uri); - if (root !== undefined && !resource.uri.startsWith(root)) { - issues.push({ - code: "resource-outside-skill-root", - severity: "error", - message: `Manifest entry is outside the skill root "${root}".`, - resourceUri: resource.uri, - }); + 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) { issues.push({ diff --git a/core/mcp/state/managedSkillsState.ts b/core/mcp/state/managedSkillsState.ts index 808663332c..0429e821d5 100644 --- a/core/mcp/state/managedSkillsState.ts +++ b/core/mcp/state/managedSkillsState.ts @@ -7,8 +7,7 @@ * 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 + * 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. @@ -20,8 +19,8 @@ */ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; -import type { SkillEntry } from "../skillsSchemas.js"; -import { LIST_MAX_PAGES } from "../listSalvage.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"; @@ -201,7 +200,17 @@ export class ManagedSkillsState extends TypedEventTarget { const skill = FIXTURE_SKILLS.find( (candidate) => `skill://${candidate.path}/SKILL.md` === uri, ); - if (!skill) throw new Error(`Unknown skill uri: ${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 envelope (`{ skill }`) is the conforming shape, and the only one the // Inspector accepts — see `GetSkillResultSchema`. return { skill: toEntry(skill) }; @@ -232,7 +254,11 @@ export function readSkillFile( }; } -/** The private handler registry the SDK dispatches through. */ +/** + * 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, @@ -241,38 +267,45 @@ interface RawHandlerHost { } interface UriRequest { - params?: { uri?: string; cursor?: string }; + params?: { uri?: string }; } +const ListSkillsParamsSchema = z.object({ cursor: z.string().optional() }); +const GetSkillParamsSchema = z.object({ uri: z.string() }); + /** * Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read` * onto an `McpServer`. - * - * `resources/read` is wrapped rather than replaced: 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. */ export function wireSkillsHandlers(mcpServer: McpServer): void { - const registry = (mcpServer.server as unknown as RawHandlerHost) - ._requestHandlers; + const lowLevel = mcpServer.server; - registry.set("skills/list", async (request) => { - const req = request as UriRequest; - return listSkillsPage(req.params?.cursor); - }); + lowLevel.setRequestHandler( + "skills/list", + { params: ListSkillsParamsSchema }, + async (params) => listSkillsPage(params.cursor), + ); - registry.set("skills/get", async (request) => { - const req = request as UriRequest; - return getSkillEntry(req.params?.uri ?? ""); - }); + lowLevel.setRequestHandler( + "skills/get", + { params: GetSkillParamsSchema }, + 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 Error(`Unknown resource: ${req.params?.uri}`); + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Unknown resource: ${req.params?.uri}`, + ); } return sdkResourcesRead(request, ctx); }); From 1cb05ca5735e4a5e7adf91ba467388ad035ecad8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 22:38:03 -0400 Subject: [PATCH 05/18] fix: address Copilot review round 4 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkillsScreen: actually call `skills/get`. The client method and its tests existed but no production caller invoked it, so #2234's acceptance criterion ("skills/get retrieves a single entry") was unmet and a server author's required handler could not be exercised. The detail pane now fetches the selected URI on demand and reports whether the fetched entry AGREES with the one skills/list advertised — both describe the same skill, so a disagreement is a server bug only a side-by-side fetch shows. - core/mcp/sha256.ts: a dependency-free SHA-256, used when `crypto.subtle` is absent. `SubtleCrypto` needs a secure context, and this app is documented as servable over plain HTTP on a LAN IP — where every skill verification threw and the UI reported a read failure for files it had fetched fine. Checked against the FIPS 180-4 vectors and differentially against WebCrypto. - skills.ts: require the `skill:` scheme in `normalizeSkillUri`. Checking only that a URI was hierarchical let `https://demo/SKILL.md` pass the name and root checks — a manifest pointing anywhere on the web, reported as conforming. - skills.ts: `missing-digest` and `missing-size` are errors. Both are required fields, and an omitted `size` is what lets a server slip past the 16 MiB pre-fetch limit while the UI reports zero errors. `warning` is now reserved for what is legal yet unverifiable — `"dynamic"`. - SkillsScreen: key the "Verify all" batch guard to the manifest. A global flag left a newly selected skill's button disabled until the previous skill's reads settled — forever, if one hung. Not changed, deliberately: whether a modern-era `skills/list` result MUST carry the SEP-2549 caching attributes. #2234's analysis records it as open and the review asserts the opposite; neither reading was checked against the normative text. Leaving the schema permissive accepts a server that omits them, while tightening on a wrong reading would reject conforming responses — the more expensive direction. `skillsSchemas.ts` states this and #2248 settles it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- clients/web/src/App.tsx | 2 + .../SkillsScreen/SkillsScreen.test.tsx | 98 +++++++++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 116 +++++++++++++++-- .../InspectorView/InspectorView.stories.tsx | 5 + .../InspectorView/InspectorView.test.tsx | 1 + .../views/InspectorView/InspectorView.tsx | 2 + .../components/views/InspectorView/types.ts | 2 + .../web/src/hooks/useServerCommands.test.tsx | 22 ++++ clients/web/src/hooks/useServerCommands.tsx | 15 +++ clients/web/src/test/core/mcp/sha256.test.ts | 85 +++++++++++++ clients/web/src/test/core/mcp/skills.test.ts | 19 ++- core/mcp/sha256.ts | 118 ++++++++++++++++++ core/mcp/skills.ts | 49 ++++++-- core/mcp/skillsSchemas.ts | 16 ++- 14 files changed, 527 insertions(+), 23 deletions(-) create mode 100644 clients/web/src/test/core/mcp/sha256.test.ts create mode 100644 core/mcp/sha256.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 930b52a1c2..617ce43851 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -835,6 +835,7 @@ function App() { onRefreshResources, onRefreshSkills, onReadSkillFile, + onGetSkill, onRefreshTasks, onTogglePaginatedLists, onLoadMoreTools, @@ -1832,6 +1833,7 @@ function App() { onSkillsUiChange: setUi.setSkillsUi, onRefreshSkills, onReadSkillFile, + onGetSkill, }; const tasksPanelProps: TasksPanelProps = { diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index e3cd52db72..54ca1a2542 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -107,6 +107,13 @@ const baseProps: SkillsScreenProps = { onUiChange: vi.fn(), onRefreshList: vi.fn(), onReadSkillFile: readFixtureFile, + // Echoes back the very entry `skills/list` advertised, so the default is the + // agreeing case; tests that care about a disagreement override it. + onGetSkill: vi.fn(async (uri: string) => { + const found = ALL_SKILLS.find((skill) => skill.uri === uri); + if (!found) throw new Error(`Unknown skill uri: ${uri}`); + return found; + }), }; // SkillsScreen is controlled: the selection and the sidebar search live in the @@ -430,6 +437,97 @@ describe("SkillsScreen", () => { expect(within(issues).getAllByText("duplicate-resource")).toHaveLength(2); }); + it("fetches the selected entry through skills/get and reports agreement", async () => { + // The acceptance criterion this exists for: `skills/get` is one of the two + // methods the extension requires, and a server author's handler is only + // exercisable if something actually calls it. + const user = userEvent.setup(); + const onGetSkill = vi.fn().mockResolvedValue(CLEAN_SKILL); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + expect(onGetSkill).toHaveBeenCalledWith(CLEAN_SKILL.uri); + expect( + await screen.findByText("skills/get agrees with skills/list"), + ).toBeInTheDocument(); + }); + + it("reports a skills/get entry that disagrees with the listing", async () => { + // Both describe the same skill, so a disagreement is a server bug that + // only a side-by-side fetch can surface. + const user = userEvent.setup(); + const onGetSkill = vi.fn().mockResolvedValue({ + ...CLEAN_SKILL, + frontmatter: { ...CLEAN_SKILL.frontmatter, description: "different" }, + }); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + expect( + await screen.findByText("skills/get disagrees with skills/list"), + ).toBeInTheDocument(); + // The fetched entry is rendered beside the verdict so the difference is + // inspectable rather than merely asserted. (Its JSON goes through + // `ContentViewer`'s highlighter, which splits tokens across elements, so + // the presence of the block is what is pinned here — the copy above is + // what states the finding.) + expect(screen.getByTestId("skills-get-result")).toBeInTheDocument(); + }); + + it("reports a failed skills/get", async () => { + const user = userEvent.setup(); + const onGetSkill = vi.fn().mockRejectedValue(new Error("-32602")); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + expect(await screen.findByText("skills/get failed")).toBeInTheDocument(); + expect(screen.getByText("-32602")).toBeInTheDocument(); + }); + + it("discards a skills/get that resolves after the selection moved on", async () => { + const user = userEvent.setup(); + let release: ((value: SkillEntry) => void) | undefined; + const onGetSkill = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + await user.click(screen.getByText("tampered")); + release?.(CLEAN_SKILL); + expect(screen.queryByTestId("skills-get-result")).not.toBeInTheDocument(); + }); + + it("frees Verify all for a newly selected skill while the old batch is hung", async () => { + // A global flag would leave the new skill's button disabled until the + // previous skill's reads settled — forever, if one of them hangs. + const user = userEvent.setup(); + const onReadSkillFile = vi.fn( + () => new Promise<{ text: string }>(() => {}), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled(); + await user.click(screen.getByText("tampered")); + expect( + screen.getByRole("button", { name: /Verify all/ }), + ).not.toBeDisabled(); + }); + it("shows the SKILL.md preview on demand", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 6dc6fa9a12..7f8014600a 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -80,6 +80,18 @@ interface PreviewState { message?: string; } +/** + * The result of the on-demand `skills/get`, plus the manifest it belongs to. + * `agrees` records whether the fetched entry matched the one `skills/list` + * returned — the reason for making the call at all. + */ +interface FetchedEntryState { + key: string | null; + entry?: SkillEntry; + agrees?: boolean; + message?: string; +} + export interface SkillsScreenProps { skills: SkillEntry[]; /** Pages the last `skills/list` walk took; shown so pagination is visible. */ @@ -91,6 +103,13 @@ export interface SkillsScreenProps { onRefreshList: () => void; /** Fetch one skill file's contents via `resources/read`, on demand. */ onReadSkillFile: (uri: string) => Promise; + /** + * Re-fetch the selected entry through `skills/get` (SEP-2640). Distinct from + * the entry `skills/list` already returned, and the point of exercising it is + * that the two must agree: a server whose `skills/get` disagrees with its own + * listing is broken in a way only a side-by-side fetch can show. + */ + onGetSkill: (uri: string) => Promise; } /** @@ -149,6 +168,15 @@ const ControlsRow = Group.withProps({ gap: "sm", }); +// The Resources header carries three action buttons beside its count badge, so +// it wraps rather than truncating the badge on a narrow detail pane — unlike +// the sidebar row above, where the search field is meant to absorb the space. +const SectionControlsRow = Group.withProps({ + justify: "space-between", + wrap: "wrap", + gap: "sm", +}); + const SearchInput = TextInput.withProps({ size: "xs", flex: 1, @@ -269,6 +297,7 @@ export function SkillsScreen({ onUiChange, onRefreshList, onReadSkillFile, + onGetSkill, }: SkillsScreenProps) { const { selectedSkillUri, search } = ui; // Both slices carry the manifest key they belong to, and every async @@ -284,9 +313,14 @@ export function SkillsScreen({ files: {}, }); const [previewState, setPreviewState] = useState({ key: null }); - // True while a "Verify all" batch is in flight; disables the button so a - // second click cannot stack another pool of workers on top. - const [batchRunning, setBatchRunning] = useState(false); + const [fetchedEntry, setFetchedEntry] = useState({ + key: null, + }); + // The manifest whose "Verify all" batch is in flight, or `null`. Keyed rather + // than a bare boolean: a global flag would leave a NEWLY selected skill's + // button disabled until the previous skill's reads settled — indefinitely, if + // one of them hangs. + const [batchKey, setBatchKey] = useState(null); // Monotonic per-row attempt token. A ref because it is claimed inside an // event handler, never during render. const nextAttempt = useRef(0); @@ -342,6 +376,7 @@ export function SkillsScreen({ useValueChange(manifestKey, (next) => { setVerification({ key: next, files: {} }); setPreviewState({ key: next }); + setFetchedEntry({ key: next }); }); const fileStates = verification.key === manifestKey ? verification.files : {}; @@ -407,12 +442,14 @@ export function SkillsScreen({ } }; const workers = Math.min(VERIFY_CONCURRENCY, manifest.length); - setBatchRunning(true); + setBatchKey(key); // 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( - () => setBatchRunning(false), + // Clears only ITS OWN batch: a stale finalizer must not free a button the + // user has since re-armed on another skill. + () => setBatchKey((prev) => (prev === key ? null : prev)), ); }, [manifest, manifestKey, verifyRow]); @@ -438,6 +475,33 @@ export function SkillsScreen({ }); }, [manifestKey, onReadSkillFile, selected]); + const fetchEntry = useCallback(() => { + if (!selected) return; + const key = manifestKey; + // Same shape as the SKILL.md read: a click handler cannot await, the chain + // ends in its own `catch`, and both arms compare the manifest key they + // started under so a late answer cannot land under another skill. + void onGetSkill(selected.uri) + .then((entry) => { + // Compared field-by-field against what `skills/list` advertised. The + // two describe the same skill, so a disagreement is a server bug that + // only shows up when both are fetched. + const agrees = JSON.stringify(entry) === JSON.stringify(selected); + setFetchedEntry((prev) => + prev.key !== null && prev.key !== key ? prev : { key, entry, agrees }, + ); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + setFetchedEntry((prev) => + prev.key !== null && prev.key !== key ? prev : { key, message }, + ); + }); + }, [manifestKey, onGetSkill, selected]); + + const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined; + const batchRunning = batchKey === manifestKey; + const preview = previewState.key === manifestKey ? previewState.contents : undefined; const previewError = @@ -561,7 +625,7 @@ export function SkillsScreen({ - + Resources @@ -570,6 +634,9 @@ export function SkillsScreen({ + + Fetch with skills/get + View SKILL.md @@ -581,7 +648,7 @@ export function SkillsScreen({ Verify all - + {selected.resources === DYNAMIC_RESOURCES ? ( This skill declares{" "} @@ -692,6 +759,41 @@ export function SkillsScreen({ })} + {fetched?.message !== undefined && ( + + {fetched.message} + + )} + {fetched?.entry !== undefined && ( + + + + {fetched.agrees + ? "The entry this server returns for this URI is identical to the one it listed." + : "The entry this server returns for this URI differs from the one it listed; both describe the same skill, so one of them is wrong."} + + {!fetched.agrees && ( + + )} + + + )} + {previewError && ( {previewError} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 0c22737912..3ca519985b 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -433,6 +433,11 @@ const skillsArgs: SkillsPanelProps = { onSkillsUiChange: fn(), onRefreshSkills: fn(), onReadSkillFile: fn(async () => ({ text: "" })), + onGetSkill: fn(async (uri: string) => ({ + uri, + frontmatter: {}, + resources: [], + })), }; const tasksArgs: TasksPanelProps = { diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index d720d1e444..4f784cb53b 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -178,6 +178,7 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps { onSkillsUiChange: vi.fn(), onRefreshSkills: vi.fn(), onReadSkillFile: vi.fn().mockResolvedValue({ text: "" }), + onGetSkill: vi.fn(), ...mergeBundle("skills", overrides), }, tasks: { diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 01c3d756c3..d620333e91 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -463,6 +463,7 @@ export function InspectorView({ onSkillsUiChange, onRefreshSkills, onReadSkillFile, + onGetSkill, } = skillsPanel; const { tasks, @@ -1061,6 +1062,7 @@ export function InspectorView({ onUiChange: onSkillsUiChange, onRefreshList: onRefreshSkills, onReadSkillFile, + onGetSkill, }; const tasksScreenProps = { tasks, diff --git a/clients/web/src/components/views/InspectorView/types.ts b/clients/web/src/components/views/InspectorView/types.ts index 4950765c7b..15e2a24988 100644 --- a/clients/web/src/components/views/InspectorView/types.ts +++ b/clients/web/src/components/views/InspectorView/types.ts @@ -322,6 +322,8 @@ export interface SkillsPanelProps { 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. */ diff --git a/clients/web/src/hooks/useServerCommands.test.tsx b/clients/web/src/hooks/useServerCommands.test.tsx index a675a284af..25c015f087 100644 --- a/clients/web/src/hooks/useServerCommands.test.tsx +++ b/clients/web/src/hooks/useServerCommands.test.tsx @@ -916,6 +916,28 @@ describe("onReadSkillFile (#2234)", () => { }); }); +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", + ); + }); +}); + describe("onRefreshSkills (#2234)", () => { it("drives the store refresh in the background", () => { const h = harness(); diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx index 9488e352ef..73eaa194ae 100644 --- a/clients/web/src/hooks/useServerCommands.tsx +++ b/clients/web/src/hooks/useServerCommands.tsx @@ -35,6 +35,7 @@ import type { 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 { UrlElicitationErrorToastMessage } from "../components/elements/Toasts/UrlElicitationErrorToastMessage"; import { errorCodeOf, @@ -219,6 +220,8 @@ export interface ServerCommands { * 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: ( @@ -973,6 +976,17 @@ export function useServerCommands({ [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"); + return inspectorClient.getSkill(uri); + }, + [inspectorClient], + ); + const onRefreshSkills = useCallback(() => { runCommandInBackground( () => refreshSkills(), @@ -997,6 +1011,7 @@ export function useServerCommands({ onReadResource, onReadResourceContents, onReadSkillFile, + onGetSkill, onSubscribeResource, onUnsubscribeResource, onCompleteArgument, 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 index f489c7feed..4fd5f52700 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -113,6 +113,10 @@ describe("skillNameFromUri", () => { expect(skillNameFromUri("SKILL.md")).toBeUndefined(); }); + it("returns undefined for a non-skill scheme", () => { + expect(skillNameFromUri("https://demo/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. @@ -141,6 +145,14 @@ describe("normalizeSkillUri", () => { expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined(); }); + it("rejects a non-skill scheme", () => { + // Checking only that a URI is hierarchical would let this through and then + // pass the name and root checks — a manifest pointing anywhere on the web, + // reported as conforming. + expect(normalizeSkillUri("https://demo/SKILL.md")).toBeUndefined(); + expect(normalizeSkillUri("file:///demo/SKILL.md")).toBeUndefined(); + }); + 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. @@ -229,7 +241,7 @@ describe("checkSkillConformance", () => { expect(issues[0].severity).toBe("warning"); }); - it("reports a manifest entry with no digest as unverifiable", () => { + it("reports a manifest entry with no digest as an error", () => { const issues = checkSkillConformance( entry({ resources: [ @@ -239,6 +251,7 @@ describe("checkSkillConformance", () => { }), ); expect(issues.map((i) => i.code)).toEqual(["missing-digest"]); + expect(issues[0].severity).toBe("error"); expect(issues[0].resourceUri).toBe("skill://demo/ref.md"); }); @@ -331,7 +344,7 @@ describe("checkSkillConformance", () => { expect(issues.map((i) => i.code)).toContain("malformed-uri"); }); - it("reports a manifest entry with no size as a warning", () => { + it("reports a manifest entry with no size as an error", () => { const issues = checkSkillConformance( entry({ resources: [ @@ -341,7 +354,7 @@ describe("checkSkillConformance", () => { }), ); expect(issues.map((i) => i.code)).toEqual(["missing-size"]); - expect(issues[0].severity).toBe("warning"); + expect(issues[0].severity).toBe("error"); }); it("reports a digest that is not sha256 + 64 lowercase hex", () => { 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 index 586b110ddd..610299fedb 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -41,6 +41,7 @@ import { 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; @@ -51,6 +52,9 @@ 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"; +/** The URI scheme SEP-2640 defines for skills, as `URL.protocol` spells it. */ +export const SKILL_URI_SCHEME = "skill:"; + /** `sha256:` followed by exactly 64 lowercase hex characters. */ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; @@ -107,6 +111,10 @@ export function isSkillsExtensionSupported( * 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 `skill:` scheme is required. Checking only that the URI is hierarchical + * would let `https://demo/SKILL.md` through and then pass the name and root + * checks — a manifest pointing anywhere on the web, reported as conforming. */ export function normalizeSkillUri(uri: string): string | undefined { let parsed: URL; @@ -115,6 +123,7 @@ export function normalizeSkillUri(uri: string): string | undefined { } catch { return undefined; } + if (parsed.protocol !== SKILL_URI_SCHEME) return undefined; return parsed.pathname.startsWith("/") ? parsed.href : undefined; } @@ -167,9 +176,10 @@ export type SkillIssueCode = | "size-limit-exceeded"; /** - * `error` marks a stated requirement of SEP-2640 that the server broke. - * `warning` marks something that is legal but leaves the Inspector unable to - * verify integrity — `"dynamic"` resources above all, which is the case most + * `error` marks a stated requirement of SEP-2640 that the server broke — + * every MUST, so a manifest reporting "0 errors" really is one the spec + * accepts. `warning` is reserved for what is **legal** yet leaves the + * Inspector unable to verify integrity: `"dynamic"` resources, the case most * worth surfacing and the one most easily buried. */ export type SkillIssueSeverity = "error" | "warning"; @@ -216,7 +226,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { issues.push({ code: "malformed-uri", severity: "error", - message: `Skill URI must end with "${SKILL_FILE_SUFFIX}" and carry a non-empty path segment before it.`, + message: `Skill URI must be a "${SKILL_URI_SCHEME}//" 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 @@ -309,10 +319,15 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { } } 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: "warning", - message: "Manifest entry declares no digest, so it cannot be verified.", + 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)) { @@ -324,14 +339,15 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { }); } if (resource.size === undefined) { - // A warning, not an error: an absent size costs the length cross-check in - // `verifySkillResource` and silently understates the 16 MiB total, but - // the digest still verifies the bytes. + // 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: "warning", + severity: "error", message: - "Manifest entry declares no size, so it is excluded from the 16 MiB total and its length cannot be cross-checked.", + "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)) { @@ -410,6 +426,15 @@ function toHex(bytes: Uint8Array): string { * 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 @@ -419,7 +444,7 @@ export async function sha256Digest(bytes: Uint8Array): Promise { // `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 crypto.subtle.digest("SHA-256", copy.buffer); + const hash = await subtle.digest("SHA-256", copy.buffer); return `sha256:${toHex(new Uint8Array(hash))}`; } diff --git a/core/mcp/skillsSchemas.ts b/core/mcp/skillsSchemas.ts index 1441b3ea49..2ebb7181f4 100644 --- a/core/mcp/skillsSchemas.ts +++ b/core/mcp/skillsSchemas.ts @@ -96,7 +96,21 @@ export const SkillEntrySchema = z.looseObject({ export type SkillEntry = z.infer; -/** `skills/list` result: a page of entries plus the opaque cursor. */ +/** + * `skills/list` result: a page of entries plus the opaque cursor. + * + * ⚠️ **Whether a modern-era (2026-07-28) result must also carry the SEP-2549 + * caching attributes `ttlMs` / `cacheScope` is unsettled here and deliberately + * not guessed.** #2234's analysis records it as an open point; a review of that + * PR asserted the opposite. Neither reading was checked against the normative + * text, and the two mistakes are not symmetric: leaving the schema permissive + * means a server that omits them is accepted (they pass through untouched when + * sent), while tightening on a wrong reading would *reject* conforming + * responses. `resources/directory/read` was removed from this module for the + * same reason. #2248 settles it against the spec. Note the SDK is no help + * either way — `skills/list` is consumer-owned, so it is absent from the + * cacheable-method registry and nothing stamps or validates these fields. + */ export const ListSkillsResultSchema = z.looseObject({ skills: z.array(SkillEntrySchema), nextCursor: z.string().optional(), From 27fafe6acbbc98a1c095f1c69221ed1e5ff90b72 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 22:57:51 -0400 Subject: [PATCH 06/18] fix(web): make the Connection Info extension sections say something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the Skills work got wrong in that modal, all found by review of the PR screenshots. - The "Skills Extension" section repeated `io.modelcontextprotocol/skills` as its value — the identifier the "Server Extensions" section two rows above already lists, so the section added nothing. What a flat key list *cannot* show is the extension's sub-options, which is the fact a server author opens this modal to check. Renamed "Skills Extension Options" and rendered as a ✓/✗ row for `directoryRead`, in the same vocabulary as the capability columns, so it reads as the same kind of claim. - The extension sections' contents were bold (`ValueText`, the value half of a label/value pair) while sitting directly beneath the capability checklists, which are plain. They are lists of items, not values, so they now use the same `Text` the checklist rows do. - Those lists were comma-joined into one line, which wraps mid-identifier in a half-width column. One row per identifier. The `skills-directory-read` tests now assert `data-supported` rather than the copy: "Not supported" contains "Supported", so a text assertion passed for either answer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../ConnectionInfoContent.test.tsx | 28 +++++-- .../ConnectionInfoContent.tsx | 82 ++++++++++++------- 2 files changed, 73 insertions(+), 37 deletions(-) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index d495f8f293..d181c36ac6 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -427,11 +427,12 @@ describe("ConnectionInfoContent", () => { expect( screen.getByText("Client Advertised Extensions"), ).toBeInTheDocument(); + // One row per identifier, not a comma-joined string: two ~30-character + // ids wrap mid-name in a half-width column. expect( - screen.getByText( - "io.modelcontextprotocol/tasks, io.modelcontextprotocol/ui", - ), + screen.getByText("io.modelcontextprotocol/tasks"), ).toBeInTheDocument(); + expect(screen.getByText("io.modelcontextprotocol/ui")).toBeInTheDocument(); }); it("renders em-dashes for the extensions sections when neither side advertises any (#1740)", () => { @@ -461,7 +462,9 @@ describe("ConnectionInfoContent", () => { protocolEra="legacy" />, ); - expect(screen.queryByText("Skills Extension")).not.toBeInTheDocument(); + expect( + screen.queryByText("Skills Extension Options"), + ).not.toBeInTheDocument(); }); it("shows the Skills extension and its directoryRead sub-flag (#2234)", () => { @@ -484,10 +487,16 @@ describe("ConnectionInfoContent", () => { protocolEra="legacy" />, ); - expect(screen.getByText("Skills Extension")).toBeInTheDocument(); - expect(screen.getByTestId("skills-directory-read")).toHaveTextContent( - "Supported", + expect(screen.getByText("Skills Extension Options")).toBeInTheDocument(); + // Asserted on the attribute, not the copy: "Not supported" contains + // "Supported", so a text check would pass for either answer. + expect(screen.getByTestId("skills-directory-read")).toHaveAttribute( + "data-supported", + "true", ); + // The section states the sub-option, not the identifier — that is already + // in "Server Extensions" and repeating it would add nothing. + expect(screen.getByText("resources/directory/read")).toBeInTheDocument(); }); it("reports directory read as unsupported for a bare skills declaration (#2234)", () => { @@ -505,8 +514,9 @@ describe("ConnectionInfoContent", () => { protocolEra="legacy" />, ); - expect(screen.getByTestId("skills-directory-read")).toHaveTextContent( - "Not supported", + expect(screen.getByTestId("skills-directory-read")).toHaveAttribute( + "data-supported", + "false", ); }); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index 20aa40e156..a9f567dcd4 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -3,6 +3,7 @@ import { Button, Code, Flex, + Group, ScrollArea, SimpleGrid, Stack, @@ -18,7 +19,6 @@ 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 { @@ -106,6 +106,15 @@ const SectionHeading = Title.withProps({ // `scrollable-region-focusable`). const ValueCode = Code.withProps({ variant: "wrapping" }); +// One declared sub-option of an extension. Mirrors `CapabilityItem`'s ✓/✗ row +// rather than reusing it: that element's `capability` prop is the closed union +// of spec capability keys, and widening it to accept an arbitrary extension +// sub-option name would collapse it to `string` and lose the typo protection +// the union buys every other caller. +const SubOptionRow = Group.withProps({ gap: "xs", wrap: "nowrap" }); + +const SubOptionMark = Text.withProps({ fw: 600 }); + const ClearOAuthButton = Button.withProps({ variant: "subtle", color: "red", @@ -161,16 +170,20 @@ function formatSession( return isModernEra(era) ? "Sessionless" : "Session-based"; } -// Render an `extensions` capability map (SEP-2133) as a comma-separated list of -// its extension identifiers, or an em dash when none are present. Works for -// either side's map: the server's negotiated `capabilities.extensions` (present -// on both eras via `getServerCapabilities()`) or the Inspector's own advertised +// The extension identifiers in an `extensions` capability map (SEP-2133), one +// per rendered row, or a single em dash when none are present. Works for either +// side's map: the server's negotiated `capabilities.extensions` (present on both +// eras via `getServerCapabilities()`) or the Inspector's own advertised // `clientCapabilities.extensions`. (#1740) +// +// A list rather than a comma-joined string (#2234): an identifier is ~30 +// characters and two of them wrap mid-name in a half-width column, which is +// what made the joined form hard to read at a glance. function formatExtensions( extensions: Record | undefined, -): string { +): string[] { const keys = extensions ? Object.keys(extensions) : []; - return keys.length > 0 ? keys.join(", ") : "—"; + return keys.length > 0 ? keys : ["\u2014"]; } const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [ @@ -357,34 +370,47 @@ export function ConnectionInfoContent({ Server Extensions - {formatExtensions(capabilities.extensions)} + {/* A plain `Text`, not the bold `ValueText`: these sections list + *items*, the way the capability columns above do, rather than + giving the value half of a label/value pair. Bolding them made + them read as emphasized answers to a question the section never + asks, and set them in a different font from the checklist rows + they sit directly beneath. */} + {formatExtensions(capabilities.extensions).map((extension) => ( + {extension} + ))} Client Advertised Extensions - - {formatExtensions(clientCapabilities.extensions)} - + {formatExtensions(clientCapabilities.extensions).map((extension) => ( + {extension} + ))} - {/* 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). */} + {/* Skills (SEP-2640). The "Server Extensions" row above already names the + identifier, so repeating it here would say nothing: what this section + adds is the extension's SUB-OPTIONS, which a flat list of keys cannot + show. `directoryRead` is the only one SEP-2640 defines, and whether a + server declared it is the fact a server author opens this modal to + check — it gates `resources/directory/read` (#2234). Rendered with the + same ✓/✗ vocabulary as the capability columns above so it reads as the + same kind of claim. */} {skillsExtension && ( - - - Skills Extension - {SKILLS_EXTENSION_KEY} - - - Directory Read - - {skillsExtension.directoryRead ? "Supported" : "Not supported"} - - - + + Skills Extension Options + + + {skillsExtension.directoryRead ? "\u2713" : "\u2717"} + + + Directory read — resources/directory/read + + + )} {instructions && ( From 754ce5bd95c7123dafd259fa7b99401bb6c82dcf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 23:11:46 -0400 Subject: [PATCH 07/18] fix: address Copilot review round 5 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of these reverse round 4, which asked for the opposite. Where the readings conflict I took the permissive one: a wrong tightening rejects conforming servers, a wrong loosening only fails to report something. - skills.ts: stop requiring the `skill:` scheme. SEP-2640 says a server SHOULD use `skill://` and explicitly allows a domain-native scheme, so round 4's requirement handed a conforming `github://…` skill a false `malformed-uri` and skipped its name and root checks. Containment is scheme-independent — it compares against THIS entry's own root — so the traversal fix that requirement came packaged with is unaffected. - skills.ts: the 512-entry and 16 MiB limits are warnings. Both are SHOULD NOTs for a server and MAYs for a host, so calling them errors contradicted the "error = a MUST was broken" contract round 4 established and told authors a permitted skill was invalid. - skills.ts: `skillEntriesMatch` compares canonically — keys sorted, manifest sorted by URI. `JSON.stringify` treated key order and manifest order as differences, though neither carries meaning. - SkillsScreen: a differing `skills/get` is "a different snapshot" (yellow), not a disagreement (red). The SEP defines it as a fresh point-in-time read, so a skill that changed since the listing legitimately differs. - useServerCommands: route `onGetSkill` through the command-scoped OAuth recovery like every other server command. An expired authorization rendered an error and stopped there — no reauthorization, no retry. - test-servers/skills.ts: stamp the modern base result envelope (`resultType` / `ttlMs` / `cacheScope`) on every skills result. The SDK stamps it only for methods in its own codec, and `skills/*` are consumer-owned, so a 2026-era connection was receiving results without it. Unconditional rather than per era: the modern leg builds a server per request, so there is no era to branch on at registration time, and on legacy they are three members no codec inspects. - composable-test-server: remove the `skills.directoryRead` option. It was publicly settable and advertised a method nothing serves, producing the exact false capability the fixture exists to help catch. `skills` is now a plain boolean; the flag returns in phase 3 (#2248) with its handler. Still not changed: whether a modern `skills/list` RESULT SCHEMA must require that envelope. Stamping it server-side is free; requiring it client-side would reject servers on a reading nobody has checked against the normative text. #2248 owns settling it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 35 ++++++-- .../screens/SkillsScreen/SkillsScreen.tsx | 39 +++++---- .../web/src/hooks/useServerCommands.test.tsx | 51 ++++++++++++ clients/web/src/hooks/useServerCommands.tsx | 20 ++++- clients/web/src/test/core/mcp/skills.test.ts | 38 +++++---- core/mcp/skills.ts | 82 +++++++++++++++---- docs/test-servers.md | 32 +++++--- test-servers/configs/skills-http.json | 8 +- test-servers/src/composable-test-server.ts | 20 +++-- test-servers/src/load-config.ts | 4 +- test-servers/src/skills.ts | 24 +++++- 11 files changed, 272 insertions(+), 81 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 54ca1a2542..19cdb38290 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -437,7 +437,7 @@ describe("SkillsScreen", () => { expect(within(issues).getAllByText("duplicate-resource")).toHaveLength(2); }); - it("fetches the selected entry through skills/get and reports agreement", async () => { + it("fetches the selected entry through skills/get and reports a match", async () => { // The acceptance criterion this exists for: `skills/get` is one of the two // methods the extension requires, and a server author's handler is only // exercisable if something actually calls it. @@ -450,13 +450,36 @@ describe("SkillsScreen", () => { ); expect(onGetSkill).toHaveBeenCalledWith(CLEAN_SKILL.uri); expect( - await screen.findByText("skills/get agrees with skills/list"), + await screen.findByText("skills/get matches skills/list"), ).toBeInTheDocument(); }); - it("reports a skills/get entry that disagrees with the listing", async () => { - // Both describe the same skill, so a disagreement is a server bug that - // only a side-by-side fetch can surface. + it("treats key and manifest order as immaterial when matching", async () => { + // The manifest is a set and JSON key order carries no meaning, so a server + // that enumerates either differently is not inconsistent — a + // `JSON.stringify` comparison would have called it one. + const user = userEvent.setup(); + const onGetSkill = vi.fn().mockResolvedValue({ + resources: [...CLEAN_SKILL.resources].reverse(), + frontmatter: { + description: CLEAN_SKILL.frontmatter.description, + name: CLEAN_SKILL.frontmatter.name, + }, + uri: CLEAN_SKILL.uri, + }); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + expect( + await screen.findByText("skills/get matches skills/list"), + ).toBeInTheDocument(); + }); + + it("reports a skills/get entry that differs from the listing", async () => { + // Shown, but not called an error: `skills/get` is a fresh snapshot, so a + // skill that genuinely changed since the listing legitimately differs. const user = userEvent.setup(); const onGetSkill = vi.fn().mockResolvedValue({ ...CLEAN_SKILL, @@ -468,7 +491,7 @@ describe("SkillsScreen", () => { screen.getByRole("button", { name: /Fetch with skills\/get/ }), ); expect( - await screen.findByText("skills/get disagrees with skills/list"), + await screen.findByText("skills/get returned a different snapshot"), ).toBeInTheDocument(); // The fetched entry is rendered beside the verdict so the difference is // inspectable rather than merely asserted. (Its JSON goes through diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 7f8014600a..b083d5eaa2 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -23,6 +23,7 @@ import { DYNAMIC_RESOURCES } from "@inspector/core/mcp/skillsSchemas.js"; import { checkSkillConformance, skillDisplayName, + skillEntriesMatch, totalSkillBytes, verifySkillResource, type SkillIssue, @@ -82,13 +83,13 @@ interface PreviewState { /** * The result of the on-demand `skills/get`, plus the manifest it belongs to. - * `agrees` records whether the fetched entry matched the one `skills/list` - * returned — the reason for making the call at all. + * `matches` records whether the fetched entry describes the same skill as the + * one `skills/list` returned — the reason for making the call at all. */ interface FetchedEntryState { key: string | null; entry?: SkillEntry; - agrees?: boolean; + matches?: boolean; message?: string; } @@ -483,12 +484,14 @@ export function SkillsScreen({ // started under so a late answer cannot land under another skill. void onGetSkill(selected.uri) .then((entry) => { - // Compared field-by-field against what `skills/list` advertised. The - // two describe the same skill, so a disagreement is a server bug that - // only shows up when both are fetched. - const agrees = JSON.stringify(entry) === JSON.stringify(selected); + // Compared semantically against what `skills/list` advertised — see + // `skillEntriesMatch` for why a `JSON.stringify` comparison would + // report key order and manifest order as differences. + const matches = skillEntriesMatch(entry, selected); setFetchedEntry((prev) => - prev.key !== null && prev.key !== key ? prev : { key, entry, agrees }, + prev.key !== null && prev.key !== key + ? prev + : { key, entry, matches }, ); }) .catch((err: unknown) => { @@ -767,20 +770,24 @@ export function SkillsScreen({ {fetched?.entry !== undefined && ( - {fetched.agrees - ? "The entry this server returns for this URI is identical to the one it listed." - : "The entry this server returns for this URI differs from the one it listed; both describe the same skill, so one of them is wrong."} + {fetched.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.agrees && ( + {!fetched.matches && ( { "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)", () => { diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx index 73eaa194ae..1fb7f052b5 100644 --- a/clients/web/src/hooks/useServerCommands.tsx +++ b/clients/web/src/hooks/useServerCommands.tsx @@ -982,9 +982,25 @@ export function useServerCommands({ const onGetSkill = useCallback( async (uri: string): Promise => { if (!inspectorClient) throw new Error("Client is not connected"); - return inspectorClient.getSkill(uri); + // 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], + [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], ); const onRefreshSkills = useCallback(() => { diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 4fd5f52700..9787d3ba78 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -107,16 +107,18 @@ describe("skillNameFromUri", () => { ); }); + 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 non-skill scheme", () => { - expect(skillNameFromUri("https://demo/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. @@ -145,12 +147,16 @@ describe("normalizeSkillUri", () => { expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined(); }); - it("rejects a non-skill scheme", () => { - // Checking only that a URI is hierarchical would let this through and then - // pass the name and root checks — a manifest pointing anywhere on the web, - // reported as conforming. - expect(normalizeSkillUri("https://demo/SKILL.md")).toBeUndefined(); - expect(normalizeSkillUri("file:///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", () => { @@ -406,10 +412,13 @@ describe("checkSkillConformance", () => { ], }), ); - expect(issues.map((i) => i.code)).toContain("size-limit-exceeded"); + const finding = issues.find((i) => i.code === "size-limit-exceeded"); + expect(finding?.severity).toBe("warning"); }); - it("reports a manifest over the 512-entry limit", () => { + // 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) => ({ @@ -419,10 +428,11 @@ describe("checkSkillConformance", () => { })), ]; const issues = checkSkillConformance(entry({ resources })); - expect(issues.map((i) => i.code)).toContain("resource-limit-exceeded"); + const finding = issues.find((i) => i.code === "resource-limit-exceeded"); + expect(finding?.severity).toBe("warning"); }); - it("reports a manifest over the 16 MiB limit", () => { + it("reports a manifest over the 16 MiB limit as a warning", () => { const issues = checkSkillConformance( entry({ resources: [ diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 610299fedb..2d9346a7ec 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -52,9 +52,6 @@ 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"; -/** The URI scheme SEP-2640 defines for skills, as `URL.protocol` spells it. */ -export const SKILL_URI_SCHEME = "skill:"; - /** `sha256:` followed by exactly 64 lowercase hex characters. */ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; @@ -112,9 +109,13 @@ export function isSkillsExtensionSupported( * rejected too: containment could not be decided on it, and silently accepting * one would reintroduce exactly the hole this function closes. * - * The `skill:` scheme is required. Checking only that the URI is hierarchical - * would let `https://demo/SKILL.md` through and then pass the name and root - * checks — a manifest pointing anywhere on the web, reported as conforming. + * 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; @@ -123,7 +124,6 @@ export function normalizeSkillUri(uri: string): string | undefined { } catch { return undefined; } - if (parsed.protocol !== SKILL_URI_SCHEME) return undefined; return parsed.pathname.startsWith("/") ? parsed.href : undefined; } @@ -176,10 +176,11 @@ export type SkillIssueCode = | "size-limit-exceeded"; /** - * `error` marks a stated requirement of SEP-2640 that the server broke — - * every MUST, so a manifest reporting "0 errors" really is one the spec - * accepts. `warning` is reserved for what is **legal** yet leaves the - * Inspector unable to verify integrity: `"dynamic"` resources, the case most + * `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"; @@ -226,7 +227,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { issues.push({ code: "malformed-uri", severity: "error", - message: `Skill URI must be a "${SKILL_URI_SCHEME}//" URI ending with "${SKILL_FILE_SUFFIX}" and carrying a non-empty path segment before it.`, + 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 @@ -250,19 +251,24 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { 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: "error", - message: `Skill declares ${entry.resources.length} resource entries, above the ${SKILL_MAX_RESOURCE_ENTRIES}-entry limit.`, + 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: "error", - message: `Skill resources total ${totalBytes} bytes, above the ${SKILL_MAX_TOTAL_BYTES}-byte (16 MiB) limit.`, + 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.`, }); } @@ -388,6 +394,50 @@ export function totalSkillBytes(resources: readonly SkillResource[]): number { ); } +/** + * 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 canonicalJson(a) === canonicalJson(b); +} + +/** Deterministic JSON: object keys sorted recursively, and a skill's manifest + * sorted by URI so its enumeration order carries no meaning. */ +function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value === null || typeof value !== "object") return value; + const entries = Object.entries(value as Record) + .map(([key, member]): [string, unknown] => { + // The manifest is a set; only its contents are meaningful. + if (key === "resources" && Array.isArray(member)) { + const sorted = [...(member as SkillResource[])].sort((x, y) => + String(x?.uri).localeCompare(String(y?.uri)), + ); + return [key, sorted.map(canonicalize)]; + } + return [key, canonicalize(member)]; + }) + .sort(([x], [y]) => x.localeCompare(y)); + return Object.fromEntries(entries); +} + /** Outcome of comparing a fetched file against its advertised digest. */ export type SkillVerificationStatus = | "verified" diff --git a/docs/test-servers.md b/docs/test-servers.md index 3c1a2d2276..5d4118de6e 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -60,13 +60,21 @@ as a missing capability rather than an error. ## Skills (SEP-2640) -`skills-http.json` advertises the `io.modelcontextprotocol/skills` extension -and serves four skills over two `skills/list` pages. It declares the extension -**bare** — `directoryRead` stays off until the fixture actually serves -`resources/directory/read` (phase 3, [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248)), so -Connection Info never reports a sub-option this server would answer `-32601` -for. Set `"skills": { "directoryRead": true }` in a config to exercise the -advertised-flag path. +`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 @@ -82,11 +90,11 @@ the Skills tab runs are untestable without them: | `dynamic-report` | `resources: "dynamic"` — a generated file set, so integrity cannot be verified at all and the tab says so rather than staying silent. | | `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 shows the extension and its `directoryRead` sub-flag; the -Inspector surfaces that flag but does not call `resources/directory/read` yet. -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. +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 index 08fa66f155..2b10585145 100644 --- a/test-servers/configs/skills-http.json +++ b/test-servers/configs/skills-http.json @@ -3,9 +3,13 @@ "name": "skills", "version": "1.0.0" }, - "tools": [{ "preset": "echo" }], + "tools": [ + { + "preset": "echo" + } + ], "resources": [], - "skills": {}, + "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 5531af723e..eadc45e35d 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -561,8 +561,14 @@ export interface ServerConfig { * 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?: { directoryRead?: boolean }; + skills?: boolean; /** * Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the * nested `elicitation` setting — the server-side half of the app-rendered @@ -831,17 +837,13 @@ export function createMcpServer(config: ServerConfig): McpServer { }; } - // Skills extension (SEP-2640): a server-declared extension. `directoryRead` - // is opt-in per config and stays OFF in `skills-http.json` until the fixture - // actually serves `resources/directory/read` — advertising a sub-option this - // server would answer `-32601` for would make Connection Info report - // "Supported" for a method that is not (phase 3, #2248). + // 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]: { - ...(config.skills.directoryRead ? { directoryRead: true } : {}), - }, + [SKILLS_EXTENSION_KEY]: {}, }; // Skill files are fetched through ordinary `resources/read`, so the // resources capability has to be advertised even when the config registers diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 1b136a8cad..ffb4dcab20 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -68,8 +68,8 @@ export interface ConfigFile { * `transport.modern`. */ tasksExtension?: boolean; /** Advertise the Skills extension (SEP-2640) and serve its fixture skills. - * `directoryRead` advertises the `resources/directory/read` sub-option. */ - skills?: { directoryRead?: boolean }; + * 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/skills.ts b/test-servers/src/skills.ts index 228595eb07..49cd307171 100644 --- a/test-servers/src/skills.ts +++ b/test-servers/src/skills.ts @@ -45,6 +45,24 @@ export const SKILLS_EXTENSION_KEY = "io.modelcontextprotocol/skills"; */ 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")}`; @@ -218,6 +236,7 @@ export function listSkillsPage(cursor?: string): Record { 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) } : {}), }; @@ -238,9 +257,9 @@ export function getSkillEntry(uri: string): Record { `Unknown skill uri: ${uri}`, ); } - // The envelope (`{ skill }`) is the conforming shape, and the only one the + // The `{ skill }` wrapper is the conforming shape, and the only one the // Inspector accepts — see `GetSkillResultSchema`. - return { skill: toEntry(skill) }; + return { ...MODERN_RESULT_ENVELOPE, skill: toEntry(skill) }; } /** The `resources/read` result for a `skill://` file, or `undefined`. */ @@ -250,6 +269,7 @@ export function readSkillFile( 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 }], }; } From 4e676a0a54242be42a4f4a996a0025ce40f4e58a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 23:21:51 -0400 Subject: [PATCH 08/18] fix: address Copilot review round 6 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of this round re-reported code the previous commit already changed (it generated 0 new comments). Two findings were genuinely open. - SkillsScreen: give "View SKILL.md" and "Fetch with skills/get" the same attempt ordering `verifyRow` got in round 3. Both were still last-completion-wins within one manifest: a double click whose older request settled last replaced the newer result. One shared `isStale` helper now covers all three actions — a result is dropped when its manifest was invalidated OR a later click already wrote. - test-servers/skills.ts + docs: stop calling all three edge cases "non-conforming". `resources: "dynamic"` is a LEGAL wire form for generated content — it is unverifiable, not invalid, which is exactly why the checker reports it as a warning. The prose contradicted the code it was describing. Now: one legal-but-unverifiable case and two actual violations. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 64 +++++++++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 80 ++++++++++++------- docs/test-servers.md | 8 +- test-servers/src/skills.ts | 20 +++-- 4 files changed, 135 insertions(+), 37 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 19cdb38290..2d4a074eef 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -551,6 +551,70 @@ describe("SkillsScreen", () => { ).not.toBeDisabled(); }); + it("keeps the newest SKILL.md preview when two reads overlap", async () => { + // Same skill, same manifest — the key cannot order these, so without an + // attempt token the older read finishing last would replace the newer + // preview with stale content. + const user = userEvent.setup(); + const resolvers: ((value: { text: string }) => void)[] = []; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((resolve) => { + resolvers.push(resolve); + }), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + const view = screen.getByRole("button", { name: /View SKILL.md/ }); + await user.click(view); + await user.click(view); + expect(resolvers).toHaveLength(2); + + resolvers[1]({ text: "# newest\n" }); + expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent( + "newest", + ); + resolvers[0]({ text: "# stale\n" }); + expect(screen.getByTestId("skill-md-preview")).not.toHaveTextContent( + "stale", + ); + }); + + it("keeps the newest skills/get result when two fetches overlap", async () => { + const user = userEvent.setup(); + const resolvers: ((value: SkillEntry) => void)[] = []; + const onGetSkill = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + const fetchButton = screen.getByRole("button", { + name: /Fetch with skills\/get/, + }); + await user.click(fetchButton); + await user.click(fetchButton); + expect(resolvers).toHaveLength(2); + + // The newer fetch matches; the older one, landing last, would otherwise + // overwrite it with a "different snapshot" verdict. + resolvers[1](CLEAN_SKILL); + expect( + await screen.findByText("skills/get matches skills/list"), + ).toBeInTheDocument(); + resolvers[0]({ + ...CLEAN_SKILL, + frontmatter: { ...CLEAN_SKILL.frontmatter, description: "stale" }, + }); + expect( + screen.getByText("skills/get matches skills/list"), + ).toBeInTheDocument(); + }); + it("shows the SKILL.md preview on demand", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index b083d5eaa2..e4dbdc3b12 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -74,9 +74,15 @@ interface VerificationState { files: Record; } -/** The SKILL.md preview, plus the manifest it belongs to (`null` as above). */ +/** + * The SKILL.md preview, plus the manifest it belongs to (`null` as above) and + * the click that produced it. The manifest key cannot order two reads issued + * for the SAME manifest, so without `attempt` a double click whose older read + * finishes last would replace the newer preview. + */ interface PreviewState { key: string | null; + attempt?: number; contents?: SkillFileContents; message?: string; } @@ -88,6 +94,8 @@ interface PreviewState { */ interface FetchedEntryState { key: string | null; + /** The click this result belongs to — see {@link PreviewState.attempt}. */ + attempt?: number; entry?: SkillEntry; matches?: boolean; message?: string; @@ -274,6 +282,21 @@ function verificationLabel(state: FileState | undefined): string { 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 "—"; @@ -322,8 +345,11 @@ export function SkillsScreen({ // button disabled until the previous skill's reads settled — indefinitely, if // one of them hangs. const [batchKey, setBatchKey] = useState(null); - // Monotonic per-row attempt token. A ref because it is claimed inside an - // event handler, never during render. + // 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(() => { @@ -457,48 +483,46 @@ export function SkillsScreen({ 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 compare - // the manifest key they started under: a read that resolves after the - // selection moved on would otherwise show one skill's SKILL.md under - // another's heading. + // `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 }, + ); void onReadSkillFile(selected.uri) - .then((contents) => { - setPreviewState((prev) => - prev.key !== null && prev.key !== key ? prev : { key, contents }, - ); - }) + .then((contents) => writePreview({ contents })) .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - setPreviewState((prev) => - prev.key !== null && prev.key !== key ? prev : { key, message }, - ); + 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 compare the manifest key they - // started under so a late answer cannot land under another skill. + // 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 }, + ); void onGetSkill(selected.uri) .then((entry) => { // Compared semantically against what `skills/list` advertised — see // `skillEntriesMatch` for why a `JSON.stringify` comparison would // report key order and manifest order as differences. - const matches = skillEntriesMatch(entry, selected); - setFetchedEntry((prev) => - prev.key !== null && prev.key !== key - ? prev - : { key, entry, matches }, - ); + writeFetched({ entry, matches: skillEntriesMatch(entry, selected) }); }) .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - setFetchedEntry((prev) => - prev.key !== null && prev.key !== key ? prev : { key, message }, - ); + writeFetched({ + message: err instanceof Error ? err.message : String(err), + }); }); }, [manifestKey, onGetSkill, selected]); diff --git a/docs/test-servers.md b/docs/test-servers.md index 5d4118de6e..4e214f344b 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -80,14 +80,16 @@ It works on **either era**: `skills/list`, `skills/get` and 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 non-conforming, because the checks -the Skills tab runs are untestable without them: +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 generated file set, so integrity cannot be verified at all and the tab says so rather than staying silent. | +| `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` diff --git a/test-servers/src/skills.ts b/test-servers/src/skills.ts index 49cd307171..b04444b723 100644 --- a/test-servers/src/skills.ts +++ b/test-servers/src/skills.ts @@ -5,12 +5,20 @@ * 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 non-conforming 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 one `"dynamic"` - * skill, one whose advertised digest does not match the bytes served, and one - * whose URI path segment disagrees with `frontmatter.name`. Each is the exact - * shape one of the checks in `core/mcp/skills.ts` exists to catch. + * **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 From 1f33ba93be90c7454182a7bdee6c636f4e10f359 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 23:36:35 -0400 Subject: [PATCH 09/18] fix: address Copilot review round 7 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills.ts: stop sorting `resources` arrays recursively. Only the entry's OWN manifest is a set; `frontmatter` is verbatim arbitrary JSON, so a custom `frontmatter.metadata.resources` array was being sorted too and two genuinely different frontmatters compared equal. The manifest sort is now done once, at the top level, and every other array keeps its order. - SkillsScreen: invalidate on the whole selected entry, not just its manifest. The `skills/get` comparison covers `frontmatter`, so a refresh that changed only a description left a stale "matches skills/list" verdict on screen — computed against an entry that no longer existed. Re-verifying after a metadata-only refresh is the cheap direction to be wrong in; showing a match against a different entry is not. - SkillsScreen: give each "Verify all" invocation a token, so a finalizer can only ever clear its own batch rather than any batch with a matching manifest key. - SkillsScreen.stories: supply `onGetSkill`. Every story rendered the "Fetch with skills/get" button, and clicking it threw. On the batch token: the A → B → A sequence the review describes is not actually reachable, because the button is disabled whenever the in-flight batch's key matches the selection — returning to A finds it disabled, not clickable. The token is defence in depth for the same property, and the test asserts what is really guaranteed rather than the unreachable path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.stories.tsx | 7 ++ .../SkillsScreen/SkillsScreen.test.tsx | 68 +++++++++++++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 53 ++++++++------- clients/web/src/test/core/mcp/skills.test.ts | 63 +++++++++++++++++ core/mcp/skills.ts | 47 ++++++++----- 5 files changed, 197 insertions(+), 41 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx index b1a9735596..df786ba17b 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx @@ -99,6 +99,13 @@ const meta: Meta = { ? { text: REF_TEXT } : { text: SELF_TEXT, mimeType: "text/markdown" }, ), + // Echoes back the entry `skills/list` advertised, so "Fetch with + // skills/get" demonstrates the matching case rather than throwing. + onGetSkill: fn(async (uri: string) => { + const found = sampleSkills.find((skill) => skill.uri === uri); + if (!found) throw new Error(`Unknown skill uri: ${uri}`); + return found; + }), }, render: (args) => , }; diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 2d4a074eef..b5be49fcb0 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -615,6 +615,74 @@ describe("SkillsScreen", () => { ).toBeInTheDocument(); }); + it("drops the skills/get verdict when a refresh changes only metadata", async () => { + // The manifest is untouched, so a manifest-only invalidation key would + // leave "matches" on screen even though it was computed against the + // previous entry — and that comparison covers `frontmatter` too. + const user = userEvent.setup(); + const onGetSkill = vi.fn().mockResolvedValue(CLEAN_SKILL); + const { rerender } = renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + expect( + await screen.findByText("skills/get matches skills/list"), + ).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByTestId("skills-get-result")).not.toBeInTheDocument(); + }); + + it("keeps Verify all disabled on returning to a skill whose batch is still running", async () => { + // This is what actually blocks a second batch for one manifest: the button + // is disabled whenever the in-flight batch's key matches the selection, so + // A → B → A comes back to a disabled button rather than a second pool. + // (`batch` also carries a per-invocation token, so a finalizer can only + // ever clear its own batch — belt and braces for the same property.) + const user = userEvent.setup(); + const onReadSkillFile = vi.fn( + () => new Promise<{ text: string }>(() => {}), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled(); + + // B is free to run its own batch... + await user.click(screen.getByText("tampered")); + expect( + screen.getByRole("button", { name: /Verify all/ }), + ).not.toBeDisabled(); + + // ...and returning to A finds its batch still in flight. + await user.click(screen.getByText("data-analysis")); + expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled(); + }); + it("shows the SKILL.md preview on demand", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index e4dbdc3b12..3dfcd343d2 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -340,11 +340,19 @@ export function SkillsScreen({ const [fetchedEntry, setFetchedEntry] = useState({ key: null, }); - // The manifest whose "Verify all" batch is in flight, or `null`. Keyed rather - // than a bare boolean: a global flag would leave a NEWLY selected skill's - // button disabled until the previous skill's reads settled — indefinitely, if - // one of them hangs. - const [batchKey, setBatchKey] = useState(null); + // The "Verify all" batch in flight, as the manifest it belongs to plus a + // token unique to that invocation, or `null`. + // + // The key alone would be a bare boolean's problem one level up: a global flag + // leaves a NEWLY selected skill's button disabled until the previous skill's + // reads settle (indefinitely, if one hangs), and a key-only guard lets two + // batches for the SAME manifest clear each other — start on A, switch to B, + // return to A and start again, and the first A batch's finalizer sees a + // matching key and frees the button while the second is still running, + // re-opening the concurrency cap it exists to hold. + const [batch, setBatch] = useState<{ key: string; token: number } | null>( + null, + ); // 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 @@ -380,20 +388,18 @@ export function SkillsScreen({ [selected], ); - // What every verdict on screen is a verdict *about*: the selected skill AND - // the manifest it advertised. Keying invalidation on the URI alone would - // leave a green `verified` badge attached to a digest the Refresh replaced, - // so the UI would vouch for content it has never checked. A primitive string - // rather than the manifest object, because `useValueChange` compares with - // `Object.is` and a fresh array every render would loop. + // 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( - () => - [ - selectedSkillUri ?? "", - selected?.resources === DYNAMIC_RESOURCES ? "dynamic" : "", - ...manifest.map((r) => `${r.uri}|${r.digest ?? ""}|${r.size ?? ""}`), - ].join("\n"), - [manifest, selected, selectedSkillUri], + () => (selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")), + [selected, selectedSkillUri], ); // Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a @@ -469,14 +475,15 @@ export function SkillsScreen({ } }; const workers = Math.min(VERIFY_CONCURRENCY, manifest.length); - setBatchKey(key); + const token = (nextAttempt.current += 1); + setBatch({ 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 batch: a stale finalizer must not free a button the - // user has since re-armed on another skill. - () => setBatchKey((prev) => (prev === key ? null : prev)), + // Clears only ITS OWN invocation: matched on the token, not the key, so + // an earlier batch settling cannot free a button a later one is holding. + () => setBatch((prev) => (prev?.token === token ? null : prev)), ); }, [manifest, manifestKey, verifyRow]); @@ -527,7 +534,7 @@ export function SkillsScreen({ }, [manifestKey, onGetSkill, selected]); const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined; - const batchRunning = batchKey === manifestKey; + const batchRunning = batch?.key === manifestKey; const preview = previewState.key === manifestKey ? previewState.contents : undefined; diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 9787d3ba78..5ea44b0966 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -10,6 +10,7 @@ import { getSkillsExtension, isSkillsExtensionSupported, normalizeSkillUri, + skillEntriesMatch, sha256Digest, skillDisplayName, skillNameFromUri, @@ -172,6 +173,68 @@ describe("normalizeSkillUri", () => { }); }); +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("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"); diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 2d9346a7ec..926c683a8f 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -411,31 +411,42 @@ export function totalSkillBytes(resources: readonly SkillResource[]): number { * presents it as "the snapshot moved" and leaves the judgement to the reader. */ export function skillEntriesMatch(a: SkillEntry, b: SkillEntry): boolean { - return canonicalJson(a) === canonicalJson(b); + return canonicalEntry(a) === canonicalEntry(b); } -/** Deterministic JSON: object keys sorted recursively, and a skill's manifest - * sorted by URI so its enumeration order carries no meaning. */ -function canonicalJson(value: unknown): string { - return JSON.stringify(canonicalize(value)); +/** + * 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, ...rest } = entry; + const manifest = Array.isArray(resources) + ? [...resources] + .sort((x, y) => String(x?.uri).localeCompare(String(y?.uri))) + .map(canonicalize) + : resources; + return JSON.stringify({ ...sortKeys(rest), 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; - const entries = Object.entries(value as Record) - .map(([key, member]): [string, unknown] => { - // The manifest is a set; only its contents are meaningful. - if (key === "resources" && Array.isArray(member)) { - const sorted = [...(member as SkillResource[])].sort((x, y) => - String(x?.uri).localeCompare(String(y?.uri)), - ); - return [key, sorted.map(canonicalize)]; - } - return [key, canonicalize(member)]; - }) - .sort(([x], [y]) => x.localeCompare(y)); - return Object.fromEntries(entries); + 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. */ From 7e291f92f0c02409ab7c42928212953e5c10a45e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 23:46:56 -0400 Subject: [PATCH 10/18] fix: address Copilot review round 8 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useServerCommands: require the returned URI to match the requested one in `onReadSkillFile`. The "a single-block response must be the block we asked for" fallback is right for a viewer and wrong here: these bytes are hashed against the requested 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 it `verified`. A normalized equivalent is still accepted (a server may echo a resolved `..`), but two unparseable URIs do not compare equal, and the error names what came back instead. - test-servers/skills.ts: build each `SKILL.md` FROM its frontmatter object. Three fixtures listed one description and served another, which is an undocumented extra violation — SEP-2640 requires the listed and served frontmatter to be identical — and 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 drift impossible rather than merely fixed. The PR description's severity table also still described the round-1 contract (limits as errors, digest/size/description as warnings) while the code says the reverse. Rewritten to match, with the MUST/SHOULD split stated rather than implied. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../web/src/hooks/useServerCommands.test.tsx | 38 +++++++++- clients/web/src/hooks/useServerCommands.tsx | 32 ++++++-- test-servers/src/skills.ts | 75 ++++++++++++------- 3 files changed, 106 insertions(+), 39 deletions(-) diff --git a/clients/web/src/hooks/useServerCommands.test.tsx b/clients/web/src/hooks/useServerCommands.test.tsx index e7b6a48e77..004f0062fa 100644 --- a/clients/web/src/hooks/useServerCommands.test.tsx +++ b/clients/web/src/hooks/useServerCommands.test.tsx @@ -863,12 +863,13 @@ describe("onReadSkillFile (#2234)", () => { }); }); - 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. + 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/reference.md", text: "x" }] }, + result: { + contents: [{ uri: "skill://demo/x/../reference.md", text: "x" }], + }, }), }); const h = harness({ client: c }); @@ -877,6 +878,35 @@ describe("onReadSkillFile (#2234)", () => { }); }); + 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({ diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx index 1fb7f052b5..90457e7806 100644 --- a/clients/web/src/hooks/useServerCommands.tsx +++ b/clients/web/src/hooks/useServerCommands.tsx @@ -36,6 +36,7 @@ import type { GetPromptState } from "../components/screens/PromptsScreen/Prompts 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, @@ -954,14 +955,31 @@ export function useServerCommands({ const onReadSkillFile = useCallback( async (uri: string): Promise => { 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); + // 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}`); + 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 diff --git a/test-servers/src/skills.ts b/test-servers/src/skills.ts index b04444b723..ace50d8768 100644 --- a/test-servers/src/skills.ts +++ b/test-servers/src/skills.ts @@ -96,53 +96,81 @@ interface FixtureFile { interface FixtureSkill { /** The `` segment; `skill:///SKILL.md` is the entry URI. */ path: string; - frontmatter: Record; + /** 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"; } -function skillMd(name: string, description: string, body: string): string { - return `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`; +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", - "Analyze a CSV and summarize its columns", + 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-notes", - "A skill whose manifest digest does not match its served bytes", + 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-report", - "A skill whose files are generated per request", + 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. +// 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( - "right-name", - "A skill whose URI path segment disagrees with its frontmatter name", + MISMATCHED_FM, "# Mismatched name\n\nServed from `wrong-folder/` while claiming the name `right-name`.", ); const FIXTURE_SKILLS: FixtureSkill[] = [ { path: "data-analysis", - frontmatter: { - name: "data-analysis", - description: "Analyze a CSV and summarize its columns", - }, + frontmatter: DATA_ANALYSIS_FM, files: [ { uri: "skill://data-analysis/SKILL.md", @@ -158,10 +186,7 @@ const FIXTURE_SKILLS: FixtureSkill[] = [ }, { path: "tampered-notes", - frontmatter: { - name: "tampered-notes", - description: "A skill whose manifest digest does not match its bytes", - }, + frontmatter: TAMPERED_FM, files: [ { uri: "skill://tampered-notes/SKILL.md", @@ -181,18 +206,12 @@ const FIXTURE_SKILLS: FixtureSkill[] = [ }, { path: "dynamic-report", - frontmatter: { - name: "dynamic-report", - description: "A skill whose files are generated per request", - }, + frontmatter: DYNAMIC_FM, files: "dynamic", }, { path: "wrong-folder", - frontmatter: { - name: "right-name", - description: "A skill whose URI segment disagrees with its name", - }, + frontmatter: MISMATCHED_FM, files: [ { uri: "skill://wrong-folder/SKILL.md", From c1a7cfb0920557a518f8185367b05b3bfd8eb97b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 4 Sep 2026 23:59:16 -0400 Subject: [PATCH 11/18] fix: address Copilot review round 9 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkillsScreen: track "Verify all" batches in a MAP keyed by manifest, not one slot. A slot remembers only the most recent batch, so A running → start B → return to A left A's button enabled and a second worker pool could be started on top of A's first, doubling the concurrency cap. The round-7 test switched to B but never STARTED B's batch, which is exactly why it missed this; it now does, and fails against the old single slot. - test-servers/skills.ts: pass `result` schemas to `setRequestHandler` for both custom methods, and type the three builders from them, so a shape change in `toEntry` / `listSkillsPage` fails `tsc` rather than shipping a fixture that claims to conform. Declared locally rather than imported from `core/mcp/skillsSchemas.ts`: a fixture validated against the client's own schema could never catch the client being wrong. One correction to the review's premise on that second point: the SDK does NOT require `{ params, result }`, and supplying `result` does not validate the response. Its own doc on `RequestHandlerSchemas` says `result` is optional and "no runtime validation is performed on the result" — it types the handler's return value. The change is worth making for that compile-time check; it does not do what was claimed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 30 +++++----- .../screens/SkillsScreen/SkillsScreen.tsx | 41 ++++++++----- test-servers/src/skills.ts | 59 +++++++++++++++++-- 3 files changed, 95 insertions(+), 35 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index b5be49fcb0..739ae968f5 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -655,12 +655,12 @@ describe("SkillsScreen", () => { expect(screen.queryByTestId("skills-get-result")).not.toBeInTheDocument(); }); - it("keeps Verify all disabled on returning to a skill whose batch is still running", async () => { - // This is what actually blocks a second batch for one manifest: the button - // is disabled whenever the in-flight batch's key matches the selection, so - // A → B → A comes back to a disabled button rather than a second pool. - // (`batch` also carries a per-invocation token, so a finalizer can only - // ever clear its own batch — belt and braces for the same property.) + it("keeps Verify all disabled per skill while batches on other skills run", async () => { + // A → *start B's batch too* → back to A. That middle step is the one that + // matters: with a single slot instead of a map, starting B's batch + // overwrote A's, so A's button read as free and a second pool of workers + // could be started on top of A's first — doubling the concurrency cap the + // button exists to hold. const user = userEvent.setup(); const onReadSkillFile = vi.fn( () => new Promise<{ text: string }>(() => {}), @@ -668,19 +668,21 @@ describe("SkillsScreen", () => { renderWithMantine( , ); + const verifyAll = () => screen.getByRole("button", { name: /Verify all/ }); + await user.click(screen.getByText("data-analysis")); - await user.click(screen.getByRole("button", { name: /Verify all/ })); - expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled(); + await user.click(verifyAll()); + expect(verifyAll()).toBeDisabled(); - // B is free to run its own batch... + // B is free to run its own batch, and does. await user.click(screen.getByText("tampered")); - expect( - screen.getByRole("button", { name: /Verify all/ }), - ).not.toBeDisabled(); + expect(verifyAll()).not.toBeDisabled(); + await user.click(verifyAll()); + expect(verifyAll()).toBeDisabled(); - // ...and returning to A finds its batch still in flight. + // Returning to A still finds A's own batch in flight. await user.click(screen.getByText("data-analysis")); - expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled(); + expect(verifyAll()).toBeDisabled(); }); it("shows the SKILL.md preview on demand", async () => { diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 3dfcd343d2..bd7b09e7e8 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -340,18 +340,20 @@ export function SkillsScreen({ const [fetchedEntry, setFetchedEntry] = useState({ key: null, }); - // The "Verify all" batch in flight, as the manifest it belongs to plus a - // token unique to that invocation, or `null`. + // Every "Verify all" batch in flight, keyed by the manifest it belongs to. // - // The key alone would be a bare boolean's problem one level up: a global flag - // leaves a NEWLY selected skill's button disabled until the previous skill's - // reads settle (indefinitely, if one hangs), and a key-only guard lets two - // batches for the SAME manifest clear each other — start on A, switch to B, - // return to A and start again, and the first A batch's finalizer sees a - // matching key and frees the button while the second is still running, - // re-opening the concurrency cap it exists to hold. - const [batch, setBatch] = useState<{ key: string; token: number } | null>( - null, + // 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 @@ -476,14 +478,21 @@ export function SkillsScreen({ }; const workers = Math.min(VERIFY_CONCURRENCY, manifest.length); const token = (nextAttempt.current += 1); - setBatch({ key, token }); + 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, not the key, so - // an earlier batch settling cannot free a button a later one is holding. - () => setBatch((prev) => (prev?.token === token ? null : prev)), + // 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]); @@ -534,7 +543,7 @@ export function SkillsScreen({ }, [manifestKey, onGetSkill, selected]); const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined; - const batchRunning = batch?.key === manifestKey; + const batchRunning = batches.has(manifestKey); const preview = previewState.key === manifestKey ? previewState.contents : undefined; diff --git a/test-servers/src/skills.ts b/test-servers/src/skills.ts index ace50d8768..18d2edca64 100644 --- a/test-servers/src/skills.ts +++ b/test-servers/src/skills.ts @@ -238,7 +238,7 @@ for (const skill of FIXTURE_SKILLS) { } /** The wire entry for one fixture skill. */ -function toEntry(skill: FixtureSkill): Record { +function toEntry(skill: FixtureSkill): z.infer { return { uri: `skill://${skill.path}/SKILL.md`, frontmatter: skill.frontmatter, @@ -254,7 +254,9 @@ function toEntry(skill: FixtureSkill): Record { } /** One `skills/list` page starting at `cursor` (an index, as a string). */ -export function listSkillsPage(cursor?: string): Record { +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 @@ -270,7 +272,9 @@ export function listSkillsPage(cursor?: string): Record { } /** The `skills/get` result for one entry URI. */ -export function getSkillEntry(uri: string): Record { +export function getSkillEntry( + uri: string, +): z.infer { const skill = FIXTURE_SKILLS.find( (candidate) => `skill://${candidate.path}/SKILL.md` === uri, ); @@ -320,6 +324,51 @@ interface UriRequest { 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`. @@ -329,13 +378,13 @@ export function wireSkillsHandlers(mcpServer: McpServer): void { lowLevel.setRequestHandler( "skills/list", - { params: ListSkillsParamsSchema }, + { params: ListSkillsParamsSchema, result: ListSkillsResultShape }, async (params) => listSkillsPage(params.cursor), ); lowLevel.setRequestHandler( "skills/get", - { params: GetSkillParamsSchema }, + { params: GetSkillParamsSchema, result: GetSkillResultShape }, async (params) => getSkillEntry(params.uri), ); From 9d162f36c6a054b4958be92f5cd5ce5ef3ea4ea1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 00:09:56 -0400 Subject: [PATCH 12/18] fix: address Copilot review round 10 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills.ts: validate `frontmatter.name` against the Agent Skills format (1–64 lowercase alphanumerics and hyphens, no leading, trailing or consecutive hyphen). Checking only for non-emptiness let `Bad Name` read as "Conforms" — and 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. Suppressed when the name is absent entirely, which `missing-name` already reports. - skills.ts: detect duplicates on the NORMALIZED identity. Containment and the read that fetches the bytes both treat normalized-equivalents as one resource, so `skill://demo/SKILL.md` and `skill://demo/x/../SKILL.md` were passing as two distinct files while naming one. The finding still reports the raw URI, and two different unparseable URIs stay distinct. - SkillsScreen: run `checkSkillConformance` on the fetched `skills/get` entry and treat an error — or an entry answering for a different URI — as invalid (red), not as "a different snapshot" (yellow). A fresh point-in-time read excuses a CHANGE; it does not excuse a violation, and answering with another skill is never a refresh of the one requested. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 33 ++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 79 +++++++++++++++---- clients/web/src/test/core/mcp/skills.test.ts | 77 ++++++++++++++++++ core/mcp/skills.ts | 37 ++++++++- 4 files changed, 208 insertions(+), 18 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index 739ae968f5..b97608ccad 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -501,6 +501,39 @@ describe("SkillsScreen", () => { expect(screen.getByTestId("skills-get-result")).toBeInTheDocument(); }); + it("calls a non-conforming skills/get entry invalid, not a new snapshot", async () => { + // A fresh snapshot excuses a CHANGE; it does not excuse a violation. An + // entry missing a digest is invalid whether or not the skill moved on. + const user = userEvent.setup(); + const onGetSkill = vi.fn().mockResolvedValue({ + ...CLEAN_SKILL, + resources: [{ uri: "skill://data-analysis/SKILL.md" }], + }); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + const result = await screen.findByTestId("skills-get-result"); + expect(result).toHaveAttribute("data-verdict", "invalid"); + expect(result).toHaveTextContent("missing-digest"); + }); + + it("calls a skills/get answer for a different uri invalid", async () => { + // Answering with another skill is never a valid refresh of the one asked + // for, however much that other skill may have changed. + const user = userEvent.setup(); + const onGetSkill = vi.fn().mockResolvedValue(TAMPERED_SKILL); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + await user.click( + screen.getByRole("button", { name: /Fetch with skills\/get/ }), + ); + const result = await screen.findByTestId("skills-get-result"); + expect(result).toHaveAttribute("data-verdict", "invalid"); + expect(result).toHaveTextContent("different URI"); + }); + it("reports a failed skills/get", async () => { const user = userEvent.setup(); const onGetSkill = vi.fn().mockRejectedValue(new Error("-32602")); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index bd7b09e7e8..2fd0fa08c9 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -97,7 +97,12 @@ interface FetchedEntryState { /** The click this result belongs to — see {@link PreviewState.attempt}. */ attempt?: number; entry?: SkillEntry; + /** Conformance findings for the FETCHED entry, in its own right. */ + issues?: SkillIssue[]; + /** True when the fetched entry describes the same skill as the listed one. */ matches?: boolean; + /** True when the fetched entry is for a different URI than was asked for. */ + wrongUri?: boolean; message?: string; } @@ -530,10 +535,21 @@ export function SkillsScreen({ ); void onGetSkill(selected.uri) .then((entry) => { - // Compared semantically against what `skills/list` advertised — see - // `skillEntriesMatch` for why a `JSON.stringify` comparison would - // report key order and manifest order as differences. - writeFetched({ entry, matches: skillEntriesMatch(entry, selected) }); + // 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), + wrongUri: entry.uri !== 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({ @@ -543,6 +559,16 @@ export function SkillsScreen({ }, [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 = @@ -810,24 +836,45 @@ export function SkillsScreen({ {fetched?.entry !== undefined && ( - {fetched.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."} + {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.matches && ( + {(fetched.issues ?? []) + .filter((issue) => issue.severity === "error") + .map((issue, index) => ( + + {issue.code}: {issue.message} + + ))} + {fetchedVerdict !== "matches" && ( { 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("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 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. @@ -385,6 +431,37 @@ describe("checkSkillConformance", () => { 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({ diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 926c683a8f..d7378d86d9 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -55,6 +55,19 @@ 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; + /** * What the server declared under `io.modelcontextprotocol/skills`. The only * sub-option SEP-2640 defines is `directoryRead`, which gates @@ -162,6 +175,7 @@ export function skillDisplayName(entry: SkillEntry): string { export type SkillIssueCode = | "dynamic-resources" | "missing-name" + | "malformed-name" | "missing-description" | "malformed-uri" | "name-path-mismatch" @@ -213,6 +227,15 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { severity: "error", message: "frontmatter.name is required but missing or empty.", }); + } else if ( + declaredName.length > SKILL_NAME_MAX_LENGTH || + !SKILL_NAME_PATTERN.test(declaredName) + ) { + 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.`, + }); } if (!entry.frontmatter.description?.trim()) { // An error, not a warning: SEP-2640 requires `description` on every skill, @@ -301,7 +324,17 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { : undefined; for (const resource of entry.resources) { - if (seenUris.has(resource.uri)) { + // 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 = normalizeSkillUri(resource.uri) ?? resource.uri; + if (seenUris.has(identity)) { issues.push({ code: "duplicate-resource", severity: "error", @@ -310,7 +343,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { resourceUri: resource.uri, }); } - seenUris.add(resource.uri); + seenUris.add(identity); if (root !== undefined) { const normalized = normalizeSkillUri(resource.uri); // An unparseable entry URI is outside the root by construction: nothing From d0302078d0ec1a58cc3bbd9f83d38ca3f3c27194 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 00:21:00 -0400 Subject: [PATCH 13/18] fix: address Copilot review round 11 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills.ts: apply the two RFC 3986 §6.2.2 percent-encoding normalizations the URL parser does not — decode an escape standing for an unreserved character, and upper-case the hex of every escape that remains. `URL.href` leaves `%72eference.md` encoded, so a server echoing an RFC-equivalent form of the URI we asked for was rejected by `onReadSkillFile` as a different resource, and an encoded name segment produced a false `name-path-mismatch`. Both are the tool calling a conforming server wrong. - oauthResume.test: assert the Skills restore path. It was wired into the setters but never checked, and the comprehensive present-value and undefined-fallback cases omitted Skills entirely. Adds it to both, plus a case restoring a REAL saved selection rather than the EMPTY value — a bug that always wrote the default would have passed the others. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- clients/web/src/lib/oauthResume.test.ts | 30 ++++++++++++++++++++ clients/web/src/test/core/mcp/skills.test.ts | 18 ++++++++++++ core/mcp/skills.ts | 22 +++++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/clients/web/src/lib/oauthResume.test.ts b/clients/web/src/lib/oauthResume.test.ts index 6977bafa37..51b11c9f77 100644 --- a/clients/web/src/lib/oauthResume.test.ts +++ b/clients/web/src/lib/oauthResume.test.ts @@ -606,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, @@ -617,12 +618,39 @@ 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(), @@ -641,6 +669,7 @@ describe("oauthResume", () => { Prompts: undefined, Resources: undefined, Apps: undefined, + Skills: undefined, Tasks: undefined, Logs: undefined, Protocol: undefined, @@ -652,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/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 8b08537092..8686a171f3 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -166,6 +166,24 @@ describe("normalizeSkillUri", () => { 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", diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index d7378d86d9..e13de7638a 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -137,7 +137,27 @@ export function normalizeSkillUri(uri: string): string | undefined { } catch { return undefined; } - return parsed.pathname.startsWith("/") ? parsed.href : 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(); + }); } /** From bd28a36f237757843804923ea3a0e027324fc7b0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 00:46:18 -0400 Subject: [PATCH 14/18] fix: address Copilot review round 12 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three findings were the same underlying inconsistency: URI comparisons on raw strings, in a flow that elsewhere deliberately treats normalized equivalents as the same resource. One `skillUriIdentity` helper now backs every comparison, so they cannot disagree. - skills.ts: `manifest-missing-self` compares identities, so a manifest listing the RFC-equivalent `skill://demo/%53KILL.md` is recognized as the entry's own file — it is fetchable as that file, so reporting it missing was the tool disagreeing with itself. - skills.ts: `skillEntriesMatch` normalizes the entry URI and every manifest URI before comparing, so a server that canonicalizes an escape between the listing and the fetch is not reported as a changed snapshot. - SkillsScreen: the `wrongUri` check uses the same identity, so a canonicalizing server is not accused of answering for a different skill — the read path already accepts exactly that equivalence. - skills.ts: apply the Agent Skills name grammar to the RAW value. Trimming first let `" demo "` pass, and whitespace is not in the grammar — so an entry could report "Conforms" with a name that can never equal its URI path segment. The trimmed copy now only distinguishes absent from invalid. - skills.ts: add `malformed-description` for a description above the 1024-character Agent Skills limit, with boundary coverage. - inspectorClient-skills.test: justify the `as unknown as` per AGENTS.md — both fields are `private` with no public setter, the asserted shape is exactly what the class declares, and the alternative is a live connection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../screens/SkillsScreen/SkillsScreen.tsx | 8 ++- .../core/mcp/inspectorClient-skills.test.ts | 15 ++++ clients/web/src/test/core/mcp/skills.test.ts | 69 +++++++++++++++++++ core/mcp/skills.ts | 62 +++++++++++++++-- 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 2fd0fa08c9..b882942b49 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -24,6 +24,7 @@ import { checkSkillConformance, skillDisplayName, skillEntriesMatch, + skillUriIdentity, totalSkillBytes, verifySkillResource, type SkillIssue, @@ -544,7 +545,12 @@ export function SkillsScreen({ writeFetched({ entry, issues: checkSkillConformance(entry), - wrongUri: entry.uri !== selected.uri, + // 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. diff --git a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts index 1ad5250cde..a68b23794e 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts @@ -45,6 +45,21 @@ describe("InspectorClient skills methods (#2234)", () => { ); } + /** + * 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; } diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 8686a171f3..6bea4241ce 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -11,6 +11,7 @@ import { isSkillsExtensionSupported, normalizeSkillUri, skillEntriesMatch, + skillUriIdentity, sha256Digest, skillDisplayName, skillNameFromUri, @@ -191,6 +192,21 @@ describe("normalizeSkillUri", () => { }); }); +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", @@ -237,6 +253,20 @@ describe("skillEntriesMatch", () => { ).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(), { @@ -307,6 +337,16 @@ describe("checkSkillConformance", () => { } }); + 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( @@ -330,6 +370,21 @@ describe("checkSkillConformance", () => { 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("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. @@ -388,6 +443,20 @@ describe("checkSkillConformance", () => { 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. diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index e13de7638a..02e3ab6cf8 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -68,6 +68,9 @@ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; 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; + /** * What the server declared under `io.modelcontextprotocol/skills`. The only * sub-option SEP-2640 defines is `directoryRead`, which gates @@ -180,6 +183,19 @@ export function skillNameFromUri(uri: string): string | undefined { 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 @@ -197,6 +213,7 @@ export type SkillIssueCode = | "missing-name" | "malformed-name" | "missing-description" + | "malformed-description" | "malformed-uri" | "name-path-mismatch" | "missing-digest" @@ -238,7 +255,11 @@ export interface SkillIssue { */ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { const issues: SkillIssue[] = []; - const declaredName = entry.frontmatter.name?.trim(); + // 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) { @@ -251,13 +272,15 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { declaredName.length > 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.`, }); } - if (!entry.frontmatter.description?.trim()) { + 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({ @@ -265,6 +288,12 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { severity: "error", message: "frontmatter.description is required but missing or empty.", }); + } else if (rawDescription.length > SKILL_DESCRIPTION_MAX_LENGTH) { + issues.push({ + code: "malformed-description", + severity: "error", + message: `frontmatter.description is ${rawDescription.length} characters, above the ${SKILL_DESCRIPTION_MAX_LENGTH}-character limit.`, + }); } if (uriName === undefined) { issues.push({ @@ -320,7 +349,16 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { // 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. - if (!entry.resources.some((resource) => resource.uri === entry.uri)) { + // 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", @@ -353,7 +391,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { // 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 = normalizeSkillUri(resource.uri) ?? resource.uri; + const identity = skillUriIdentity(resource.uri); if (seenUris.has(identity)) { issues.push({ code: "duplicate-resource", @@ -478,13 +516,23 @@ export function skillEntriesMatch(a: SkillEntry, b: SkillEntry): boolean { * a set; every other array keeps its order. */ function canonicalEntry(entry: SkillEntry): string { - const { resources, ...rest } = entry; + 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] - .sort((x, y) => String(x?.uri).localeCompare(String(y?.uri))) + .map((resource) => ({ + ...resource, + uri: skillUriIdentity(String(resource?.uri)), + })) + .sort((x, y) => x.uri.localeCompare(y.uri)) .map(canonicalize) : resources; - return JSON.stringify({ ...sortKeys(rest), resources: manifest }); + return JSON.stringify({ + ...sortKeys(rest), + uri: skillUriIdentity(uri), + resources: manifest, + }); } /** Object keys sorted recursively; array ORDER is preserved throughout. */ From 4d7dd180d9d1886e256985ae0380c68ba62cc44f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 01:40:28 -0400 Subject: [PATCH 15/18] fix: address Copilot review round 13 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkillsScreen: put the SESSION in the invalidation key. This screen stays mounted across a disconnect, so content alone cannot tell server A's entry from an identical-looking one on server B — A's in-flight verification could land afterwards and report `verified` for a file never read from B, and a retained batch entry could leave B's Verify all disabled. `useInspectorStores` now exposes a `sessionNonce`, bumped on both create and destroy so it never repeats across a reconnect, and App keys on `${activeServerId}:${sessionNonce}` — the server id alone would repeat, which is one of the crossings this exists to prevent. - SkillsScreen: match the selection by `skillUriIdentity`, in both the lookup and the NavLink active check. A refresh that canonicalizes `skill://demo/%53KILL.md` names the same skill, and the detail pane was emptying out because the server changed its spelling — the last raw-string URI comparison left after round 12. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- clients/web/src/App.tsx | 5 ++ .../SkillsScreen/SkillsScreen.test.tsx | 62 +++++++++++++++++++ .../screens/SkillsScreen/SkillsScreen.tsx | 33 +++++++--- .../InspectorView/InspectorView.stories.tsx | 1 + .../InspectorView/InspectorView.test.tsx | 1 + .../views/InspectorView/InspectorView.tsx | 2 + .../components/views/InspectorView/types.ts | 6 ++ .../web/src/hooks/useInspectorStores.test.tsx | 15 +++++ clients/web/src/hooks/useInspectorStores.ts | 17 +++++ 9 files changed, 135 insertions(+), 7 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 617ce43851..ad85341364 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -459,6 +459,7 @@ function App() { tasks, refreshTasks, clearCompletedTasks, + sessionNonce, skills, skillsPageCount, skillsLoadError, @@ -1826,6 +1827,10 @@ function App() { }; const skillsPanelProps: SkillsPanelProps = { + // Server id AND per-connect nonce: the id alone would repeat on a + // reconnect to the same server, which is one of the crossings this key + // exists to prevent. + skillsSessionKey: `${activeServerId ?? ""}:${sessionNonce}`, skills, skillsPageCount, skillsLoadError, diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index b97608ccad..b70067f576 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -101,6 +101,7 @@ const readFixtureFile = vi.fn(async (uri: string) => { }); const baseProps: SkillsScreenProps = { + sessionKey: "session-1", skills: ALL_SKILLS, pageCount: 2, ui: EMPTY_SKILLS_UI, @@ -718,6 +719,67 @@ describe("SkillsScreen", () => { expect(verifyAll()).toBeDisabled(); }); + it("discards a verification that lands after the session changed", async () => { + // This screen stays mounted across a disconnect, so content alone does not + // tell server A's entry from an identical-looking one on server B. Without + // the session in the key, A's in-flight read would land and report + // `verified` for a file that was never read from B. + const user = userEvent.setup(); + let release: ((value: { text: string }) => void) | undefined; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((resolve) => { + release = resolve; + }), + ); + const { rerender } = renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /Verify all/ })); + + // Same entry, different session. + rerender( + , + ); + release?.({ text: SELF_TEXT }); + expect(screen.queryByText("verified")).not.toBeInTheDocument(); + // ...and the batch guard did not carry over either. + expect( + screen.getByRole("button", { name: /Verify all/ }), + ).not.toBeDisabled(); + }); + + it("keeps the selection when a refresh canonicalizes the skill's URI", async () => { + // The selection is stored as the URI the list gave us, so a server that + // re-spells it must not empty the detail pane for the same skill. + renderWithMantine( + , + ); + expect(screen.getByTestId("skill-detail")).toBeInTheDocument(); + expect( + screen.queryByText("Select a skill to view details"), + ).not.toBeInTheDocument(); + }); + it("shows the SKILL.md preview on demand", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index b882942b49..35a39f1fee 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -108,6 +108,14 @@ interface FetchedEntryState { } export interface SkillsScreenProps { + /** + * Identity of the connected session. Part of the invalidation key below, so + * a verification still in flight when the user switches servers cannot land + * afterwards and report a verdict for an identical-looking entry on the new + * one — this screen stays mounted across a disconnect, so content alone does + * not distinguish the two. + */ + sessionKey: string; skills: SkillEntry[]; /** Pages the last `skills/list` walk took; shown so pagination is visible. */ pageCount: number; @@ -320,6 +328,7 @@ function shortDigest(digest: string | undefined): string { * the Inspector fetches only what the user asks it to. */ export function SkillsScreen({ + sessionKey, skills, pageCount, loadError, @@ -378,10 +387,15 @@ export function SkillsScreen({ ); }, [skills, search]); - const selected = useMemo( - () => skills.find((skill) => skill.uri === selectedSkillUri), - [skills, selectedSkillUri], - ); + // 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) : []), @@ -406,8 +420,9 @@ export function SkillsScreen({ // not. A primitive string, because `useValueChange` compares with `Object.is` // and a fresh object every render would loop. const manifestKey = useMemo( - () => (selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")), - [selected, selectedSkillUri], + () => + `${sessionKey}\n${selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")}`, + [selected, selectedSkillUri, sessionKey], ); // Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a @@ -623,7 +638,11 @@ export function SkillsScreen({ return ( diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 3ca519985b..f8b2445a17 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -427,6 +427,7 @@ const appsArgs: AppsPanelProps = { }; const skillsArgs: SkillsPanelProps = { + skillsSessionKey: "story-session", skills: [], skillsPageCount: 0, skillsUi: EMPTY_SKILLS_UI, diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 4f784cb53b..d717dcfb1b 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -172,6 +172,7 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps { ...mergeBundle("apps", overrides), }, skills: { + skillsSessionKey: "test-session", skills: [], skillsPageCount: 0, skillsUi: EMPTY_SKILLS_UI, diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index d620333e91..ff7d8a0cb0 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -456,6 +456,7 @@ export function InspectorView({ onRefreshApps, } = appsPanel; const { + skillsSessionKey, skills, skillsPageCount, skillsLoadError, @@ -1055,6 +1056,7 @@ export function InspectorView({ onSortChange: setConsoleSort, }; const skillsScreenProps = { + sessionKey: skillsSessionKey, skills, pageCount: skillsPageCount, loadError: skillsLoadError, diff --git a/clients/web/src/components/views/InspectorView/types.ts b/clients/web/src/components/views/InspectorView/types.ts index 15e2a24988..1eff14681a 100644 --- a/clients/web/src/components/views/InspectorView/types.ts +++ b/clients/web/src/components/views/InspectorView/types.ts @@ -313,6 +313,12 @@ export interface AppsPanelProps { /** 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; diff --git a/clients/web/src/hooks/useInspectorStores.test.tsx b/clients/web/src/hooks/useInspectorStores.test.tsx index 2ec027e2b8..d13713cc55 100644 --- a/clients/web/src/hooks/useInspectorStores.test.tsx +++ b/clients/web/src/hooks/useInspectorStores.test.tsx @@ -257,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 3ad32fad43..bff13c9f4f 100644 --- a/clients/web/src/hooks/useInspectorStores.ts +++ b/clients/web/src/hooks/useInspectorStores.ts @@ -86,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. @@ -162,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); @@ -177,6 +191,7 @@ export function useInspectorStores({ storesRef.current = null; fetchLogRef.current = null; setStores(null); + setSessionNonce((n) => n + 1); }, []); const createStores = useCallback( @@ -214,6 +229,7 @@ export function useInspectorStores({ storesRef.current = next; fetchLogRef.current = fetchRequestLogState; setStores(next); + setSessionNonce((n) => n + 1); }, [destroyStores], ); @@ -340,6 +356,7 @@ export function useInspectorStores({ return { stores, + sessionNonce, createStores, destroyStores, fetchLogRef, From 11ac134975244908d42d8e74d66a8a281c653b43 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 02:01:18 -0400 Subject: [PATCH 16/18] feat: require the modern list envelope on skills/list (review round 14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review finally quoted the normative text, which is what I asked for across five rounds before declining: SEP-2640's `skills/list` section says "In protocol versions 2026-07-28 and later, the result also carries … `ttlMs` and `cacheScope`." So the era-aware validation is in. - skillsSchemas: `ModernListSkillsResultSchema` = the page plus the base list envelope, with field shapes mirroring `ModernResultEnvelopeSchema` in `listSalvage.ts` so this repo's two statements of a modern envelope cannot drift. `InspectorClient.listSkills` picks it from the negotiated era; the legacy shape stays permissive, because those are 2026-era attributes a legacy server has no business sending. Nothing else catches this: `skills/*` is consumer-owned, so it is absent from the SDK's cacheable-method registry and a modern server answering `{ skills: [] }` was reaching the conformance UI as a clean list. - skills.ts: count `frontmatter.description` (and `name`) length in Unicode CODE POINTS. `String.length` counts UTF-16 code units, so 600 non-BMP characters measured as 1200 and a perfectly valid description was reported `malformed-description` — a conforming server failed by an off-by-encoding, which is the direction this module works hardest to avoid. The Agent Skills reference validator uses Python `len()`. - #2248 narrowed: the `skills/list` half is settled here; what remains is whether `skills/get` carries the same attributes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../core/mcp/inspectorClient-skills.test.ts | 35 ++++++++++ clients/web/src/test/core/mcp/skills.test.ts | 18 +++++ .../src/test/core/mcp/skillsSchemas.test.ts | 51 ++++++++++++++ core/mcp/inspectorClient.ts | 12 +++- core/mcp/skills.ts | 21 +++++- core/mcp/skillsSchemas.ts | 68 ++++++++++++------- 6 files changed, 178 insertions(+), 27 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts index a68b23794e..779fdaf6bb 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts @@ -27,6 +27,7 @@ describe("InspectorClient skills methods (#2234)", () => { }; interface SkillsInternals { + protocolEra: string | undefined; client: { request: ( req: { method: string; params: Record }, @@ -146,6 +147,40 @@ describe("InspectorClient skills methods (#2234)", () => { ).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("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 diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index 6bea4241ce..eb4258d93f 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -378,6 +378,24 @@ describe("checkSkillConformance", () => { 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) } }), diff --git a/clients/web/src/test/core/mcp/skillsSchemas.test.ts b/clients/web/src/test/core/mcp/skillsSchemas.test.ts index 724c98cc38..085e60b893 100644 --- a/clients/web/src/test/core/mcp/skillsSchemas.test.ts +++ b/clients/web/src/test/core/mcp/skillsSchemas.test.ts @@ -3,6 +3,7 @@ import { DYNAMIC_RESOURCES, GetSkillResultSchema, ListSkillsResultSchema, + ModernListSkillsResultSchema, SKILLS_EXTENSION_KEY, SKILLS_GET_METHOD, SKILLS_LIST_METHOD, @@ -87,6 +88,56 @@ describe("ListSkillsResultSchema", () => { }); }); +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); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 07a9cc6df7..70b95eb714 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -147,6 +147,7 @@ import { buildClientExtensions } from "./extensions.js"; import { GetSkillResultSchema, ListSkillsResultSchema, + ModernListSkillsResultSchema, SKILLS_GET_METHOD, SKILLS_LIST_METHOD, type SkillEntry, @@ -5561,11 +5562,20 @@ export class InspectorClient extends InspectorClientEventTarget { // 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 }, - ListSkillsResultSchema, + resultSchema, this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: SKILLS_LIST_METHOD }, diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts index 02e3ab6cf8..aaf4145c16 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -71,6 +71,21 @@ 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 @@ -269,7 +284,7 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { message: "frontmatter.name is required but missing or empty.", }); } else if ( - declaredName.length > SKILL_NAME_MAX_LENGTH || + codePointLength(declaredName) > SKILL_NAME_MAX_LENGTH || !SKILL_NAME_PATTERN.test(declaredName) ) { // Reaches here for `" demo "` too: the grammar sees the untrimmed value. @@ -288,11 +303,11 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] { severity: "error", message: "frontmatter.description is required but missing or empty.", }); - } else if (rawDescription.length > SKILL_DESCRIPTION_MAX_LENGTH) { + } else if (codePointLength(rawDescription) > SKILL_DESCRIPTION_MAX_LENGTH) { issues.push({ code: "malformed-description", severity: "error", - message: `frontmatter.description is ${rawDescription.length} characters, above the ${SKILL_DESCRIPTION_MAX_LENGTH}-character limit.`, + message: `frontmatter.description is ${codePointLength(rawDescription)} characters, above the ${SKILL_DESCRIPTION_MAX_LENGTH}-character limit.`, }); } if (uriName === undefined) { diff --git a/core/mcp/skillsSchemas.ts b/core/mcp/skillsSchemas.ts index 2ebb7181f4..bb3a1160dd 100644 --- a/core/mcp/skillsSchemas.ts +++ b/core/mcp/skillsSchemas.ts @@ -11,17 +11,21 @@ * 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 whole wire surface.** SEP-2640 is Accepted, so the method - * names and the entry shape are settled, but the skill *format* is delegated to - * the independently-versioned Agent Skills specification and the SEP leaves the - * `skills/get` caching attributes (SEP-2549 `ttlMs` / `cacheScope`) open. Keeping - * every wire type here makes a spec revision a single-file edit (#2234). + * **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). * - * Schemas are deliberately permissive (`looseObject`, and a `digest` typed as a - * plain string rather than a hex-constrained one) so a non-conforming server is - * *surfaced* rather than rejected — the Inspector is a conformance tool, and a - * malformed digest is a finding to report, not a parse error to swallow. The - * structural checks live in `skills.ts`. + * **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"; @@ -97,19 +101,8 @@ export const SkillEntrySchema = z.looseObject({ export type SkillEntry = z.infer; /** - * `skills/list` result: a page of entries plus the opaque cursor. - * - * ⚠️ **Whether a modern-era (2026-07-28) result must also carry the SEP-2549 - * caching attributes `ttlMs` / `cacheScope` is unsettled here and deliberately - * not guessed.** #2234's analysis records it as an open point; a review of that - * PR asserted the opposite. Neither reading was checked against the normative - * text, and the two mistakes are not symmetric: leaving the schema permissive - * means a server that omits them is accepted (they pass through untouched when - * sent), while tightening on a wrong reading would *reject* conforming - * responses. `resources/directory/read` was removed from this module for the - * same reason. #2248 settles it against the spec. Note the SDK is no help - * either way — `skills/list` is consumer-owned, so it is absent from the - * cacheable-method registry and nothing stamps or validates these fields. + * `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), @@ -118,6 +111,35 @@ export const ListSkillsResultSchema = z.looseObject({ 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`. * From a561cd1daa747cad59ed878d8b0c534a90a28ee2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 02:21:41 -0400 Subject: [PATCH 17/18] fix: address Copilot review round 15 on #2251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 15 generated no new inline comments; all five suppressed findings were real and unaddressed. - SkillsScreen: claim the attempt BEFORE issuing the request, for both the SKILL.md preview and `skills/get`. Recording it only on settle left a window where an older request that happened to resolve first was still considered current and published while a newer one was in flight — `verifyRow` already claimed its row up front, these two did not. Tests answer the OLDER request first and assert nothing is published. - SkillsScreen: "No skills listed", not "No skills". SEP-2640 lets a server return an empty or partial catalog and says an empty result is not proof it has none — an unlisted skill is still fetchable by URI — so the old copy had the tool asserting something the protocol explicitly does not. - SkillsScreen: each row's Verify button gets an `aria-label` carrying its URI. Every row's visible text is "Verify" and the URI cell is not programmatically associated with the button, so a screen-reader user had no way to tell which file each control checked. - inspectorClient: attribute a rejected `skills/get` envelope with `markResponseRejected`, so the Protocol tab stops rendering it as a clean success while the screen shows an error. In the client rather than a store because `skills/get` has none. Gated on `isClientDecodeRejection`, with a test that a transport failure is NOT attributed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../SkillsScreen/SkillsScreen.test.tsx | 87 +++++++++++++++++-- .../screens/SkillsScreen/SkillsScreen.tsx | 25 +++++- .../core/mcp/inspectorClient-skills.test.ts | 47 ++++++++++ core/mcp/inspectorClient.ts | 37 ++++++-- 4 files changed, 176 insertions(+), 20 deletions(-) diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx index b70067f576..88df0cbb1a 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx @@ -153,9 +153,14 @@ describe("SkillsScreen", () => { expect(root).toHaveAttribute("data-skill-page-count", "2"); }); - it("renders 'No skills' when the list is empty", () => { + it("says the list was empty without claiming there are no skills", () => { + // SEP-2640 lets a server return an empty or partial catalog and says an + // empty result is not proof it has none — an unlisted skill is still + // fetchable by URI — so "No skills" would be the tool asserting something + // the protocol explicitly does not. renderWithMantine(); - expect(screen.getByText("No skills")).toBeInTheDocument(); + expect(screen.getByText("No skills listed")).toBeInTheDocument(); + expect(screen.queryByText("No skills")).not.toBeInTheDocument(); }); it("renders a load failure above the list", () => { @@ -236,10 +241,13 @@ describe("SkillsScreen", () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("data-analysis")); - const manifest = screen.getByTestId("skill-manifest"); - // One row at a time: the first row's own Verify button, not "Verify all". + // Addressed by its accessible name, which carries the URI — every row's + // visible text is just "Verify", so that name is what tells a + // screen-reader user (and this test) which file the button checks. await user.click( - within(manifest).getAllByRole("button", { name: "Verify" })[0], + screen.getByRole("button", { + name: "Verify skill://data-analysis/reference.md", + }), ); expect(await screen.findByText("verified")).toBeInTheDocument(); }); @@ -374,10 +382,9 @@ describe("SkillsScreen", () => { />, ); await user.click(screen.getByText("data-analysis")); - const manifest = screen.getByTestId("skill-manifest"); - const rowVerify = within(manifest).getAllByRole("button", { - name: "Verify", - })[0]; + const rowVerify = screen.getByRole("button", { + name: "Verify skill://data-analysis/SKILL.md", + }); await user.click(rowVerify); await user.click(rowVerify); expect(resolvers).toHaveLength(2); @@ -780,6 +787,68 @@ describe("SkillsScreen", () => { ).not.toBeInTheDocument(); }); + it("rejects an older preview read even when it resolves FIRST", async () => { + // The ordering hole: recording an attempt only when it settles leaves a + // window where the older request is still considered current. Claiming it + // before the request goes out is what makes the older callback stale + // immediately, whatever order the two resolve in. + const user = userEvent.setup(); + const resolvers: ((value: { text: string }) => void)[] = []; + const onReadSkillFile = vi.fn( + () => + new Promise<{ text: string }>((resolve) => { + resolvers.push(resolve); + }), + ); + renderWithMantine( + , + ); + await user.click(screen.getByText("data-analysis")); + const view = screen.getByRole("button", { name: /View SKILL.md/ }); + await user.click(view); + await user.click(view); + expect(resolvers).toHaveLength(2); + + // The OLDER read answers first, while the newer one is still in flight. + resolvers[0]({ text: "# stale\n" }); + expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument(); + resolvers[1]({ text: "# newest\n" }); + expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent( + "newest", + ); + }); + + it("rejects an older skills/get even when it resolves FIRST", async () => { + const user = userEvent.setup(); + const resolvers: ((value: SkillEntry) => void)[] = []; + const onGetSkill = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + renderWithMantine(); + await user.click(screen.getByText("data-analysis")); + const fetchButton = screen.getByRole("button", { + name: /Fetch with skills\/get/, + }); + await user.click(fetchButton); + await user.click(fetchButton); + expect(resolvers).toHaveLength(2); + + // The older fetch answers first with a differing entry; it must not + // publish a verdict while the newer one is pending. + resolvers[0]({ + ...CLEAN_SKILL, + frontmatter: { ...CLEAN_SKILL.frontmatter, description: "stale" }, + }); + expect(screen.queryByTestId("skills-get-result")).not.toBeInTheDocument(); + resolvers[1](CLEAN_SKILL); + expect( + await screen.findByText("skills/get matches skills/list"), + ).toBeInTheDocument(); + }); + it("shows the SKILL.md preview on demand", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 35a39f1fee..909625acd1 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -529,6 +529,13 @@ export function SkillsScreen({ 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) => { @@ -549,6 +556,9 @@ export function SkillsScreen({ 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 @@ -628,7 +638,12 @@ export function SkillsScreen({ )} {filtered.length === 0 ? ( - No skills + // 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); @@ -783,8 +798,14 @@ export function SkillsScreen({ {verificationLabel(state)} void verifyRow(index, resource, manifestKey) diff --git a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts index 779fdaf6bb..989a144850 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-skills.test.ts @@ -2,6 +2,7 @@ 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). @@ -181,6 +182,52 @@ describe("InspectorClient skills methods (#2234)", () => { }); }); + 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 diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 70b95eb714..7b2964f281 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -203,6 +203,7 @@ import { LIST_MAX_PAGES, ModernResultEnvelopeSchema, isSalvageableRejection, + isClientDecodeRejection, listPaginationExceeded, toolItemSchemaForEra, nextCursorOf, @@ -5603,15 +5604,33 @@ export class InspectorClient extends InspectorClientEventTarget { }; // `GetSkillResultSchema` unwraps the envelope, so there is nothing to // unwrap here. - return this.invokeMcpClient( - () => - this.client!.request( - { method: SKILLS_GET_METHOD, params }, - GetSkillResultSchema, - this.getRequestOptions(this.progressTokenOf(metadata)), - ), - { method: SKILLS_GET_METHOD }, - ); + 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; + } } /** From 1ee958091e82ce74922197faa3cbc8264849faa0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 02:42:47 -0400 Subject: [PATCH 18/18] feat(skills): address Copilot review round 16 on #2234 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 16 reported no inline comments and three suppressed findings, all valid: - `getSkillsExtension` treated a non-object extension value (`false`, `"skills"`) as a declaration. SEP-2133 declares an extension as an object of sub-options, so a primitive is not one — now rejected, matching `appElicitation.ts`. - The `onGetSkill` prop doc claimed the two responses "must agree" and that a difference means a broken server, contradicting the implementation, which reports a difference as a finding rather than a fault. Reworded to what the code does. - No real-transport test exercised the fixture's `skills/*` handlers. Added `src/test/integration/mcp/inspectorClient-skills.test.ts`, parameterized over both protocol eras: capability advertisement, the paged `skills/list` walk (direct and through `ManagedSkillsState`), `skills/get`, the `-32602` for an unknown URI, `resources/read` of a skill file, and delegation of an ordinary resource through the fixture's `resources/read` wrapper. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall --- .../screens/SkillsScreen/SkillsScreen.tsx | 13 +- clients/web/src/test/core/mcp/skills.test.ts | 14 +- .../mcp/inspectorClient-skills.test.ts | 183 ++++++++++++++++++ core/mcp/skills.ts | 8 +- 4 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-skills.test.ts diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx index 909625acd1..e484213516 100644 --- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx +++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx @@ -127,10 +127,15 @@ export interface SkillsScreenProps { /** Fetch one skill file's contents via `resources/read`, on demand. */ onReadSkillFile: (uri: string) => Promise; /** - * Re-fetch the selected entry through `skills/get` (SEP-2640). Distinct from - * the entry `skills/list` already returned, and the point of exercising it is - * that the two must agree: a server whose `skills/get` disagrees with its own - * listing is broken in a way only a side-by-side fetch can show. + * Re-fetch the selected entry through `skills/get` (SEP-2640) — the + * extension's second required method, which nothing else in the app calls. + * + * It is a **fresh point-in-time snapshot**, so a conforming result may + * legitimately differ from an older listing, and the screen presents a + * difference as an updated snapshot rather than a fault. What it does treat + * as a fault is the fetched entry being non-conforming in its own right, or + * answering for a different URI than the one requested — neither of which a + * fresh read excuses. */ onGetSkill: (uri: string) => Promise; } diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts index eb4258d93f..f4ea62e26c 100644 --- a/clients/web/src/test/core/mcp/skills.test.ts +++ b/clients/web/src/test/core/mcp/skills.test.ts @@ -88,10 +88,16 @@ describe("getSkillsExtension", () => { ).toBeUndefined(); }); - it("treats a non-object declaration as declared with no sub-options", () => { - expect(getSkillsExtension(caps({ [SKILLS_EXTENSION_KEY]: true }))).toEqual({ - directoryRead: false, - }); + 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", () => { 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/core/mcp/skills.ts b/core/mcp/skills.ts index aaf4145c16..5d4883744e 100644 --- a/core/mcp/skills.ts +++ b/core/mcp/skills.ts @@ -109,9 +109,13 @@ export function getSkillsExtension( capabilities: ServerCapabilities | undefined, ): SkillsExtensionSupport | undefined { const declared = capabilities?.extensions?.[SKILLS_EXTENSION_KEY]; - if (declared === undefined || declared === null) return undefined; + // 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 = - typeof declared === "object" && (declared as { directoryRead?: unknown }).directoryRead === true; return { directoryRead }; }