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
7 changed files with 4750 additions and 191 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue