feat: Skills extension phase 3 — CLI, TUI, directory reads, frontmatter check
Closes #2248. Completes SEP-2640 support across all three clients, and closes the one obligation #2234 deliberately left open. - `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML frontmatter against the entry the listing advertised, field by field. No digest can cover this: a digest is taken over the bytes the server served, so it proves the file was not altered in transit and says nothing about whether the listing described it honestly. Needs a real YAML parser; `yaml` was already a root dependency, so this adds no package — but it does newly put it on core/'s import graph, hence the three bundler `external` lists. - `resources/directory/read`: result schemas defined against the normative text, `InspectorClient.readResourceDirectory`, a Directory section on the Skills screen, and `--method resources/directory/read` in the CLI. The client refuses the call locally when the server did not declare `directoryRead`, which is the SEP's MUST NOT. A child the directory lists but the entry does not is marked `not listed` rather than merged into the manifest — the SEP calls a directory result a live observation and forbids treating it as extending the manifest. - CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus `--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a violation. Reads are sequential; a conforming manifest may declare 512 entries. - TUI: a Skills pane, shown only when the server declares the extension. Each row carries its conformance verdict as a glyph as well as a colour, since the pane is read over ssh and through script(1). Enter verifies. - `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording it per file: it says the session's authorization expired, so absorbing it would report N identical read failures and swallow the error the TUI and the web commands key off to reauthorize. Two open questions settled, both in code comments where they will be found: `skills/get` carries no caching attributes because SEP-2640 leaves the question open in as many words, so requiring them would fail a conforming server; and there is no `PagedSkillsState` because every consumer of this list is a whole-catalog verdict — computed over page one of three, "this server's skills conform" is not merely partial but wrong. The fixture grows two skills, both for checks that were otherwise undemonstrable: `lying-listing` (listing and file disagree, digest still verifies) and `stale-manifest` (serves a file its manifest does not declare). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
219a08e1b5
commit
9a8488681e
17 changed files with 4750 additions and 191 deletions
|
|
@ -10,6 +10,7 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
|
||||
import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
|
||||
|
|
@ -837,6 +838,7 @@ function App() {
|
|||
onRefreshSkills,
|
||||
onReadSkillFile,
|
||||
onGetSkill,
|
||||
onReadResourceDirectory,
|
||||
onRefreshTasks,
|
||||
onTogglePaginatedLists,
|
||||
onLoadMoreTools,
|
||||
|
|
@ -1839,6 +1841,14 @@ function App() {
|
|||
onRefreshSkills,
|
||||
onReadSkillFile,
|
||||
onGetSkill,
|
||||
// Passed only when the server declared `directoryRead`, which is what gates
|
||||
// the screen's Directory section. SEP-2640 makes calling
|
||||
// `resources/directory/read` against a server that did not declare it a
|
||||
// MUST NOT, so withholding the callback expresses the rule in the type
|
||||
// rather than trusting a boolean beside it to be honoured.
|
||||
...(getSkillsExtension(capabilities)?.directoryRead
|
||||
? { onReadResourceDirectory }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const tasksPanelProps: TasksPanelProps = {
|
||||
|
|
|
|||
|
|
@ -28,21 +28,57 @@ const NOTES_TEXT = "different\n";
|
|||
const REF_DIGEST = await sha256Digest(textToBytes(REF_TEXT));
|
||||
const SELF_DIGEST = await sha256Digest(textToBytes(SELF_TEXT));
|
||||
|
||||
type Frontmatter = { name: string; description: string };
|
||||
|
||||
/**
|
||||
* The `SKILL.md` a given frontmatter implies.
|
||||
*
|
||||
* Every fixture's served file is built from the very frontmatter its entry
|
||||
* advertises, so the two agree by construction. SEP-2640 requires that match
|
||||
* field for field and #2248 added the check that enforces it — hand-writing
|
||||
* the file instead would report a frontmatter discrepancy in every fixture
|
||||
* here, drowning the tests that are actually about one. Same discipline, and
|
||||
* the same reason, as `skillMd` in `test-servers/src/skills.ts`.
|
||||
*/
|
||||
function skillMdFor(fm: Frontmatter): string {
|
||||
return `---\nname: ${fm.name}\ndescription: ${fm.description}\n---\n\n# ${fm.name}\n`;
|
||||
}
|
||||
|
||||
/** The entry's own SKILL.md manifest row: derived text, and its real digest. */
|
||||
async function selfEntry(uri: string, fm: Frontmatter) {
|
||||
const text = skillMdFor(fm);
|
||||
return {
|
||||
uri,
|
||||
digest: await sha256Digest(textToBytes(text)),
|
||||
size: textToBytes(text).byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
const CLEAN_FM: Frontmatter = {
|
||||
name: "data-analysis",
|
||||
description: "Analyze a CSV and summarize its columns",
|
||||
};
|
||||
const TAMPERED_FM: Frontmatter = {
|
||||
name: "tampered",
|
||||
description: "Bad digest",
|
||||
};
|
||||
const DYNAMIC_FM: Frontmatter = {
|
||||
name: "dynamic-report",
|
||||
description: "Generated files",
|
||||
};
|
||||
const MISMATCHED_FM: Frontmatter = {
|
||||
name: "right-name",
|
||||
description: "Name disagreement",
|
||||
};
|
||||
|
||||
// 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",
|
||||
},
|
||||
frontmatter: CLEAN_FM,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
await selfEntry("skill://data-analysis/SKILL.md", CLEAN_FM),
|
||||
{
|
||||
uri: "skill://data-analysis/reference.md",
|
||||
digest: REF_DIGEST,
|
||||
|
|
@ -53,13 +89,9 @@ const CLEAN_SKILL: SkillEntry = {
|
|||
|
||||
const TAMPERED_SKILL: SkillEntry = {
|
||||
uri: "skill://tampered/SKILL.md",
|
||||
frontmatter: { name: "tampered", description: "Bad digest" },
|
||||
frontmatter: TAMPERED_FM,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://tampered/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
await selfEntry("skill://tampered/SKILL.md", TAMPERED_FM),
|
||||
{
|
||||
// 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
|
||||
|
|
@ -73,20 +105,14 @@ const TAMPERED_SKILL: SkillEntry = {
|
|||
|
||||
const DYNAMIC_SKILL: SkillEntry = {
|
||||
uri: "skill://dynamic-report/SKILL.md",
|
||||
frontmatter: { name: "dynamic-report", description: "Generated files" },
|
||||
frontmatter: DYNAMIC_FM,
|
||||
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,
|
||||
},
|
||||
],
|
||||
frontmatter: MISMATCHED_FM,
|
||||
resources: [await selfEntry("skill://wrong-folder/SKILL.md", MISMATCHED_FM)],
|
||||
};
|
||||
|
||||
const ALL_SKILLS = [
|
||||
|
|
@ -96,11 +122,20 @@ const ALL_SKILLS = [
|
|||
MISMATCHED_SKILL,
|
||||
];
|
||||
|
||||
/** A `resources/read` that serves the fixture bytes for any known URI. */
|
||||
/**
|
||||
* A `resources/read` that serves the fixture bytes for any known URI. A skill's
|
||||
* own `SKILL.md` comes from {@link skillMdFor}, so it agrees with the entry's
|
||||
* advertised frontmatter by construction; a test that needs a disagreement
|
||||
* supplies its own entry.
|
||||
*/
|
||||
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 owner = ALL_SKILLS.find((skill) => skill.uri === uri);
|
||||
return {
|
||||
text: owner ? skillMdFor(owner.frontmatter as Frontmatter) : SELF_TEXT,
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
});
|
||||
|
||||
const baseProps: SkillsScreenProps = {
|
||||
|
|
@ -576,15 +611,11 @@ describe("SkillsScreen", () => {
|
|||
{
|
||||
...CLEAN_SKILL,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
await selfEntry("skill://data-analysis/SKILL.md", CLEAN_FM),
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: `sha256:${"d".repeat(64)}`,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
size: textToBytes(skillMdFor(CLEAN_FM)).byteLength,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -1717,3 +1748,416 @@ describe("SkillsScreen", () => {
|
|||
expect(screen.queryByText("too late")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Directory section (`resources/directory/read`, SEP-2640, #2248).
|
||||
*
|
||||
* Gated on the CALLBACK's presence, not on a boolean beside it: the SEP makes
|
||||
* calling the method against a server that has not declared `directoryRead` a
|
||||
* MUST NOT, and an absent callback is that rule expressed in the type.
|
||||
*/
|
||||
describe("SkillsScreen directory browsing (#2248)", () => {
|
||||
const ROOT = "skill://data-analysis";
|
||||
const CHILD_FILE = {
|
||||
uri: "skill://data-analysis/reference.md",
|
||||
name: "reference.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
const CHILD_DIR = {
|
||||
uri: "skill://data-analysis/templates",
|
||||
name: "templates",
|
||||
mimeType: "inode/directory",
|
||||
};
|
||||
const NESTED = {
|
||||
uri: "skill://data-analysis/templates/invoice.md",
|
||||
name: "invoice.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
|
||||
function directoryReader(
|
||||
pages: Record<string, { resources: unknown[]; nextCursor?: string }>,
|
||||
) {
|
||||
return vi.fn(async (uri: string, cursor?: string) => {
|
||||
const page = pages[cursor === undefined ? uri : `${uri}#${cursor}`];
|
||||
if (!page) throw new Error(`no page for ${uri} ${cursor ?? ""}`);
|
||||
return page as never;
|
||||
});
|
||||
}
|
||||
|
||||
it("renders no Directory section when the server did not declare directoryRead", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Directory/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads on a click, never on selection", async () => {
|
||||
// Every round trip on this screen is asked for — the same posture "Fetch
|
||||
// entry" takes.
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE] },
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(onReadResourceDirectory).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
expect(onReadResourceDirectory).toHaveBeenCalledWith(ROOT, undefined);
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).getByText(
|
||||
"skill://data-analysis/reference.md",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the Directory section on the clean skill and read its root.
|
||||
*
|
||||
* Shared because the descend/ascend assertions below would otherwise each
|
||||
* repeat four sequential `userEvent` clicks inside one 5s budget — enough to
|
||||
* make them the first thing to time out when the suite runs under load,
|
||||
* which is a property of the test rather than of the screen.
|
||||
*/
|
||||
async function openRoot(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
reader: ReturnType<typeof directoryReader>,
|
||||
) {
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadResourceDirectory={reader} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
it("descends into a child directory", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE, CHILD_DIR] },
|
||||
[CHILD_DIR.uri]: { resources: [NESTED] },
|
||||
}),
|
||||
);
|
||||
// A directory child is labelled as one and descends rather than opening in
|
||||
// the viewer; the listing is not recursive, so this is a second call.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Open directory ${CHILD_DIR.uri}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).getByText(NESTED.uri),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Up only below the skill root, and returns to it", async () => {
|
||||
// Ascent is bounded by the root: this section browses the selected skill's
|
||||
// tree, and walking above it would leave every other section's subject
|
||||
// behind.
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE, CHILD_DIR] },
|
||||
[CHILD_DIR.uri]: { resources: [NESTED] },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Up" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Open directory ${CHILD_DIR.uri}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Up" })).toBeInTheDocument(),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Up" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).getByText(CHILD_FILE.uri),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Up" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a file child in the viewer rather than descending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE] },
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
readFixtureFile.mockClear();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `View ${CHILD_FILE.uri}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(readFixtureFile).toHaveBeenCalledWith(CHILD_FILE.uri),
|
||||
);
|
||||
});
|
||||
|
||||
it("pages manually, accumulating children rather than replacing them", async () => {
|
||||
// The cursor belongs to the client per the SEP, and this screen is what a
|
||||
// server author uses to see their own pagination work — auto-walking it
|
||||
// would hide the behaviour under test.
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE], nextCursor: "1" },
|
||||
[`${ROOT}#1`]: { resources: [CHILD_DIR] },
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load more" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
const table = within(screen.getByTestId("skill-directory"));
|
||||
expect(table.getByText(CHILD_FILE.uri)).toBeInTheDocument();
|
||||
expect(table.getByText(CHILD_DIR.uri)).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Load more" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a child the manifest declares as listed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({ [ROOT]: { resources: [CHILD_FILE] } }),
|
||||
);
|
||||
const table = within(screen.getByTestId("skill-directory"));
|
||||
expect(table.getByText("listed")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("skill-directory-unlisted"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags a child the entry does not declare, without merging the two views", async () => {
|
||||
// SEP-2640: a directory read is "a live observation" and hosts "MUST NOT
|
||||
// treat the directory result as extending the manifest". The Inspector is
|
||||
// not a host and does not refuse the read — what it must not do is present
|
||||
// the child as one of the skill's files without saying where it came from.
|
||||
const user = userEvent.setup();
|
||||
const STRAY = {
|
||||
uri: "skill://data-analysis/added-later.md",
|
||||
name: "added-later.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({ [ROOT]: { resources: [CHILD_FILE, STRAY] } }),
|
||||
);
|
||||
const table = within(screen.getByTestId("skill-directory"));
|
||||
expect(table.getByText("listed")).toBeInTheDocument();
|
||||
expect(table.getByText("not listed")).toBeInTheDocument();
|
||||
const banner = screen.getByTestId("skill-directory-unlisted");
|
||||
expect(banner).toHaveTextContent(/1 file here that the held/);
|
||||
// The recovery path the SEP names, rather than "read error".
|
||||
expect(banner).toHaveTextContent(/skills\/get/);
|
||||
});
|
||||
|
||||
it("gives a subdirectory no listed/unlisted verdict", async () => {
|
||||
// A manifest lists files, so a directory is not a missing entry — a "not
|
||||
// listed" chip on one would report a defect that is not there.
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({ [ROOT]: { resources: [CHILD_DIR] } }),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).queryByText("not listed"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("skill-directory-unlisted"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives a dynamic skill's children no verdict either", async () => {
|
||||
// `"dynamic"` advertises no manifest, so there is nothing for a child to be
|
||||
// missing from — and a directory read is the only way its files are
|
||||
// discoverable at all, which is the case the method exists for.
|
||||
const user = userEvent.setup();
|
||||
const reader = directoryReader({
|
||||
"skill://dynamic-report": {
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://dynamic-report/generated.md",
|
||||
name: "generated.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadResourceDirectory={reader} />,
|
||||
);
|
||||
await user.click(screen.getByText("dynamic-report"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).queryByText("not listed"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("skill-directory-unlisted"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says an empty directory is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={directoryReader({ [ROOT]: { resources: [] } })}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("This directory is empty.")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a read failure without losing the section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = vi.fn(async () => {
|
||||
throw new Error("-32602 Not a directory resource");
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={
|
||||
onReadResourceDirectory as unknown as SkillsScreenProps["onReadResourceDirectory"]
|
||||
}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Not a directory resource/)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Directory/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops a listing when the selection changes mid-read", async () => {
|
||||
// A read still in flight when the user switches skills must not land
|
||||
// afterwards and paint one skill's tree under another's name.
|
||||
const user = userEvent.setup();
|
||||
let release: ((value: unknown) => void) | undefined;
|
||||
const onReadResourceDirectory = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}) as never,
|
||||
);
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await user.click(screen.getByText("right-name"));
|
||||
release?.({ resources: [CHILD_FILE] });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("skill-directory")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no Directory section for a skill whose URI is malformed", async () => {
|
||||
// There is no root to browse, and `malformed-uri` already reports it in
|
||||
// Conformance.
|
||||
const user = userEvent.setup();
|
||||
const odd: SkillEntry = {
|
||||
uri: "not-a-uri",
|
||||
frontmatter: { name: "odd", description: "d" },
|
||||
resources: [],
|
||||
};
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
skills={[odd]}
|
||||
onReadResourceDirectory={directoryReader({})}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("odd"));
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Directory/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillsScreen frontmatter cross-check (#2248)", () => {
|
||||
it("reports a listing whose frontmatter disagrees with the served SKILL.md", async () => {
|
||||
// The violation no digest can catch — the digest is over the bytes served
|
||||
// and says nothing about whether the listing described them honestly.
|
||||
const user = userEvent.setup();
|
||||
const lying: SkillEntry = {
|
||||
...CLEAN_SKILL,
|
||||
frontmatter: {
|
||||
name: "data-analysis",
|
||||
description: "Not what the file says",
|
||||
},
|
||||
};
|
||||
renderWithMantine(<ControlledSkillsScreen skills={[lying]} />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId("skill-frontmatter-issues"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Scoped to the findings block: the skill's own description is rendered in
|
||||
// the header too, so an unscoped match would pass on the wrong element.
|
||||
expect(
|
||||
within(screen.getByTestId("skill-frontmatter-issues")).getByText(
|
||||
/Not what the file says/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports nothing when the served frontmatter agrees", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No structural issues")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("skill-frontmatter-issues"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,17 +19,25 @@ import {
|
|||
import { MdSearch, MdVerifiedUser } from "react-icons/md";
|
||||
import { RiArrowRightSLine } from "react-icons/ri";
|
||||
import type {
|
||||
DirectoryReadResult,
|
||||
SkillEntry,
|
||||
SkillResource,
|
||||
} from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import { DYNAMIC_RESOURCES } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import {
|
||||
DIRECTORY_MIME_TYPE,
|
||||
DYNAMIC_RESOURCES,
|
||||
} from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import {
|
||||
checkSkillConformance,
|
||||
checkSkillFrontmatterMatch,
|
||||
skillDisplayName,
|
||||
skillFileBytes,
|
||||
skillEntriesMatch,
|
||||
skillUriIdentity,
|
||||
SKILL_FILE_SUFFIX,
|
||||
totalSkillBytes,
|
||||
verifySkillResource,
|
||||
type SkillFileContents,
|
||||
type SkillIssue,
|
||||
type SkillVerification,
|
||||
} from "@inspector/core/mcp/skills.js";
|
||||
|
|
@ -37,11 +45,7 @@ import { CodeHighlight } from "../../elements/CodeHighlight/CodeHighlight";
|
|||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import {
|
||||
skillFileBytes,
|
||||
type SkillFileContents,
|
||||
} from "../../../utils/skillFileBytes";
|
||||
import { splitSkillFile } from "../../../utils/splitSkillFile";
|
||||
import { splitSkillFile } from "@inspector/core/mcp/skillFile.js";
|
||||
import {
|
||||
inferMimeFromUri,
|
||||
isGenericMime,
|
||||
|
|
@ -126,6 +130,38 @@ interface FetchedEntryState {
|
|||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One child of a directory resource, as `resources/directory/read` returned it.
|
||||
* Structurally the base protocol's `Resource`; only the three members this
|
||||
* section renders are named.
|
||||
*/
|
||||
interface DirectoryChild {
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory browser's state: which directory is on screen, the children
|
||||
* gathered so far, and the cursor for the next page.
|
||||
*
|
||||
* Pages **accumulate** rather than replacing, unlike a paged list elsewhere in
|
||||
* the app, because a directory listing is one thing split across responses — a
|
||||
* reader descending a tree wants the directory's contents, not page 2 of them.
|
||||
* `key` invalidates it exactly as it does every other async slot here.
|
||||
*/
|
||||
interface DirectoryState {
|
||||
key: string | null;
|
||||
attempt?: number;
|
||||
/** The directory being shown (or read). */
|
||||
uri?: string;
|
||||
children?: DirectoryChild[];
|
||||
/** Cursor for the page after `children`, when the server sent one. */
|
||||
nextCursor?: string;
|
||||
loading?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SkillsScreenProps {
|
||||
/**
|
||||
* Identity of the connected session. Part of the invalidation key below, so
|
||||
|
|
@ -157,6 +193,20 @@ export interface SkillsScreenProps {
|
|||
* fresh read excuses.
|
||||
*/
|
||||
onGetSkill: (uri: string) => Promise<SkillEntry>;
|
||||
/**
|
||||
* One page of `resources/directory/read` (SEP-2640), or **`undefined` when
|
||||
* the server did not declare `directoryRead`** — which is how the Directory
|
||||
* section is gated.
|
||||
*
|
||||
* Gated by the prop's presence rather than by a boolean beside it, so the
|
||||
* section cannot be rendered without a way to populate it: the SEP makes
|
||||
* calling this method against a server that has not declared the sub-flag a
|
||||
* MUST NOT, and an absent callback is that rule expressed in the type.
|
||||
*/
|
||||
onReadResourceDirectory?: (
|
||||
uri: string,
|
||||
cursor?: string,
|
||||
) => Promise<DirectoryReadResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -437,7 +487,13 @@ const SkillTitle = Text.withProps({
|
|||
const SECTION_FLEX = "0 1 auto";
|
||||
|
||||
/** Every section this screen can render, in display order. */
|
||||
const ALL_SECTIONS = ["conformance", "resources", "frontmatter", "resource"];
|
||||
const ALL_SECTIONS = [
|
||||
"conformance",
|
||||
"resources",
|
||||
"directory",
|
||||
"frontmatter",
|
||||
"resource",
|
||||
];
|
||||
|
||||
/**
|
||||
* The open set for the FIRST render.
|
||||
|
|
@ -597,6 +653,7 @@ export function SkillsScreen({
|
|||
onRefreshList,
|
||||
onReadSkillFile,
|
||||
onGetSkill,
|
||||
onReadResourceDirectory,
|
||||
}: SkillsScreenProps) {
|
||||
const { selectedSkillUri, search } = ui;
|
||||
// Both slices carry the manifest key they belong to, and every async
|
||||
|
|
@ -615,6 +672,7 @@ export function SkillsScreen({
|
|||
const [fetchedEntry, setFetchedEntry] = useState<FetchedEntryState>({
|
||||
key: null,
|
||||
});
|
||||
const [directory, setDirectory] = useState<DirectoryState>({ 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:
|
||||
|
|
@ -716,6 +774,11 @@ export function SkillsScreen({
|
|||
setVerification({ key: next, files: {} });
|
||||
setPreviewState({ key: next });
|
||||
setFetchedEntry({ key: next });
|
||||
// A directory listing is a live observation of a path under the *selected*
|
||||
// skill, so it is invalidated with everything else — carrying one across a
|
||||
// selection change would show the previous skill's tree under the new
|
||||
// skill's name.
|
||||
setDirectory({ key: next });
|
||||
// Conformance tracks whether it has anything to say: an entry with no
|
||||
// errors and no warnings opens collapsed, because "0 error(s), 0
|
||||
// warning(s)" on the header already carries the whole message and an
|
||||
|
|
@ -940,6 +1003,86 @@ export function SkillsScreen({
|
|||
showResource(selectedUri, manifestKey);
|
||||
}, [manifestKey, selectedUri, showResource]);
|
||||
|
||||
/**
|
||||
* The skill's root directory: its entry URI with `/SKILL.md` removed.
|
||||
*
|
||||
* Computed from the NORMALIZED URI, like every containment decision in
|
||||
* `core/mcp/skills.ts`, so a `..` segment cannot produce a root the resolved
|
||||
* path does not carry. `undefined` for a malformed entry URI — there is no
|
||||
* root to browse, and `malformed-uri` already reports that in Conformance.
|
||||
*/
|
||||
const skillRoot = useMemo(() => {
|
||||
if (!selected) return undefined;
|
||||
const normalized = skillUriIdentity(selected.uri);
|
||||
return normalized.endsWith(SKILL_FILE_SUFFIX)
|
||||
? normalized.slice(0, -SKILL_FILE_SUFFIX.length)
|
||||
: undefined;
|
||||
}, [selected]);
|
||||
|
||||
/**
|
||||
* Read one page of a directory, replacing the listing (`cursor` omitted) or
|
||||
* appending to it (`cursor` given).
|
||||
*
|
||||
* Keyed and attempt-stamped exactly as the other async slots here, so a read
|
||||
* still in flight when the user descends into a different directory — or
|
||||
* switches skills — cannot land afterwards and paint one directory's children
|
||||
* under another's path.
|
||||
*/
|
||||
const readDirectory = useCallback(
|
||||
(uri: string, key: string, cursor?: string) => {
|
||||
if (!onReadResourceDirectory) return;
|
||||
const attempt = (nextAttempt.current += 1);
|
||||
const write = (next: Omit<DirectoryState, "key" | "attempt">) =>
|
||||
setDirectory((prev) => {
|
||||
if (prev.key !== null && prev.key !== key) return prev;
|
||||
if (prev.attempt !== undefined && prev.attempt > attempt) return prev;
|
||||
return { key, attempt, ...next };
|
||||
});
|
||||
// The path is claimed before the request goes out, so the header names
|
||||
// the directory being read rather than continuing to announce the
|
||||
// previous one for as long as the read takes.
|
||||
setDirectory((prev) =>
|
||||
prev.key !== null && prev.key !== key
|
||||
? prev
|
||||
: {
|
||||
key,
|
||||
attempt,
|
||||
uri,
|
||||
// Pages accumulate, so a "load more" keeps what is on screen;
|
||||
// a fresh read of a different directory starts empty.
|
||||
children: cursor === undefined ? undefined : prev.children,
|
||||
loading: true,
|
||||
},
|
||||
);
|
||||
// A click handler cannot await, and this chain terminates in its own
|
||||
// `catch`, which surfaces the message in the section.
|
||||
void onReadResourceDirectory(uri, cursor)
|
||||
.then((page) => {
|
||||
setDirectory((prev) => {
|
||||
if (prev.key !== null && prev.key !== key) return prev;
|
||||
if (prev.attempt !== undefined && prev.attempt > attempt) {
|
||||
return prev;
|
||||
}
|
||||
const held = cursor === undefined ? [] : (prev.children ?? []);
|
||||
return {
|
||||
key,
|
||||
attempt,
|
||||
uri,
|
||||
children: [...held, ...page.resources],
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
write({
|
||||
uri,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
},
|
||||
[onReadResourceDirectory],
|
||||
);
|
||||
|
||||
const fetchEntry = useCallback(() => {
|
||||
if (!selected) return;
|
||||
const key = manifestKey;
|
||||
|
|
@ -1004,6 +1147,50 @@ export function SkillsScreen({
|
|||
const batchRunning = batches.has(manifestKey);
|
||||
|
||||
const previewCurrent = previewState.key === manifestKey;
|
||||
/**
|
||||
* The identities of every file the held entry's manifest lists.
|
||||
*
|
||||
* SEP-2640 is explicit that a directory read is *"a live observation that may
|
||||
* run ahead of or behind"* the entry, and that **"Hosts MUST NOT treat the
|
||||
* directory result as extending the manifest"** — a child the server lists
|
||||
* but the entry does not is, to a host acting on the skill, a verification
|
||||
* failure exactly as a digest mismatch is. The Inspector is not a host and
|
||||
* does not refuse the read; what it must not do is present such a child as
|
||||
* one of the skill's files without saying which view it came from. So the two
|
||||
* views are labelled rather than merged.
|
||||
*
|
||||
* Compared on the normalized identity, like every other URI comparison here.
|
||||
*/
|
||||
const manifestIdentities = useMemo(
|
||||
() => new Set(manifest.map((resource) => skillUriIdentity(resource.uri))),
|
||||
[manifest],
|
||||
);
|
||||
|
||||
// The directory slot, but only when it belongs to the current manifest —
|
||||
// same guard every other async slot on this screen uses.
|
||||
const directoryCurrent = directory.key === manifestKey;
|
||||
const directoryUri = directoryCurrent ? directory.uri : undefined;
|
||||
const directoryChildren = directoryCurrent ? directory.children : undefined;
|
||||
const directoryError = directoryCurrent ? directory.message : undefined;
|
||||
const directoryLoading = directoryCurrent && directory.loading === true;
|
||||
const directoryNextCursor = directoryCurrent
|
||||
? directory.nextCursor
|
||||
: undefined;
|
||||
/**
|
||||
* Children the directory listed that the held entry's manifest does not.
|
||||
* Directories are excluded: a manifest lists files, so a directory is not a
|
||||
* missing entry. So is a `"dynamic"` skill, which advertises no manifest for
|
||||
* anything to be missing from.
|
||||
*/
|
||||
const unlistedChildren = useMemo(() => {
|
||||
if (directoryChildren === undefined || isDynamic) return [];
|
||||
return directoryChildren.filter(
|
||||
(child) =>
|
||||
child.mimeType !== DIRECTORY_MIME_TYPE &&
|
||||
!manifestIdentities.has(skillUriIdentity(child.uri)),
|
||||
);
|
||||
}, [directoryChildren, isDynamic, manifestIdentities]);
|
||||
|
||||
const preview = previewCurrent ? previewState.contents : undefined;
|
||||
const previewError = previewCurrent ? previewState.message : undefined;
|
||||
// The file the viewer is showing (or fetching). Falls back to the skill's own
|
||||
|
|
@ -1089,10 +1276,13 @@ export function SkillsScreen({
|
|||
() => [
|
||||
"conformance",
|
||||
...(isDynamic ? [] : ["resources"]),
|
||||
...(onReadResourceDirectory && skillRoot !== undefined
|
||||
? ["directory"]
|
||||
: []),
|
||||
...(previewParts?.frontmatter !== undefined ? ["frontmatter"] : []),
|
||||
"resource",
|
||||
],
|
||||
[isDynamic, previewParts],
|
||||
[isDynamic, onReadResourceDirectory, previewParts, skillRoot],
|
||||
);
|
||||
const allSectionsOpen = sectionIds.every((id) => openSections.includes(id));
|
||||
|
||||
|
|
@ -1113,6 +1303,35 @@ export function SkillsScreen({
|
|||
state.status === "done" && state.verification.status === "mismatch",
|
||||
).length;
|
||||
|
||||
/**
|
||||
* The SEP-2640 frontmatter cross-check, run against the file on screen — but
|
||||
* **only when that file is the skill's own `SKILL.md`**.
|
||||
*
|
||||
* The obligation is that the entry's `frontmatter` match the frontmatter of
|
||||
* the file the entry names; running it against a supporting file would report
|
||||
* `frontmatter-absent` for every one of them, which is the tool inventing a
|
||||
* defect. `showingSkillMd` is the same identity comparison the rest of this
|
||||
* screen uses.
|
||||
*
|
||||
* It is deliberately **not** part of the static `issues` above: those are
|
||||
* derived from the listing alone and are available the moment the list
|
||||
* arrives, while this one needs a `resources/read` the user asked for. Folding
|
||||
* them together would make the header badge's count change on its own the
|
||||
* first time a file happened to be fetched.
|
||||
*/
|
||||
const frontmatterIssues = useMemo(() => {
|
||||
if (!selected || !showingSkillMd || previewParts === undefined) return [];
|
||||
// Reconstructed from the split rather than re-derived from the payload, so
|
||||
// the check reads exactly the bytes the Frontmatter section displays.
|
||||
if (previewParts.frontmatter === undefined) {
|
||||
return checkSkillFrontmatterMatch(selected, previewParts.body);
|
||||
}
|
||||
return checkSkillFrontmatterMatch(
|
||||
selected,
|
||||
`---\n${previewParts.frontmatter}\n---\n\n${previewParts.body}`,
|
||||
);
|
||||
}, [selected, showingSkillMd, previewParts]);
|
||||
|
||||
const errorCount = issues.filter((i) => i.severity === "error").length;
|
||||
const warningCount = issues.length - errorCount;
|
||||
|
||||
|
|
@ -1348,6 +1567,25 @@ export function SkillsScreen({
|
|||
))}
|
||||
</IssueStack>
|
||||
)}
|
||||
{/* The frontmatter cross-check renders in Conformance
|
||||
rather than beside the Frontmatter section, because it
|
||||
is a *finding about the entry* and every other finding
|
||||
about the entry is here — a reader checking "does this
|
||||
skill conform" must not have to know that one class of
|
||||
violation is filed somewhere else. */}
|
||||
{frontmatterIssues.length > 0 && (
|
||||
<IssueStack data-testid="skill-frontmatter-issues">
|
||||
{frontmatterIssues.map((issue, index) => (
|
||||
<Alert
|
||||
key={`frontmatter:${index}`}
|
||||
color={issueColor(issue)}
|
||||
title={issue.code}
|
||||
>
|
||||
<Text size="sm">{issue.message}</Text>
|
||||
</Alert>
|
||||
))}
|
||||
</IssueStack>
|
||||
)}
|
||||
{manifest.map((resource, index) => {
|
||||
const state = fileStates[index];
|
||||
if (state?.status === "done") {
|
||||
|
|
@ -1597,6 +1835,197 @@ export function SkillsScreen({
|
|||
</Accordion.Item>
|
||||
)}
|
||||
|
||||
{/* Gated on the CALLBACK, which the parent supplies only when
|
||||
the server declared `directoryRead`. SEP-2640 makes calling
|
||||
`resources/directory/read` against a server that has not
|
||||
declared it a MUST NOT, so an absent section is that rule
|
||||
rather than a UI preference. */}
|
||||
{onReadResourceDirectory && skillRoot !== undefined && (
|
||||
<Accordion.Item
|
||||
value="directory"
|
||||
flex={SECTION_FLEX}
|
||||
mih={
|
||||
openSections.includes("directory")
|
||||
? OPEN_SECTION_MIN_HEIGHT
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<SectionHeading>Directory</SectionHeading>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel tabIndex={0}>
|
||||
<Stack gap="xs">
|
||||
{/* Read on a click, never on selection. A directory read
|
||||
is a live round trip, and this screen's posture is
|
||||
that every one of them is asked for — the same reason
|
||||
"Fetch entry" is a button and not an effect. */}
|
||||
<SectionControlsRow>
|
||||
<MonoCaption>{directoryUri ?? skillRoot}</MonoCaption>
|
||||
<Group gap="xs">
|
||||
{/* Ascending is bounded by the skill root: this
|
||||
section browses the selected skill's tree, and
|
||||
walking above it would leave the subject of every
|
||||
other section on screen. */}
|
||||
{directoryUri !== undefined &&
|
||||
directoryUri !== skillRoot && (
|
||||
<FetchButton
|
||||
onClick={() =>
|
||||
readDirectory(
|
||||
directoryUri.slice(
|
||||
0,
|
||||
directoryUri.lastIndexOf("/"),
|
||||
),
|
||||
manifestKey,
|
||||
)
|
||||
}
|
||||
>
|
||||
Up
|
||||
</FetchButton>
|
||||
)}
|
||||
<FetchButton
|
||||
loading={directoryLoading}
|
||||
onClick={() =>
|
||||
readDirectory(skillRoot, manifestKey)
|
||||
}
|
||||
>
|
||||
{directoryChildren === undefined
|
||||
? "Read directory"
|
||||
: "Reload root"}
|
||||
</FetchButton>
|
||||
</Group>
|
||||
</SectionControlsRow>
|
||||
{directoryError !== undefined && (
|
||||
<Alert color="red" variant="light" title="Read failed">
|
||||
{directoryError}
|
||||
</Alert>
|
||||
)}
|
||||
{/* Stated in prose the first time the two views
|
||||
disagree, because the per-row chip alone does not say
|
||||
why it matters — and "the skill changed and needs
|
||||
re-approval" is what SEP-2640 asks a host to present
|
||||
here, rather than a read error. */}
|
||||
{unlistedChildren.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
title="This directory lists files the entry does not"
|
||||
data-testid="skill-directory-unlisted"
|
||||
>
|
||||
The server is serving {unlistedChildren.length} file
|
||||
{unlistedChildren.length === 1 ? "" : "s"} here that
|
||||
the held <Code>skills/list</Code> entry does not
|
||||
declare. A directory read is a live observation and
|
||||
does <strong>not</strong> extend the manifest: to a
|
||||
host acting on this skill, reading one of these is a
|
||||
verification failure equivalent to a digest mismatch.
|
||||
Re-fetch the entry with <Code>skills/get</Code> to see
|
||||
whether the skill has changed.
|
||||
</Alert>
|
||||
)}
|
||||
{directoryChildren !== undefined &&
|
||||
(directoryChildren.length === 0 ? (
|
||||
<EmptyState>This directory is empty.</EmptyState>
|
||||
) : (
|
||||
<ManifestTable data-testid="skill-directory">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>URI</Table.Th>
|
||||
<Table.Th>MIME type</Table.Th>
|
||||
<Table.Th>In manifest</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{directoryChildren.map((child, index) => {
|
||||
const isDir =
|
||||
child.mimeType === DIRECTORY_MIME_TYPE;
|
||||
// A directory is not a manifest entry in the
|
||||
// first place — a manifest lists files — so it
|
||||
// is neither listed nor unlisted and gets no
|
||||
// verdict rather than a misleading "no".
|
||||
const listed = manifestIdentities.has(
|
||||
skillUriIdentity(child.uri),
|
||||
);
|
||||
return (
|
||||
// Index-keyed for the same reason the
|
||||
// manifest rows are: a server repeating a URI
|
||||
// is a defect to display, not two rows to
|
||||
// collapse into one.
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<ResourceUriButton
|
||||
// A directory descends; a file opens in
|
||||
// the viewer below. One column, two
|
||||
// destinations, so the label says which
|
||||
// for a reader who cannot see the MIME
|
||||
// column at a glance.
|
||||
aria-label={
|
||||
isDir
|
||||
? `Open directory ${child.uri}`
|
||||
: `View ${child.uri}`
|
||||
}
|
||||
onClick={() =>
|
||||
isDir
|
||||
? readDirectory(
|
||||
child.uri,
|
||||
manifestKey,
|
||||
)
|
||||
: showResource(
|
||||
child.uri,
|
||||
manifestKey,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDir ? `${child.name}/` : child.name}
|
||||
</ResourceUriButton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<MonoCaption>{child.uri}</MonoCaption>
|
||||
</Table.Td>
|
||||
<Table.Td>{child.mimeType ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{isDir || isDynamic ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<CountBadge
|
||||
color={listed ? "green" : "yellow"}
|
||||
>
|
||||
{listed ? "listed" : "not listed"}
|
||||
</CountBadge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</ManifestTable>
|
||||
))}
|
||||
{/* Paging is manual because the SEP gives the cursor to
|
||||
the client and this screen is what a server author
|
||||
uses to see their own pagination work. Auto-walking it
|
||||
would hide exactly the behaviour under test. */}
|
||||
{directoryNextCursor !== undefined &&
|
||||
directoryUri !== undefined && (
|
||||
<FetchButton
|
||||
loading={directoryLoading}
|
||||
onClick={() =>
|
||||
readDirectory(
|
||||
directoryUri,
|
||||
manifestKey,
|
||||
directoryNextCursor,
|
||||
)
|
||||
}
|
||||
>
|
||||
Load more
|
||||
</FetchButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
|
||||
{/* Rendered ONLY when the file on display actually carries
|
||||
frontmatter. A skill's manifest files generally do not, and a
|
||||
section that lingered would be showing the previously selected
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@ export function InspectorView({
|
|||
onRefreshSkills,
|
||||
onReadSkillFile,
|
||||
onGetSkill,
|
||||
onReadResourceDirectory,
|
||||
} = skillsPanel;
|
||||
const {
|
||||
tasks,
|
||||
|
|
@ -1065,6 +1066,7 @@ export function InspectorView({
|
|||
onRefreshList: onRefreshSkills,
|
||||
onReadSkillFile,
|
||||
onGetSkill,
|
||||
onReadResourceDirectory,
|
||||
};
|
||||
const tasksScreenProps = {
|
||||
tasks,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ import type {
|
|||
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 { SkillFileContents } from "@inspector/core/mcp/skills.js";
|
||||
import type { DirectoryReadResult } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import type { TasksUiState } from "../../screens/TasksScreen/TasksScreen";
|
||||
import type { ProtocolUiState } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import type { NetworkUiState } from "../../screens/NetworkScreen/NetworkScreen";
|
||||
|
|
@ -330,6 +331,15 @@ export interface SkillsPanelProps {
|
|||
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
|
||||
/** Re-fetch the selected entry through `skills/get`. */
|
||||
onGetSkill: (uri: string) => Promise<SkillEntry>;
|
||||
/**
|
||||
* One page of `resources/directory/read`, or **`undefined` when the server
|
||||
* did not declare `directoryRead`** — which is what gates the Skills screen's
|
||||
* Directory section (SEP-2640 makes the call a MUST NOT otherwise).
|
||||
*/
|
||||
onReadResourceDirectory?: (
|
||||
uri: string,
|
||||
cursor?: string,
|
||||
) => Promise<DirectoryReadResult>;
|
||||
}
|
||||
|
||||
/** The Tasks monitor: the task list, its progress map, and actions. */
|
||||
|
|
|
|||
|
|
@ -946,6 +946,76 @@ describe("onReadSkillFile (#2234)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("onReadResourceDirectory (#2248)", () => {
|
||||
const PAGE = { resources: [{ uri: "skill://demo/x.md", name: "x.md" }] };
|
||||
|
||||
it("passes the uri and cursor straight through to the client", async () => {
|
||||
// Not aggregated across pages: SEP-2640 says the listing is not recursive
|
||||
// and the client descends, so the cursor belongs to the caller.
|
||||
const readResourceDirectory = vi.fn().mockResolvedValue(PAGE);
|
||||
const h = harness({ client: client({ readResourceDirectory }) });
|
||||
await expect(
|
||||
h.api().onReadResourceDirectory("skill://demo", "3"),
|
||||
).resolves.toBe(PAGE);
|
||||
expect(readResourceDirectory).toHaveBeenCalledWith("skill://demo", "3");
|
||||
});
|
||||
|
||||
it("throws when there is no client", async () => {
|
||||
await expect(
|
||||
harness().api().onReadResourceDirectory("skill://demo"),
|
||||
).rejects.toThrow("Client is not connected");
|
||||
});
|
||||
|
||||
it("retries once after a satisfied recovery", async () => {
|
||||
const recover = vi.fn().mockResolvedValue(true);
|
||||
const readResourceDirectory = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(authError())
|
||||
.mockResolvedValue(PAGE);
|
||||
const h = harness({
|
||||
client: client({ readResourceDirectory }),
|
||||
activeServerId: "a",
|
||||
recovery: { handleCommandScopedAuthRecovery: recover },
|
||||
});
|
||||
await expect(h.api().onReadResourceDirectory("skill://demo")).resolves.toBe(
|
||||
PAGE,
|
||||
);
|
||||
expect(readResourceDirectory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rethrows when the recovery was not satisfied", async () => {
|
||||
const h = harness({
|
||||
client: client({
|
||||
readResourceDirectory: vi.fn().mockRejectedValue(authError()),
|
||||
}),
|
||||
activeServerId: "a",
|
||||
recovery: {
|
||||
handleCommandScopedAuthRecovery: vi.fn().mockResolvedValue(false),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
h.api().onReadResourceDirectory("skill://demo"),
|
||||
).rejects.toBeInstanceOf(AuthRecoveryRequiredError);
|
||||
});
|
||||
|
||||
it("rethrows a non-auth failure untouched", async () => {
|
||||
// The MUST NOT refusal for an undeclared `directoryRead` is raised by the
|
||||
// client and must reach the caller unchanged, not be mistaken for auth.
|
||||
const h = harness({
|
||||
client: client({
|
||||
readResourceDirectory: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("did not declare directoryRead")),
|
||||
}),
|
||||
activeServerId: "a",
|
||||
recovery: { handleCommandScopedAuthRecovery: vi.fn() },
|
||||
});
|
||||
await expect(
|
||||
h.api().onReadResourceDirectory("skill://demo"),
|
||||
).rejects.toThrow(/directoryRead/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onGetSkill (#2234)", () => {
|
||||
it("routes the uri through the client's skills/get", async () => {
|
||||
const getSkill = vi.fn().mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ 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 { SkillFileContents } from "@inspector/core/mcp/skills.js";
|
||||
import type { DirectoryReadResult } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import { normalizeSkillUri } from "@inspector/core/mcp/skills.js";
|
||||
import { UrlElicitationErrorToastMessage } from "../components/elements/Toasts/UrlElicitationErrorToastMessage";
|
||||
|
|
@ -222,6 +223,11 @@ export interface ServerCommands {
|
|||
* over.
|
||||
*/
|
||||
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
|
||||
/** One page of `resources/directory/read` (SEP-2640). */
|
||||
onReadResourceDirectory: (
|
||||
uri: string,
|
||||
cursor?: string,
|
||||
) => Promise<DirectoryReadResult>;
|
||||
/** Re-fetch one skill entry through `skills/get` (SEP-2640). */
|
||||
onGetSkill: (uri: string) => Promise<SkillEntry>;
|
||||
onSubscribeResource: (uri: string) => void;
|
||||
|
|
@ -1026,6 +1032,37 @@ export function useServerCommands({
|
|||
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
|
||||
);
|
||||
|
||||
/**
|
||||
* One page of `resources/directory/read` (SEP-2640) — the direct children of
|
||||
* a directory resource.
|
||||
*
|
||||
* Not aggregated across pages, unlike the skills walk: the SEP says the
|
||||
* listing is not recursive and clients descend by calling again, so the
|
||||
* cursor belongs to the caller doing the descending. `InspectorClient` refuses
|
||||
* the call outright when the server did not declare `directoryRead`, which is
|
||||
* the spec's MUST NOT — nothing here has to re-check it.
|
||||
*/
|
||||
const onReadResourceDirectory = useCallback(
|
||||
async (uri: string, cursor?: string): Promise<DirectoryReadResult> => {
|
||||
if (!inspectorClient) throw new Error("Client is not connected");
|
||||
// Same shared auth recovery as every other server command (#2174).
|
||||
const read = () => inspectorClient.readResourceDirectory(uri, cursor);
|
||||
try {
|
||||
return await read();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthRecoveryRequiredError && activeServerId) {
|
||||
const satisfied = await handleCommandScopedAuthRecovery(err, {
|
||||
serverId: activeServerId,
|
||||
source: "resource",
|
||||
});
|
||||
if (satisfied) return read();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
|
||||
);
|
||||
|
||||
const onRefreshSkills = useCallback(() => {
|
||||
runCommandInBackground(
|
||||
() => refreshSkills(),
|
||||
|
|
@ -1050,6 +1087,7 @@ export function useServerCommands({
|
|||
onReadResource,
|
||||
onReadResourceContents,
|
||||
onReadSkillFile,
|
||||
onReadResourceDirectory,
|
||||
onGetSkill,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
|
|
|
|||
|
|
@ -228,6 +228,166 @@ describe("InspectorClient skills methods (#2234)", () => {
|
|||
expect(marked).toEqual([]);
|
||||
});
|
||||
|
||||
/** Declare the extension with (or without) the `directoryRead` sub-flag. */
|
||||
function declareSkills(client: InspectorClient, directoryRead: boolean) {
|
||||
internals(client).capabilities = {
|
||||
extensions: { [SKILLS_EXTENSION_KEY]: { directoryRead } },
|
||||
} as ServerCapabilities;
|
||||
}
|
||||
|
||||
describe("readResourceDirectory (#2248)", () => {
|
||||
const CHILD = {
|
||||
uri: "skill://demo/ref.md",
|
||||
name: "ref.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
|
||||
it("throws when not connected, before the capability gate", async () => {
|
||||
// The order matters: a disconnected client has no capabilities either,
|
||||
// so checking the extension first would report every disconnected call
|
||||
// as a missing `directoryRead` declaration.
|
||||
const client = makeClient();
|
||||
declareSkills(client, true);
|
||||
await expect(
|
||||
client.readResourceDirectory("skill://demo"),
|
||||
).rejects.toThrow(/not connected/i);
|
||||
});
|
||||
|
||||
it("refuses the call when the server did not declare directoryRead", async () => {
|
||||
// SEP-2640 makes this a MUST NOT for the client, so it is refused
|
||||
// locally rather than sent and answered -32601. A request we were never
|
||||
// allowed to make must not appear in the Protocol log as a server fault.
|
||||
const client = makeClient();
|
||||
const request = stubRequest(client, { resources: [] });
|
||||
declareSkills(client, false);
|
||||
await expect(
|
||||
client.readResourceDirectory("skill://demo"),
|
||||
).rejects.toThrow(/directoryRead/);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the call when the extension is absent entirely", async () => {
|
||||
const client = makeClient();
|
||||
stubRequest(client, { resources: [] });
|
||||
await expect(
|
||||
client.readResourceDirectory("skill://demo"),
|
||||
).rejects.toThrow(/directoryRead/);
|
||||
});
|
||||
|
||||
it("sends the uri and no cursor on the first page", async () => {
|
||||
const client = makeClient();
|
||||
const request = stubRequest(client, { resources: [CHILD] });
|
||||
declareSkills(client, true);
|
||||
const page = await client.readResourceDirectory("skill://demo");
|
||||
expect(request.mock.calls[0][0].method).toBe("resources/directory/read");
|
||||
expect(request.mock.calls[0][0].params.uri).toBe("skill://demo");
|
||||
expect(request.mock.calls[0][0].params).not.toHaveProperty("cursor");
|
||||
expect(page.resources).toEqual([CHILD]);
|
||||
});
|
||||
|
||||
it("forwards a cursor and returns the server's nextCursor", async () => {
|
||||
const client = makeClient();
|
||||
const request = stubRequest(client, {
|
||||
resources: [],
|
||||
nextCursor: "3",
|
||||
});
|
||||
declareSkills(client, true);
|
||||
const page = await client.readResourceDirectory("skill://demo", "2");
|
||||
expect(request.mock.calls[0][0].params.cursor).toBe("2");
|
||||
expect(page.nextCursor).toBe("3");
|
||||
});
|
||||
|
||||
it("forwards an empty-string cursor, which is a legal opaque value", async () => {
|
||||
const client = makeClient();
|
||||
const request = stubRequest(client, { resources: [] });
|
||||
declareSkills(client, true);
|
||||
await client.readResourceDirectory("skill://demo", "");
|
||||
expect(request.mock.calls[0][0].params.cursor).toBe("");
|
||||
});
|
||||
|
||||
it("stamps call metadata as _meta", async () => {
|
||||
const client = makeClient();
|
||||
const request = stubRequest(client, { resources: [] });
|
||||
declareSkills(client, true);
|
||||
await client.readResourceDirectory("skill://demo", undefined, {
|
||||
progressToken: "p",
|
||||
});
|
||||
expect(request.mock.calls[0][0].params._meta).toMatchObject({
|
||||
progressToken: "p",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires resultType on a modern connection", async () => {
|
||||
const client = makeClient();
|
||||
internals(client).protocolEra = "modern";
|
||||
stubRequest(client, { resources: [] });
|
||||
declareSkills(client, true);
|
||||
await expect(
|
||||
client.readResourceDirectory("skill://demo"),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts a legacy result without resultType", async () => {
|
||||
const client = makeClient();
|
||||
stubRequest(client, { resources: [CHILD] });
|
||||
declareSkills(client, true);
|
||||
await expect(
|
||||
client.readResourceDirectory("skill://demo"),
|
||||
).resolves.toMatchObject({ resources: [CHILD] });
|
||||
});
|
||||
|
||||
it("attributes a rejected decode to the exchange it came from", async () => {
|
||||
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.InvalidResult,
|
||||
"Invalid result for resources/directory/read",
|
||||
);
|
||||
},
|
||||
};
|
||||
declareSkills(client, true);
|
||||
await expect(
|
||||
client.readResourceDirectory("skill://demo"),
|
||||
).rejects.toBeDefined();
|
||||
expect(marked).toEqual(["resources/directory/read"]);
|
||||
});
|
||||
|
||||
it("does NOT attribute a request that never produced a response", async () => {
|
||||
// Marking here would stamp an earlier, successful exchange.
|
||||
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",
|
||||
);
|
||||
},
|
||||
};
|
||||
declareSkills(client, true);
|
||||
await expect(
|
||||
client.readResourceDirectory("skill://demo"),
|
||||
).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
|
||||
|
|
|
|||
117
clients/web/src/test/core/mcp/skillFile.test.ts
Normal file
117
clients/web/src/test/core/mcp/skillFile.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseSkillFrontmatter,
|
||||
splitSkillFile,
|
||||
} from "@inspector/core/mcp/skillFile.js";
|
||||
|
||||
describe("splitSkillFile", () => {
|
||||
it("separates a leading frontmatter fence from the body", () => {
|
||||
expect(splitSkillFile("---\nname: a\n---\n\n# Title\n\nBody\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "# Title\n\nBody\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no frontmatter for a file that has none", () => {
|
||||
expect(splitSkillFile("# Title\n\nBody\n")).toEqual({
|
||||
body: "# Title\n\nBody\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an unterminated opening fence alone rather than eating the file", () => {
|
||||
// `---` with no closing fence is not frontmatter. Treating it as such would
|
||||
// truncate the document to nothing, which is far worse than showing it.
|
||||
const text = "---\nnot really frontmatter\n\n# Title\n";
|
||||
expect(splitSkillFile(text)).toEqual({ body: text });
|
||||
});
|
||||
|
||||
it("does not treat a horizontal rule mid-file as frontmatter", () => {
|
||||
const text = "# Title\n\n---\n\nAfter the rule\n";
|
||||
expect(splitSkillFile(text)).toEqual({ body: text });
|
||||
});
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
expect(splitSkillFile("---\r\nname: a\r\n---\r\n\r\n# Title\r\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "# Title\r\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty body when the file is nothing but frontmatter", () => {
|
||||
expect(splitSkillFile("---\nname: a\n---\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles a closing fence with no trailing newline", () => {
|
||||
// The file ends ON the fence, so there is no newline after it to split at.
|
||||
expect(splitSkillFile("---\nname: a\n---")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a body that follows the fence with no blank line", () => {
|
||||
// The blank line between fence and body is a convention, not a rule —
|
||||
// stripping unconditionally would eat the first line of a file without one.
|
||||
expect(splitSkillFile("---\nname: a\n---\nBody\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "Body\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every frontmatter line, not just the first", () => {
|
||||
expect(
|
||||
splitSkillFile("---\nname: a\ndescription: b\n---\n\nBody\n"),
|
||||
).toEqual({ frontmatter: "name: a\ndescription: b", body: "Body\n" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSkillFrontmatter (#2248)", () => {
|
||||
it("parses a mapping of fields", () => {
|
||||
expect(parseSkillFrontmatter("name: demo\ndescription: A demo")).toEqual({
|
||||
fields: { name: "demo", description: "A demo" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-string scalars as their YAML 1.2 core types", () => {
|
||||
const parsed = parseSkillFrontmatter("n: 1\nb: true\nl: [1, 2]");
|
||||
expect(parsed).toEqual({ fields: { n: 1, b: true, l: [1, 2] } });
|
||||
});
|
||||
|
||||
it("leaves a timestamp-shaped value a string", () => {
|
||||
// The other side of the comparison arrived over JSON-RPC and can only hold
|
||||
// JSON types, so a `Date` here would report a conforming server as broken.
|
||||
// This is the YAML 1.2 core schema doing its job — under 1.1 it would be a
|
||||
// Date and the check would be wrong.
|
||||
const parsed = parseSkillFrontmatter("when: 2001-12-14t21:59:43.10-05:00");
|
||||
expect(parsed).toEqual({
|
||||
fields: { when: "2001-12-14t21:59:43.10-05:00" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads an empty block as a mapping of no fields, not an error", () => {
|
||||
expect(parseSkillFrontmatter("")).toEqual({ fields: {} });
|
||||
expect(parseSkillFrontmatter("# just a comment")).toEqual({ fields: {} });
|
||||
});
|
||||
|
||||
it("reports a scalar block as an error rather than as no fields", () => {
|
||||
// `just a string` parses successfully as a scalar. Reporting it as an
|
||||
// empty mapping would present a malformed file as one that merely omitted
|
||||
// every field.
|
||||
const parsed = parseSkillFrontmatter("just a string");
|
||||
expect(parsed).toEqual({ error: expect.stringContaining("mapping") });
|
||||
});
|
||||
|
||||
it("reports a sequence block as an error", () => {
|
||||
expect(parseSkillFrontmatter("- one\n- two")).toEqual({
|
||||
error: expect.stringContaining("mapping"),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid YAML with the parser's own message", () => {
|
||||
const parsed = parseSkillFrontmatter("a: [1,");
|
||||
expect("error" in parsed && parsed.error.length > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
37
clients/web/src/test/core/mcp/skillFileBytes.test.ts
Normal file
37
clients/web/src/test/core/mcp/skillFileBytes.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { skillFileBytes } from "@inspector/core/mcp/skills.js";
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
SKILL_MAX_TOTAL_BYTES,
|
||||
base64ToBytes,
|
||||
checkSkillConformance,
|
||||
checkSkillFrontmatterMatch,
|
||||
getSkillsExtension,
|
||||
isSkillsExtensionSupported,
|
||||
normalizeSkillUri,
|
||||
|
|
@ -843,3 +844,137 @@ describe("verifySkillResource", () => {
|
|||
expect(result.expectedDigest).toBe("sha256:nope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSkillFrontmatterMatch (#2248)", () => {
|
||||
const entry = (frontmatter: Record<string, unknown>): SkillEntry => ({
|
||||
uri: "skill://demo/SKILL.md",
|
||||
frontmatter,
|
||||
resources: [],
|
||||
});
|
||||
const file = (yaml: string, body = "# Demo\n") =>
|
||||
`---\n${yaml}\n---\n\n${body}`;
|
||||
|
||||
it("reports nothing when every field agrees", () => {
|
||||
expect(
|
||||
checkSkillFrontmatterMatch(
|
||||
entry({ name: "demo", description: "A demo" }),
|
||||
file("name: demo\ndescription: A demo"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches a listing that advertises a different description", () => {
|
||||
// The violation no digest can catch: the digest is over the bytes the
|
||||
// server served and says nothing about whether the listing described them
|
||||
// honestly.
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ name: "demo", description: "Reads a spreadsheet" }),
|
||||
file("name: demo\ndescription: Emails the spreadsheet"),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe("frontmatter-mismatch");
|
||||
// Equivalent to a digest mismatch per the SEP, so it must be an error.
|
||||
expect(issues[0].severity).toBe("error");
|
||||
// The diagnosis, not just the verdict — a server author has to be able to
|
||||
// fix it from the message alone.
|
||||
expect(issues[0].message).toContain("Reads a spreadsheet");
|
||||
expect(issues[0].message).toContain("Emails the spreadsheet");
|
||||
expect(issues[0].resourceUri).toBe("skill://demo/SKILL.md");
|
||||
});
|
||||
|
||||
it("reports one finding per differing field", () => {
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ name: "a", description: "x" }),
|
||||
file("name: b\ndescription: y"),
|
||||
);
|
||||
expect(issues).toHaveLength(2);
|
||||
expect(issues.map((i) => i.message.match(/"(\w+)"/)?.[1])).toEqual([
|
||||
"description",
|
||||
"name",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a field the file declares and the listing omits", () => {
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ name: "demo", description: "A demo" }),
|
||||
file("name: demo\ndescription: A demo\nlicense: MIT"),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].message).toMatch(/declares "license".*omits it/);
|
||||
});
|
||||
|
||||
it("reports a field the listing declares and the file omits", () => {
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ name: "demo", description: "A demo", license: "MIT" }),
|
||||
file("name: demo\ndescription: A demo"),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].message).toMatch(
|
||||
/listing declares "license".*served SKILL.md omits it/,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a file with no frontmatter block as a violation", () => {
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ name: "demo" }),
|
||||
"# Demo\n\nNo fence here.\n",
|
||||
);
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "frontmatter-absent",
|
||||
severity: "error",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports unparsable YAML as its own code, not as a mismatch", () => {
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ name: "demo" }),
|
||||
file("a: [1,"),
|
||||
);
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "frontmatter-unparsable",
|
||||
severity: "error",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("compares nested mappings by content, not by key order", () => {
|
||||
// Key order is not meaningful in JSON or YAML, so calling it a discrepancy
|
||||
// would report a conforming server as broken.
|
||||
expect(
|
||||
checkSkillFrontmatterMatch(
|
||||
entry({ meta: { b: 2, a: 1 } }),
|
||||
file("meta:\n a: 1\n b: 2"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats array ORDER as significant", () => {
|
||||
// A YAML sequence is ordered, so two orderings are two different values.
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ tags: ["a", "b"] }),
|
||||
file("tags: [b, a]"),
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe("frontmatter-mismatch");
|
||||
});
|
||||
|
||||
it("distinguishes an explicit null from an absent field", () => {
|
||||
// `license:` with no value parses to null — a field that is present and
|
||||
// holds null, which is not the same fact as a field that is not there.
|
||||
const issues = checkSkillFrontmatterMatch(
|
||||
entry({ license: null }),
|
||||
file("license:"),
|
||||
);
|
||||
expect(issues).toEqual([]);
|
||||
expect(
|
||||
checkSkillFrontmatterMatch(entry({}), file("license:")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports nothing for two empty frontmatters", () => {
|
||||
expect(checkSkillFrontmatterMatch(entry({}), file(""))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DIRECTORY_MIME_TYPE,
|
||||
DirectoryReadResultSchema,
|
||||
ModernDirectoryReadResultSchema,
|
||||
RESOURCES_DIRECTORY_READ_METHOD,
|
||||
DYNAMIC_RESOURCES,
|
||||
GetSkillResultSchema,
|
||||
ListSkillsResultSchema,
|
||||
|
|
@ -160,3 +164,123 @@ describe("GetSkillResultSchema", () => {
|
|||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory read schemas (#2248)", () => {
|
||||
const CHILD = {
|
||||
uri: "skill://demo/templates/invoice.md",
|
||||
name: "invoice.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
|
||||
it("names the method and the directory MIME type", () => {
|
||||
expect(RESOURCES_DIRECTORY_READ_METHOD).toBe("resources/directory/read");
|
||||
expect(DIRECTORY_MIME_TYPE).toBe("inode/directory");
|
||||
});
|
||||
|
||||
it("parses the SEP's own worked example", () => {
|
||||
// Verbatim from SEP-2640's `resources/directory/read` example, including a
|
||||
// subdirectory child. If the schema cannot read the spec's own example it
|
||||
// is wrong whatever else it accepts.
|
||||
const example = {
|
||||
resultType: "complete",
|
||||
resources: [
|
||||
CHILD,
|
||||
{
|
||||
uri: "skill://demo/templates/regional",
|
||||
name: "regional",
|
||||
mimeType: "inode/directory",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(DirectoryReadResultSchema.safeParse(example).success).toBe(true);
|
||||
expect(ModernDirectoryReadResultSchema.safeParse(example).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an empty directory", () => {
|
||||
const parsed = DirectoryReadResultSchema.parse({ resources: [] });
|
||||
expect(parsed.resources).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries nextCursor through", () => {
|
||||
const parsed = DirectoryReadResultSchema.parse({
|
||||
resources: [CHILD],
|
||||
nextCursor: "7",
|
||||
});
|
||||
expect(parsed.nextCursor).toBe("7");
|
||||
});
|
||||
|
||||
it("rejects a child that is not a base-protocol Resource", () => {
|
||||
// `name` is required on `Resource`, and the SEP says a directory child IS
|
||||
// one. Accepting a child here that `resources/list` would reject is the
|
||||
// inconsistency the shared SDK schema exists to prevent.
|
||||
expect(
|
||||
DirectoryReadResultSchema.safeParse({
|
||||
resources: [{ uri: "skill://demo/x.md" }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a result whose resources member is not an array", () => {
|
||||
expect(
|
||||
DirectoryReadResultSchema.safeParse({ resources: "nope" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires resultType on the modern variant only", () => {
|
||||
const legacyShape = { resources: [CHILD] };
|
||||
expect(DirectoryReadResultSchema.safeParse(legacyShape).success).toBe(true);
|
||||
expect(ModernDirectoryReadResultSchema.safeParse(legacyShape).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT require the caching attributes on the modern variant", () => {
|
||||
// The deliberate asymmetry with `ModernListSkillsResultSchema`: SEP-2640
|
||||
// states `ttlMs`/`cacheScope` for a modern `skills/list` and says nothing
|
||||
// of the kind for this method, whose only worked example omits them.
|
||||
// Requiring them would fail a server that matched the spec's own example.
|
||||
expect(
|
||||
ModernDirectoryReadResultSchema.safeParse({
|
||||
resultType: "complete",
|
||||
resources: [],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
ModernListSkillsResultSchema.safeParse({
|
||||
resultType: "complete",
|
||||
skills: [],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still accepts the caching attributes when a server sends them", () => {
|
||||
// Permitted, not mandated — a schema is not the place to reject an extra
|
||||
// member the spec leaves open.
|
||||
expect(
|
||||
ModernDirectoryReadResultSchema.safeParse({
|
||||
resultType: "complete",
|
||||
resources: [],
|
||||
ttlMs: 60,
|
||||
cacheScope: "public",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GetSkillResultSchema caching attributes (#2248)", () => {
|
||||
it("accepts a result with the caching attributes and one without", () => {
|
||||
// SEP-2640 leaves the question open in as many words, so both are
|
||||
// conforming and neither may be reported as a defect.
|
||||
expect(GetSkillResultSchema.safeParse({ skill: ENTRY }).success).toBe(true);
|
||||
expect(
|
||||
GetSkillResultSchema.safeParse({
|
||||
skill: ENTRY,
|
||||
resultType: "complete",
|
||||
ttlMs: 0,
|
||||
cacheScope: "public",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
329
clients/web/src/test/core/mcp/skillsVerification.test.ts
Normal file
329
clients/web/src/test/core/mcp/skillsVerification.test.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { InspectorClientProtocol } from "@inspector/core/mcp/inspectorClientProtocol.js";
|
||||
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import { sha256Digest } from "@inspector/core/mcp/skills.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
allSkillsVerified,
|
||||
verifySkills,
|
||||
} from "@inspector/core/mcp/skillsVerification.js";
|
||||
|
||||
/**
|
||||
* `verifySkills` is the fetch-and-verify half of the SEP-2640 checks (#2248) —
|
||||
* the part the pure checkers in `skills.ts` deliberately do not do. What these
|
||||
* pin is the fetching policy and the failure handling, since the checks
|
||||
* themselves are covered in `skills.test.ts`.
|
||||
*/
|
||||
describe("verifySkills (#2248)", () => {
|
||||
const SKILL_MD = "---\nname: demo\ndescription: A demo\n---\n\n# Demo\n";
|
||||
const REF = "# Reference\n";
|
||||
|
||||
async function entry(
|
||||
overrides: Partial<SkillEntry> = {},
|
||||
): Promise<SkillEntry> {
|
||||
return {
|
||||
uri: "skill://demo/SKILL.md",
|
||||
frontmatter: { name: "demo", description: "A demo" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://demo/SKILL.md",
|
||||
digest: await sha256Digest(new TextEncoder().encode(SKILL_MD)),
|
||||
size: new TextEncoder().encode(SKILL_MD).byteLength,
|
||||
},
|
||||
{
|
||||
uri: "skill://demo/ref.md",
|
||||
digest: await sha256Digest(new TextEncoder().encode(REF)),
|
||||
size: new TextEncoder().encode(REF).byteLength,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A client whose `resources/read` answers from a URI → text map. */
|
||||
function clientServing(files: Record<string, string | Error>): {
|
||||
client: InspectorClientProtocol;
|
||||
readResource: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const readResource = vi.fn(async (uri: string) => {
|
||||
const served = files[uri];
|
||||
if (served === undefined) throw new Error(`unknown resource ${uri}`);
|
||||
if (served instanceof Error) throw served;
|
||||
return { result: { contents: [{ uri, text: served }] } };
|
||||
});
|
||||
return {
|
||||
client: { readResource } as unknown as InspectorClientProtocol,
|
||||
readResource,
|
||||
};
|
||||
}
|
||||
|
||||
it("verifies a clean skill and reports ok", async () => {
|
||||
const skill = await entry();
|
||||
const { client } = clientServing({
|
||||
"skill://demo/SKILL.md": SKILL_MD,
|
||||
"skill://demo/ref.md": REF,
|
||||
});
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.name).toBe("demo");
|
||||
expect(report.conformance).toEqual([]);
|
||||
expect(report.frontmatter).toEqual([]);
|
||||
expect(report.files.map((f) => f.status)).toEqual(["verified", "verified"]);
|
||||
expect(allSkillsVerified([report])).toBe(true);
|
||||
});
|
||||
|
||||
it("reads each manifest file exactly once", async () => {
|
||||
// The entry's own SKILL.md is needed twice — for its digest and for the
|
||||
// frontmatter cross-check — and reading it twice would both double the
|
||||
// load and risk comparing two different snapshots.
|
||||
const skill = await entry();
|
||||
const { client, readResource } = clientServing({
|
||||
"skill://demo/SKILL.md": SKILL_MD,
|
||||
"skill://demo/ref.md": REF,
|
||||
});
|
||||
await verifySkills(client, [skill]);
|
||||
expect(readResource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports a digest mismatch and fails the skill", async () => {
|
||||
const skill = await entry();
|
||||
const { client } = clientServing({
|
||||
"skill://demo/SKILL.md": SKILL_MD,
|
||||
"skill://demo/ref.md": "different bytes entirely\n",
|
||||
});
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.files[1].status).toBe("mismatch");
|
||||
expect(allSkillsVerified([report])).toBe(false);
|
||||
});
|
||||
|
||||
it("catches a listing whose frontmatter differs from the served file", async () => {
|
||||
const skillMd = "---\nname: demo\ndescription: Something else\n---\n\n#\n";
|
||||
const bytes = new TextEncoder().encode(skillMd);
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://demo/SKILL.md",
|
||||
frontmatter: { name: "demo", description: "A demo" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://demo/SKILL.md",
|
||||
// The digest is over the bytes actually served, so it VERIFIES —
|
||||
// which is the whole reason this check has to exist separately.
|
||||
digest: await sha256Digest(bytes),
|
||||
size: bytes.byteLength,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { client } = clientServing({ "skill://demo/SKILL.md": skillMd });
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0].status).toBe("verified");
|
||||
expect(report.frontmatter).toHaveLength(1);
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("records a read failure per file instead of aborting the report", async () => {
|
||||
// A report that stopped at the first unreadable file would hide every
|
||||
// finding after it, which defeats the point of running this in CI.
|
||||
const skill = await entry();
|
||||
const { client } = clientServing({
|
||||
"skill://demo/SKILL.md": new Error("boom"),
|
||||
"skill://demo/ref.md": REF,
|
||||
});
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0]).toMatchObject({
|
||||
status: "read-error",
|
||||
reason: "boom",
|
||||
});
|
||||
expect(report.files[1].status).toBe("verified");
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a response with no content blocks as a read failure", async () => {
|
||||
const skill = await entry();
|
||||
const readResource = vi.fn(async () => ({ result: { contents: [] } }));
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0]).toMatchObject({ status: "read-error" });
|
||||
expect(report.files[0].reason).toMatch(/no content blocks/);
|
||||
});
|
||||
|
||||
it("reports a block carrying neither text nor blob as a read failure", async () => {
|
||||
// Never as an empty file: an empty Uint8Array has a perfectly good
|
||||
// SHA-256, so a silent fallback would report a confident, wrong mismatch.
|
||||
const skill = await entry();
|
||||
const readResource = vi.fn(async (uri: string) => ({
|
||||
result: { contents: [{ uri, mimeType: "text/markdown" }] },
|
||||
}));
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0].status).toBe("read-error");
|
||||
expect(report.files[0].reason).toMatch(/neither text nor blob/);
|
||||
});
|
||||
|
||||
it("still runs the frontmatter check for a dynamic skill", async () => {
|
||||
// `"dynamic"` waives integrity, not the frontmatter identity requirement —
|
||||
// the SKILL.md is still served and still has to match what was listed.
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://gen/SKILL.md",
|
||||
frontmatter: { name: "gen", description: "Listed" },
|
||||
resources: "dynamic",
|
||||
};
|
||||
const { client, readResource } = clientServing({
|
||||
"skill://gen/SKILL.md":
|
||||
"---\nname: gen\ndescription: Served\n---\n\n# Gen\n",
|
||||
});
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files).toEqual([]);
|
||||
expect(readResource).toHaveBeenCalledWith(
|
||||
"skill://gen/SKILL.md",
|
||||
undefined,
|
||||
);
|
||||
expect(report.frontmatter).toHaveLength(1);
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a dynamic skill whose served frontmatter agrees", async () => {
|
||||
// The `dynamic-resources` finding is a WARNING, and a warning must not fail
|
||||
// the report — a conforming generated skill would otherwise fail CI.
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://gen/SKILL.md",
|
||||
frontmatter: { name: "gen", description: "Same" },
|
||||
resources: "dynamic",
|
||||
};
|
||||
const { client } = clientServing({
|
||||
"skill://gen/SKILL.md":
|
||||
"---\nname: gen\ndescription: Same\n---\n\n# Gen\n",
|
||||
});
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.conformance).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "dynamic-resources",
|
||||
severity: "warning",
|
||||
}),
|
||||
]);
|
||||
expect(report.ok).toBe(true);
|
||||
});
|
||||
|
||||
const authError = () =>
|
||||
new AuthRecoveryRequiredError(new URL("https://auth.example/authorize"), {
|
||||
reason: "expired",
|
||||
} as never);
|
||||
|
||||
it("re-throws an auth-recovery error instead of recording it per file", async () => {
|
||||
// Not a property of the file in flight: the session's authorization
|
||||
// expired, so every remaining read fails the same way. Absorbing it would
|
||||
// produce N identical read failures AND swallow the one error a caller
|
||||
// keys off to start a reauthorization.
|
||||
const skill = await entry();
|
||||
const readResource = vi.fn(async () => {
|
||||
throw authError();
|
||||
});
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
await expect(verifySkills(client, [skill])).rejects.toBeInstanceOf(
|
||||
AuthRecoveryRequiredError,
|
||||
);
|
||||
// Stops at the first read rather than walking the rest of the manifest.
|
||||
expect(readResource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-throws an auth-recovery error from a dynamic skill's SKILL.md read", async () => {
|
||||
// The other read site: a dynamic skill has no manifest, so its SKILL.md is
|
||||
// fetched by the fallback below the loop, which has its own catch.
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://gen/SKILL.md",
|
||||
frontmatter: { name: "gen" },
|
||||
resources: "dynamic",
|
||||
};
|
||||
const readResource = vi.fn(async () => {
|
||||
throw authError();
|
||||
});
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
await expect(verifySkills(client, [skill])).rejects.toBeInstanceOf(
|
||||
AuthRecoveryRequiredError,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the frontmatter check when the SKILL.md cannot be read", async () => {
|
||||
// The read failure is reported once, as a file result. Reporting it again
|
||||
// as a phantom `frontmatter-absent` would invent a second defect.
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://gen/SKILL.md",
|
||||
frontmatter: { name: "gen" },
|
||||
resources: "dynamic",
|
||||
};
|
||||
const readResource = vi.fn(async () => {
|
||||
throw new Error("unreachable");
|
||||
});
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails a skill whose static conformance has an error", async () => {
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://wrong/SKILL.md",
|
||||
frontmatter: { name: "right", description: "d" },
|
||||
resources: [],
|
||||
};
|
||||
const { client } = clientServing({
|
||||
"skill://wrong/SKILL.md":
|
||||
"---\nname: right\ndescription: d\n---\n\n# X\n",
|
||||
});
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.conformance.map((i) => i.code)).toContain(
|
||||
"name-path-mismatch",
|
||||
);
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a canonicalized URI in the served content block", async () => {
|
||||
// A server may answer with an RFC-equivalent spelling of the URI asked
|
||||
// for; matching the block by URI would reject a conforming server.
|
||||
const bytes = new TextEncoder().encode(SKILL_MD);
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://demo/SKILL.md",
|
||||
frontmatter: { name: "demo", description: "A demo" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://demo/SKILL.md",
|
||||
digest: await sha256Digest(bytes),
|
||||
size: bytes.byteLength,
|
||||
},
|
||||
],
|
||||
};
|
||||
const readResource = vi.fn(async () => ({
|
||||
result: {
|
||||
contents: [{ uri: "skill://demo/%53KILL.md", text: SKILL_MD }],
|
||||
},
|
||||
}));
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0].status).toBe("verified");
|
||||
});
|
||||
|
||||
it("reports every skill it was given, in order", async () => {
|
||||
const a = await entry();
|
||||
const b = await entry({ uri: "skill://demo/SKILL.md" });
|
||||
const { client } = clientServing({
|
||||
"skill://demo/SKILL.md": SKILL_MD,
|
||||
"skill://demo/ref.md": REF,
|
||||
});
|
||||
const reports = await verifySkills(client, [a, b]);
|
||||
expect(reports).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("forwards request metadata to every read", async () => {
|
||||
const skill = await entry();
|
||||
const { client, readResource } = clientServing({
|
||||
"skill://demo/SKILL.md": SKILL_MD,
|
||||
"skill://demo/ref.md": REF,
|
||||
});
|
||||
await verifySkills(client, [skill], { progressToken: "p" });
|
||||
expect(readResource).toHaveBeenCalledWith("skill://demo/SKILL.md", {
|
||||
progressToken: "p",
|
||||
});
|
||||
});
|
||||
|
||||
it("allSkillsVerified is true for an empty report", async () => {
|
||||
expect(allSkillsVerified([])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,10 @@ 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 {
|
||||
allSkillsVerified,
|
||||
verifySkills,
|
||||
} from "@inspector/core/mcp/skillsVerification.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
type TestServerHttp,
|
||||
|
|
@ -96,9 +100,11 @@ describe("Skills extension over a real transport (#2234)", () => {
|
|||
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.
|
||||
// `directoryRead` is declared because the fixture now serves the
|
||||
// method (#2248) — the declaration and the handler are one switch, so
|
||||
// this can never report a sub-option the server does not answer.
|
||||
expect(getSkillsExtension(connected.getCapabilities())).toEqual({
|
||||
directoryRead: false,
|
||||
directoryRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -115,13 +121,18 @@ describe("Skills extension over a real transport (#2234)", () => {
|
|||
// 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.
|
||||
// The fixture pages at two over five skills, so a client that stops
|
||||
// here sees less than 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();
|
||||
expect(second.nextCursor).toBeDefined();
|
||||
|
||||
const third = await connected.listSkills(second.nextCursor);
|
||||
expect(third.skills).toHaveLength(2);
|
||||
expect(third.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("walks every page through the managed store", async () => {
|
||||
|
|
@ -134,9 +145,11 @@ describe("Skills extension over a real transport (#2234)", () => {
|
|||
"data-analysis",
|
||||
"tampered-notes",
|
||||
"dynamic-report",
|
||||
"stale-manifest",
|
||||
"lying-listing",
|
||||
"right-name",
|
||||
]);
|
||||
expect(store.getPagination()).toEqual({ pageCount: 2 });
|
||||
expect(store.getPagination()).toEqual({ pageCount: 3 });
|
||||
} finally {
|
||||
store.destroy();
|
||||
}
|
||||
|
|
@ -171,6 +184,127 @@ describe("Skills extension over a real transport (#2234)", () => {
|
|||
expect("text" in block && block.text).toContain("Column rules");
|
||||
});
|
||||
|
||||
it("reads a directory and pages through its children", async () => {
|
||||
// The whole `resources/directory/read` round trip against a real
|
||||
// server: the client's `directoryRead` gate, the era-selected result
|
||||
// schema, and the fixture's cursor.
|
||||
const started = await startSkillsServer(modern);
|
||||
const connected = await connect(started.url, modern);
|
||||
const first = await connected.readResourceDirectory(
|
||||
"skill://data-analysis",
|
||||
);
|
||||
// Pages at one child, so a client ignoring `nextCursor` is visibly
|
||||
// wrong here rather than merely lucky.
|
||||
expect(first.resources).toHaveLength(1);
|
||||
expect(first.nextCursor).toBeDefined();
|
||||
const second = await connected.readResourceDirectory(
|
||||
"skill://data-analysis",
|
||||
first.nextCursor,
|
||||
);
|
||||
expect(second.nextCursor).toBeUndefined();
|
||||
expect(
|
||||
[...first.resources, ...second.resources].map((r) => r.uri).sort(),
|
||||
).toEqual([
|
||||
"skill://data-analysis/SKILL.md",
|
||||
"skill://data-analysis/reference.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists a dynamic skill's files, which is what the method is for", async () => {
|
||||
// `dynamic-report` advertises no manifest, so a directory read is the
|
||||
// only way its files are discoverable at all — the case SEP-2640 says
|
||||
// directory reading earns its place for.
|
||||
const started = await startSkillsServer(modern);
|
||||
const connected = await connect(started.url, modern);
|
||||
const page = await connected.readResourceDirectory(
|
||||
"skill://dynamic-report",
|
||||
);
|
||||
expect(page.resources[0].uri).toBe("skill://dynamic-report/SKILL.md");
|
||||
});
|
||||
|
||||
it("lists a file the entry's manifest does not declare", async () => {
|
||||
// The stale-snapshot case SEP-2640 governs: a directory read is "a
|
||||
// live observation" that may run ahead of the held entry, and hosts
|
||||
// MUST NOT treat it as extending the manifest. The entry itself is
|
||||
// fully conforming — only the two views disagree — so nothing but this
|
||||
// comparison can surface it.
|
||||
const started = await startSkillsServer(modern);
|
||||
const connected = await connect(started.url, modern);
|
||||
const entry = await connected.getSkill(
|
||||
"skill://stale-manifest/SKILL.md",
|
||||
);
|
||||
const declared = new Set(
|
||||
(entry.resources === "dynamic" ? [] : entry.resources).map(
|
||||
(r) => r.uri,
|
||||
),
|
||||
);
|
||||
expect(declared).toEqual(new Set(["skill://stale-manifest/SKILL.md"]));
|
||||
|
||||
const first = await connected.readResourceDirectory(
|
||||
"skill://stale-manifest",
|
||||
);
|
||||
const second = await connected.readResourceDirectory(
|
||||
"skill://stale-manifest",
|
||||
first.nextCursor,
|
||||
);
|
||||
const children = [...first.resources, ...second.resources].map(
|
||||
(r) => r.uri,
|
||||
);
|
||||
expect(children).toContain("skill://stale-manifest/added-later.md");
|
||||
expect(declared.has("skill://stale-manifest/added-later.md")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// And the entry still verifies clean — the disagreement is the whole
|
||||
// defect, and no digest check can see it.
|
||||
const [report] = await verifySkills(connected, [entry]);
|
||||
expect(report.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("answers -32602 for a URI that is not a directory resource", async () => {
|
||||
const started = await startSkillsServer(modern);
|
||||
const connected = await connect(started.url, modern);
|
||||
await expect(
|
||||
connected.readResourceDirectory("skill://data-analysis/SKILL.md"),
|
||||
).rejects.toThrow(/Not a directory resource/);
|
||||
});
|
||||
|
||||
it("verifies the whole catalog, failing exactly the three bad skills", async () => {
|
||||
// End to end against the fixture: conformance, digests and the
|
||||
// frontmatter cross-check, over a real transport. The three failures
|
||||
// are one per violation class, and `dynamic-report` passing is the
|
||||
// assertion that a warning does not fail a report.
|
||||
const started = await startSkillsServer(modern);
|
||||
const connected = await connect(started.url, modern);
|
||||
const store = new ManagedSkillsState(connected);
|
||||
try {
|
||||
const skills = await store.refresh();
|
||||
const reports = await verifySkills(connected, skills);
|
||||
expect(reports.filter((r) => !r.ok).map((r) => r.name)).toEqual([
|
||||
"tampered-notes",
|
||||
"lying-listing",
|
||||
"right-name",
|
||||
]);
|
||||
expect(allSkillsVerified(reports)).toBe(false);
|
||||
|
||||
const tampered = reports.find((r) => r.name === "tampered-notes")!;
|
||||
expect(tampered.files.some((f) => f.status === "mismatch")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The one violation only the frontmatter check can catch: its digest
|
||||
// verifies, because the digest is over the bytes the server served.
|
||||
const lying = reports.find((r) => r.name === "lying-listing")!;
|
||||
expect(lying.files.every((f) => f.status === "verified")).toBe(true);
|
||||
expect(lying.frontmatter[0].code).toBe("frontmatter-mismatch");
|
||||
|
||||
const dynamic = reports.find((r) => r.name === "dynamic-report")!;
|
||||
expect(dynamic.ok).toBe(true);
|
||||
} finally {
|
||||
store.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
/**
|
||||
* 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.");
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
/**
|
||||
* Split a skill file into its YAML frontmatter and its body (#2263).
|
||||
*
|
||||
* A pure transform with no I/O and no subsystem of its own, so it lives in
|
||||
* `utils/` rather than `lib/` — and in its own module rather than in
|
||||
* `SkillsScreen.tsx`, because a component file that also exports a function
|
||||
* defeats React Fast Refresh (`react-refresh/only-export-components`).
|
||||
*/
|
||||
|
||||
export interface SkillFileParts {
|
||||
/**
|
||||
* The raw YAML between the fences, fences excluded — `undefined` when the
|
||||
* file has no frontmatter at all. Raw rather than parsed: this app carries no
|
||||
* YAML parser, and showing the bytes the server actually served is the more
|
||||
* useful answer for a conformance tool anyway.
|
||||
*/
|
||||
frontmatter?: string;
|
||||
/** Everything after the closing fence, or the whole file when there is none. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate a leading YAML frontmatter fence from the rest of a skill file.
|
||||
*
|
||||
* The Skills screen renders the two halves in different places — the
|
||||
* frontmatter in its own collapsible section, the body in the file viewer — and
|
||||
* deriving both from **one** split is what stops them disagreeing: the section
|
||||
* can never show one file's frontmatter while the viewer shows another's, and a
|
||||
* file with no frontmatter cannot leave a stale section on screen.
|
||||
*
|
||||
* It also matters for rendering: the markdown renderer has no frontmatter
|
||||
* support, so an un-split `---\nname: …\n---` is read as a setext heading and
|
||||
* painted as a title above the document's real one.
|
||||
*
|
||||
* Two deliberate conservatisms, because this must never eat content:
|
||||
*
|
||||
* - Only a fence at the very **start** of the file counts. A `---` anywhere
|
||||
* else is a horizontal rule and is left in the body.
|
||||
* - A file that opens with `---` but never closes the fence is **not**
|
||||
* frontmatter; the whole file is returned as the body rather than being
|
||||
* truncated to nothing.
|
||||
*/
|
||||
export function splitSkillFile(text: string): SkillFileParts {
|
||||
if (!/^---[ \t]*\r?\n/.test(text)) return { body: text };
|
||||
const rest = text.slice(text.indexOf("\n") + 1);
|
||||
const close = rest.search(/^---[ \t]*\r?$/m);
|
||||
if (close === -1) return { body: text };
|
||||
const frontmatter = rest.slice(0, close).replace(/\r?\n$/, "");
|
||||
const after = rest.slice(close);
|
||||
const newline = after.indexOf("\n");
|
||||
if (newline === -1) return { frontmatter, body: "" };
|
||||
// Drop the blank line conventionally left between the fence and the body, so
|
||||
// the document does not open with dead space.
|
||||
return { frontmatter, body: after.slice(newline + 1).replace(/^\r?\n/, "") };
|
||||
}
|
||||
|
|
@ -57,6 +57,13 @@ export default defineConfig({
|
|||
// reached by a manifest edit rather than an omission.
|
||||
"ajv",
|
||||
"zod",
|
||||
// Newly on `core/`'s runtime import graph as of #2248:
|
||||
// `core/mcp/skillFile.ts` parses a served SKILL.md's YAML frontmatter to
|
||||
// check it against the entry the listing advertised (SEP-2640). Already a
|
||||
// root `dependency` — it was reached from `test-servers/src` — so this
|
||||
// adds no package, but a root-declared dependency `core/` imports must be
|
||||
// named in all three `external` lists or tsup inlines it here.
|
||||
"yaml",
|
||||
// Reached through `core/` but not through this client's own code today.
|
||||
// AGENTS.md requires every root-declared package `core/` imports at runtime
|
||||
// in ALL three lists regardless, because which client reaches one is a
|
||||
|
|
|
|||
Loading…
Reference in a new issue