feat: support the Skills extension (SEP-2640) with digest verification - #2251
feat: support the Skills extension (SEP-2640) with digest verification#2251cliffhall wants to merge 20 commits into
Conversation
Adds detection, enumeration, and conformance checking for the Skills extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234, which is everything its Acceptance list names. Skills is a *server*-declared extension, read off the connecting server's `capabilities.extensions`, so it is deliberately absent from `ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises and the user toggles, and an entry there would be a meaningless toggle. `skills/list` and `skills/get` are consumer-owned extension methods that neither era codec defines, so the SDK's era gate skips them and they go out as ordinary `client.request` calls with explicit result schemas. The raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is not era-gated: a legacy-era server that declares the extension is serving it. The checks are the point, not the list view. `core/mcp/skills.ts` reports each obligation SEP-2640 states — the name/path invariant, the digest format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which means integrity cannot be verified at all. Digest verification hashes the fetched bytes with WebCrypto and returns a mismatch with both digests attached rather than throwing, because showing a mismatch loudly is the whole value proposition. Files are fetched on demand: SEP-2640 is explicit that a `resources/read` of a `SKILL.md` is not a load and confers no standing, so none of the SEP's host machinery is implemented. The `skills-http` fixture serves four skills over two pages, three of them deliberately non-conforming — without those the verification code is untestable. Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248. Closes #2234 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>
There was a problem hiding this comment.
🟡 Changes recommended
Critical and moderate issues remain in manifest validation, pagination safety, verification state, and advertised capabilities.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds SEP-2640 Skills extension support, including discovery, conformance reporting, digest verification, and web Inspector integration.
Changes:
- Adds Skills schemas, client APIs, pagination, validation, and digest verification.
- Introduces a Skills tab, capability reporting, and persisted UI state.
- Adds test-server fixtures, tests, stories, and documentation.
File summaries
| File | Description |
|---|---|
test-servers/src/skills.ts |
Implements Skills fixtures and handlers. |
test-servers/src/resolve-config.ts |
Resolves Skills configuration. |
test-servers/src/load-config.ts |
Defines Skills configuration types. |
test-servers/src/composable-test-server.ts |
Advertises and wires Skills support. |
test-servers/configs/skills-http.json |
Adds the Skills showcase server. |
docs/test-servers.md |
Documents the Skills fixture. |
core/react/useManagedSkills.ts |
Exposes Skills state to React. |
core/mcp/state/managedSkillsState.ts |
Manages paginated Skills loading. |
core/mcp/state/index.ts |
Exports Skills state types. |
core/mcp/skillsSchemas.ts |
Defines Skills wire schemas. |
core/mcp/skills.ts |
Implements conformance and verification logic. |
core/mcp/inspectorClientProtocol.ts |
Adds Skills client contracts. |
core/mcp/inspectorClient.ts |
Implements Skills requests. |
core/mcp/__tests__/fakeInspectorClient.ts |
Extends the test client for Skills. |
clients/web/src/utils/skillFileBytes.ts |
Converts resource payloads into bytes. |
clients/web/src/utils/skillFileBytes.test.ts |
Tests resource decoding. |
clients/web/src/utils/inspectorTabs.ts |
Registers the Skills tab. |
clients/web/src/utils/inspectorTabs.test.ts |
Tests Skills tab registration. |
clients/web/src/test/core/react/useManagedSkills.test.tsx |
Tests the React Skills hook. |
clients/web/src/test/core/mcp/state/managedSkillsState.test.ts |
Tests managed Skills state. |
clients/web/src/test/core/mcp/skillsSchemas.test.ts |
Tests Skills schemas. |
clients/web/src/test/core/mcp/skills.test.ts |
Tests conformance and digest helpers. |
clients/web/src/test/core/mcp/inspectorClient-skills.test.ts |
Tests Skills client requests. |
clients/web/src/lib/oauthResume.ts |
Persists Skills UI state. |
clients/web/src/lib/oauthResume.test.ts |
Tests OAuth resume state. |
clients/web/src/hooks/useTabUiState.ts |
Adds lifted Skills tab state. |
clients/web/src/hooks/useServerCommands.tsx |
Adds Skills read and refresh commands. |
clients/web/src/hooks/useServerCommands.test.tsx |
Tests Skills commands. |
clients/web/src/hooks/useInspectorStores.ts |
Creates and exposes the Skills store. |
clients/web/src/hooks/useInspectorStores.test.tsx |
Tests Skills store integration. |
clients/web/src/components/views/InspectorView/types.ts |
Defines Skills panel properties. |
clients/web/src/components/views/InspectorView/InspectorView.tsx |
Gates and renders the Skills tab. |
clients/web/src/components/views/InspectorView/InspectorView.test.tsx |
Tests Skills tab visibility. |
clients/web/src/components/views/InspectorView/InspectorView.stories.tsx |
Adds Skills view story data. |
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx |
Implements the Skills conformance UI. |
clients/web/src/components/screens/SkillsScreen/SkillsScreen.test.tsx |
Tests Skills interactions and verdicts. |
clients/web/src/components/screens/SkillsScreen/SkillsScreen.stories.tsx |
Adds Skills screen stories. |
clients/web/src/components/screens/screenUiState.ts |
Registers default Skills UI state. |
clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx |
Displays Skills capability details. |
clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx |
Tests Skills capability presentation. |
clients/web/src/App.tsx |
Connects Skills state and commands. |
clients/web/README.md |
Documents Skills automation attributes. |
Review details
Suppressed comments (5)
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:321
- This completion is not tied to the selection that started it. If the user selects skill B before skill A's read resolves, the render-time reset runs first and this callback then repopulates B's pane with A's
SKILL.md(the rejection path has the same race). Tag the request/result with the selected URI or discard it when it is no longer current.
void onReadSkillFile(selected.uri)
.then((contents) => {
setPreview(contents);
setPreviewError(null);
})
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:288
- Fetching and hashing
SKILL.mdnever performs the required field-by-field comparison between its YAML frontmatter andselected.frontmatter. A server can therefore advertise one description/metadata object, serve different frontmatter with a matching digest, and still be shown asConforms/verified. Parse the fetched top-levelSKILL.mdand surface a verification failure for any discrepancy.
const contents = await onReadSkillFile(resource.uri);
const verification = await verifySkillResource(
resource,
skillFileBytes(contents),
);
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:292
- A verification started for the old manifest can resolve after selection or list refresh has invalidated the state, and this continuation then writes the stale verdict back under the resource URI. Tag each request with the current selection/manifest revision and discard both success and failure completions when that revision has changed.
const contents = await onReadSkillFile(resource.uri);
const verification = await verifySkillResource(
resource,
skillFileBytes(contents),
);
setFileStates((prev) => ({
...prev,
[resource.uri]: { status: "done", verification },
}));
clients/web/src/components/views/InspectorView/types.ts:315
- The old “Tasks monitor” doc comment now attaches to
SkillsPanelProps, whileTasksPanelPropsloses its description. Move that comment immediately above the Tasks interface so generated/editor documentation describes the correct API.
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
core/mcp/skills.ts:334
- Verification ignores the declared byte size. SEP-2640 requires a fetched file whose
byteLengthdiffers fromresource.sizeto fail verification equivalently to a digest mismatch, so a server can currently advertise an incorrect size and still receive averifiedverdict. Comparebytes.byteLengthbefore hashing and surface expected/actual sizes.
- Files reviewed: 42/42 changed files
- Comments generated: 9
- Review effort level: Balanced
- skills.ts: report a manifest that omits the skill's own SKILL.md (an empty list included), duplicate URIs, entries outside the skill root, and a missing size. `Conforms` was reachable for manifests that break invariants SEP-2640 states. - skills.ts: cross-check the declared byte length before hashing. A size that disagrees fails verification on its own — the digest is taken over the bytes the server served, so agreeing with it says nothing about whether the manifest describes them. - skills.ts: copy the view instead of slicing its backing store in `sha256Digest`. `SharedArrayBuffer.prototype.slice()` returns another SharedArrayBuffer, which `crypto.subtle.digest` rejects — the cast claimed to handle the exact input that would have thrown. No cast now. - skills.ts: state the one obligation NOT checked here — that an entry's frontmatter matches the fetched SKILL.md's. The digest cannot cover it, and closing it needs a YAML parser, so it is tracked on #2248. - managedSkillsState: cap the walk at LIST_MAX_PAGES. The repeated-cursor guard only catches a server stuck on one cursor; endlessly unique ones walked forever. Raises rather than truncating, like the salvage walk. - managedSkillsState: gate every write on a session generation, so a walk that resolves after a disconnect or destroy cannot repopulate a cleared store or deliver the previous session's skills into the next. - inspectorClient: send a cursor when it is `!== undefined`, not when it is truthy. An opaque cursor may be `""`, and dropping it re-requested page one — which the store then reported as a repeated-cursor failure. - SkillsScreen: key verdict invalidation on the manifest (URI + digests + sizes), not the URI alone. A Refresh that changed the manifest left a green badge attached to a digest nothing had checked. - SkillsScreen: epoch-guard every read continuation, so a fetch that resolves after the selection moved on cannot write into the new one. - SkillsScreen: bound "Verify all" to 4 concurrent reads. A conforming manifest may hold 512 files. - skillsSchemas: drop the guessed `resources/directory/read` result schema. Nothing calls it, so an unverified shape could sit wrong indefinitely; phase 3 adds it against the normative text. - skills-http.json: declare the extension bare. It advertised `directoryRead: true` with no handler, so Connection Info reported "Supported" for a method that answers -32601. - types.ts: restore the Tasks doc comment the Skills interface displaced. 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>
Review round 1 — all nine findings addressed (da8cc16)Every one of these was right; eight are fixed and one is deferred with the reason stated in the source. Mirrored here at PR level because inline replies go hidden once the fix is pushed. Manifest completeness —
|
| Code | Severity | Catches |
|---|---|---|
manifest-missing-self |
error | A manifest without the skill's own SKILL.md — resources: [] included, since an empty list is that case |
duplicate-resource |
error | The same URI listed twice |
resource-outside-skill-root |
error | An entry outside skill://<path>/, which relative references resolve against |
missing-size |
warning | No size, so the entry is excluded from the 16 MiB total and its length cannot be cross-checked |
resource-outside-skill-root is skipped when the entry URI is itself malformed — there is no root to measure against and malformed-uri already reports that, so checking anyway would present one defect as many. missing-size is a warning rather than an error because the digest still verifies the bytes; what is lost is the cross-check below.
This changed the test fixtures: several of them omitted their own SKILL.md and were being asserted as clean, which was the bug in miniature.
Declared size ignored during verification
Fixed, and checked before hashing. Your reasoning is what makes it more than a nicety: the digest is taken over the bytes the server served, so agreeing with it says nothing about whether the manifest describes those bytes. verifySkillResource now returns mismatch with expectedSize / actualSize and a reason naming both, and a verified result echoes them too. Checking first also means a 16 MiB file that was never going to verify is not hashed.
sha256Digest and SharedArrayBuffer
Fixed, and you are right that the cast was worse than useless — SharedArrayBuffer.prototype.slice() returns another SharedArrayBuffer, so the function would have thrown for the exact input the comment claimed it handled. Now new Uint8Array(bytes), which always allocates a plain ArrayBuffer and copies only the view's range. The cast is gone entirely; the existing test that hashes a subarray into a larger buffer still pins the range behavior.
Unbounded pagination — managedSkillsState
Fixed with SKILLS_MAX_PAGES = LIST_MAX_PAGES, imported from listSalvage rather than re-declared, so the two pagination paths in this repo cannot drift. The repeated-cursor guard and the cap catch genuinely different shapes — one cursor forever versus an endlessly unique one — so both stay. The cap raises rather than truncating, for the reason listPaginationExceeded documents: returning what we have would present a partial list as a complete one. Test asserts the throw, the call count, and that nothing is committed.
Stale continuation after disconnect / destroy
Fixed with a session generation, advanced by reset() and destroy(). Every write is gated on it. The rejection is still re-thrown when the session has moved on — the caller's auth-recovery wrapper keys off it — but the state is left alone, so a dead session's failure cannot surface in the live one. Two tests: a resolve and a reject, each landing after a disconnect.
Empty-string cursor — inspectorClient.listSkills
Fixed: cursor !== undefined, not truthiness. The failure mode you describe is the nasty part — dropping "" re-requests page one, and the store then reports a repeated-cursor error, so a conforming server is made to look broken.
Stale verdicts across a manifest refresh — SkillsScreen
Fixed. Invalidation is keyed on a manifest signature (selected URI + each entry's URI, digest and size) rather than the URI alone, so a Refresh that changes the manifest drops the verdicts. Keyed as a primitive string because useValueChange compares with Object.is and a fresh array every render would loop. New test: verify, then re-render the same skill with a different digest, and assert the green badge is gone.
Verification / preview continuations racing the selection
Fixed with an epoch ref, bumped by the same invalidation. Both arms of the SKILL.md read and both arms of verifyFile discard their result when the epoch has moved on. Three tests hold a read open, switch skills, then resolve or reject it.
Verify all concurrency
Fixed: four workers pulling from a shared cursor, so a 512-entry manifest is 4 in-flight reads and each row still flips to checking… and then to its verdict as it lands, rather than all at once.
resources/directory/read — schema and fixture
Both retracted rather than corrected, which I think is the right call for a surface nothing calls yet.
- The schema is removed. You may well be right that it is
resourcesand notcontents— I did not verify either against the normative text, and that is the problem: an unexercised schema in the one module that is supposed to be the authority on the wire format could sit wrong indefinitely without failing anything. A comment now says so, and Skills extension phase 3: CLI methods, TUI pane, resources/directory/read, and paginated mode #2248 adds it alongside the call that uses it. - The fixture declares the extension bare. It advertised
directoryRead: truewith no handler, so Connection Info reported "Supported" for a method that answers-32601.directoryReadstays available as a config option;skills-http.jsonjust does not claim it. The Connection Info screenshot in the PR body is re-captured and now reads "Not supported" — which still demonstrates the row.
Tasks doc comment
Fixed — restored above TasksPanelProps.
Frontmatter field-by-field comparison — deferred, deliberately
The one I have not fixed, and your framing of it is correct: the digest cannot cover it, because a digest proves the file was not altered in transit and says nothing about whether the listing described that file honestly. A server can advertise one description, serve another, and pass every check here.
Closing it needs a YAML parser. There is no YAML dependency in this repo's runtime surface today, and adding one is a [Dependency placement] decision of its own rather than something to slip into this PR. So it is tracked on #2248, and — more importantly — the gap is now stated in core/mcp/skills.ts's module header, where someone reading Conforms will find it, instead of being silently absent.
npm run local:gate is green. New per-file coverage still clears ≥90 on all four dimensions (SkillsScreen.tsx 98.4 / 95.6 / 100 / 100).
There was a problem hiding this comment.
🟡 Changes recommended
Five unresolved moderate findings affect verification identity, size validation, schema compliance, and reconnect behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (5)
Previously missed (1) — in code that hasn't changed since the last review.
core/mcp/skillsSchemas.ts:140
- The accepted SEP now specifies the
skills/getresult as an envelope containingskill; it is no longer ambiguous. Accepting an inline entry silently normalizes a non-conforming server response, which conflicts with this PR's conformance-checking purpose and the description's claim that both forms remain plausible. RequireGetSkillEnvelopeSchemaand update the inline-result test accordingly.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:573
verifySkillResourcealso returnsmismatchfor a size disagreement before hashing, withactualDigestunset and the explanation inreason. This branch labels that case “Digest mismatch” and rendersactual undefined, hiding the actual size failure. Render the supplied reason for non-digest mismatches instead.
title="Digest mismatch"
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:618
onReadSkillFileexplicitly supports blob content, but the preview dropspreview.bloband substitutes an empty string whenevertextis absent. A server returning a base64SKILL.mdtherefore shows a blank preview even though verification reads the correct bytes. Pass a text/blobcontentsobject toContentViewerso its resource-content path performs the decoding.
<ContentViewer
block={{ type: "text", text: preview.text ?? "" }}
mimeType={preview.mimeType ?? "text/markdown"}
copyable
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:305
useValueChangeruns this callback during render and explicitly requires setState-only purity; mutatingepoch.currenthere escapes React. If concurrent React abandons this render, the ref increment remains and an in-flight verification for the still-committed selection is silently discarded. Keep the generation in React state and gate functional state updates by the manifest key instead of mutating a ref during render.
useValueChange(manifestKey, () => {
epoch.current += 1;
setFileStates({});
setPreview(null);
setPreviewError(null);
core/mcp/state/managedSkillsState.ts:155
- A reconnect can permanently miss its skills load. If disconnect occurs while a walk is awaiting
listSkills,reset()advances the generation but leavesrunningtrue; the reconnect-triggered refresh then returns here, and when the stale walk eventually clearsrunningthere is no retry (or it can stay blocked forever if that request hangs). Track the in-flight generation/token instead of one boolean so a new session may start, and ensure a stalefinallycannot clear the new session's guard.
- Files reviewed: 42/42 changed files
- Comments generated: 3
- Review effort level: Balanced
- SkillsScreen: key verdicts and React elements by manifest ROW INDEX,
not URI. The checker deliberately tolerates a duplicated URI so it can
report `duplicate-resource`; a URI key collided those rows into one
verdict, so verifying either updated both and "Verify all" raced two
different digest/size declarations into the same slot.
- SkillsScreen: hold the invalidation generation in React state keyed by
the manifest, not a ref bumped during render. `useValueChange` runs its
callback during render and requires setState-only purity — an abandoned
render left the ref incremented and silently discarded a live
verification.
- SkillsScreen: title a size disagreement "Size mismatch" and render its
reason. `verifySkillResource` catches it before hashing, so the alert
was showing "actual undefined" under "Digest mismatch".
- SkillsScreen: pass `contents` to ContentViewer so a base64 SKILL.md
renders. The text-block form substituted "" and painted a blank preview
for a file verification had just read correctly.
- skills.ts: add `malformed-size` for a size that is not a non-negative
safe integer, and exclude such values from the 16 MiB total. A negative
one could pull the sum back under the limit and hide a violation.
- managedSkillsState: make the overlap guard per-session instead of a
boolean. A disconnect during an in-flight walk left it set, so the
reconnect's own load no-oped and was never retried — permanently, if
the stale request never settled. A stale `finally` can no longer clear
the live session's guard either.
- skillsSchemas: require the `{ skill }` envelope for `skills/get`. The
accepted SEP settles it, and normalizing an inline entry would let a
non-conforming response past the one place that could report it.
- #2248 and the PR description: corrected — they said
`ReadResourceDirectoryResultSchema` was already declared, which round 1
removed. #2248 now owns defining it, and records the frontmatter
cross-check gap too.
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>
Review round 2 — all eight findings addressed (bfeebd4)Two of these caught bugs that round 1's own fixes introduced, which is the useful kind of second pass. Duplicate URIs share one verdict, and one React keyFixed. This one is on me from round 1: I added
|
There was a problem hiding this comment.
🟡 Changes recommended
Critical URI-containment and multiple moderate correctness and concurrency issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
clients/web/README.md:99
- This now names four screens, but the following sentence still says the smoke script “drives all three,” while the new Skills row correctly says Skills is not driven yet. Reword this paragraph to distinguish the four contracts from the three currently exercised by the smoke.
The Tools, Resources, Prompts and Skills screens each expose a `data-testid` plus a
small set of `data-*` attributes, so a headless driver can `waitForSelector` on
a deterministic signal rather than on visible copy. `scripts/smoke-web-tabs.mjs`
drives all three against `test-servers/configs/web-tabs-http.json` ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)).
test-servers/src/skills.ts:257
- This extension does not need the private
_requestHandlersescape hatch: unlike modern Tasks, the SDK supports consumer-owned methods through publicsetRequestHandleroverloads when explicit schemas are supplied. Depending on an underscored SDK field can break this fixture on an SDK update and also bypasses request/result validation; register these methods through the public API instead.
- Files reviewed: 42/42 changed files
- Comments generated: 8
- Review effort level: Balanced
- 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>
Review round 3 — all ten findings addressed (6a08c77)URI containment missed
|
There was a problem hiding this comment.
🟡 Changes recommended
Six moderate conformance and functionality issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (5)
Previously missed (2) — in code that hasn't changed since the last review.
core/mcp/skills.ts:422
SubtleCryptois unavailable in non-secure browser contexts, but the Inspector explicitly supports plain-HTTP LAN hosting (clients/web/README.md:382-386). Athttp://192.168…, this throws for every file and the UI misreports hashing as a read failure. Provide a browser-safe SHA-256 fallback or handle/document an HTTPS requirement explicitly.
core/mcp/skillsSchemas.ts:103skills/listis cacheable in protocol revision 2026-07-28: the accepted SEP requiresttlMsandcacheScopeon its result. Because this is a consumer-owned method, the SDK has no method-keyed wire schema for it, so this loose schema currently accepts a modern response that omits both fields. Add era-aware validation for the modern result envelope (while retaining the legacy shape) so the Inspector does not silently accept a non-conforming response.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:345
batchRunningis global rather than keyed tomanifestKey. If the user changes skills while “Verify all” is running, the new skill remains loading/disabled until every old request settles; one hung old read blocks bulk verification indefinitely. Track the active batch by manifest key/generation and let stale finalizers clear only their own batch.
useValueChange(manifestKey, (next) => {
setVerification({ key: next, files: {} });
setPreviewState({ key: next });
});
core/mcp/skills.ts:332
sizeis also a required field for every manifest entry, not an optional integrity hint. Reporting a missing value as only a warning lets a server evade the 16 MiB pre-fetch limit while the UI still reports zero conformance errors; this should be an error like other violated MUST requirements.
core/mcp/skills.ts:314- A digest is required on every manifest resource by SEP-2640, so classifying its absence as a warning makes an invalid entry show “0 errors.” This also conflicts with
SkillIssueSeverity's contract that requirement violations are errors; keep the permissive wire schema, but report this finding as an error.
- Files reviewed: 42/42 changed files
- Comments generated: 3
- Review effort level: Balanced
- SkillsScreen: actually call `skills/get`. The client method and its tests existed but no production caller invoked it, so #2234's acceptance criterion ("skills/get retrieves a single entry") was unmet and a server author's required handler could not be exercised. The detail pane now fetches the selected URI on demand and reports whether the fetched entry AGREES with the one skills/list advertised — both describe the same skill, so a disagreement is a server bug only a side-by-side fetch shows. - core/mcp/sha256.ts: a dependency-free SHA-256, used when `crypto.subtle` is absent. `SubtleCrypto` needs a secure context, and this app is documented as servable over plain HTTP on a LAN IP — where every skill verification threw and the UI reported a read failure for files it had fetched fine. Checked against the FIPS 180-4 vectors and differentially against WebCrypto. - skills.ts: require the `skill:` scheme in `normalizeSkillUri`. Checking only that a URI was hierarchical let `https://demo/SKILL.md` pass the name and root checks — a manifest pointing anywhere on the web, reported as conforming. - skills.ts: `missing-digest` and `missing-size` are errors. Both are required fields, and an omitted `size` is what lets a server slip past the 16 MiB pre-fetch limit while the UI reports zero errors. `warning` is now reserved for what is legal yet unverifiable — `"dynamic"`. - SkillsScreen: key the "Verify all" batch guard to the manifest. A global flag left a newly selected skill's button disabled until the previous skill's reads settled — forever, if one hung. Not changed, deliberately: whether a modern-era `skills/list` result MUST carry the SEP-2549 caching attributes. #2234's analysis records it as open and the review asserts the opposite; neither reading was checked against the normative text. Leaving the schema permissive accepts a server that omits them, while tightening on a wrong reading would reject conforming responses — the more expensive direction. `skillsSchemas.ts` states this and #2248 settles it. 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>
Review round 4 — six fixed, two declined with reasons (1cb05ca)
|
There was a problem hiding this comment.
🟡 Changes recommended
Multiple unresolved moderate issues affect SEP-2640 conformance, OAuth recovery, and advertised server capabilities.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
core/mcp/skillsSchemas.ts:106
- The accepted SEP now requires modern-era
skills/listresults to carry the base list envelope (resultType,ttlMs, andcacheScope). Because this consumer-owned method bypasses the SDK codec, this permissive schema currently accepts an invalid modern response. Select an era-aware result schema here just as the existing modern list paths validateModernResultEnvelopeSchema.
core/mcp/skillsSchemas.ts:131 - The open question for
skills/getis only whether it also carriesttlMs/cacheScope; the accepted modern response still has the requiredresultType: "complete". Since the SDK does not codec-check this extension method, requiring only{ skill }accepts a malformed modern result (and the new fixture emits that malformed shape). Make this envelope era-aware and requireresultTypeon modern connections.
test-servers/src/composable-test-server.ts:844 directoryRead: trueis publicly accepted by the config and reaches this branch, butwireSkillsHandlersnever registersresources/directory/read. Such a config therefore advertises support and then returns Method not found, exactly the false capability the comment says to avoid. Reject/remove this option until phase 3 or implement the handler before advertising it.
test-servers/src/skills.ts:223
- This response omits the modern list envelope even though the fixture is documented as working in either era. On a 2026-07-28 connection SEP-2640 requires
resultType,ttlMs, andcacheScope; because this is a consumer-owned method, the SDK will not add them. Return the modern envelope when serving the modern era so this fixture is conforming.
- Files reviewed: 44/44 changed files
- Comments generated: 5
- Review effort level: Balanced
Three things the Skills work got wrong in that modal, all found by review of the PR screenshots. - The "Skills Extension" section repeated `io.modelcontextprotocol/skills` as its value — the identifier the "Server Extensions" section two rows above already lists, so the section added nothing. What a flat key list *cannot* show is the extension's sub-options, which is the fact a server author opens this modal to check. Renamed "Skills Extension Options" and rendered as a ✓/✗ row for `directoryRead`, in the same vocabulary as the capability columns, so it reads as the same kind of claim. - The extension sections' contents were bold (`ValueText`, the value half of a label/value pair) while sitting directly beneath the capability checklists, which are plain. They are lists of items, not values, so they now use the same `Text` the checklist rows do. - Those lists were comma-joined into one line, which wraps mid-identifier in a half-width column. One row per identifier. The `skills-directory-read` tests now assert `data-supported` rather than the copy: "Not supported" contains "Supported", so a text assertion passed for either answer. 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>
There was a problem hiding this comment.
🔵 Needs a closer look
Multiple moderate correctness, authentication, validation, and race-condition issues remain unresolved.
Review details
Suppressed comments (9)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:468
- Repeated “View SKILL.md” clicks have the same last-completion-wins race: an older read that finishes last replaces the newer preview because selection/manifest keys cannot distinguish attempts within one manifest. Track an attempt token or disable the action while pending.
This issue also appears on line 484 of the same file.
core/mcp/skillsSchemas.ts:106
- This is no longer unsettled in the accepted SEP: for protocol version 2026-07-28 and later, every
skills/listpage additionally carries SEP-2549'sttlMsandcacheScope. Keeping one permissive schema accepts a modern response that omits or malforms required cache fields, contrary to this module's role as the wire validator. Add era-aware modern validation while retaining the legacy shape for older connections.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:492
- Repeated “Fetch with skills/get” clicks for the same manifest are not ordered. If the older request resolves after a newer one, it overwrites the newer snapshot because both share the same key. Add a request-attempt token (as
verifyRowdoes) or disable the action while its request is pending.
void onGetSkill(selected.uri)
.then((entry) => {
// Compared field-by-field against what `skills/list` advertised. The
// two describe the same skill, so a disagreement is a server bug that
// only shows up when both are fetched.
const agrees = JSON.stringify(entry) === JSON.stringify(selected);
setFetchedEntry((prev) =>
prev.key !== null && prev.key !== key ? prev : { key, entry, agrees },
);
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:490
- JSON object member order is not semantically significant, and the SEP describes
resourcesas a set, butJSON.stringifyis sensitive to object-key and array order. A conformingskills/getresponse that serializes frontmatter keys—or the same resource set—in a different order will be shown as a server bug. Compare the entry structurally, with order-insensitive object fields and resource-set semantics.
// Compared field-by-field against what `skills/list` advertised. The
// two describe the same skill, so a disagreement is a server bug that
// only shows up when both are fetched.
const agrees = JSON.stringify(entry) === JSON.stringify(selected);
setFetchedEntry((prev) =>
clients/web/src/hooks/useServerCommands.tsx:987
- This new server command bypasses the auth-recovery path that the hook requires every command to use. If
skills/getreturns anAuthRecoveryRequiredError, the Skills screen only shows a generic failure and neither applies stored credentials nor initiates reauthorization. Route the request through the same command-scoped resource recovery used byresources/read.
const onGetSkill = useCallback(
async (uri: string): Promise<SkillEntry> => {
if (!inspectorClient) throw new Error("Client is not connected");
return inspectorClient.getSkill(uri);
},
[inspectorClient],
core/mcp/skills.ts:127
- SEP-2640 only recommends
skill://; it explicitly allows server-native schemes such asgithub://owner/repo/skills/refunds/SKILL.mdand says no scheme is privileged. This check therefore marks a conforming custom-scheme entry asmalformed-uriand also prevents its root/resource checks from running. Normalize any hierarchical absolute URI and enforce the/SKILL.md/parent-name constraints independently of the scheme.
core/mcp/skills.ts:257 - The SEP defines these as interoperability limits: hosts MUST support up to them, but servers only SHOULD NOT exceed them and hosts MAY support larger skills. Reporting an over-limit entry as an
errorcontradicts the stated invariant above that errors correspond to violated MUST requirements and makes a legal larger skill look non-conforming. These two limit findings should be warnings (or otherwise labeled as portability warnings), including the size-limit branch below.
docs/test-servers.md:76 - The dynamic skill is legal under SEP-2640, so only two of these entries are non-conforming. Reword this as three edge cases (one unverifiable dynamic case and two conformance violations) to avoid documenting a supported wire form as invalid.
test-servers/src/skills.ts:12 resources: "dynamic"is explicitly a conforming SEP-2640 form for generated content; it is unverifiable, not non-conforming. Calling all three edge cases non-conforming makes this fixture's contract inaccurate. Distinguish the legal dynamic warning from the two actual violations.
- Files reviewed: 44/44 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Moderate correctness issues remain in URI normalization, name validation, and modern skills/list schema enforcement.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
core/mcp/skillsSchemas.ts:106
- The accepted SEP is no longer ambiguous here: for protocol version 2026-07-28 and later,
skills/listmust carry the base list envelope, includingttlMsandcacheScope(andresultType). Because this loose schema is used for every era, a modern server can omit those required fields and still appear successful in this conformance UI. Select an era-aware schema inInspectorClient.listSkills, combining the modern result envelope with this page schema while retaining the permissive legacy shape.
clients/web/src/test/core/mcp/inspectorClient-skills.test.ts:49
- This double cast bypasses the type system without the inline safety justification required for
as unknown as. Since the test intentionally reaches private SDK state, document why this structural view is safe and why the public API cannot provide the same isolated setup.
return client as unknown as SkillsInternals;
core/mcp/skills.ts:267
- This only rejects an absent/blank description, but the Agent Skills specification limits
descriptionto 1–1024 characters. A 1025-character value therefore receives no finding and the UI can incorrectly report “Conforms”; add amalformed-descriptionerror and boundary coverage.
- Files reviewed: 44/44 changed files
- Comments generated: 3
- Review effort level: Balanced
All three findings were the same underlying inconsistency: URI comparisons on raw strings, in a flow that elsewhere deliberately treats normalized equivalents as the same resource. One `skillUriIdentity` helper now backs every comparison, so they cannot disagree. - skills.ts: `manifest-missing-self` compares identities, so a manifest listing the RFC-equivalent `skill://demo/%53KILL.md` is recognized as the entry's own file — it is fetchable as that file, so reporting it missing was the tool disagreeing with itself. - skills.ts: `skillEntriesMatch` normalizes the entry URI and every manifest URI before comparing, so a server that canonicalizes an escape between the listing and the fetch is not reported as a changed snapshot. - SkillsScreen: the `wrongUri` check uses the same identity, so a canonicalizing server is not accused of answering for a different skill — the read path already accepts exactly that equivalence. - skills.ts: apply the Agent Skills name grammar to the RAW value. Trimming first let `" demo "` pass, and whitespace is not in the grammar — so an entry could report "Conforms" with a name that can never equal its URI path segment. The trimmed copy now only distinguishes absent from invalid. - skills.ts: add `malformed-description` for a description above the 1024-character Agent Skills limit, with boundary coverage. - inspectorClient-skills.test: justify the `as unknown as` per AGENTS.md — both fields are `private` with no public setter, the asserted shape is exactly what the class declares, and the alternative is a live connection. 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>
There was a problem hiding this comment.
🟡 Changes recommended
A critical cross-session verification-state flaw and two moderate conformance issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 44/44 changed files
- Comments generated: 3
- Review effort level: Balanced
| const manifestKey = useMemo( | ||
| () => (selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")), | ||
| [selected, selectedSkillUri], |
There was a problem hiding this comment.
Fixed in 4d7dd18, and this was a genuinely subtle one — thank you. You're right that the screen stays mounted across a disconnect, so content cannot distinguish server A's entry from an identical-looking one on B. useInspectorStores now exposes a sessionNonce, bumped on both createStores and destroyStores so it never repeats across a reconnect, and App keys the screen on ${activeServerId}:${sessionNonce} — the server id alone would repeat on a reconnect to the same server, which is one of the crossings this is meant to prevent. Because the session is part of the same key the batch map uses, your second point (a retained entry leaving B's button disabled) falls out too; the test asserts both.
| const selected = useMemo( | ||
| () => skills.find((skill) => skill.uri === selectedSkillUri), | ||
| [skills, selectedSkillUri], | ||
| ); |
There was a problem hiding this comment.
Fixed in 4d7dd18 — skillUriIdentity in both the selected lookup and the NavLink active check. This was the last raw-string URI comparison left after the previous round's cleanup, and the symptom you describe is the worst kind: the detail pane silently empties for a skill that is still right there, because the server re-spelled its URI. Test renders with a %53KILL.md selection against a canonically-spelled list and asserts the detail pane is still shown.
| export const ListSkillsResultSchema = z.looseObject({ | ||
| skills: z.array(SkillEntrySchema), | ||
| nextCursor: z.string().optional(), |
There was a problem hiding this comment.
Still declined — eighth round for this one, and the reasoning is unchanged from round 5. The server half is done: the fixture stamps the full modern envelope (resultType / ttlMs / cacheScope), matching this repo's own ModernResultEnvelopeSchema. The client half stays permissive because making it strict means the Inspector rejects a modern server's response on a reading nobody in this thread has verified against the normative text — and the two mistakes are not symmetric: permissive accepts a server that omits the fields (and passes them through untouched when sent), while a wrong tightening fails servers that are doing nothing wrong. skillsSchemas.ts records the open question and why it is not guessed; #2248 owns settling it and names era-aware validation as the shape of the fix. The offer stands: quote the SEP line and I will make the change in this PR rather than the follow-up.
There was a problem hiding this comment.
Reversing my decline — this is now implemented (11ac134). The latest round quoted the normative text, which is exactly what I said five rounds running I was waiting for: "In protocol versions 2026-07-28 and later, the result also carries … ttlMs and cacheScope." With the sentence in hand the asymmetry I was reasoning from no longer applies — it isn't a coin flip any more, so the tightening is right.
ModernListSkillsResultSchema is the page plus the base list envelope, with field shapes mirroring ModernResultEnvelopeSchema in listSalvage.ts (this repo's existing statement of a modern envelope, already applied to modern list results on the salvage path) so the two can't drift. InspectorClient.listSkills selects it from the negotiated era; the legacy schema stays permissive, since those are 2026-era attributes a legacy server has no business sending. Your point about why it matters is the one I'd underweighted: skills/* is consumer-owned, so it's absent from the SDK's cacheable-method registry and nothing else would ever catch it — a modern server answering { skills: [] } was reaching the conformance UI as a clean list.
Tests: modern rejects a page with no envelope, and rejects ttlMs: -1 / 0.5 / an unknown cacheScope; modern accepts a page carrying it; legacy still accepts a page without it. Plus two on InspectorClient proving the era actually selects the schema.
#2248 is narrowed accordingly — the skills/list half is settled here; what remains open there is only whether skills/get carries the same attributes, which the quoted sentence doesn't cover.
- SkillsScreen: put the SESSION in the invalidation key. This screen stays
mounted across a disconnect, so content alone cannot tell server A's entry
from an identical-looking one on server B — A's in-flight verification
could land afterwards and report `verified` for a file never read from B,
and a retained batch entry could leave B's Verify all disabled.
`useInspectorStores` now exposes a `sessionNonce`, bumped on both create
and destroy so it never repeats across a reconnect, and App keys on
`${activeServerId}:${sessionNonce}` — the server id alone would repeat,
which is one of the crossings this exists to prevent.
- SkillsScreen: match the selection by `skillUriIdentity`, in both the
lookup and the NavLink active check. A refresh that canonicalizes
`skill://demo/%53KILL.md` names the same skill, and the detail pane was
emptying out because the server changed its spelling — the last raw-string
URI comparison left after round 12.
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>
There was a problem hiding this comment.
🔵 Needs a closer look
Modern list results need era-aware validation, and description limits must count Unicode code points.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
core/mcp/skills.ts:297
- JavaScript's
string.lengthcounts UTF-16 code units, not Unicode characters. The Agent Skills limit is 1024 characters, and its reference validator uses Pythonlen(description)(code points), so a valid description containing 600 non-BMP characters is incorrectly reported asmalformed-descriptionwith length 1200. Count code points before applying and reporting the limit.
core/mcp/skillsSchemas.ts:116
- The accepted SEP now resolves the point this comment calls “unsettled”: its
skills/listsection states, “In protocol versions 2026-07-28 and later, the result also carries …ttlMsandcacheScope.” This schema therefore accepts a non-conforming modern response such as{ skills: [] }, and the managed store/UI report it as a successful list. Please select an era-aware schema inlistSkillsso the modern path requires the base list-caching fields while the legacy path remains permissive.
- Files reviewed: 44/44 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The review finally quoted the normative text, which is what I asked for
across five rounds before declining: SEP-2640's `skills/list` section says
"In protocol versions 2026-07-28 and later, the result also carries …
`ttlMs` and `cacheScope`." So the era-aware validation is in.
- skillsSchemas: `ModernListSkillsResultSchema` = the page plus the base
list envelope, with field shapes mirroring `ModernResultEnvelopeSchema`
in `listSalvage.ts` so this repo's two statements of a modern envelope
cannot drift. `InspectorClient.listSkills` picks it from the negotiated
era; the legacy shape stays permissive, because those are 2026-era
attributes a legacy server has no business sending.
Nothing else catches this: `skills/*` is consumer-owned, so it is absent
from the SDK's cacheable-method registry and a modern server answering
`{ skills: [] }` was reaching the conformance UI as a clean list.
- skills.ts: count `frontmatter.description` (and `name`) length in Unicode
CODE POINTS. `String.length` counts UTF-16 code units, so 600 non-BMP
characters measured as 1200 and a perfectly valid description was
reported `malformed-description` — a conforming server failed by an
off-by-encoding, which is the direction this module works hardest to
avoid. The Agent Skills reference validator uses Python `len()`.
- #2248 narrowed: the `skills/list` half is settled here; what remains is
whether `skills/get` carries the same attributes.
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>
Round 14 — no new inline threads; both suppressed findings addressed (11ac134)This round generated 0 new comments, so there was nothing new to reply to inline. Both suppressed items are done:
#2248 is narrowed: the On the gate
|
There was a problem hiding this comment.
🔵 Needs a closer look
Multiple moderate issues remain in request ordering, protocol error reporting, and Skills UI semantics.
Review details
Suppressed comments (5)
Previously missed (3) — in code that hasn't changed since the last review.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:532
- A newer preview request is not recorded until it settles, so if two clicks overlap and the older request resolves first, its contents are accepted and shown even though a newer request is already in flight. Claim the attempt immediately (and clear the previous preview) before starting
onReadSkillFile, asverifyRowdoes, so every older continuation is rejected regardless of completion order.
This issue also appears on line 552 of the same file.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:631
- SEP-2640 explicitly permits
skills/listto return an empty or partial catalog and says an empty result must not be treated as proof that the server has no skills. Rendering “No skills” makes that false claim to the user; label this as “No skills listed” (or “skills/list returned no entries”) so unlisted skills remain possible viaskills/get.
core/mcp/inspectorClient.ts:5614 - A malformed
skills/getenvelope is rejected byGetSkillResultSchema, but this path never callsmarkResponseRejected. The button shows an error while the Protocol tab leaves the response looking like a clean success. Mirror theskills/listdecode-rejection handling so schema failures are attributed to theskills/getexchange while the correlation window is current.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:552
- This has the same ordering hole as the preview request: attempt 2 is only written after it settles, so attempt 1 can resolve first and temporarily publish a stale
skills/getverdict while attempt 2 is pending. Write the new attempt into state before issuing the request so the older callback is stale immediately.
void onGetSkill(selected.uri)
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:793
- Every per-resource control has the same accessible name,
Verify, and the URI cell is not programmatically associated with the button. Screen-reader users navigating controls cannot tell which file each button verifies; include the resource URI in anaria-labelwhile keeping the visible text concise.
Verify
- Files reviewed: 44/44 changed files
- Comments generated: 0 new
- Review effort level: Balanced
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>
Round 15 — no new inline threads; all five suppressed findings fixed (a561cd1)Again 0 new comments, so nothing new to attach an inline reply to. All five suppressed items were real and unaddressed:
|
There was a problem hiding this comment.
🔵 Needs a closer look
Skills capability detection must reject invalid non-object extension declarations before exposing or invoking the extension.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
core/mcp/skills.ts:116
- A non-null primitive extension value (for example
falseor"skills") is currently treated as support, which exposes the Skills tab and sendsskills/listeven though SEP-2640 defines the declaration as an object. Reject non-object values here, matching the existing extension parsing incore/mcp/appElicitation.ts:52-55.
clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx:133
- This contract incorrectly says the two responses must agree and that a difference makes the server broken. SEP-2640 defines
skills/getas a fresh point-in-time snapshot, so a conforming result may legitimately differ from an older listing; the implementation below already presents that case as an updated snapshot. Update this documentation to match the actual protocol semantics.
* Re-fetch the selected entry through `skills/get` (SEP-2640). Distinct from
* the entry `skills/list` already returned, and the point of exercising it is
* that the two must agree: a server whose `skills/get` disagrees with its own
* listing is broken in a way only a side-by-side fetch can show.
test-servers/src/skills.ts:383
- No automated real-transport test exercises these newly registered handlers: the Skills client tests stub
client.request, and the screen tests mock callbacks. That leaves the integration-sensitive claims here—customsetRequestHandler, the privateresources/readwrapper, pagination, and operation in both protocol eras—unguarded. Add a web integration test that connects to this fixture and callsskills/list,skills/get, andresources/read(including a modern-era connection).
- Files reviewed: 44/44 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Round 16 reported no inline comments and three suppressed findings, all valid: - `getSkillsExtension` treated a non-object extension value (`false`, `"skills"`) as a declaration. SEP-2133 declares an extension as an object of sub-options, so a primitive is not one — now rejected, matching `appElicitation.ts`. - The `onGetSkill` prop doc claimed the two responses "must agree" and that a difference means a broken server, contradicting the implementation, which reports a difference as a finding rather than a fault. Reworded to what the code does. - No real-transport test exercised the fixture's `skills/*` handlers. Added `src/test/integration/mcp/inspectorClient-skills.test.ts`, parameterized over both protocol eras: capability advertisement, the paged `skills/list` walk (direct and through `ManagedSkillsState`), `skills/get`, the `-32602` for an unknown URI, `resources/read` of a skill file, and delegation of an ordinary resource through the fixture's `resources/read` wrapper. 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>
Round 16 — all three suppressed findings addressed (1ee9580)No inline comments this round; the three suppressed findings were all valid and are all fixed. 1. 2. The 3. No real-transport test exercised the fixture's handlers. Added One thing worth recording, since it changed what I could assert: I first wrote a standalone test reading
|
Round 17 — clean45/45 files reviewed, 0 new inline comments and no suppressed findings — the first fully clean round on this PR (round 16 was zero-inline but still carried three suppressed items, all since fixed in The verdict is 🔵 Needs a closer look on the grounds that the protocol, digest-verification, state and UI surface is broad enough to warrant human review — that's a scope judgment about the change, not a finding against it. Requesting one more round as the confirming pass, per the practice on this repo of not treating a single zero-comment round as the end. |
Round 18 — 🟢 Approval recommended, second consecutive clean round45/45 files reviewed, 0 new inline comments, nothing suppressed — the same as round 17, so this is the confirming pass and the review loop is finished. Where it landed, across 18 rounds: every acceptance criterion on #2234 is met (phases 1 and 2), The standing 🔵 from round 17 was a scope judgment — the protocol, digest-verification, state and UI surface is broad — not a finding, and round 18 resolves to approval on the same diff. |
Closes #2234
Adds Inspector support for the Skills extension (SEP-2640,
io.modelcontextprotocol/skills) — phases 1 and 2 of the issue's plan, which is everything its Acceptance list names. Phase 3 (CLI methods, TUI pane,resources/directory/read, paged mode) is filed as #2248.The extension is a server-declared one, so it is read off the connecting server's
capabilities.extensionsand deliberately not added toADVERTISABLE_EXTENSIONS— that registry is the catalog of extensions the Inspector advertises and the user toggles in Server Settings, and putting Skills there would produce a meaningless toggle. The precedent followed iscore/mcp/appElicitation.ts, which reads the server side the same way.No SDK change, and no raw-wire channel
skills/list,skills/getandresources/directory/readare consumer-owned extension methods that neither era codec defines, so the SDK's era gate skips them (Protocol._assertOutboundRequestInEraonly fires for names a codec knows) andassertCapabilityForMethodfalls through to a no-op. The entire client-side mechanism is an ordinaryclient.request(…, ResultSchema)with an explicit result schema.That is why the raw-wire escape hatch modern
tasks/*needs is not used here, and why the Skills tab — unlike Tasks — is not era-gated: a legacy-era server that declares the extension is serving it, so it gets the tab.More than a viewer
SEP-2640 puts real obligations on whoever consumes a skill, and each one is a check a server author wants run against their implementation.
core/mcp/skills.tsproduces a structured finding list rather than a boolean.The severity split is load-bearing, so it is stated rather than implied:
errormeans a MUST was broken, so a skill reporting "0 errors" really is one the spec accepts;warningis everything the spec permits but a consumer still wants told about.dynamic-resourcesresources: "dynamic"is a legal form for generated content. Nothing is wrong with it — integrity simply cannot be verified at all, which is the case most easily buriedresource-limit-exceeded/size-limit-exceededmissing-name/missing-descriptionmalformed-uri/SKILL.mdwith a path segment before it. The scheme is not constrained —skill://is a SHOULD, and a domain-native scheme likegithub://is explicitly allowedname-path-mismatch/SKILL.mdmust equalfrontmatter.namemanifest-missing-selfSKILL.md— an empty list included — cannot be checked against the skillduplicate-resource/resource-outside-skill-root…/refunds/../other.mdcannot slip through a prefix test)missing-digest/malformed-digestdigestis required, and must besha256:+ 64 lowercase hexmissing-sizesizeis required, and an omitted one is how a server slips past the 16 MiB pre-fetch limit while looking cleanDigest verification is separate and on demand.
verifySkillResourcecross-checks the declared byte length first (cheap, and a length that disagrees is a real inconsistency even when the digest matches), then hashes with WebCrypto — falling back tocore/mcp/sha256.tswherecrypto.subtleis absent, which is any plain-HTTP LAN deployment. A mismatch is returned with both digests attached, never thrown, because showing it loudly is the whole value proposition and a throw would collapse it into a generic failure message.Fetching is on demand for a spec reason too: SEP-2640 is explicit that a
resources/readof aSKILL.mdis not a load and confers no standing, so the Inspector reads only what the user asks it to verify. None of the SEP's host machinery — activation, per-skill consent, content-bound approval — is implemented. Surface and verify.What changed
core/mcp/skillsSchemas.ts— the wire surface for the two methods this PR calls. Permissive where a non-conforming server should be reported rather than rejected at the parse (looseObject,digesttyped as a plain string, so the conformance checks can name the problem); strict where a shape is settled —GetSkillResultSchemarequires the{ skill }envelope and rejects an entry returned inline, because normalizing that silently would let a non-conforming response past the one place that could have reported it. There is deliberately noresources/directory/readresult schema: nothing calls that method yet, so an unverified shape could sit wrong indefinitely without failing anything. Phase 3 (Skills extension phase 3: CLI methods, TUI pane, resources/directory/read, and paginated mode #2248) adds it against the normative text, alongside the call.mcp/skills.ts— detection, the conformance checks, and digest verification.errormeans a stated SEP requirement was broken, so "0 errors" is an answer the spec would give;warningis reserved for what is legal yet leaves integrity unverifiable, which is"dynamic"and nothing else.mcp/sha256.ts— a dependency-free SHA-256 for whencrypto.subtleis absent. Not an optimization:SubtleCryptorequires a secure context, and this app is documented as servable over plain HTTP on a LAN IP, where every verification would otherwise throw.mcp/inspectorClient.ts—getSkillsExtension(),listSkills(cursor),getSkill(uri). Both required methods are called by the UI, not merely available: the Skills tab pages throughskills/listand fetches the selected entry withskills/geton demand.mcp/state/managedSkillsState.ts— walks everyskills/listpage, clears on disconnect, records the last failure as observable state. Deliberately not aManagedListStatesubclass: that base is built around a top-levelServerCapabilitieskey to gate on and alist_changednotification to debounce, and Skills has neither. It also guards against a server that repeats a cursor, which would otherwise walk forever.react/useManagedSkills.ts—useStoreSnapshot, neveruseState+ a subscribing effect.clients/web/SkillsScreen— a conformance view: the frontmatter, every finding, and the resource manifest with a per-file verdict and both Verify all and per-row Verify. Selection changes drop the verdicts during render viauseValueChange, so a newly selected skill never paints a frame carrying the previous one's results.directoryRead, and that sub-flag is what a server author opens the modal to confirm. The two extension lists beside it also stopped being bold, comma-joined single lines: they are lists of items sitting directly under the capability checklists, so they now read like them.test-servers/skills.ts+configs/skills-http.json— four skills over twoskills/listpages, three of them deliberately non-conforming (a digest mismatch, a"dynamic"skill, a name/path disagreement). Without those the verification code is untestable. Works on either era, for the reason above.Testing
npm run local:gatepasses. New per-file coverage clears ≥90 on all four dimensions. Four rounds of Copilot review are folded in — see the PR comments for what each finding was and how it was answered, including the two that were deliberately declined and why.Screenshots below.
Screenshots
Captured headlessly against the built prod bundle connected to
test-servers/configs/skills-http.json.The Skills tab. Four skills over two
skills/listpages — the Protocol panel shows both calls, which is what proves the cursor walk ran. The sidebar badge counts each skill's static findings.A conforming skill, verified. Both files — the skill's own
SKILL.mdand itsreference.md— hash to the digests the manifest advertised, and both lengths agree with the declared sizes.skills/get, the extension's second required method. Fetched on demand and compared against the entryskills/listadvertised — the two describe the same skill, so a disagreement is a server bug only a side-by-side fetch can surface. The Protocol panel shows the call.A digest mismatch.
notes.mdadvertises a well-formed digest of bytes the server does not serve. The row badge flips toMISMATCHand the alert names the file with both digests — expected and actual — rather than a generic failure.resources: "dynamic". No manifest, so there is nothing to verify and Verify all is disabled rather than a button that silently does nothing.A name/path disagreement. Served from
wrong-folder/while claiming the nameright-name— the one structural invariant SEP-2640 states outright.Connection Info. "Server Extensions" lists the identifier; Skills Extension Options adds what a flat key list cannot show — the extension's sub-options, in the same ✓/✗ vocabulary as the capability columns.
directoryReadis the only one SEP-2640 defines, and the fixture declares the extension bare: it serves noresources/directory/read, so reporting it as supported would have been a lie.