fix: address Copilot review round 1 on #2248

Five findings, all real.

**Critical — `verifySkills` selected `contents[0]`.** These bytes are hashed
against the file's advertised digest, so accepting a block the server
labelled something else verifies one file's content against another file's
digest, and can report that as `verified`. A false pass is worse than a
missing check. `contentsFor(result, uri)` now selects by NORMALIZED identity,
which keeps the canonicalized-echo case that motivated the original code
while rejecting an unrelated block, and reports a read error when nothing
answers for the URI. This is what `onReadSkillFile` already did; the two
paths hash bytes against digests and must not disagree about which bytes.

**`skills/get` was dispatched without the extension gate** that `skills/list`
had. Hoisted into `assertSkillsSupported` so the two cannot drift — declaring
the extension commits a server to both. Without it, an undeclared server's
-32601 is indistinguishable to a script from the -32602 a declared server
returns for a URI it does not serve.

**The TUI could strand a user on a hidden Skills tab.** The tab left the bar
when the gate went false but `activeTab` did not, so the pane kept rendering
for a server that never declared the extension. Reset to `info`, following
the Auth precedent — additionally gated on `connected`, because this gate
reads a server declaration and would otherwise fire during a reconnect.

**The TUI keyed a verdict by URI**, so a refresh replacing the manifest under
the same URI left hashes computed for the previous snapshot describing the
new one. Keyed on the entry now, as the web screen already was.

**A failed "Load more" discarded the pages on screen and the retry cursor.**
Both are preserved now, and the success and failure paths share one
staleness-guarded `commit` helper so they cannot drift on which results they
may write — which is how they came to disagree.

Two nits: the six-skill counts. The docs sentence was more wrong than
reported (five awkward skills of six, three outright violations) and is
rewritten rather than renumbered.

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:43:58 -04:00
commit 775107776d
2 changed files with 452 additions and 56 deletions

View file

@ -78,6 +78,21 @@ describe("runMethod skills dispatch (#2248)", () => {
).rejects.toMatchObject({ exitCode: EXIT_CODES.USAGE });
});
it("rejects skills/get too when the server declares no extension", async () => {
// Declaring the extension commits a server to BOTH methods, so gating one
// and not the other is inconsistent with the thing being checked — and an
// undeclared server's -32601 is indistinguishable to a script from the
// -32602 a declared server returns for a URI it does not serve.
const client = mockClient({
getSkillsExtension: vi.fn().mockReturnValue(undefined),
getSkill: vi.fn(),
});
await expect(
runMethod(client, { method: "skills/get", uri: "skill://x/SKILL.md" }),
).rejects.toMatchObject({ exitCode: EXIT_CODES.USAGE });
expect(client.getSkill).not.toHaveBeenCalled();
});
it("keeps the { skill } envelope on skills/get", async () => {
// The client unwraps it for callers that want the entry; a CLI whose
// contract is "print the result" must not quietly reshape the wire form.

View file

@ -24,6 +24,28 @@ import type {
MethodOutcome,
} from "./method-types.js";
/**
* Refuse a `skills/*` call against a server that never declared the extension.
*
* Shared by `skills/list` and `skills/get` so the two cannot drift: declaring
* the extension commits a server to both, so a client that gates one and not
* the other is inconsistent with the thing it is checking. Not needed for
* `resources/directory/read`, whose stricter `directoryRead` gate lives in
* `InspectorClient` itself.
*/
function assertSkillsSupported(
inspectorClient: InspectorClient,
method: string,
): void {
if (!inspectorClient.getSkillsExtension()) {
throw new CliExitCodeError(
EXIT_CODES.USAGE,
`Server does not declare the ${SKILLS_EXTENSION_KEY} extension, so ${method} is not available.`,
{ code: "skills_unsupported" },
);
}
}
/**
* Run one MCP method against a connected {@link InspectorClient}.
* Core method dispatch used by the CLI (and other Inspector Node runners).
@ -299,13 +321,7 @@ export async function runMethod(
// list, which is right for a UI that must render *something*, and wrong
// for a CLI where "this server has no skills" and "this server does not
// serve skills at all" are different answers a script has to tell apart.
if (!inspectorClient.getSkillsExtension()) {
throw new CliExitCodeError(
EXIT_CODES.USAGE,
`Server does not declare the ${SKILLS_EXTENSION_KEY} extension, so ${args.method} is not available.`,
{ code: "skills_unsupported" },
);
}
assertSkillsSupported(inspectorClient, args.method);
managedSkillsState = new ManagedSkillsState(inspectorClient);
const skills = await managedSkillsState.refresh(args.metadata);
if (args.verify) {
@ -330,6 +346,12 @@ export async function runMethod(
"URI is required for skills/get method. Use --uri to specify the skill URI.",
);
}
// Same gate as `skills/list`, and for the same reason. Without it an
// undeclared server answers `-32601`, which a script cannot tell apart
// from the `-32602` a *declared* server returns for a skill URI it does
// not serve — "this server has no Skills support" and "no such skill"
// are different answers (Copilot).
assertSkillsSupported(inspectorClient, args.method);
const skill = await inspectorClient.getSkill(args.uri, args.metadata);
if (args.verify) {
const reports = await verifySkills(