fix: address Copilot review round 15 on #2251

Round 15 generated no new inline comments; all five suppressed findings
were real and unaddressed.

- SkillsScreen: claim the attempt BEFORE issuing the request, for both the
  SKILL.md preview and `skills/get`. Recording it only on settle left a
  window where an older request that happened to resolve first was still
  considered current and published while a newer one was in flight —
  `verifyRow` already claimed its row up front, these two did not. Tests
  answer the OLDER request first and assert nothing is published.
- SkillsScreen: "No skills listed", not "No skills". SEP-2640 lets a server
  return an empty or partial catalog and says an empty result is not proof
  it has none — an unlisted skill is still fetchable by URI — so the old
  copy had the tool asserting something the protocol explicitly does not.
- SkillsScreen: each row's Verify button gets an `aria-label` carrying its
  URI. Every row's visible text is "Verify" and the URI cell is not
  programmatically associated with the button, so a screen-reader user had
  no way to tell which file each control checked.
- inspectorClient: attribute a rejected `skills/get` envelope with
  `markResponseRejected`, so the Protocol tab stops rendering it as a clean
  success while the screen shows an error. In the client rather than a
  store because `skills/get` has none. Gated on `isClientDecodeRejection`,
  with a test that a transport failure is NOT attributed.

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-05 02:21:41 -04:00
commit a561cd1daa
4 changed files with 176 additions and 20 deletions

View file

