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:
cliffhall 2026-09-07 18:10:37 -04:00
commit 9a8488681e
2 changed files with 4750 additions and 191 deletions

View file

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

View file

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