fix: address Copilot review round 3 on #2251

- skills.ts: decide root containment on NORMALIZED URIs. A raw prefix
  check passed `skill://acme/billing/refunds/../other.md`, which starts
  with the advertised root but resolves outside it — a traversal the
  conformance report was reporting as clean.
- skills.ts: `normalizeSkillUri` also rejects a relative string, so
  `demo/SKILL.md` is `malformed-uri` rather than a skill path, and an
  opaque-path URI (`skill:demo/..`), which the parser leaves un-normalized
  and on which containment cannot be decided.
- skills.ts: `missing-description` is an error. SEP-2640 requires it, so
  an absent one must not read as "0 errors" in the conformance summary.
- managedSkillsState: call `markResponseRejected` for a decode rejection,
  as every managed list does. An invalid `skills/list` result was showing
  in the Protocol tab as a clean success.
- SkillsScreen: per-row attempt token. The manifest key cannot tell two
  verifications of the SAME row apart, so a double click (or a row button
  pressed during "Verify all") let an older read finish last and overwrite
  the newer verdict.
- SkillsScreen: disable "Verify all" while a batch runs. The concurrency
  cap is per invocation, so repeated clicks stacked pools — 4, then 8,
  then 12.
- SkillsScreen: include the finding index in each issue alert's key. Three
  identical URIs produce two `duplicate-resource` findings with the same
  code and URI, and React was free to drop the extras — hiding findings in
  exactly the malformed input this view exists to inspect.
- test-servers/skills.ts: register `skills/list` and `skills/get` through
  the PUBLIC `setRequestHandler` with explicit param schemas. The private
  `_requestHandlers` map is now reached only to wrap `resources/read`,
  which has to chain onto the SDK's handler rather than replace it — the
  one thing the public API cannot express, and the comment now says so
  instead of citing the tasks fixture.
- test-servers/skills.ts: raise `-32602` for an unknown `skills/get` URI.
  A plain Error mapped to a generic server failure, making the fixture
  non-conforming outside its three documented bad cases.
- clients/web/README.md: the paragraph named four contracts while still
  saying the smoke "drives all three".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-04 22:06:36 -04:00
commit 6a08c77c92
2 changed files with 376 additions and 59 deletions

View file

@ -9,6 +9,7 @@ import {
checkSkillConformance,
getSkillsExtension,
isSkillsExtensionSupported,
normalizeSkillUri,
sha256Digest,
skillDisplayName,
skillNameFromUri,
@ -112,11 +113,47 @@ describe("skillNameFromUri", () => {
expect(skillNameFromUri("SKILL.md")).toBeUndefined();
});
it("returns undefined for a relative string", () => {
// SEP-2640 requires a full resource URI; treating `demo/SKILL.md` as one
// would let a non-conforming entry report a name and pass the path check.
expect(skillNameFromUri("demo/SKILL.md")).toBeUndefined();
});
it("reads the name off the RESOLVED path, not the raw string", () => {
expect(skillNameFromUri("skill://acme/wrong/../demo/SKILL.md")).toBe(
"demo",
);
});
it("returns undefined when the segment before the suffix is empty", () => {
expect(skillNameFromUri("skill:///SKILL.md")).toBeUndefined();
});
});
describe("normalizeSkillUri", () => {
it("resolves traversal segments", () => {
expect(normalizeSkillUri("skill://acme/billing/refunds/../other.md")).toBe(
"skill://acme/billing/other.md",
);
});
it("rejects a relative string, which is not a resource URI", () => {
expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined();
});
it("rejects an opaque-path URI, which the parser does not normalize", () => {
// `skill:demo/../x.md` parses but keeps its `..` verbatim, so containment
// could not be decided on it — accepting it would reopen the hole.
expect(normalizeSkillUri("skill:demo/SKILL.md")).toBeUndefined();
});
it("leaves an already-normal URI alone", () => {
expect(normalizeSkillUri("skill://demo/SKILL.md")).toBe(
"skill://demo/SKILL.md",
);
});
});
describe("skillDisplayName", () => {
it("prefers the declared frontmatter name", () => {
expect(skillDisplayName(entry())).toBe("demo");
@ -148,12 +185,14 @@ describe("checkSkillConformance", () => {
expect(issues[0].severity).toBe("error");
});
it("reports a missing description as a warning", () => {
it("reports a missing description as an error", () => {
// SEP-2640 requires `description`, so an absent one is a format violation
// and must not read as "0 errors" in the conformance summary.
const issues = checkSkillConformance(
entry({ frontmatter: { name: "demo" } }),
);
expect(issues.map((i) => i.code)).toEqual(["missing-description"]);
expect(issues[0].severity).toBe("warning");
expect(issues[0].severity).toBe("error");
});
it("reports a URI that does not carry a skill path", () => {
@ -235,6 +274,35 @@ describe("checkSkillConformance", () => {
expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
});
it("reports an entry that traverses out of the skill root", () => {
// The raw string starts with the root; the resolved path does not. A
// prefix check alone would miss this and report `Conforms`.
const issues = checkSkillConformance(
entry({
uri: "skill://demo/refunds/SKILL.md",
frontmatter: { name: "refunds", description: "d" },
resources: [
{ uri: "skill://demo/refunds/SKILL.md", digest: DIGEST, size: 1 },
{ uri: "skill://demo/refunds/../other.md", digest: DIGEST, size: 1 },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
});
it("reports an unparseable manifest entry as outside the root", () => {
// Nothing can establish that a non-URI is inside a root.
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "not a uri", digest: DIGEST, size: 1 },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
});
it("reports a manifest entry outside the skill root", () => {
const issues = checkSkillConformance(
entry({

View file

@ -6,6 +6,7 @@ import {
SKILLS_MAX_PAGES,
SKILLS_PAGE_LIMIT_MESSAGE,
} from "@inspector/core/mcp/state/managedSkillsState";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function skill(name: string): SkillEntry {
@ -216,6 +217,36 @@ describe("ManagedSkillsState", () => {
expect(state.getError()).toBe(failure);
});
describe("Protocol-entry attribution", () => {
it("attributes a decode rejection to its skills/list Protocol entry", async () => {
// Without this the Protocol tab renders an invalid `skills/list` result
// as a clean success — the same gap every managed list closed in #1953.
client.setStatus("connected");
const rejection = new SdkError(
SdkErrorCode.InvalidResult,
"Invalid result for skills/list: skills required",
);
client.listSkills.mockRejectedValueOnce(rejection);
await expect(state.refresh()).rejects.toThrow(rejection);
expect(client.markResponseRejected).toHaveBeenCalledWith(
"skills/list",
rejection.message,
);
});
it("does NOT attribute a transport failure", async () => {
// No response frame arrived, so the last-answered id still points at an
// EARLIER successful call; marking it would stamp "Rejected by the
// Inspector" onto an exchange that worked.
client.setStatus("connected");
client.listSkills.mockRejectedValueOnce(
new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed"),
);
await expect(state.refresh()).rejects.toThrow();
expect(client.markResponseRejected).not.toHaveBeenCalled();
});
});
it("wraps a non-Error rejection", async () => {
client.setStatus("connected");
client.listSkills.mockRejectedValueOnce("just a string");