**The depth guard covered only the YAML side.** The listing arrives over
JSON-RPC so it cannot be cyclic, but it is just as unbounded in depth — and
both `jsonLikeEqual` and its message formatter walk it, so a server
advertising a deeply nested value crashed the tool exactly as a cyclic served
one did. Reproduced first: 60,000 levels against a shallow served value gives
`RangeError: Maximum call stack size exceeded`. `jsonGraphError` is shared
now and runs over `entry.frontmatter` before the file is parsed — there is no
point reading a SKILL.md if the thing to compare it against is unusable.
**A frontmatter mismatch stayed behind a collapsed section.** Round 7 made
the badge count these findings; the alerts explaining a mandatory
verification failure were still one click away on a section that opens
collapsed for a structurally clean entry. Conformance now reveals itself when
they appear, keyed on the entry so it fires once rather than fighting a user
who collapses it again.
That reveal exposed three fixtures serving one skill's SKILL.md for every URI
— a genuine frontmatter mismatch the check was right to report. The stale-
batch invariant test and the Storybook fixtures now derive each file from the
frontmatter its own entry advertises, with digests computed from those bytes
rather than hard-coded, so the class of drift is closed rather than the
instances patched. `SAMPLE_FM` is the single source for both halves.
One story subtlety worth recording: only a skill's OWN SKILL.md is derived.
Serving it for `notes.md` too made the tampered fixture fail its SIZE check
first, which is a different finding from the digest mismatch that story
exists to demonstrate.
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>
Four findings.
**The CLI dropped what it promised to print.** `getSkill` transforms the
`skills/get` envelope to a `SkillEntry`, so every other member the loose
schema accepted was gone before the CLI saw it — including the `ttlMs` /
`cacheScope` SEP-2640 explicitly leaves open. The code contradicted a comment
two lines above it saying a CLI whose contract is "print the result" must not
reshape one. `getSkillResult` returns the envelope whole; `getSkill` is a
one-line unwrap over it, which matches the callers: the UIs want the entry,
the CLI wants the result.
**The frontmatter verdict and the digest came from different reads.** The
preview fetch and the Verify fetch are separate `resources/read` calls, so a
resource changing between them could pair a verified digest with a
frontmatter verdict for other bytes. `VerificationState.entryText` now holds
the SKILL.md as the verification read it, written in the SAME state update as
the verdict — a separate write was clobbered, because `write` returns a fresh
object and drops anything not named in it. The preview stays the fallback so
a reader who has not clicked Verify still gets the check.
**The Conformance badge excluded the frontmatter findings** it renders, so it
said `0 error(s)` above a red mismatch. Digest and size mismatches stay in
their own badge: "the listing is wrong" and "the bytes are wrong" are
different answers.
**A repeated skill URI collided two React keys** in the TUI list, in a pane
whose job is to show both entries. Index-keyed like the manifest rows.
The web fixture also gains its collision pair in the served-file lookup —
without it they were handed another skill's SKILL.md, which the badge fix
correctly began reporting as a frontmatter error.
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>
**A cyclic YAML alias crashed the tool.** `meta: &m [*m]` parses cleanly into
a self-referential array, and the frontmatter comparison is a recursive walk,
so a hostile server could take down `--verify` or the TUI with a stack
overflow instead of receiving a finding. Verified before fixing: it really
did raise `RangeError: Maximum call stack size exceeded`.
Rejected at the parse, so no consumer has to be cycle-safe on its own, and it
becomes an ordinary `frontmatter-unparsable` finding. Detection is per-PATH
rather than per-graph — `seen` unwinds on the way back up — so an alias reused
across siblings, which is ordinary YAML and represents fine in JSON, is not
mistaken for a cycle. A depth bound closes the same hole by its other door: a
legal, acyclic but absurdly nested document exhausts the stack just as well,
and a cycle check alone passes it.
**The web frontmatter check was gated on the presentation MIME.** It ran off
`previewParts`, which exists only when the displayed type is recognized as
markdown — so a `SKILL.md` a server typed `text/plain`, or served as a base64
blob, skipped a mandatory comparison while the pane still read as clean. It
now decodes the same fetched bytes the digest is taken over, which also stops
the two answers describing different derivations of the payload.
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>
Eight findings. Two were visible in a terminal capture I had already taken
and read past.
**A digest mismatch rendered with no digests.** `verifySkillResource` sets
`reason` for a SIZE mismatch — which short-circuits before hashing — while a
digest mismatch carries `expectedDigest` / `actualDigest` and no `reason`, so
the TUI printed a bare `✗ notes.md` with nothing to act on. `failureDetail`
now falls back to the expected/actual pair, truncated to fit the pane.
**A dynamic skill's read failure rendered nowhere.** Its synthetic
`read-error` row lives in the report, not the manifest, and the manifest is
empty for such a skill by definition — so the pane said only "Verification
FAILED". There is a Read failures block for any report file the manifest does
not cover, matched on normalized identity.
**The non-finite sentinel could be aliased.** A listing whose value genuinely
was `{"#non-finite":"NaN"}` canonicalized identically to a served `.nan`, so
the round-3 fix moved the bug rather than closing it. Any encoding into the
value space can be aliased by a document containing the encoding, so the
comparison is structural now (`jsonLikeEqual`) and there is no sentinel at
all; `Object.is` gives NaN === NaN while keeping ±Infinity distinct.
Serialization is used only for the message.
**`parseSkillFrontmatter("null")` returned `{ fields: {} }`,** contradicting
its own non-mapping contract: an explicit null scalar parses to exactly what
an empty block does. They are told apart by the source, since the parsed
value cannot.
Also: the `files` doc comment still claimed it is empty for a dynamic skill,
which the round-2 fix made untrue; and three inline Mantine elements with two
static styling props each are extracted to `.withProps()` constants, per the
convention the rest of the file follows.
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>
SEP-2640: hosts MUST NOT assume name uniqueness, and when two entries in one
listing collide on `name` a host MUST disambiguate them rather than silently
discarding or preferring one.
`checkSkillConformance` structurally cannot see this — it takes one entry and
a collision is a property of the pair — so `checkSkillNameCollisions` walks
the listing and returns a finding per colliding entry, each naming the
others. All three clients merge it into the entry's own findings, so it
carries through the header badge, the CLI report and the TUI row marks with
no new surface.
**It is a `warning`, not an `error`,** and that is the severity split doing
its job. The obligation is on the *consumer*: a server may legitimately
publish two skills with the same name under different paths, and the SEP's
own `acme/billing/refunds` example is exactly that shape. Calling it an error
would tell a conforming author their catalog is invalid. `--verify` therefore
still exits 0 for a collision.
In the web screen it renders as a banner directly under the Conformance
header and is filtered out of the findings list, the same treatment
`dynamic-resources` gets: it changes how everything below it should be read,
and stating one fact twice reads as two findings.
Fixed while adding it: an entry whose only finding was a banner one rendered
an EMPTY findings container instead of "no structural issues", because the
list's presence was decided on the unfiltered set while its contents were
filtered. `listedIssues` is now derived once and used for both.
The fixture gains `acme/reports` + `globex/reports`, both fully conforming
and sharing the name `reports` — also the only fixture with a multi-segment
skill path, which nothing else exercised. Eight skills over four pages now,
and the integration test walks the cursor to exhaustion rather than asserting
a fixed page count, so a future fixture does not require editing it.
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>
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>
Closes#2248.
Completes SEP-2640 support across all three clients, and closes the one
obligation #2234 deliberately left open.
- `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML
frontmatter against the entry the listing advertised, field by field. No
digest can cover this: a digest is taken over the bytes the server served,
so it proves the file was not altered in transit and says nothing about
whether the listing described it honestly. Needs a real YAML parser; `yaml`
was already a root dependency, so this adds no package — but it does newly
put it on core/'s import graph, hence the three bundler `external` lists.
- `resources/directory/read`: result schemas defined against the normative
text, `InspectorClient.readResourceDirectory`, a Directory section on the
Skills screen, and `--method resources/directory/read` in the CLI. The
client refuses the call locally when the server did not declare
`directoryRead`, which is the SEP's MUST NOT. A child the directory lists
but the entry does not is marked `not listed` rather than merged into the
manifest — the SEP calls a directory result a live observation and forbids
treating it as extending the manifest.
- CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus
`--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a
violation. Reads are sequential; a conforming manifest may declare 512
entries.
- TUI: a Skills pane, shown only when the server declares the extension. Each
row carries its conformance verdict as a glyph as well as a colour, since
the pane is read over ssh and through script(1). Enter verifies.
- `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording
it per file: it says the session's authorization expired, so absorbing it
would report N identical read failures and swallow the error the TUI and
the web commands key off to reauthorize.
Two open questions settled, both in code comments where they will be found:
`skills/get` carries no caching attributes because SEP-2640 leaves the
question open in as many words, so requiring them would fail a conforming
server; and there is no `PagedSkillsState` because every consumer of this
list is a whole-catalog verdict — computed over page one of three, "this
server's skills conform" is not merely partial but wrong.
The fixture grows two skills, both for checks that were otherwise
undemonstrable: `lying-listing` (listing and file disagree, digest still
verifies) and `stale-manifest` (serves a file its manifest does not declare).
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>
The tail of the `Long Skill Document` story collapsed and reopened the Skill
Resource accordion and then compared all four section heights against the
pre-collapse sample with an exact `toEqual`. `userEvent.click` resolves once the
event is dispatched, ahead of React committing the `openSections` update,
remounting the panel's content, and the browser running the layout pass that
redistributes height across the flex sections — so the read sampled the panes
mid-redistribution, two heights off by 23px in opposite directions and summing
to the same total.
There is no panel animation involved: the accordion sets `transitionDuration={0}`
because a height animation fights its flex sizing, and App.css restores a
transition only for the chevron transform.
Read the geometry under `waitFor` so it retries until the panes settle. The
reopened layout converges on the pre-collapse geometry rather than passing
through it, so retrying cannot mask a real regression; a tolerance would have
hidden the race instead of removing it, and would still fail on an early enough
sample.
Closes#2278
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtdD6rzc1ddT28ZNE9wUjV
Signed-off-by: cliffhall <cliff@futurescale.com>
`autoReadKey` cached the manifest key of the entry it had read for. When the
selected entry left `skills` — a refresh in flight, a disconnect — the render
emptied the preview but the ref kept the key. An identical entry reappearing
therefore matched the cache, the automatic read was skipped, and the viewer
stayed blank with no recovery short of reselecting. Clearing the guard while
no entry is selected is what lets the reappearance re-read.
Also corrects the guard's stated scope. It cannot survive a `ScreenStage`
remount — a remount mints a fresh ref — so the round-11 comment claiming it
did was wrong. That read is correct behavior rather than a gap: `previewState`
is local state and dies with the same unmount, so on return the viewer is
empty and the read is what refills it. Suppressing it would mean hoisting the
preview bytes above `ScreenStage` and showing bytes fetched under a connection
that may no longer exist.
The reappearance test is a real regression guard: restoring the defect fails
it while the older one-read-per-selection test still passes, so it is both
detecting and non-redundant. That older test remains a specification, for the
reason it already states, and now points at this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 11: zero inline comments, two findings in the suppressed
block. Third round where the inline count alone would have ended the cycle.
The automatic SKILL.md read fired twice. I noted this possibility early and
dismissed it as harmless dev noise, which was wrong twice over: `ScreenStage`
remounts this screen while the selection persists, so it is not dev-only, and
this app is a protocol inspector — a phantom `resources/read` in the Protocol
panel that the user's own client would never send is the tool misreporting the
conversation, not merely a wasted round trip. An `autoReadKey` ref makes the
replay a no-op.
Panel scroll positions survived a skill change. `scrollTop` lives on the DOM
node rather than in React state, so switching between two long manifests showed
the new one part-way down with its first rows hidden, which reads as missing
data. The accordion is keyed by `manifestKey` so every panel gets a fresh scroll
container; `openSections` is controlled and survives.
⚠️ The test for the first fix is a SPECIFICATION, not a regression guard, and is
labelled as such in the file. It was written, then checked by removing the
guard — and it still passed. Three shapes were tried (a plain re-render, a
StrictMode wrapper, StrictMode with the selection present at mount) and none
reproduce the replay, because this environment does not double-invoke mount
effects. The fix stands on the reported reasoning; the test pins the contract of
one read per selection and must not be read as evidence the defect is fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Zero inline comments, three findings in the review's suppressed block — the
second round where the inline count alone would have ended the cycle. All three
are places where my own words did not match the code.
The Frontmatter section claimed to show "the raw YAML the server served, not a
re-serialised object". It did not: `ContentViewer`'s plain-text branch
pretty-prints anything that parses as JSON, and every JSON mapping is also valid
YAML — so `{"name":"a"}` came back re-serialised beneath a comment promising
verbatim bytes. For a conformance tool that is the one thing it must not do. It
now renders through `CodeHighlight` directly.
The green alert was titled "Conforms". Consolidating every verdict into this one
section means it can sit directly above a red digest mismatch, where an
unqualified "Conforms" contradicts the result on screen — the static checks
passing says nothing about the bytes. Retitled "No structural issues", which is
what it actually summarises. The contradiction was created by the consolidation
and the title was never revisited.
And the PR description said the shield stays on both integrity-checking
controls, while the per-row Verify rendered text only. The claim was right and
the code was not, so the row button now carries it too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 9, two findings.
The Skill Resource control shows the displayed file's name, and that control is
PINNED — it never scrolls — so a resource URI ending in a long breakable segment
grew the header. Fifth instance of the same class.
It took three attempts, and the mutation table is what exposed each one.
`lineClamp: 1` stopped the text wrapping but the row then wrapped the caption
onto its own line (+30.6px), because flexbox wraps before it shrinks. A
`nowrap` row improved that to +21.7px but Mantine's Accordion label wrapper
still would not let the caption shrink, so `miw: 0` had nothing to act on. A
hard `maw: "50%"` gives the ellipsis a definite width, and the control now grows
by 0px.
Worth recording that the FIRST version of that guard passed against the defect:
it asserted an absolute `height < 120px`, which an unclamped name satisfies
because it only wraps to a few lines. Comparing the control against its own
baseline — measured with a short name, then re-measured after selecting the
hostile one — is what makes it detect anything at all.
Second finding: `SkillDescription` carried `size: "sm"`, leaving typography at
the call site directly beneath a comment of mine asserting that these constants
carry layout only and that typography belongs in a `ThemeText` variant. Added a
`skillDescription` variant; only the clamp stays here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 8: the skill URI was still unbounded in the pane's fixed
header. It is the fourth instance of a pattern named in this PR's own round-7
reply — anything server-controlled and variable-height that sits beside the
zero-basis accordion sizes the layout — and it was directly above the
description clamped last round, which says something about naming a class not
being the same as sweeping for it.
So this audits the whole header rather than the reported element. All three
server-controlled strings are now bounded: the name truncates (already did),
the URI clamps to two lines, the description to three, and both clamps carry
the full value on a `title`. `HostileHeader` asserts the class is closed — a
long name, a 30-segment URI and a 400-word description, with the accordion
still taking more than half the card, every section non-zero, and the pane not
scrolling.
That new story then caught an accessibility defect this refactor introduced and
would otherwise have shipped: `scrollable-region-focusable`. Making each panel
its own scroll container means a panel whose content holds no focusable element
— the Conformance alerts, for instance — cannot be scrolled by keyboard at all.
Every `Accordion.Panel` now carries `tabIndex={0}`.
The geometric assertion lives in Storybook rather than the unit test, because a
CSS bound is not observable through happy-dom's `getComputedStyle`; the unit
test pins the contract that makes clamping safe instead — the full value stays
reachable on a `title`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Four findings — three inline, one suppressed.
`verifyRow` opened the Conformance section, and every "Verify all" worker calls
it once per row as it advances, so a batch begun on one skill kept calling it
after the user had switched away. The keyed writes discard those results, but an
open-state update is not keyed to a manifest, so obsolete work could reopen the
section on the newly selected skill. The section is now opened at the two USER
entry points — the row button and "Verify all" — through `openConformance`, and
`verifyRow` carries a note saying why it must not do it itself.
The skill description was an unbounded sibling of a zero-basis accordion in the
pane's fixed header. SEP-2640 permits 1,024 characters and a non-conforming
server can send more; in a narrow pane that wraps far enough to crowd the
sections out — the same "server-controlled content sizes the layout" trap
`viewerFlex` documents. Clamped to three lines, with the full text on a `title`.
`inferMimeFromUri` matched against the whole URI minus query and fragment, so an
authority ending in a mapped suffix was read as a filename:
`https://documentation.md` returned Markdown despite a pathname of `/`. It now
inspects `URL.pathname`, keeping the string fallback for URIs that cannot be
parsed.
And its decoding was too coarse. `decodeURIComponent` throws not only on a
malformed escape but on a syntactically valid octet that is not valid UTF-8, so
a single `%FF` anywhere in the path abandoned decoding for the whole string and
`skill://a/%FF/reference%2Emd` lost its `%2E`. Each escape run is now decoded
independently, and a run that is not valid UTF-8 falls back to decoding its
ASCII octets one at a time — all suffix matching needs.
Both behavioural fixes carry regression tests verified to fail with the defect
restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Four findings — two inline, two in the review's suppressed block.
"Expand all" wrote `sectionIds` — the sections rendered at that instant — into
`openSections`, which outlives any one skill. Pressing it while the SKILL.md
read was still in flight dropped `frontmatter`, so the section arrived collapsed
with the control offering to expand all over again; a dynamic skill did the same
to `resources`. It now expands to the complete set, and `sectionIds` decides
only the toggle's label. The new test is verified to detect the defect: with
`sectionIds` restored it fails.
`inferMimeFromUri` matched the raw path, so a percent-encoded suffix such as
`reference%2Emd` produced no MIME at all — no markdown renderer and no
frontmatter split — even though `skillUriIdentity` already treats that spelling
as the same file. It now decodes defensively, falling back to the raw path on a
malformed escape rather than throwing inside what is only a guess.
Two comment fixes, both cases of a deliberate decision being contradicted by the
prose around it. `theme/Accordion.ts` still asserted "sized to content and never
shrink; overflow moves to the root" after round 5 moved the sections back to
`flex: 0 1 auto` with a floor — the exact stale rationale that makes a
load-bearing layout easy to regress. And the component doc, the
`onReadSkillFile` doc in `useServerCommands.tsx` and a `test-servers` fixture
comment all still said skill files are read only when the user asks to verify
them, one of them still naming the removed "View SKILL.md" button. All three now
separate reading (on selection, and not a load under SEP-2640) from verification
(strictly on demand).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 5, and it caught a fix that traded one bug for another.
Refusing to shrink (`0 0 auto`) was introduced to stop a squeezed section
slicing its content mid-line. It did that, and reintroduced the original defect
at scale: a CONFORMING manifest may declare 512 rows, and a section holding its
full intrinsic height pushes the file viewer off the bottom of the pane, so
reaching the file means scrolling past the manifest — precisely the "the file is
behind the manifest" problem this screen was refactored to end.
`0 1 auto` with the existing `OPEN_SECTION_MIN_HEIGHT` floor satisfies both: a
section gives up space until it reaches the floor, its panel scrolls internally
from there, and the viewer keeps the remainder. The floor is what stops the
shrink becoming the crush that sliced content — a panel at the floor scrolls
rather than clips. Both wrong directions are now recorded on the constant so
neither is re-derived.
`LongManifest` is the sibling story to `LongSkillDocument`, asserting that the
viewer's header stays within the card, that the Resources panel scrolls
internally, and that the pane does not scroll as one column. Verified to detect
the defect rather than merely pass: with `0 0 auto` restored it fails while the
other eight stories pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 4. Both findings arrived in the review's *suppressed*
block with zero inline comments, so a round that reported "0 new comments" was
in fact carrying two real defects.
`useValueChange` deliberately does not fire on the first render, so the
clean-entry auto-collapse it drives never applied to a screen that MOUNTS with
a skill already selected — a restored `SkillsUiState` does exactly that, and
Conformance would sit expanded until some later selection change started
honouring the rule. The rule was written entirely in the change handler and the
initial state was never considered. `openSections` now seeds through
`initialOpenSections`, applying the same judgement up front while the
render-time synchronisation continues to handle every subsequent change.
The Resources panel still held `selected.resources === DYNAMIC_RESOURCES ? … :
…` inside a `{!isDynamic && …}` guard, and `isDynamic` is that exact
comparison, so the alert arm was unreachable. It is residue from moving the
dynamic banner into Conformance: the outer guard was added and the inner
ternary never removed. The manifest table now renders directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3. Precedence was `declared ?? inferred`, so a server
serving SKILL.md as `text/plain` or `application/octet-stream` defeated the
`.md` inference — and `ResourcePreviewPanel` already records that servers
commonly do exactly that. The file was then not recognised as markdown, so its
YAML frontmatter stayed in the viewer and the Frontmatter section disappeared,
for a perfectly valid skill.
A SPECIFIC declared type still wins, because a server that says `text/csv`
knows its own resource. Only the generic types yield to the suffix, via a new
`isGenericMime` that normalises parameters and casing the same way
`isMarkdownMime` does. Both directions are covered: `text/plain` on a `.md` URI
splits, `text/csv` on a `.md` URI does not.
The round's other finding — that `DetailCard` is not a flex container, so the
detail column has no definite height — is declined as a false positive, with
evidence on the thread: Mantine's own Card.css sets `display: flex;
flex-direction: column` on the card root, `DetailCard` still carries
`h: "100%"`, and the `LongSkillDocument` story added last round asserts the
exact behaviour claimed broken, in a real browser.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Four findings, all real.
The `text/markdown` fallback applied to every manifest resource, but SEP-2640
expects a manifest to carry supporting scripts, examples and assets with types
of their own — an extensionless, untyped blob was being decoded and rendered as
markdown rather than falling back to binary. The fallback is now scoped to the
skill's own SKILL.md; anything else passes `undefined` and lets ContentViewer
apply its octet-stream/text default.
`mismatchCount` counts `status: "mismatch"`, which `verifySkillResource` uses
for both digest AND size failures, so the badge misreported a size-only failure
as a digest mismatch. Renamed to "N mismatch(es)" rather than filtering: a size
failure is a real verification failure and dropping it from the count would hide
it. The alerts still distinguish the two by title.
`isMarkdownMime` compared the raw string, so `text/markdown; charset=utf-8` and
any casing variant were rejected. That silently skipped the frontmatter split —
the fence stayed in the rendered document and the Frontmatter section vanished,
for a perfectly valid response. It now normalises to the base lowercase type.
The flex-basis regression this refactor exists to fix had no browser-level
guard: the unit tests only inspect accordion state, and the long test-server
fixture is never exercised by Storybook. `LongSkillDocument` is modelled on
`ResourcesScreen`'s `ManyResources` and asserts that every section keeps a
non-zero height, that sections tile without overlapping the header below, that
the viewer scrolls within its own panel, that the detail pane does not scroll as
one column, and that collapse-then-reopen restores identical geometry — the
sequence the original bug needed in order to show itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
Review on #2265 surfaced that the pane reported judgement from three sources
in three different places: static findings in Conformance, per-file digest
verification beside the manifest table, and the `skills/get` comparison in a
region of its own. All three now render in Conformance.
That also resolves the first Copilot finding structurally rather than with the
cap it suggested. The free-standing `skills/get` region had a content-sized
basis competing with an accordion whose basis was 0, so a large fetched entry
starved the accordion; moving the verdict inside deletes the competing region
entirely, and `FetchResultScroll` goes with it.
A red `N digest mismatch(es)` badge joins the errors/warnings badge, kept
separate because it is a runtime result — folding it into the error count would
make that number change meaning after a click. Conformance auto-collapses for an
entry with no findings and auto-opens whenever a verification, a fetch or a new
selection gives it something to say. Its badge is yellow for a warnings-only
entry; green read as "nothing to see".
A dynamic skill states its case once: the prose banner moves into Conformance
and the Resources section is dropped entirely, while the finding still counts
toward the warning total.
The second Copilot finding is fixed as suggested. Frontmatter splitting keyed on
the payload arm rather than the MIME, so a markdown blob kept its frontmatter
while a multi-document YAML file lost its first document. It now keys on an
effective MIME, with `inferMimeFromUri` extracted to `src/utils/` and shared
with `ResourcePreviewPanel` instead of duplicated.
Also: `Verify` gets its own table column so the buttons align rather than
tracking the verdict badge's width; the header actions take
`ListChangedIndicator`'s Refresh style and expand/collapse-all uses the shared
`ListToggle`; the `skills/get` copy no longer implies the entry defines the rule
it breaks; findings move below the JSON they annotate; and section spacing moves
into the `skillSections` accordion variant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
The pane was one scrolling column: SKILL.md sat below the manifest behind a
"View SKILL.md" button, the frontmatter pushed it further out of view, and
nothing could be put away.
It is now a flex column that does not scroll as a whole. Conformance,
Resources, Frontmatter and Skill Resource are four collapsible sections of one
`disclosure` accordion, and the file viewer claims the remaining height and
scrolls internally.
The viewer's flex-basis is 0, not auto, and that is load-bearing: `auto` makes
an item's basis its content height, and a rendered document is ~1,795px for the
data-analysis fixture. That basis joined the sum the container distributes,
over-constraining the column so the shrink factors crushed the siblings —
Conformance to a 2px panel, Resources to zero — with their contents spilling
over the headers below. Collapsing the viewer removed the giant basis, so the
symptom appeared on collapse-then-reopen. Open sections also carry a `mih`
floor, since the disclosure CSS sets `min-height: 0` on an active item.
Frontmatter now follows the displayed file rather than the selected skill.
Both halves come from one `splitSkillFile` call, so the section and the viewer
cannot disagree, and a file with no frontmatter renders no section at all.
Also: SKILL.md loads on selection (a `resources/read` is not a load under
SEP-2640), the manifest highlights on hover with each URI a button that swaps
the viewed file, both whole-skill actions moved to the pane header, and the
Conformance badge goes yellow for a warnings-only entry instead of green.
Flat CSS properties moved out of call sites into theme variants (ThemeText
gains four; a new ThemeTable takes the manifest font size).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
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 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>
- 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>
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>
- skills.ts: validate `frontmatter.name` against the Agent Skills format
(1–64 lowercase alphanumerics and hyphens, no leading, trailing or
consecutive hyphen). Checking only for non-emptiness let `Bad Name` read
as "Conforms" — and the name is not decorative: it must equal the URI
path segment, so a name that cannot appear in a URI is a contradiction
the entry cannot satisfy. Suppressed when the name is absent entirely,
which `missing-name` already reports.
- skills.ts: detect duplicates on the NORMALIZED identity. Containment and
the read that fetches the bytes both treat normalized-equivalents as one
resource, so `skill://demo/SKILL.md` and `skill://demo/x/../SKILL.md`
were passing as two distinct files while naming one. The finding still
reports the raw URI, and two different unparseable URIs stay distinct.
- SkillsScreen: run `checkSkillConformance` on the fetched `skills/get`
entry and treat an error — or an entry answering for a different URI —
as invalid (red), not as "a different snapshot" (yellow). A fresh
point-in-time read excuses a CHANGE; it does not excuse a violation, and
answering with another skill is never a refresh of the one requested.
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>
- SkillsScreen: track "Verify all" batches in a MAP keyed by manifest, not
one slot. A slot remembers only the most recent batch, so A running →
start B → return to A left A's button enabled and a second worker pool
could be started on top of A's first, doubling the concurrency cap. The
round-7 test switched to B but never STARTED B's batch, which is exactly
why it missed this; it now does, and fails against the old single slot.
- test-servers/skills.ts: pass `result` schemas to `setRequestHandler` for
both custom methods, and type the three builders from them, so a shape
change in `toEntry` / `listSkillsPage` fails `tsc` rather than shipping a
fixture that claims to conform. Declared locally rather than imported
from `core/mcp/skillsSchemas.ts`: a fixture validated against the
client's own schema could never catch the client being wrong.
One correction to the review's premise on that second point: the SDK does
NOT require `{ params, result }`, and supplying `result` does not validate
the response. Its own doc on `RequestHandlerSchemas` says `result` is
optional and "no runtime validation is performed on the result" — it types
the handler's return value. The change is worth making for that
compile-time check; it does not do what was claimed.
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>
- skills.ts: stop sorting `resources` arrays recursively. Only the entry's
OWN manifest is a set; `frontmatter` is verbatim arbitrary JSON, so a
custom `frontmatter.metadata.resources` array was being sorted too and
two genuinely different frontmatters compared equal. The manifest sort is
now done once, at the top level, and every other array keeps its order.
- SkillsScreen: invalidate on the whole selected entry, not just its
manifest. The `skills/get` comparison covers `frontmatter`, so a refresh
that changed only a description left a stale "matches skills/list"
verdict on screen — computed against an entry that no longer existed.
Re-verifying after a metadata-only refresh is the cheap direction to be
wrong in; showing a match against a different entry is not.
- SkillsScreen: give each "Verify all" invocation a token, so a finalizer
can only ever clear its own batch rather than any batch with a matching
manifest key.
- SkillsScreen.stories: supply `onGetSkill`. Every story rendered the
"Fetch with skills/get" button, and clicking it threw.
On the batch token: the A → B → A sequence the review describes is not
actually reachable, because the button is disabled whenever the in-flight
batch's key matches the selection — returning to A finds it disabled, not
clickable. The token is defence in depth for the same property, and the
test asserts what is really guaranteed rather than the unreachable path.
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>
Most of this round re-reported code the previous commit already changed
(it generated 0 new comments). Two findings were genuinely open.
- SkillsScreen: give "View SKILL.md" and "Fetch with skills/get" the same
attempt ordering `verifyRow` got in round 3. Both were still
last-completion-wins within one manifest: a double click whose older
request settled last replaced the newer result. One shared `isStale`
helper now covers all three actions — a result is dropped when its
manifest was invalidated OR a later click already wrote.
- test-servers/skills.ts + docs: stop calling all three edge cases
"non-conforming". `resources: "dynamic"` is a LEGAL wire form for
generated content — it is unverifiable, not invalid, which is exactly
why the checker reports it as a warning. The prose contradicted the
code it was describing. Now: one legal-but-unverifiable case and two
actual violations.
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>
Two of these reverse round 4, which asked for the opposite. Where the
readings conflict I took the permissive one: a wrong tightening rejects
conforming servers, a wrong loosening only fails to report something.
- skills.ts: stop requiring the `skill:` scheme. SEP-2640 says a server
SHOULD use `skill://` and explicitly allows a domain-native scheme, so
round 4's requirement handed a conforming `github://…` skill a false
`malformed-uri` and skipped its name and root checks. Containment is
scheme-independent — it compares against THIS entry's own root — so the
traversal fix that requirement came packaged with is unaffected.
- skills.ts: the 512-entry and 16 MiB limits are warnings. Both are
SHOULD NOTs for a server and MAYs for a host, so calling them errors
contradicted the "error = a MUST was broken" contract round 4
established and told authors a permitted skill was invalid.
- skills.ts: `skillEntriesMatch` compares canonically — keys sorted,
manifest sorted by URI. `JSON.stringify` treated key order and manifest
order as differences, though neither carries meaning.
- SkillsScreen: a differing `skills/get` is "a different snapshot"
(yellow), not a disagreement (red). The SEP defines it as a fresh
point-in-time read, so a skill that changed since the listing
legitimately differs.
- useServerCommands: route `onGetSkill` through the command-scoped OAuth
recovery like every other server command. An expired authorization
rendered an error and stopped there — no reauthorization, no retry.
- test-servers/skills.ts: stamp the modern base result envelope
(`resultType` / `ttlMs` / `cacheScope`) on every skills result. The SDK
stamps it only for methods in its own codec, and `skills/*` are
consumer-owned, so a 2026-era connection was receiving results without
it. Unconditional rather than per era: the modern leg builds a server
per request, so there is no era to branch on at registration time, and
on legacy they are three members no codec inspects.
- composable-test-server: remove the `skills.directoryRead` option. It was
publicly settable and advertised a method nothing serves, producing the
exact false capability the fixture exists to help catch. `skills` is now
a plain boolean; the flag returns in phase 3 (#2248) with its handler.
Still not changed: whether a modern `skills/list` RESULT SCHEMA must
require that envelope. Stamping it server-side is free; requiring it
client-side would reject servers on a reading nobody has checked against
the normative text. #2248 owns settling 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>
- 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>
- 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>
- 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>
- 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>
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>
The panel tests stopped at the disabled button, which leaves the wire claim
unverified: a screen that flagged the draft and still fired its callback
would have satisfied every one of them.
`ToolsScreen` and `AppsScreen` now assert `onCallTool` / `onOpenApp` are not
called for a refused draft, and ARE called once the value is rewritten with
the declared type — so the draft is demonstrably what blocks the dispatch
rather than an inert screen.
The plain and "Run as task" paths need no separate case: the panel has one
Execute button whose disabled state does not depend on the toggle, and the
callTool/callToolStream split happens later in App.tsx, downstream of the
callback a refused draft never reaches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's eighth pass, three comments, all real.
- The precision check now reads the **source text**. Inferring the literal from
`String(value)` was wrong in both directions: it accepted every full-form
integer at or above 1e21 (`1000000000000000000001` parses to `1e21` and is
written back as `1e+21`), and refused `2^54`, which is written out in full
and exactly representable. `hasImpreciseIntegerLiteral` scans the draft for
numeric tokens — skipping strings, so a digit-string value is untouched — and
compares `BigInt(literal)` against `BigInt(Number(literal))`, which is exact.
- `""` is now reported as a surviving cursor for `tools/list` only. It builds
its params with `cursor !== undefined` and carries the empty string
deliberately; the other four list adapters use a truthiness check and drop
it, so the editor showed `{"cursor":""}` while `{}` went out. The asymmetry
looks like a latent bug in those four, but this function's job is to describe
what the dispatch does.
- `callTool` runs every string-valued argument through `convertToolParameters`,
so `{"count":"2"}` against a numeric schema is sent as `{"count":2}`.
`reshapedReplayParam` now takes the tool and detects that by running the same
conversion and comparing, rather than restating its rules — the two cannot
drift. `tools` is threaded to `ProtocolEntry` for it, optional throughout: an
unknown tool skips the check rather than blocking a replay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Extracts the Ace integration #2094 carried inline into a shared
`elements/JsonEditor`, and renders it on every surface where the web
client types or displays JSON.
The element is deliberately text in, text out. The two editing contracts
above it disagree about what an unparseable draft means — JsonObjectInput
keeps the last valid object and says nothing to its parent, SchemaJsonField
reports `undefined` and blocks submission (#2020) — so parsing, and the
draft/value split that goes with it, stays with each caller.
Surfaces: SchemaJsonField (inherited by all four SchemaForm consumers), a
new "Edit as JSON" switch for the whole arguments object, the server.json
File Contents box, the raw JSON-RPC request box, ContentViewer's JSON
branch (read-only, inherited by its eleven consumers), and a new "Edit and
replay" modal on a Protocol entry.
Ace read-only replaces the CodeHighlight JSON path and the Prism `json`
grammar loader is dropped: folding, not colorization, is what it adds, and
two highlighters for one language drift. `wrap={false}` callers and untyped
text that only looks like JSON keep the plain renderer.
Closes#2151
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
The web client had exactly one connected-flow smoke and it was the Apps
tab. `smoke:web:browser` proves the bundle boots and paints but never
connects; `smoke:web:app` / `smoke:web:elicit` connect but only drive the
Apps surface. So the three highest-traffic tabs were unexercised through
the prod bundle end to end — everything covering them (unit tests,
Storybook, the integration project) mocks or bypasses the prod server, so
a regression living only in the built bundle or in the prod Hono
backend's wiring of a tab passed all of them.
`scripts/smoke-web-tabs.mjs` drives all three against one server on one
browser launch (~5s added wall-clock): Tools runs `list_items` and
asserts its `structuredContent` section rendered (#1908), Resources reads
`events` with the template list asserted separately — `resources/list`
and `resources/templates/list` are different calls and either can fail
alone — and Prompts fetches the argument-less `simple_prompt`.
Every assertion is on a `data-*` attribute, never on copy, mirroring the
`data-app-status` precedent: each screen now carries a readiness contract
(`data-tool-count` / `data-call-status` and its Resources / Prompts
equivalents), tabulated in the web README and pinned by screen unit tests
so a rename fails loudly there rather than as an opaque timeout here.
The deep-link connect step is extracted to `lib/deep-link-connect.mjs`
rather than copied a third time. That matters because `parseDeepLink`
ignores a link it cannot validate rather than reporting one, so a drifted
copy fails as an opaque connect timeout in whichever smoke was not
updated. `smoke:web:elicit`'s hand-rolled copy is retired onto it.
Chromium-only and deliberately not in `ENGINE_SMOKES`: the engine tiers
exist for the MCP Apps sandbox's genuinely divergent primitives, and
these tabs are ordinary React and Mantine. Network, Protocol, Logs and
Tasks remain uncovered — a stated gap, not an oversight.
Closes#2148
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 4 on #2109. A `tools/list` may repeat a name (#1957) and
ToolsScreen already disambiguates those rows with a key of its own, but the
detail panel handed SchemaForm only the name — so switching between two
same-named tools looked like the same entity and left the first one's enlarged
fields (and its in-progress number/JSON text, which predates this PR) in place.
ToolDetailPanel now takes an optional `resetKey`, defaulting to the name for
callers whose list cannot repeat one, and ToolsScreen passes the row key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1 on #2109.
- Activating Enlarge from the keyboard unmounted the focused button and left
focus on the document body, so the next Tab restarted at the top of the page.
The text area now takes focus on mount, caret after the text already typed.
- Every field's enlarge button carried the same "Enlarge" name, so a form with
several string arguments exposed indistinguishable controls. Each now reads
"Enlarge <field label>". Queries that matched a field by its label alone are
scoped to the textbox role, since the button's name now contains it too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Let a server hand a form `elicitation/create` to an MCP App and return the
app's ordinary `ElicitResult`, with the native elicitation form as the
fallback on every failure.
No second extension, no custom method, and no custom result shape: the only
new wire surface is a nested `elicitation` flag on the existing
`io.modelcontextprotocol/ui` extension on each side, plus
`_meta.ui.resourceUri` on the request. App rendering is selected only when
all four gates hold — client `elicitation.form`, client MCP Apps MIME type,
the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://`
URI on the request.
Only the web client advertises the nested client-side setting, and only
because it has a sandbox renderer: supplying
`InspectorClientOptions.appElicitation` is what opts a client in, so CLI and
TUI keep advertising the MIME type without ever claiming they can resolve an
elicitation through an app.
Routing lives in the one funnel both entry points already use
(`enqueuePendingElicitation`), so the inbound handler and the MRTR driver
cannot diverge. Ownership is request-scoped — a per-request id keys the
renderer, iframe and bridge — so two concurrent elicitations can never
resolve through each other's bridges. An explicit `decline`/`cancel` is a
completed elicitation and goes back to the server; everything else (absent or
malformed metadata, resource/sandbox/bridge failure, an app with no
elicitation capability, a timeout, an invalid result or one that fails the
requested schema) falls back.
The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet
consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5)
predates that PR. `core/mcp/appElicitation.ts` and
`AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for
deletion once a release containing it ships. One consequence needed its own
seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that
strips `elicitation`, so an app that correctly advertises it would look like
one that did not, silently turning every negotiated elicitation into a
fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and
prefers the bridge's own value once it carries the key.
`AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so
the same renderer and bridge factory serve an App tool and an elicitation
without either faking the other's shape.
Adds the public fixture (`app_choose_option` + a self-contained
`ui://demo/choose-option.html` app covering accept/decline/cancel), its two
showcase configs, and `smoke:web:elicit`, which drives the negotiated path
end to end in headless Chromium and then the same tool against a server that
never advertised the capability, asserting the native form takes it.
Closes#1854
Signed-off-by: cliffhall <cliff@futurescale.com>
#1961 keyed the sidebar's React children by source position, but everything
downstream still identified a tool by `tool.name`. With a `tools/list` that
repeats a name, both copies highlighted at once, the click guard made the
other copy a no-op, and `tools.find((t) => t.name === …)` always resolved to
the first — so a later copy rendered but could never be opened.
Thread the row key through selection instead:
- `utils/toolUtils.ts` now owns `toolRowKey` (moved out of ToolControls) and
adds `findToolByRowKey`, which compares computed keys rather than parsing
one so the two can't drift.
- `ToolControls`: `selectedName` → `selectedKey`; the highlight and the click
guard compare keys, and `onSelectTool` emits the row key.
- `ToolsScreen`: `ToolsUiState.selectedToolName` → `selectedToolKey`, with
both lookups going through `findToolByRowKey`. Renamed through
`screenUiState.ts` and `App.tsx`'s `onToolsUiChange`.
`onCallTool` still sends `selectedTool.name` — the duplicated name is the
genuine wire identity; only the UI identity needed to be unique.
Also prefixes the excluded (SEP-2243) rows' keys: they render into the same
`Stack` as the list rows but index within their own list, so an excluded tool
sharing a name and index with a listed one collided in the key space.
Closes#2001
Signed-off-by: cliffhall <cliff@futurescale.com>
A floated promise is invisible at review time — the call reads like an
awaited one minus four characters — and the unhandled rejection it
produces surfaces in a different test, in a different file, as a stack
pointing at SDK internals. In #1947 two un-held `callTool` calls made
`npm run ci` unpassable and took a full investigation to attribute; the
fix was two lines. `@typescript-eslint/no-floating-promises` catches the
class outright, so enable it at `error` everywhere.
The rule is type-aware and `tseslint.configs.recommended` is not, so each
scope's config gains a parser project. The four clients name their own
tsconfig projects (both of them — `src` and `__tests__`/`server` live in
different ones). The root scope has no tsconfig at all, so add
`tsconfig.lint.json`: a lint-only project covering `core/**`,
`test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and
gating nothing. It sets `moduleResolution: bundler` deliberately — under
NodeNext `core/`'s extensionless imports fail to resolve, every import
degrades to `any`, and the rule silently stops seeing promises at all.
Measured surface, and the fixes:
- core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono
invokes subscribers with a bare `subscriber()`, so a promise returned
from the listener is floated one layer up; `closeAbortedStream` owns
the rejection where it can actually be owned.
- tui (5) — `handleDisconnect` had no catch of its own and its only
caller is a key handler that cannot await, so a failing disconnect was
a live unhandled rejection; it now surfaces the message the way
`handleConnect` does. The rest are `void` at callers that genuinely
cannot await, over callees that already own their failures.
- web (92) — 83 are Storybook `expect(...)`, which is instrumented and
returns a promise, now awaited (`scrollAreaStoryAssertions` is async
for the same reason). Two tests were asserting against a provider call
they never awaited. `open(url)` in both web servers now warns instead
of escaping a synchronous callback.
- cli, launcher — already clean.
Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s
to ~19s, inside `validate`. Narrowing the loaded projects is the lever if
that needs reducing later; dropping the rule is not.
Closes#1959
Signed-off-by: cliffhall <cliff@futurescale.com>
The SDK validates a list result as a whole, so a single non-conforming entry
rejected the entire response and the panel showed "Couldn't load resources"
with zero items — the exact opposite of what a debugging tool should do with a
server that is 90% right. Reported against a PHP server whose empty
`annotations` object reaches the wire as `[]` rather than `{}`.
`listAll*` now falls back to a lenient re-walk when — and only when — the SDK
rejects a result it received (`InvalidResult` / `UnsupportedResultType`), keeps
the entries that validate, and reports the rest. A conforming server takes the
strict, cache-aware path exactly as before.
Salvaging is not accepting: the dropped entries are named with their reason in
a warning above the list, and the Protocol entry is still marked rejected, so
the wire truth survives. When every entry validates, the original error is
rethrown rather than swallowed — the rejection was about something the
per-item pass cannot explain.
`isClientDecodeRejection` moves to the new module and is now shared with
managedListState, which had the only copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
A list load that failed — a transport error, or a result the SDK's era
codec rejects as invalid — reported nothing. The connect-time refresh is
fired with no caller to await it (`void this.refresh()`), so its rejection
became an unhandled rejection: the header said "Connected", the panel
rendered an empty list indistinguishable from a server that has none, and
the failed exchange showed in the Protocol panel as a clean success.
Two surfaces now tell the truth:
- ManagedListState records the failure as observable state (`getError` +
an `errorChange` event) and still re-throws, so App's auth-recovery
wrapper keeps working. The four `useManaged*` hooks expose it via a
shared `useManagedListError`, and the Tools/Prompts/Resources sidebars
render a `ListLoadError` alert with the reason and a Retry.
- A response the client refused is attributed back to its Protocol entry
(`markResponseRejected` → `responseRejected` → `MessageEntry.clientError`),
which now renders an Error status and a "Rejected by the Inspector"
alert rather than the green resultType badge alone. The SDK gives no
request id with a decode failure, so the id is recovered by correlation;
see the comment on `markResponseRejected` for why that is exact.
Also stops `listAllTools` from dropping a failing excluded-tools walk on
the floor — it stays non-fatal but is logged.
Closes#1953
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
Codebase-wide sweep bringing the web client into strict compliance with the AGENTS.md TypeScript and Mantine/React conventions: ~170 inline 2+-static-prop elements extracted to named .withProps() constants across ~77 files, the AGENTS.md rule wording tightened to match (static-prop boundary + Accordion/Transition/data-*/Box exemptions), a real ScreenStage Box->Flex layout regression fixed (direction: column) with a Storybook regression guard, and an App.tsx double-cast tightened. Closes#1762.
* core: refactor serverList.ts object↔Record casts into typed helper (#1690)
Remove the four pre-existing `as unknown as Record<string, unknown>` /
`as unknown as MCPServerConfig` casts in core/mcp/serverList.ts:
- stripInspectorFields: rewrite as clone + `delete` over INSPECTOR_FIELD_KEYS.
Every Inspector-extension key is optional on StoredMCPServer, so the result
is still (a subtype of) MCPServerConfig with no cast.
- oauth-strip in extractSecretsFromStored: `oauth` is optional, so a plain
`delete stripped.oauth` is legal — drop the cast.
- mcpConfigToServerEntries: route the one genuinely-needed widening through a
single documented `toRecord(value: object)` helper. Taking `object` makes the
single `as Record<string, unknown>` legal (no TS2352), so the double cast
disappears from the call site.
Net: zero `as unknown as` in serverList.ts; the one remaining structural cast
lives inside the audited toRecord helper. Behavior-neutral — existing coverage
(79 serverList tests) unchanged; full `npm run ci` passes.
Closes#1690
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* core+web+test-servers: eliminate/justify remaining `as unknown as` casts (#1690)
Codebase-wide follow-on to the serverList refactor: audit every `as unknown
as` double cast outside tests and either remove it or ensure it carries a
justifying comment (the review bar is "no *unjustified* double cast").
Removed (widened via a shared helper or narrowed to a single cast):
- Promote the serverList `toRecord(value: object)` widening helper to
core/json/jsonUtils.ts and reuse it at core/mcp/node/config.ts (normalize
loop), core/mcp/remote/node/server.ts (pino level dispatch), and
clients/web/src/App.tsx (window global lookup).
- inspectorClient.rawWireRequest: type the frame as `JSONRPCRequest` and
narrow only `params` with a single structural cast (the SDK's `_meta`-typed
params is what blocked direct assignment).
- toolOutputValidation: SDK `outputSchema` → `JsonSchemaType` is a single cast.
- test-server-fixtures: the optional `task` handle is reachable with a single
cast (target field is optional).
Justified in place (genuinely unavoidable — private SDK internals, partial
interface stubs, third-party/nominal bridges): the inspectorClient private-map
accesses, resourceContext + tokenAuthProvider partial `OAuthClientProvider`
stubs, the ext-apps peer bridge, modern/legacy `_requestHandlers` bypasses,
and the happy-dom `matchMedia` test mock.
Behavior-neutral. Full `npm run ci` passes (validate, ≥90 coverage gate,
smoke, Storybook).
Refs #1690
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* address @claude review nits: consistent cast justifications + drop reflow noise (#1690)
- test-servers/src/modern-tasks.ts: add an inline justification above the
`_requestHandlers` double cast, mirroring the sibling in
composable-test-server.ts (was leaning on the function JSDoc only).
- Annotate the partial-`AppBridge` mock double casts in the story fixtures
(AppsScreen ×2, AppRenderer, InspectorView) so no non-test-file double cast
is unjustified.
- inspectorClient.ts: revert unrelated Prettier reflow hunks (protocolVersion,
task-content array, timeout-reject wrap, reconnect guard) so the diff is the
single rawWireRequest cast change only.
Behavior-neutral (comment/format-only). `npm run ci` green apart from a known
flaky redaction test whose assertion collided with a random request id.
Refs #1690
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* actually revert the reflow noise this time (#1690)
The reflow reverts in cc187ea were silently undone by a `prettier --write` I
ran on the same file right after, so the four Prettier-only hunks from 6a3afa4a
were still in the diff (caught in re-review). Revert them for real now — and
without re-running Prettier on the file:
- core/mcp/inspectorClient.ts: restore the protocolVersion line, task-content
array, timeout-reject call, and reconnect guard to their origin/v2/main form,
so the file's diff is only the single rawWireRequest cast change.
- test-servers/src/modern-tasks.ts: restore the ModernTaskStatus union to its
one-line form (the leading-`|` reflow was also Prettier churn), leaving only
the new `_requestHandlers` justification comment.
Formatting-only; `npm run validate` green.
Refs #1690
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Tools: x-mcp-header annotation tooling + -32602 unknown-tool rendering (#1632)
Card 9/10 of the SDK-v2 + New Spec stack. Adds SEP-2243 `x-mcp-header`
tooling to the Tools tab and adjusts result rendering for SDK v2's
unknown-tool behavior, and bumps the SDK to 2.0.0-beta.5.
- **`core/json/xMcpHeader.ts`**: a faithful port of the SDK's (non-public)
`scanXMcpHeaderDeclarations` — validates every SEP-2243 constraint
(primitive types incl. `number`, RFC 9110 token, case-insensitive
uniqueness, statically-reachable `properties`-only chain) and derives the
mirrored `Mcp-Param-{Name}` params for a kept tool.
- **Excluded tools**: the SDK silently drops tools with invalid annotations
from `tools/list`. InspectorClient now re-lists the RAW (unfiltered) list
and scans it to recompute the excluded set with reasons — gated to the
modern non-stdio era (matching the SDK's gate), recomputed on every
aggregate refresh, surfaced via a new `excludedToolsChange` event +
`getExcludedTools()` and `useInspectorClient().excludedTools`. The Tools
sidebar lists them struck-through under an "Excluded (SEP-2243)" divider
with the reason on hover.
- **Mirrored args**: ToolDetailPanel shows a "Mirrored request headers"
section listing each arg → `Mcp-Param-{Name}` (with the browser-omits note).
- **-32602 rendering**: SDK v2 rejects an unknown-tool call with `-32602`
instead of an `isError` result, which previously rendered nothing. New
ToolCallErrorPanel renders a thrown tool-call error, with a targeted
"Unknown Tool" hint for `-32602`.
Adds unit + Storybook + integration coverage (a real modern server with an
invalid-header tool fixture) for every path. `npm run ci`-equivalent checks
green.
Closes#1632
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test-servers: x-mcp-header modern showcase config + preset (#1632)
Register the `invalid_header_tool` fixture as a preset and add
`xmcpheader-modern-http.json` — a modern HTTP showcase serving echo, a
valid-header `get_weather`, and the invalid-header tool — so the Tools-tab
x-mcp-header tooling (mirrored headers, excluded-tools surface, -32602
rendering) can be exercised manually. Documented in the README alongside the
other per-SEP showcase configs. Smoke-tested end to end in the web client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: add #1632 proof screenshots to pr-screenshots
End-to-end web-client verification of the x-mcp-header Tools tooling against
the modern showcase server: excluded-tools surface + reason, mirrored-headers
section, and the -32602 unknown-tool error panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* Respond to review: disambiguate -32602, doc/search nits (#1632)
Addresses the review on PR #1735:
1. -32602 "Unknown Tool" mislabel: `-32602` is generic Invalid params, so a
known tool called with bad arguments hit the same code and rendered under
the definitive "Unknown Tool" heading. Replace `isUnknownToolError` with
`classifyToolCallError(code, message)` → "unknown-tool" only when the
message names it (not found / unknown tool / …), else "invalid-params"
(new "Invalid Parameters" heading + schema hint), else "generic".
2. Double pagination walk: expand the `listAllTools` comment to document that
the excluded-tools recompute is a deliberately un-cached second raw walk
(doubles list round-trips on modern non-stdio, runs even on a cache-served
aggregate) — accepted for a debugging tool, no-op on legacy/stdio.
3. Doc nit: `scanXMcpHeaderDeclarations` no longer claims to visit "$ref
targets" — reworded to note `$ref` is never followed (refs resolve into the
unreachable `$defs` body).
4. Search nit: the excluded-list filter now matches name AND title, matching
the main tools filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* Tighten unknown-tool matcher to be tool-scoped (#1632 re-review nit)
The re-review noted the -32602 disambiguation was symmetric: broad markers
("does not exist", "unrecognized") could inversely mislabel a genuine
invalid-params message (e.g. "property 'region' does not exist") as "Unknown
Tool". Scope the not-found/does-not-exist family to require the word "tool" in
the same clause (matching the SDK's "Tool <name> not found"); keep "unknown
tool" / "no such tool" as standalone unambiguous markers. Adds a
tool-less-"does not exist" test asserting it classifies as invalid-params.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* Add trigger_invalid_params showcase tool + Invalid Parameters screenshot (#1632)
Adds a `trigger_invalid_params` spec-error trigger to the modern showcase
server: the injector answers its `tools/call` with a real `-32602 Invalid
params` JSON-RPC error whose message does NOT name an unknown tool. This lets
the "Invalid Parameters" branch of the tool-call error panel (added in the
review response) be exercised live, distinct from the unknown-tool `-32602`.
- `SPEC_ERROR_TRIGGERS` gains the `trigger_invalid_params` entry (HTTP 200,
in-band -32602) + preset registration.
- Enable `injectSpecErrors` on `xmcpheader-modern-http.json` and add the tool.
- README + pr-screenshots README document it; adds the live "Invalid
Parameters" proof screenshot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>