@ -153,9 +153,14 @@ describe("SkillsScreen", () => {
expect(root).toHaveAttribute("data-skill-page-count", "2"); expect(root).toHaveAttribute("data-skill-page-count", "2");
}); });
it("renders 'No skills' when the list is empty", () => { it("says the list was empty without claiming there are no skills", () => {
// SEP-2640 lets a server return an empty or partial catalog and says an
// empty result is not proof it has none — an unlisted skill is still
// fetchable by URI — so "No skills" would be the tool asserting something
// the protocol explicitly does not.
renderWithMantine(<SkillsScreen {...baseProps} skills={[]} />); renderWithMantine(<SkillsScreen {...baseProps} skills={[]} />);
expect(screen.getByText("No skills")).toBeInTheDocument(); expect(screen.getByText("No skills listed")).toBeInTheDocument();
expect(screen.queryByText("No skills")).not.toBeInTheDocument();
}); });
it("renders a load failure above the list", () => { it("renders a load failure above the list", () => {
@ -236,10 +241,13 @@ describe("SkillsScreen", () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />); renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis")); await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest"); // Addressed by its accessible name, which carries the URI — every row's
// One row at a time: the first row's own Verify button, not "Verify all". // visible text is just "Verify", so that name is what tells a
// screen-reader user (and this test) which file the button checks.
await user.click( await user.click(
within(manifest).getAllByRole("button", { name: "Verify" })[0], screen.getByRole("button", {
name: "Verify skill://data-analysis/reference.md",
}),
); );
expect(await screen.findByText("verified")).toBeInTheDocument(); expect(await screen.findByText("verified")).toBeInTheDocument();
}); });
@ -374,10 +382,9 @@ describe("SkillsScreen", () => {
/>, />,
); );
await user.click(screen.getByText("data-analysis")); await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest"); const rowVerify = screen.getByRole("button", {
const rowVerify = within(manifest).getAllByRole("button", { name: "Verify skill://data-analysis/SKILL.md",
name: "Verify", });
})[0];
await user.click(rowVerify); await user.click(rowVerify);
await user.click(rowVerify); await user.click(rowVerify);
expect(resolvers).toHaveLength(2); expect(resolvers).toHaveLength(2);
@ -780,6 +787,68 @@ describe("SkillsScreen", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("rejects an older preview read even when it resolves FIRST", async () => {
// The ordering hole: recording an attempt only when it settles leaves a
// window where the older request is still considered current. Claiming it
// before the request goes out is what makes the older callback stale
// immediately, whatever order the two resolve in.
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} />,
);
await user.click(screen.getByText("data-analysis"));
const view = screen.getByRole("button", { name: /View SKILL.md/ });
await user.click(view);
await user.click(view);
expect(resolvers).toHaveLength(2);
// The OLDER read answers first, while the newer one is still in flight.
resolvers[0]({ text: "# stale\n" });
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
resolvers[1]({ text: "# newest\n" });
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
"newest",
);
});
it("rejects an older skills/get even when it resolves FIRST", async () => {
const user = userEvent.setup();
const resolvers: ((value: SkillEntry) => void)[] = [];
const onGetSkill = vi.fn(
() =>
new Promise<SkillEntry>((resolve) => {
resolvers.push(resolve);
}),
);
renderWithMantine(<ControlledSkillsScreen onGetSkill={onGetSkill} />);
await user.click(screen.getByText("data-analysis"));
const fetchButton = screen.getByRole("button", {
name: /Fetch with skills\/get/,
});
await user.click(fetchButton);
await user.click(fetchButton);
expect(resolvers).toHaveLength(2);
// The older fetch answers first with a differing entry; it must not
// publish a verdict while the newer one is pending.
resolvers[0]({
...CLEAN_SKILL,
frontmatter: { ...CLEAN_SKILL.frontmatter, description: "stale" },
});
expect(screen.queryByTestId("skills-get-result")).not.toBeInTheDocument();
resolvers[1](CLEAN_SKILL);
expect(
await screen.findByText("skills/get matches skills/list"),
).toBeInTheDocument();
});
it("shows the SKILL.md preview on demand", async () => { it("shows the SKILL.md preview on demand", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />); renderWithMantine(<ControlledSkillsScreen />);

View file

@ -529,6 +529,13 @@ export function SkillsScreen({
setPreviewState((prev) => setPreviewState((prev) =>
isStale(prev, key, attempt) ? prev : { key, attempt, ...next }, isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
); );
// Claimed BEFORE the request goes out, the way `verifyRow` claims its row.
// Recording the attempt only on settle leaves a window where an older
// request that happens to resolve first is still considered current, and
// publishes its contents while a newer one is in flight. Clearing the
// previous result at the same time also means the pane doesn't keep
// showing the old file while the new read is running.
writePreview({});
void onReadSkillFile(selected.uri) void onReadSkillFile(selected.uri)
.then((contents) => writePreview({ contents })) .then((contents) => writePreview({ contents }))
.catch((err: unknown) => { .catch((err: unknown) => {
@ -549,6 +556,9 @@ export function SkillsScreen({
setFetchedEntry((prev) => setFetchedEntry((prev) =>
isStale(prev, key, attempt) ? prev : { key, attempt, ...next }, isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
); );
// Claimed before the request goes out — see `showSkillMd` for why settling
// is too late.
writeFetched({});
void onGetSkill(selected.uri) void onGetSkill(selected.uri)
.then((entry) => { .then((entry) => {
// The fetched entry is checked ON ITS OWN before being compared. A // The fetched entry is checked ON ITS OWN before being compared. A
@ -628,7 +638,12 @@ export function SkillsScreen({
</Alert> </Alert>
)} )}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<EmptyState>No skills</EmptyState> // Not "No skills": SEP-2640 lets a server return an empty or
// partial catalog, and says an empty result must not be read as
// proof it has none — an unlisted skill can still be fetched by
// URI with `skills/get`. Claiming otherwise would be the tool
// asserting something the protocol explicitly does not.
<EmptyState>No skills listed</EmptyState>
) : ( ) : (
filtered.map((skill) => { filtered.map((skill) => {
const skillIssues = checkSkillConformance(skill); const skillIssues = checkSkillConformance(skill);
@ -783,8 +798,14 @@ export function SkillsScreen({
{verificationLabel(state)} {verificationLabel(state)}
</CountBadge> </CountBadge>
<RowVerifyButton <RowVerifyButton
// Every row's button reads "Verify", so the
// visible text alone gives a screen-reader
// user no way to tell which file each one
// checks; the URI cell is in the same row but
// is not programmatically associated with it.
aria-label={`Verify ${resource.uri}`}
// A click handler cannot await, and // A click handler cannot await, and
// `verifyFile` owns its own failures — it // `verifyRow` owns its own failures — it
// records them as this row's state. // records them as this row's state.
onClick={() => onClick={() =>
void verifyRow(index, resource, manifestKey) void verifyRow(index, resource, manifestKey)

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import type { ServerCapabilities } from "@modelcontextprotocol/client"; import type { ServerCapabilities } from "@modelcontextprotocol/client";
import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js"; import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
/** /**
* Unit coverage for the Skills extension methods (#2234, SEP-2640). * Unit coverage for the Skills extension methods (#2234, SEP-2640).
@ -181,6 +182,52 @@ describe("InspectorClient skills methods (#2234)", () => {
}); });
}); });
it("attributes a rejected skills/get envelope to its Protocol entry", async () => {
// Without this the Skills screen shows an error while the Protocol tab
// renders the same exchange as a clean success. Done in the client rather
// than a store because `skills/get` has none — the screen calls it.
const client = makeClient();
const marked: [string, string][] = [];
(
client as unknown as {
markResponseRejected: (m: string, r: string) => void;
}
).markResponseRejected = (method, reason) => {
marked.push([method, reason]);
};
internals(client).client = {
request: async () => {
throw new SdkError(
SdkErrorCode.InvalidResult,
"Invalid result for skills/get",
);
},
};
await expect(client.getSkill("skill://demo/SKILL.md")).rejects.toThrow();
expect(marked).toEqual([["skills/get", "Invalid result for skills/get"]]);
});
it("does NOT attribute a transport failure on skills/get", async () => {
// No response frame arrived, so the last-answered id still points at an
// earlier, successful exchange; marking it would stamp that one.
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");
},
};
await expect(client.getSkill("skill://demo/SKILL.md")).rejects.toThrow();
expect(marked).toEqual([]);
});
it("rejects a skills/list result that is not a skills page", async () => { 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 // The explicit result schema is the whole client-side mechanism for a
// consumer-owned extension method, so a nonconforming result must fail // consumer-owned extension method, so a nonconforming result must fail

View file

@ -203,6 +203,7 @@ import {
LIST_MAX_PAGES, LIST_MAX_PAGES,
ModernResultEnvelopeSchema, ModernResultEnvelopeSchema,
isSalvageableRejection, isSalvageableRejection,
isClientDecodeRejection,
listPaginationExceeded, listPaginationExceeded,
toolItemSchemaForEra, toolItemSchemaForEra,
nextCursorOf, nextCursorOf,
@ -5603,15 +5604,33 @@ export class InspectorClient extends InspectorClientEventTarget {
}; };
// `GetSkillResultSchema` unwraps the envelope, so there is nothing to // `GetSkillResultSchema` unwraps the envelope, so there is nothing to
// unwrap here. // unwrap here.
return this.invokeMcpClient( try {
() => return await this.invokeMcpClient(
this.client!.request( () =>
{ method: SKILLS_GET_METHOD, params }, this.client!.request(
GetSkillResultSchema, { method: SKILLS_GET_METHOD, params },
this.getRequestOptions(this.progressTokenOf(metadata)), GetSkillResultSchema,
), this.getRequestOptions(this.progressTokenOf(metadata)),
{ method: SKILLS_GET_METHOD }, ),
); { method: SKILLS_GET_METHOD },
);
} catch (err) {
// Attribute a rejected envelope to the exchange it came from, so the
// Protocol tab stops rendering it as a clean success while the Skills
// screen shows an error — the same handling every managed list gets
// (#1953). Done here rather than in a store because `skills/get` has
// none: the screen calls it directly. Must happen in this catch, while
// the correlation window is still current, and ONLY for a decode
// rejection — a request that never produced a response would otherwise
// stamp an earlier, successful exchange.
if (isClientDecodeRejection(err)) {
this.markResponseRejected(
SKILLS_GET_METHOD,
err instanceof Error ? err.message : String(err),
);
}
throw err;
}
} }
/** /**