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
7 changed files with 4750 additions and 191 deletions

View file

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

View 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);
});
});

View 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/,
);
});
});

View file

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

View file

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

View 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);
});
});

View file

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