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

@ -6,6 +6,7 @@ import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills";
import {
renderWithMantine,
screen,
waitFor,
within,
} from "../../../test/renderWithMantine";
import {
@ -335,6 +336,100 @@ describe("SkillsScreen", () => {
expect(preview).toHaveTextContent("from a blob");
});
it("keeps the newest verdict when two verifications of one row overlap", async () => {
// Same row, same manifest — so the manifest key cannot tell these apart.
// Without a per-row attempt token the older read finishing last would
// overwrite the newer verdict and leave the UI reporting stale bytes.
const user = userEvent.setup();
const resolvers: ((value: { text: string }) => void)[] = [];
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
resolvers.push(resolve);
}),
);
renderWithMantine(
<ControlledSkillsScreen
onReadSkillFile={onReadSkillFile}
skills={[
{
...CLEAN_SKILL,
resources: [
{
uri: "skill://data-analysis/SKILL.md",
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
},
],
},
]}
/>,
);
await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest");
const rowVerify = within(manifest).getAllByRole("button", {
name: "Verify",
})[0];
await user.click(rowVerify);
await user.click(rowVerify);
expect(resolvers).toHaveLength(2);
// The SECOND read answers first with the matching bytes, then the first
// read answers with bytes that would verify as a mismatch.
resolvers[1]({ text: SELF_TEXT });
expect(await screen.findByText("verified")).toBeInTheDocument();
resolvers[0]({ text: "stale bytes\n" });
// Still the newer verdict.
expect(await screen.findByText("verified")).toBeInTheDocument();
expect(screen.queryByText("mismatch")).not.toBeInTheDocument();
});
it("disables Verify all while a batch is running", async () => {
// The concurrency cap is per invocation, so a second click would start a
// second pool of four rather than reusing the first.
const user = userEvent.setup();
const pending: ((value: { text: string }) => void)[] = [];
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
pending.push(resolve);
}),
);
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const verifyAll = screen.getByRole("button", { name: /Verify all/ });
await user.click(verifyAll);
expect(verifyAll).toBeDisabled();
// Release every read the batch started; the button frees only once the
// whole batch settles, not once the first file does.
await waitFor(() => expect(pending.length).toBeGreaterThan(0));
for (const resolve of pending) resolve({ text: SELF_TEXT });
await waitFor(() => expect(verifyAll).not.toBeDisabled());
});
it("renders every duplicate finding rather than collapsing them", async () => {
// Three identical URIs produce two `duplicate-resource` findings with the
// same code and URI. A key built from those alone would make React drop
// the extras — hiding findings in exactly the malformed input this view is
// for.
const user = userEvent.setup();
const dup = {
uri: "skill://data-analysis/SKILL.md",
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
};
renderWithMantine(
<ControlledSkillsScreen
skills={[{ ...CLEAN_SKILL, resources: [dup, dup, dup] }]}
/>,
);
await user.click(screen.getByText("data-analysis"));
const issues = screen.getByTestId("skill-issues");
expect(within(issues).getAllByText("duplicate-resource")).toHaveLength(2);
});
it("shows the SKILL.md preview on demand", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Alert,
Badge,
@ -42,11 +42,18 @@ import {
*/
const VERIFY_CONCURRENCY = 4;
/** Per-row verification progress. */
type FileState =
/**
* Per-row verification progress. `attempt` is the click that produced it: two
* verifications of the SAME row in the SAME manifest (a double click, or a row
* button pressed while "Verify all" is running) are not distinguished by the
* manifest key, so without it an older read finishing last would overwrite the
* newer verdict and leave the UI reporting bytes it no longer fetched.
*/
type FileState = { attempt: number } & (
| { status: "pending" }
| { status: "done"; verification: SkillVerification }
| { status: "error"; message: string };
| { status: "error"; message: string }
);
/**
* Verification verdicts plus the manifest they belong to. Rows are keyed by
@ -277,6 +284,12 @@ export function SkillsScreen({
files: {},
});
const [previewState, setPreviewState] = useState<PreviewState>({ key: null });
// True while a "Verify all" batch is in flight; disables the button so a
// second click cannot stack another pool of workers on top.
const [batchRunning, setBatchRunning] = useState(false);
// Monotonic per-row attempt token. A ref because it is claimed inside an
// event handler, never during render.
const nextAttempt = useRef(0);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
@ -342,24 +355,32 @@ export function SkillsScreen({
*/
const verifyRow = useCallback(
async (index: number, resource: SkillResource, key: string) => {
// Claimed synchronously, so two verifications of this row are ordered
// before either read starts.
const attempt = (nextAttempt.current += 1);
const write = (state: FileState) =>
setVerification((prev) => {
// `null` is the un-adopted initial manifest; any other mismatch is a
// continuation from a manifest that has since been invalidated.
if (prev.key !== null && prev.key !== key) return prev;
const files = prev.key === key ? prev.files : {};
// A newer attempt for this row already wrote — an older read
// finishing last must not overwrite it.
const held = files[index];
if (held !== undefined && held.attempt > attempt) return prev;
return { key, files: { ...files, [index]: state } };
});
write({ status: "pending" });
write({ attempt, status: "pending" });
try {
const contents = await onReadSkillFile(resource.uri);
const result = await verifySkillResource(
resource,
skillFileBytes(contents),
);
write({ status: "done", verification: result });
write({ attempt, status: "done", verification: result });
} catch (err) {
write({
attempt,
status: "error",
message: err instanceof Error ? err.message : String(err),
});
@ -386,7 +407,13 @@ export function SkillsScreen({
}
};
const workers = Math.min(VERIFY_CONCURRENCY, manifest.length);
void Promise.all(Array.from({ length: workers }, () => worker()));
setBatchRunning(true);
// The concurrency cap is per invocation, so without the button being
// disabled below, a second click would start a second pool of four and a
// third would make it twelve — the flood the cap exists to prevent.
void Promise.all(Array.from({ length: workers }, () => worker())).finally(
() => setBatchRunning(false),
);
}, [manifest, manifestKey, verifyRow]);
const showSkillMd = useCallback(() => {
@ -509,9 +536,15 @@ export function SkillsScreen({
</Alert>
) : (
<IssueStack data-testid="skill-issues">
{issues.map((issue) => (
{issues.map((issue, index) => (
<Alert
key={`${issue.code}:${issue.resourceUri ?? ""}`}
// The index is load-bearing, not decoration: a manifest
// repeating one URI three times yields three
// `duplicate-resource` findings with identical code and
// URI, and a key built from those alone would make
// React drop the extras — hiding findings in exactly
// the malformed input this view exists to inspect.
key={`${index}:${issue.code}:${issue.resourceUri ?? ""}`}
color={issueColor(issue)}
title={issue.code}
>
@ -542,7 +575,8 @@ export function SkillsScreen({
</VerifyButton>
<VerifyButton
onClick={verifyAll}
disabled={manifest.length === 0}
disabled={manifest.length === 0 || batchRunning}
loading={batchRunning}
>
Verify all
</VerifyButton>