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