feat: support the Skills extension (SEP-2640) with digest verification
Adds detection, enumeration, and conformance checking for the Skills extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234, which is everything its Acceptance list names. Skills is a *server*-declared extension, read off the connecting server's `capabilities.extensions`, so it is deliberately absent from `ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises and the user toggles, and an entry there would be a meaningless toggle. `skills/list` and `skills/get` are consumer-owned extension methods that neither era codec defines, so the SDK's era gate skips them and they go out as ordinary `client.request` calls with explicit result schemas. The raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is not era-gated: a legacy-era server that declares the extension is serving it. The checks are the point, not the list view. `core/mcp/skills.ts` reports each obligation SEP-2640 states — the name/path invariant, the digest format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which means integrity cannot be verified at all. Digest verification hashes the fetched bytes with WebCrypto and returns a mismatch with both digests attached rather than throwing, because showing a mismatch loudly is the whole value proposition. Files are fetched on demand: SEP-2640 is explicit that a `resources/read` of a `SKILL.md` is not a load and confers no standing, so none of the SEP's host machinery is implemented. The `skills-http` fixture serves four skills over two pages, three of them deliberately non-conforming — without those the verification code is untestable. Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248. Closes #2234 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
1c1e70820b
commit
63623b754b
10 changed files with 3684 additions and 4 deletions
|
|
@ -452,6 +452,64 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.getAllByText("—")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides the Skills section when the server declares no skills extension (#2234)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="legacy"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Skills Extension")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Skills extension and its directoryRead sub-flag (#2234)", () => {
|
||||
// The generic "Server Extensions" row lists the identifier; the sub-flag
|
||||
// that gates `resources/directory/read` is what this section adds, and it
|
||||
// is the fact a server author opens the modal to confirm.
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
extensions: {
|
||||
"io.modelcontextprotocol/skills": { directoryRead: true },
|
||||
},
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="legacy"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Skills Extension")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("skills-directory-read")).toHaveTextContent(
|
||||
"Supported",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports directory read as unsupported for a bare skills declaration (#2234)", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
capabilities: {
|
||||
...fullResult.capabilities,
|
||||
extensions: { "io.modelcontextprotocol/skills": {} },
|
||||
},
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
protocolEra="legacy"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("skills-directory-read")).toHaveTextContent(
|
||||
"Not supported",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders client registration kind when provided", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import type { ServerType } from "@inspector/core/mcp/types.js";
|
||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||
import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
|
||||
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
|
||||
import {
|
||||
CapabilityItem,
|
||||
|
|
@ -259,6 +261,9 @@ export function ConnectionInfoContent({
|
|||
}: ConnectionInfoContentProps) {
|
||||
const { serverInfo, protocolVersion, capabilities, instructions } =
|
||||
initializeResult;
|
||||
// `undefined` when the server declared no Skills extension, which is what
|
||||
// hides the section below — an absent extension has no sub-flags to report.
|
||||
const skillsExtension = getSkillsExtension(capabilities);
|
||||
|
||||
// Only trust `serverInfo` when the server actually reported it; otherwise the
|
||||
// name is a catalog fallback. Both rows `?.trim()` before the `||` (not `??`)
|
||||
|
|
@ -362,6 +367,26 @@ export function ConnectionInfoContent({
|
|||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Skills (SEP-2640). The generic "Server Extensions" row above lists the
|
||||
identifier, but not the one sub-option the extension defines —
|
||||
`directoryRead`, which gates `resources/directory/read`. That flag is
|
||||
exactly what a server author opens this modal to confirm, so it gets a
|
||||
row of its own rather than being flattened into a key list (#2234). */}
|
||||
{skillsExtension && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Stack gap="xs">
|
||||
<SectionHeading>Skills Extension</SectionHeading>
|
||||
<ValueText>{SKILLS_EXTENSION_KEY}</ValueText>
|
||||
</Stack>
|
||||
<Stack gap="xs">
|
||||
<SectionHeading>Directory Read</SectionHeading>
|
||||
<ValueText data-testid="skills-directory-read">
|
||||
{skillsExtension.directoryRead ? "Supported" : "Not supported"}
|
||||
</ValueText>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{instructions && (
|
||||
<Stack gap="xs">
|
||||
<SectionHeading>Server Instructions</SectionHeading>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import { useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
|
||||
import { SkillsScreen } from "./SkillsScreen";
|
||||
import type { SkillsUiState } from "./SkillsScreen";
|
||||
import { EMPTY_SKILLS_UI } from "../screenUiState";
|
||||
|
||||
// SkillsScreen is controlled (selection and search live in the parent as one
|
||||
// `ui` object — see #1417). This wrapper holds that state so the play-driven
|
||||
// clicks drive the detail pane, mirroring how App owns it in the real app.
|
||||
function StatefulSkillsScreen(args: ComponentProps<typeof SkillsScreen>) {
|
||||
const [ui, setUi] = useState<SkillsUiState>(args.ui ?? EMPTY_SKILLS_UI);
|
||||
return <SkillsScreen {...args} ui={ui} onUiChange={setUi} />;
|
||||
}
|
||||
|
||||
const REF_TEXT = "# Column rules\n";
|
||||
// The digest of REF_TEXT, so the clean skill really does verify when the
|
||||
// "Verify all" story runs — a placeholder here would demo a false green.
|
||||
const REF_DIGEST =
|
||||
"sha256:e201429aa2684958ca1a0537ab4eb4b7eb3a81c71e7cc7a11397eb500738e015";
|
||||
|
||||
const sampleSkills: SkillEntry[] = [
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
frontmatter: {
|
||||
name: "data-analysis",
|
||||
description: "Analyze a CSV and summarize its columns",
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://data-analysis/reference.md",
|
||||
digest: REF_DIGEST,
|
||||
size: REF_TEXT.length,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
uri: "skill://tampered-notes/SKILL.md",
|
||||
frontmatter: {
|
||||
name: "tampered-notes",
|
||||
description: "Advertises a digest its bytes do not match",
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://tampered-notes/notes.md",
|
||||
digest: `sha256:${"b".repeat(64)}`,
|
||||
size: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
uri: "skill://dynamic-report/SKILL.md",
|
||||
frontmatter: {
|
||||
name: "dynamic-report",
|
||||
description: "Generated files, so integrity cannot be verified",
|
||||
},
|
||||
resources: "dynamic",
|
||||
},
|
||||
{
|
||||
uri: "skill://wrong-folder/SKILL.md",
|
||||
frontmatter: {
|
||||
name: "right-name",
|
||||
description: "URI path segment disagrees with frontmatter.name",
|
||||
},
|
||||
resources: [],
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof SkillsScreen> = {
|
||||
title: "Screens/SkillsScreen",
|
||||
component: SkillsScreen,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
skills: sampleSkills,
|
||||
pageCount: 2,
|
||||
ui: EMPTY_SKILLS_UI,
|
||||
onUiChange: fn(),
|
||||
onRefreshList: fn(),
|
||||
onReadSkillFile: fn(async (uri: string) =>
|
||||
uri.endsWith("reference.md")
|
||||
? { text: REF_TEXT }
|
||||
: { text: `# ${uri}\n`, mimeType: "text/markdown" },
|
||||
),
|
||||
},
|
||||
render: (args) => <StatefulSkillsScreen {...args} />,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SkillsScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { skills: [], pageCount: 0 },
|
||||
};
|
||||
|
||||
export const LoadFailed: Story = {
|
||||
args: { loadError: new Error("skills/list failed: -32601 Method not found") },
|
||||
};
|
||||
|
||||
export const ConformingSkill: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByText("data-analysis"));
|
||||
await expect(canvas.getByText("Conforms")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const NameMismatch: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByText("right-name"));
|
||||
await expect(canvas.getByText("name-path-mismatch")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DynamicResources: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByText("dynamic-report"));
|
||||
await expect(canvas.getByText("Dynamic resources")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DigestMismatch: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByText("tampered-notes"));
|
||||
await userEvent.click(canvas.getByRole("button", { name: /Verify all/ }));
|
||||
await expect(
|
||||
await canvas.findByText("Digest mismatch"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
|
||||
import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills";
|
||||
import {
|
||||
renderWithMantine,
|
||||
screen,
|
||||
within,
|
||||
} from "../../../test/renderWithMantine";
|
||||
import {
|
||||
SkillsScreen,
|
||||
type SkillsScreenProps,
|
||||
type SkillsUiState,
|
||||
} from "./SkillsScreen";
|
||||
import { EMPTY_SKILLS_UI } from "../screenUiState";
|
||||
|
||||
const REF_TEXT = "# Column rules\n";
|
||||
// Computed once at module load so the fixture's advertised digest really is the
|
||||
// digest of the bytes the fake read returns — a hard-coded constant here would
|
||||
// make the "verified" test pass for the wrong reason if the encoder changed.
|
||||
const REF_DIGEST = await sha256Digest(textToBytes(REF_TEXT));
|
||||
|
||||
const CLEAN_SKILL: SkillEntry = {
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
frontmatter: {
|
||||
name: "data-analysis",
|
||||
description: "Analyze a CSV and summarize its columns",
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://data-analysis/reference.md",
|
||||
digest: REF_DIGEST,
|
||||
size: REF_TEXT.length,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TAMPERED_SKILL: SkillEntry = {
|
||||
uri: "skill://tampered/SKILL.md",
|
||||
frontmatter: { name: "tampered", description: "Bad digest" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://tampered/notes.md",
|
||||
digest: `sha256:${"b".repeat(64)}`,
|
||||
size: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const DYNAMIC_SKILL: SkillEntry = {
|
||||
uri: "skill://dynamic-report/SKILL.md",
|
||||
frontmatter: { name: "dynamic-report", description: "Generated files" },
|
||||
resources: "dynamic",
|
||||
};
|
||||
|
||||
const MISMATCHED_SKILL: SkillEntry = {
|
||||
uri: "skill://wrong-folder/SKILL.md",
|
||||
frontmatter: { name: "right-name", description: "Name disagreement" },
|
||||
resources: [],
|
||||
};
|
||||
|
||||
const ALL_SKILLS = [
|
||||
CLEAN_SKILL,
|
||||
TAMPERED_SKILL,
|
||||
DYNAMIC_SKILL,
|
||||
MISMATCHED_SKILL,
|
||||
];
|
||||
|
||||
/** A `resources/read` that serves the fixture bytes for any known URI. */
|
||||
const readFixtureFile = vi.fn(async (uri: string) => {
|
||||
if (uri === "skill://data-analysis/reference.md") return { text: REF_TEXT };
|
||||
if (uri === "skill://tampered/notes.md") return { text: "different\n" };
|
||||
return { text: `# ${uri}\n`, mimeType: "text/markdown" };
|
||||
});
|
||||
|
||||
const baseProps: SkillsScreenProps = {
|
||||
skills: ALL_SKILLS,
|
||||
pageCount: 2,
|
||||
ui: EMPTY_SKILLS_UI,
|
||||
onUiChange: vi.fn(),
|
||||
onRefreshList: vi.fn(),
|
||||
onReadSkillFile: readFixtureFile,
|
||||
};
|
||||
|
||||
// SkillsScreen is controlled: the selection and the sidebar search live in the
|
||||
// parent (App) as one `ui` object so they persist across tab navigation
|
||||
// (#1417). This host holds that state so clicking a skill actually selects it.
|
||||
function ControlledSkillsScreen(props: Partial<SkillsScreenProps> = {}) {
|
||||
const [ui, setUi] = useState<SkillsUiState>({
|
||||
...EMPTY_SKILLS_UI,
|
||||
...props.ui,
|
||||
});
|
||||
return (
|
||||
<SkillsScreen
|
||||
{...baseProps}
|
||||
{...props}
|
||||
ui={ui}
|
||||
onUiChange={(next) => {
|
||||
setUi(next);
|
||||
props.onUiChange?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SkillsScreen", () => {
|
||||
it("renders the empty state until a skill is selected", () => {
|
||||
renderWithMantine(<SkillsScreen {...baseProps} />);
|
||||
expect(
|
||||
screen.getByText("Select a skill to view details"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes the readiness contract the headless tab smoke keys off", () => {
|
||||
renderWithMantine(<SkillsScreen {...baseProps} />);
|
||||
const root = screen.getByTestId("skills-screen");
|
||||
expect(root).toHaveAttribute("data-skill-count", "4");
|
||||
expect(root).toHaveAttribute("data-skill-page-count", "2");
|
||||
});
|
||||
|
||||
it("renders 'No skills' when the list is empty", () => {
|
||||
renderWithMantine(<SkillsScreen {...baseProps} skills={[]} />);
|
||||
expect(screen.getByText("No skills")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a load failure above the list", () => {
|
||||
renderWithMantine(
|
||||
<SkillsScreen {...baseProps} loadError={new Error("nope")} />,
|
||||
);
|
||||
expect(screen.getByText("Could not load skills")).toBeInTheDocument();
|
||||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the sidebar by name and by URI", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.type(screen.getByLabelText("Search skills"), "wrong-folder");
|
||||
// The matching skill's *name* is `right-name`, so a hit here proves the URI
|
||||
// is searched too and not just the display name.
|
||||
expect(screen.getByText("right-name")).toBeInTheDocument();
|
||||
expect(screen.queryByText("data-analysis")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRefreshList when Refresh is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefreshList = vi.fn();
|
||||
renderWithMantine(
|
||||
<SkillsScreen {...baseProps} onRefreshList={onRefreshList} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(onRefreshList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a conforming skill as conforming", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(screen.getByText("Conforms")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("skill-issues")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the name/path mismatch as a distinct, named finding", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("right-name"));
|
||||
const issues = screen.getByTestId("skill-issues");
|
||||
expect(within(issues).getByText("name-path-mismatch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the dynamic warning and no manifest table", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("dynamic-report"));
|
||||
expect(screen.getByText("Dynamic resources")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("skill-manifest")).not.toBeInTheDocument();
|
||||
// "Verify all" has nothing to verify, so it is disabled rather than a
|
||||
// button that silently does nothing.
|
||||
expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("verifies a file whose bytes match its digest", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findByText("verified")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a digest mismatch loudly, with both digests", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("tampered"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findByText("Digest mismatch")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`expected sha256:${"b".repeat(64)}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("verifies a single file from its own row button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const manifest = screen.getByTestId("skill-manifest");
|
||||
await user.click(within(manifest).getByRole("button", { name: "Verify" }));
|
||||
expect(await screen.findByText("verified")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a failed read as a read failure, not a mismatch", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadSkillFile = vi.fn().mockRejectedValue(new Error("403"));
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findByText("Could not read file")).toBeInTheDocument();
|
||||
expect(screen.getByText("403")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wraps a non-Error read rejection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadSkillFile = vi.fn().mockRejectedValue("plain string");
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findByText("plain string")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the SKILL.md preview on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a failed SKILL.md read", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadSkillFile = vi.fn().mockRejectedValue(new Error("gone"));
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(
|
||||
await screen.findByText("Could not read SKILL.md"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wraps a non-Error SKILL.md rejection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadSkillFile = vi.fn().mockRejectedValue("bare");
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(await screen.findByText("bare")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops verification results when the selection changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findByText("verified")).toBeInTheDocument();
|
||||
|
||||
// A verdict belongs to the skill it was computed for; carrying it across a
|
||||
// selection change would attribute one skill's result to another.
|
||||
await user.click(screen.getByText("tampered"));
|
||||
expect(screen.queryByText("verified")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the SKILL.md preview when the selection changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("tampered"));
|
||||
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an em dash for a manifest entry with no size", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
skills={[
|
||||
{
|
||||
...CLEAN_SKILL,
|
||||
resources: [{ uri: "skill://data-analysis/reference.md" }],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const manifest = screen.getByTestId("skill-manifest");
|
||||
// Three em dashes in the row: the size cell, the digest cell, and the
|
||||
// not-yet-run verification badge — which stays distinct from
|
||||
// "unverifiable" so an absent digest is never mistaken for an unrun check.
|
||||
expect(within(manifest).getAllByText("—")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("truncates a long digest but shows a short one whole", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
skills={[
|
||||
{
|
||||
...CLEAN_SKILL,
|
||||
resources: [
|
||||
{ uri: "skill://data-analysis/a.md", digest: "sha256:short" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(screen.getByText("sha256:short")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a file with no advertised digest as unverifiable, not verified", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
skills={[
|
||||
{
|
||||
...CLEAN_SKILL,
|
||||
resources: [{ uri: "skill://data-analysis/reference.md" }],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findByText("unverifiable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
588
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx
Normal file
588
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Flex,
|
||||
Group,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { MdRefresh, MdSearch, MdVerifiedUser } from "react-icons/md";
|
||||
import type {
|
||||
SkillEntry,
|
||||
SkillResource,
|
||||
} from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import { DYNAMIC_RESOURCES } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import {
|
||||
checkSkillConformance,
|
||||
skillDisplayName,
|
||||
totalSkillBytes,
|
||||
verifySkillResource,
|
||||
type SkillIssue,
|
||||
type SkillVerification,
|
||||
} from "@inspector/core/mcp/skills.js";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import {
|
||||
skillFileBytes,
|
||||
type SkillFileContents,
|
||||
} from "../../../utils/skillFileBytes";
|
||||
|
||||
/** Per-file verification progress, keyed by the manifest entry's URI. */
|
||||
type FileState =
|
||||
| { status: "pending" }
|
||||
| { status: "done"; verification: SkillVerification }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export interface SkillsScreenProps {
|
||||
skills: SkillEntry[];
|
||||
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
|
||||
pageCount: number;
|
||||
/** A failed list walk, rendered above the sidebar list. */
|
||||
loadError?: Error | null;
|
||||
ui: SkillsUiState;
|
||||
onUiChange: (next: SkillsUiState) => void;
|
||||
onRefreshList: () => void;
|
||||
/** Fetch one skill file's contents via `resources/read`, on demand. */
|
||||
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection and the sidebar search — controlled by the parent (App) as one
|
||||
* object so they persist across tab navigation within a live session (#1417).
|
||||
* Verification results stay local to the screen: they are derived from a live
|
||||
* `resources/read` round trip that is torn down with the screen, so persisting
|
||||
* them would restore a verdict without the fetch that produced it.
|
||||
*/
|
||||
export interface SkillsUiState {
|
||||
selectedSkillUri?: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
const ScreenLayout = Flex.withProps({
|
||||
variant: "screen",
|
||||
h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))",
|
||||
gap: "md",
|
||||
p: "xl",
|
||||
align: "flex-start",
|
||||
});
|
||||
|
||||
const Sidebar = Stack.withProps({
|
||||
w: 340,
|
||||
flex: "0 0 auto",
|
||||
});
|
||||
|
||||
const SidebarCard = Card.withProps({
|
||||
withBorder: true,
|
||||
padding: "lg",
|
||||
});
|
||||
|
||||
const DetailCard = Card.withProps({
|
||||
withBorder: true,
|
||||
padding: "lg",
|
||||
flex: 1,
|
||||
h: "100%",
|
||||
});
|
||||
|
||||
const DetailScroll = ScrollArea.withProps({
|
||||
type: "auto",
|
||||
scrollbars: "y",
|
||||
offsetScrollbars: true,
|
||||
h: "100%",
|
||||
});
|
||||
|
||||
const EmptyState = Text.withProps({
|
||||
c: "dimmed",
|
||||
ta: "center",
|
||||
py: "xl",
|
||||
});
|
||||
|
||||
const ControlsRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
gap: "sm",
|
||||
});
|
||||
|
||||
const SearchInput = TextInput.withProps({
|
||||
size: "xs",
|
||||
flex: 1,
|
||||
leftSection: <MdSearch aria-hidden size={14} />,
|
||||
});
|
||||
|
||||
const RefreshButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "compact-xs",
|
||||
leftSection: <MdRefresh aria-hidden size={14} />,
|
||||
});
|
||||
|
||||
const VerifyButton = Button.withProps({
|
||||
variant: "light",
|
||||
size: "compact-sm",
|
||||
leftSection: <MdVerifiedUser aria-hidden size={14} />,
|
||||
});
|
||||
|
||||
// A `Text` renders a `<p>`, so a section heading must never *wrap* the count
|
||||
// badge beside it — a `<div>` inside a `<p>` is invalid HTML that React reports
|
||||
// as a hydration error and the Storybook run fails on. Heading and badge sit
|
||||
// side by side in an `InlineRow` instead.
|
||||
const SectionHeading = Text.withProps({
|
||||
fw: 600,
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const MonoCaption = Text.withProps({
|
||||
size: "xs",
|
||||
c: "dimmed",
|
||||
ff: "monospace",
|
||||
});
|
||||
|
||||
const DetailStack = Stack.withProps({
|
||||
gap: "md",
|
||||
});
|
||||
|
||||
const IssueStack = Stack.withProps({
|
||||
gap: "xs",
|
||||
});
|
||||
|
||||
const ManifestTable = Table.withProps({
|
||||
striped: true,
|
||||
withTableBorder: true,
|
||||
fz: "xs",
|
||||
verticalSpacing: "xs",
|
||||
});
|
||||
|
||||
const CountBadge = Badge.withProps({
|
||||
size: "xs",
|
||||
variant: "light",
|
||||
});
|
||||
|
||||
// A tight, non-wrapping row — used for the detail-pane action pair and for the
|
||||
// badge + Verify button inside a manifest cell.
|
||||
const InlineRow = Group.withProps({
|
||||
gap: "xs",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const RowVerifyButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "compact-xs",
|
||||
});
|
||||
|
||||
const SkillTitle = Text.withProps({
|
||||
fw: 600,
|
||||
size: "lg",
|
||||
truncate: true,
|
||||
});
|
||||
|
||||
/** Colour token for a finding's severity — errors read as failures. */
|
||||
function issueColor(issue: SkillIssue): string {
|
||||
return issue.severity === "error" ? "red" : "yellow";
|
||||
}
|
||||
|
||||
/** Colour token for a per-file verification verdict. */
|
||||
function verificationColor(status: SkillVerification["status"]): string {
|
||||
if (status === "verified") return "green";
|
||||
if (status === "mismatch") return "red";
|
||||
return "yellow";
|
||||
}
|
||||
|
||||
/**
|
||||
* The short label a manifest row shows for its verdict. `—` (not yet run) is
|
||||
* deliberately distinct from `unverifiable` (run, but nothing to compare
|
||||
* against): conflating them would hide the fact that a server advertised no
|
||||
* digest.
|
||||
*/
|
||||
function verificationLabel(state: FileState | undefined): string {
|
||||
if (!state) return "—";
|
||||
if (state.status === "pending") return "checking…";
|
||||
if (state.status === "error") return "read failed";
|
||||
return state.verification.status;
|
||||
}
|
||||
|
||||
/** `sha256:abcd…wxyz`, so a long digest stays readable in a table cell. */
|
||||
function shortDigest(digest: string | undefined): string {
|
||||
if (!digest) return "—";
|
||||
return digest.length <= 24 ? digest : `${digest.slice(0, 16)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Skills screen (SEP-2640) — a conformance view, not just a list.
|
||||
*
|
||||
* The sidebar lists the skills the server enumerated; the detail pane shows the
|
||||
* entry's frontmatter, every conformance finding
|
||||
* (`checkSkillConformance`), and the resource manifest with a per-file
|
||||
* verification verdict. Verification is on demand: SEP-2640 says a
|
||||
* `resources/read` of a skill file is not a load and confers no standing, so
|
||||
* the Inspector fetches only what the user asks it to.
|
||||
*/
|
||||
export function SkillsScreen({
|
||||
skills,
|
||||
pageCount,
|
||||
loadError,
|
||||
ui,
|
||||
onUiChange,
|
||||
onRefreshList,
|
||||
onReadSkillFile,
|
||||
}: SkillsScreenProps) {
|
||||
const { selectedSkillUri, search } = ui;
|
||||
const [fileStates, setFileStates] = useState<Record<string, FileState>>({});
|
||||
const [preview, setPreview] = useState<SkillFileContents | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
// Changing the selection invalidates every verdict and the SKILL.md preview:
|
||||
// they belong to the skill that was selected. Adjusted DURING RENDER via
|
||||
// `useValueChange` rather than in an effect, so the new skill never paints
|
||||
// for a frame carrying the previous one's verification results.
|
||||
useValueChange(selectedSkillUri, () => {
|
||||
setFileStates({});
|
||||
setPreview(null);
|
||||
setPreviewError(null);
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return skills;
|
||||
return skills.filter(
|
||||
(skill) =>
|
||||
skillDisplayName(skill).toLowerCase().includes(needle) ||
|
||||
skill.uri.toLowerCase().includes(needle),
|
||||
);
|
||||
}, [skills, search]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => skills.find((skill) => skill.uri === selectedSkillUri),
|
||||
[skills, selectedSkillUri],
|
||||
);
|
||||
|
||||
const issues = useMemo(
|
||||
() => (selected ? checkSkillConformance(selected) : []),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const manifest: SkillResource[] = useMemo(
|
||||
() =>
|
||||
selected && selected.resources !== DYNAMIC_RESOURCES
|
||||
? selected.resources
|
||||
: [],
|
||||
[selected],
|
||||
);
|
||||
|
||||
const verifyFile = useCallback(
|
||||
async (resource: SkillResource) => {
|
||||
setFileStates((prev) => ({
|
||||
...prev,
|
||||
[resource.uri]: { status: "pending" },
|
||||
}));
|
||||
try {
|
||||
const contents = await onReadSkillFile(resource.uri);
|
||||
const verification = await verifySkillResource(
|
||||
resource,
|
||||
skillFileBytes(contents),
|
||||
);
|
||||
setFileStates((prev) => ({
|
||||
...prev,
|
||||
[resource.uri]: { status: "done", verification },
|
||||
}));
|
||||
} catch (err) {
|
||||
setFileStates((prev) => ({
|
||||
...prev,
|
||||
[resource.uri]: {
|
||||
status: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[onReadSkillFile],
|
||||
);
|
||||
|
||||
const verifyAll = useCallback(() => {
|
||||
// Held rather than floated: each `verifyFile` owns its own failures (it
|
||||
// records them as per-file state), and this handler cannot be async, so the
|
||||
// settled promise is discarded explicitly at one place instead of per file.
|
||||
void Promise.all(manifest.map((resource) => verifyFile(resource)));
|
||||
}, [manifest, verifyFile]);
|
||||
|
||||
const showSkillMd = useCallback(() => {
|
||||
if (!selected) return;
|
||||
// A click handler cannot await, and this chain terminates in its own
|
||||
// `catch` that surfaces the message in the preview slot.
|
||||
void onReadSkillFile(selected.uri)
|
||||
.then((contents) => {
|
||||
setPreview(contents);
|
||||
setPreviewError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setPreview(null);
|
||||
setPreviewError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, [onReadSkillFile, selected]);
|
||||
|
||||
const errorCount = issues.filter((i) => i.severity === "error").length;
|
||||
const warningCount = issues.length - errorCount;
|
||||
|
||||
return (
|
||||
// `data-*` readiness contract for the headless tab smoke (#2148); see
|
||||
// clients/web/README.md#core-tab-automation-contract.
|
||||
<ScreenLayout
|
||||
data-testid="skills-screen"
|
||||
data-skill-count={skills.length}
|
||||
data-skill-page-count={pageCount}
|
||||
>
|
||||
<Sidebar>
|
||||
<SidebarCard>
|
||||
<Stack gap="sm">
|
||||
<ControlsRow>
|
||||
<SearchInput
|
||||
aria-label="Search skills"
|
||||
placeholder="Search skills"
|
||||
value={search}
|
||||
onChange={(event) =>
|
||||
onUiChange({ ...ui, search: event.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<RefreshButton onClick={onRefreshList}>Refresh</RefreshButton>
|
||||
</ControlsRow>
|
||||
{loadError && (
|
||||
<Alert color="red" title="Could not load skills">
|
||||
{loadError.message}
|
||||
</Alert>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState>No skills</EmptyState>
|
||||
) : (
|
||||
filtered.map((skill) => {
|
||||
const skillIssues = checkSkillConformance(skill);
|
||||
const errors = skillIssues.filter(
|
||||
(i) => i.severity === "error",
|
||||
).length;
|
||||
return (
|
||||
<NavLink
|
||||
key={skill.uri}
|
||||
active={skill.uri === selectedSkillUri}
|
||||
label={skillDisplayName(skill)}
|
||||
description={skill.uri}
|
||||
onClick={() =>
|
||||
onUiChange({ ...ui, selectedSkillUri: skill.uri })
|
||||
}
|
||||
rightSection={
|
||||
skillIssues.length > 0 ? (
|
||||
<CountBadge color={errors > 0 ? "red" : "yellow"}>
|
||||
{skillIssues.length}
|
||||
</CountBadge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<MonoCaption>
|
||||
{skills.length} skill(s) over {pageCount} page(s)
|
||||
</MonoCaption>
|
||||
</Stack>
|
||||
</SidebarCard>
|
||||
</Sidebar>
|
||||
|
||||
<DetailCard>
|
||||
{!selected ? (
|
||||
<EmptyState>Select a skill to view details</EmptyState>
|
||||
) : (
|
||||
<DetailScroll>
|
||||
<DetailStack data-testid="skill-detail">
|
||||
<Stack gap={4}>
|
||||
<SkillTitle>{skillDisplayName(selected)}</SkillTitle>
|
||||
<MonoCaption>{selected.uri}</MonoCaption>
|
||||
</Stack>
|
||||
|
||||
{selected.frontmatter.description && (
|
||||
<Text size="sm">{selected.frontmatter.description}</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<InlineRow>
|
||||
<SectionHeading>Conformance</SectionHeading>
|
||||
<CountBadge color={errorCount > 0 ? "red" : "green"}>
|
||||
{errorCount} error(s), {warningCount} warning(s)
|
||||
</CountBadge>
|
||||
</InlineRow>
|
||||
{issues.length === 0 ? (
|
||||
<Alert color="green" title="Conforms">
|
||||
No structural issues found in this entry.
|
||||
</Alert>
|
||||
) : (
|
||||
<IssueStack data-testid="skill-issues">
|
||||
{issues.map((issue) => (
|
||||
<Alert
|
||||
key={`${issue.code}:${issue.resourceUri ?? ""}`}
|
||||
color={issueColor(issue)}
|
||||
title={issue.code}
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{issue.message}</Text>
|
||||
{issue.resourceUri && (
|
||||
<MonoCaption>{issue.resourceUri}</MonoCaption>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
))}
|
||||
</IssueStack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<ControlsRow>
|
||||
<InlineRow>
|
||||
<SectionHeading>Resources</SectionHeading>
|
||||
<CountBadge>
|
||||
{manifest.length} file(s), {totalSkillBytes(manifest)}{" "}
|
||||
bytes
|
||||
</CountBadge>
|
||||
</InlineRow>
|
||||
<InlineRow>
|
||||
<VerifyButton onClick={showSkillMd}>
|
||||
View SKILL.md
|
||||
</VerifyButton>
|
||||
<VerifyButton
|
||||
onClick={verifyAll}
|
||||
disabled={manifest.length === 0}
|
||||
>
|
||||
Verify all
|
||||
</VerifyButton>
|
||||
</InlineRow>
|
||||
</ControlsRow>
|
||||
{selected.resources === DYNAMIC_RESOURCES ? (
|
||||
<Alert color="yellow" title="Dynamic resources">
|
||||
This skill declares{" "}
|
||||
<Code>resources: "dynamic"</Code> — its files are
|
||||
generated, so no manifest is advertised and integrity cannot
|
||||
be verified.
|
||||
</Alert>
|
||||
) : (
|
||||
<ManifestTable data-testid="skill-manifest">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>URI</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Digest</Table.Th>
|
||||
<Table.Th>Verification</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{manifest.map((resource) => {
|
||||
const state = fileStates[resource.uri];
|
||||
const color =
|
||||
state?.status === "done"
|
||||
? verificationColor(state.verification.status)
|
||||
: state?.status === "error"
|
||||
? "red"
|
||||
: "gray";
|
||||
return (
|
||||
<Table.Tr key={resource.uri}>
|
||||
<Table.Td>{resource.uri}</Table.Td>
|
||||
<Table.Td>{resource.size ?? "—"}</Table.Td>
|
||||
<Table.Td>{shortDigest(resource.digest)}</Table.Td>
|
||||
<Table.Td>
|
||||
<InlineRow>
|
||||
<CountBadge color={color}>
|
||||
{verificationLabel(state)}
|
||||
</CountBadge>
|
||||
<RowVerifyButton
|
||||
// A click handler cannot await, and
|
||||
// `verifyFile` owns its own failures — it
|
||||
// records them as this row's state.
|
||||
onClick={() => void verifyFile(resource)}
|
||||
>
|
||||
Verify
|
||||
</RowVerifyButton>
|
||||
</InlineRow>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</ManifestTable>
|
||||
)}
|
||||
{manifest.map((resource) => {
|
||||
const state = fileStates[resource.uri];
|
||||
if (state?.status === "done") {
|
||||
const { verification } = state;
|
||||
if (verification.status === "mismatch") {
|
||||
return (
|
||||
<Alert
|
||||
key={`mismatch:${resource.uri}`}
|
||||
color="red"
|
||||
title="Digest mismatch"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<MonoCaption>{resource.uri}</MonoCaption>
|
||||
<MonoCaption>
|
||||
expected {verification.expectedDigest}
|
||||
</MonoCaption>
|
||||
<MonoCaption>
|
||||
actual {verification.actualDigest}
|
||||
</MonoCaption>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (state?.status === "error") {
|
||||
return (
|
||||
<Alert
|
||||
key={`read:${resource.uri}`}
|
||||
color="red"
|
||||
title="Could not read file"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<MonoCaption>{resource.uri}</MonoCaption>
|
||||
<Text size="sm">{state.message}</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{previewError && (
|
||||
<Alert color="red" title="Could not read SKILL.md">
|
||||
{previewError}
|
||||
</Alert>
|
||||
)}
|
||||
{preview && (
|
||||
<Stack gap="xs" data-testid="skill-md-preview">
|
||||
<SectionHeading>SKILL.md</SectionHeading>
|
||||
<ContentViewer
|
||||
block={{ type: "text", text: preview.text ?? "" }}
|
||||
mimeType={preview.mimeType ?? "text/markdown"}
|
||||
copyable
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<SectionHeading>Frontmatter</SectionHeading>
|
||||
<ContentViewer
|
||||
block={{
|
||||
type: "text",
|
||||
text: JSON.stringify(selected.frontmatter, null, 2),
|
||||
}}
|
||||
mimeType="application/json"
|
||||
copyable
|
||||
/>
|
||||
</Stack>
|
||||
</DetailStack>
|
||||
</DetailScroll>
|
||||
)}
|
||||
</DetailCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import type {
|
|||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
SkillsPanelProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./types";
|
||||
|
|
@ -38,6 +39,7 @@ import {
|
|||
EMPTY_APPS_UI,
|
||||
EMPTY_PROMPTS_UI,
|
||||
EMPTY_RESOURCES_UI,
|
||||
EMPTY_SKILLS_UI,
|
||||
EMPTY_TASKS_UI,
|
||||
EMPTY_LOGS_UI,
|
||||
EMPTY_PROTOCOL_UI,
|
||||
|
|
@ -424,6 +426,15 @@ const appsArgs: AppsPanelProps = {
|
|||
onRefreshApps: fn(),
|
||||
};
|
||||
|
||||
const skillsArgs: SkillsPanelProps = {
|
||||
skills: [],
|
||||
skillsPageCount: 0,
|
||||
skillsUi: EMPTY_SKILLS_UI,
|
||||
onSkillsUiChange: fn(),
|
||||
onRefreshSkills: fn(),
|
||||
onReadSkillFile: fn(async () => ({ text: "" })),
|
||||
};
|
||||
|
||||
const tasksArgs: TasksPanelProps = {
|
||||
tasks: demoTasks,
|
||||
progressByTaskId: demoProgressByTaskId,
|
||||
|
|
@ -484,6 +495,7 @@ const meta: Meta<typeof InspectorView> = {
|
|||
prompts: promptsArgs,
|
||||
resources: resourcesArgs,
|
||||
apps: appsArgs,
|
||||
skills: skillsArgs,
|
||||
tasks: tasksArgs,
|
||||
logs: logsArgs,
|
||||
protocol: protocolArgs,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
EMPTY_APPS_UI,
|
||||
EMPTY_PROMPTS_UI,
|
||||
EMPTY_RESOURCES_UI,
|
||||
EMPTY_SKILLS_UI,
|
||||
EMPTY_TASKS_UI,
|
||||
EMPTY_LOGS_UI,
|
||||
EMPTY_PROTOCOL_UI,
|
||||
|
|
@ -170,6 +171,15 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps {
|
|||
onRefreshApps: vi.fn(),
|
||||
...mergeBundle("apps", overrides),
|
||||
},
|
||||
skills: {
|
||||
skills: [],
|
||||
skillsPageCount: 0,
|
||||
skillsUi: EMPTY_SKILLS_UI,
|
||||
onSkillsUiChange: vi.fn(),
|
||||
onRefreshSkills: vi.fn(),
|
||||
onReadSkillFile: vi.fn().mockResolvedValue({ text: "" }),
|
||||
...mergeBundle("skills", overrides),
|
||||
},
|
||||
tasks: {
|
||||
tasks: [],
|
||||
tasksUi: EMPTY_TASKS_UI,
|
||||
|
|
@ -1369,6 +1379,50 @@ describe("InspectorView", () => {
|
|||
expect(labels).not.toContain("Tasks");
|
||||
});
|
||||
|
||||
it("hides the Skills tab when the server declares no skills extension (#2234)", async () => {
|
||||
renderWithMantine(
|
||||
<StatefulInspectorViewHost
|
||||
{...makeProps({
|
||||
connection: {
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: initWithCapabilities({ tools: {} }),
|
||||
},
|
||||
servers: { servers: [sampleServer] },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
const labels = radios.map((r) => r.getAttribute("value"));
|
||||
expect(labels).toContain("Tools");
|
||||
expect(labels).not.toContain("Skills");
|
||||
});
|
||||
|
||||
it("shows the Skills tab on a LEGACY connection that declares the extension (#2234)", async () => {
|
||||
// Unlike Tasks, Skills is not era-gated: `skills/*` are not spec method
|
||||
// names in either codec, so a legacy-era server that declares the
|
||||
// extension is serving it and must get the tab.
|
||||
renderWithMantine(
|
||||
<StatefulInspectorViewHost
|
||||
{...makeProps({
|
||||
connection: {
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
protocolEra: "legacy",
|
||||
initializeResult: initWithCapabilities({
|
||||
tools: {},
|
||||
extensions: { "io.modelcontextprotocol/skills": {} },
|
||||
}),
|
||||
},
|
||||
servers: { servers: [sampleServer] },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const radios = await screen.findAllByRole("radio");
|
||||
const labels = radios.map((r) => r.getAttribute("value"));
|
||||
expect(labels).toContain("Skills");
|
||||
});
|
||||
|
||||
it("shows the Tasks tab when the server advertises tasks even with no tasks yet", async () => {
|
||||
renderWithMantine(
|
||||
<StatefulInspectorViewHost
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
|||
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
|
||||
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||
import { isSkillsExtensionSupported } from "@inspector/core/mcp/skills.js";
|
||||
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
||||
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
|
||||
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
|
||||
|
|
@ -22,6 +23,7 @@ import { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
|
|||
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
|
||||
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import { SkillsScreen } from "../../screens/SkillsScreen/SkillsScreen";
|
||||
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
|
||||
import { ProtocolScreen } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
|
||||
|
|
@ -37,6 +39,7 @@ import type {
|
|||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
SkillsPanelProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./types";
|
||||
|
|
@ -118,6 +121,7 @@ function useListCompact(
|
|||
}
|
||||
|
||||
const SERVERS_TAB = INSPECTOR_SERVERS_TAB;
|
||||
const SKILLS_TAB = "Skills";
|
||||
const TASKS_TAB = "Tasks";
|
||||
const LOGS_TAB = "Logs";
|
||||
const PROTOCOL_TAB = "Protocol";
|
||||
|
|
@ -151,6 +155,7 @@ const ALL_TABS: string[] = [
|
|||
"Tools",
|
||||
"Prompts",
|
||||
"Resources",
|
||||
SKILLS_TAB,
|
||||
TASKS_TAB,
|
||||
LOGS_TAB,
|
||||
PROTOCOL_TAB,
|
||||
|
|
@ -328,6 +333,7 @@ export interface InspectorViewProps {
|
|||
prompts: PromptsPanelProps;
|
||||
resources: ResourcesPanelProps;
|
||||
apps: AppsPanelProps;
|
||||
skills: SkillsPanelProps;
|
||||
tasks: TasksPanelProps;
|
||||
logs: LogsPanelProps;
|
||||
protocol: ProtocolPanelProps;
|
||||
|
|
@ -343,6 +349,7 @@ export function InspectorView({
|
|||
prompts: promptsPanel,
|
||||
resources: resourcesPanel,
|
||||
apps: appsPanel,
|
||||
skills: skillsPanel,
|
||||
tasks: tasksPanel,
|
||||
logs: logsPanel,
|
||||
protocol: protocolPanel,
|
||||
|
|
@ -448,6 +455,15 @@ export function InspectorView({
|
|||
onAppError,
|
||||
onRefreshApps,
|
||||
} = appsPanel;
|
||||
const {
|
||||
skills,
|
||||
skillsPageCount,
|
||||
skillsLoadError,
|
||||
skillsUi,
|
||||
onSkillsUiChange,
|
||||
onRefreshSkills,
|
||||
onReadSkillFile,
|
||||
} = skillsPanel;
|
||||
const {
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
|
|
@ -654,6 +670,10 @@ export function InspectorView({
|
|||
capabilities?.tasks !== undefined ||
|
||||
capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined;
|
||||
const hasLogging = capabilities?.logging !== undefined;
|
||||
// Skills (SEP-2640) is a server-declared *extension*, and unlike Tasks it
|
||||
// is not era-gated: `skills/*` are not spec method names in either codec,
|
||||
// so a legacy-era server that declares the extension is serving it.
|
||||
const hasSkills = isSkillsExtensionSupported(capabilities);
|
||||
return ALL_TABS.filter((t) => {
|
||||
if (t === NETWORK_TAB && isStdio) return false;
|
||||
// Console is the stdio process's stderr stream — shown only for stdio
|
||||
|
|
@ -663,6 +683,7 @@ export function InspectorView({
|
|||
if (t === "Apps" && !hasApps) return false;
|
||||
if (t === "Prompts" && !hasPrompts) return false;
|
||||
if (t === "Resources" && !hasResources) return false;
|
||||
if (t === SKILLS_TAB && !hasSkills) return false;
|
||||
if (t === "Tasks" && !hasTasks) return false;
|
||||
if (t === "Logs" && !hasLogging) return false;
|
||||
return true;
|
||||
|
|
@ -1032,6 +1053,15 @@ export function InspectorView({
|
|||
sortDirection: consoleSort,
|
||||
onSortChange: setConsoleSort,
|
||||
};
|
||||
const skillsScreenProps = {
|
||||
skills,
|
||||
pageCount: skillsPageCount,
|
||||
loadError: skillsLoadError,
|
||||
ui: skillsUi,
|
||||
onUiChange: onSkillsUiChange,
|
||||
onRefreshList: onRefreshSkills,
|
||||
onReadSkillFile,
|
||||
};
|
||||
const tasksScreenProps = {
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
|
|
@ -1240,6 +1270,9 @@ export function InspectorView({
|
|||
onCompactChange={setResourcesCompact}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === SKILLS_TAB}>
|
||||
<SkillsScreen {...skillsScreenProps} />
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === TASKS_TAB}>
|
||||
<TasksScreen {...tasksScreenProps} />
|
||||
</ScreenStage>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ import type {
|
|||
ResourcesUiState,
|
||||
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import type { LogsUiState } from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import type { SkillsUiState } from "../../screens/SkillsScreen/SkillsScreen";
|
||||
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import type { SkillFileContents } from "../../../utils/skillFileBytes";
|
||||
import type { TasksUiState } from "../../screens/TasksScreen/TasksScreen";
|
||||
import type { ProtocolUiState } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import type { NetworkUiState } from "../../screens/NetworkScreen/NetworkScreen";
|
||||
|
|
@ -309,6 +312,19 @@ export interface AppsPanelProps {
|
|||
}
|
||||
|
||||
/** The Tasks monitor: the task list, its progress map, and actions. */
|
||||
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
|
||||
export interface SkillsPanelProps {
|
||||
skills: SkillEntry[];
|
||||
/** Pages the last `skills/list` walk took. */
|
||||
skillsPageCount: number;
|
||||
skillsLoadError?: Error | null;
|
||||
skillsUi: SkillsUiState;
|
||||
onSkillsUiChange: (next: SkillsUiState) => void;
|
||||
onRefreshSkills: () => void;
|
||||
/** Read one skill file (`resources/read`) so its digest can be checked. */
|
||||
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
|
||||
}
|
||||
|
||||
export interface TasksPanelProps {
|
||||
tasks: Task[];
|
||||
progressByTaskId?: Record<string, TaskProgress>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue