Commit graph inspector/clients/web/src/components/groups
Author SHA1 Message Date
cliffhall
f5a3ebfc8b feat: make Conformance the one place a skill verdict is reported (#2263)
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>
2026-09-05 23:45:14 -04:00
Cliff Hall
5af54301a6
Merge branch 'v2/main' into v2/feat/2234-skills-extension 2026-09-05 12:50:10 -04:00
cliffhall
dc2dd2bfcf fix(web): remove the wall-clock race in the import-JSON debounce guard test
`ServerImportJsonModal` > "guards against a live edit made before the
debounce re-validates" opened its window on the real clock: it pasted
invalid JSON and clicked Add Server, relying on less than
VALIDATE_DEBOUNCE_MS (300ms) of wall time elapsing in between. When more
did, the debounce landed first, `canAdd` went false, and the click hit a
disabled button — a no-op that sets no submit error, so the
`findByText(/Fix the validation errors/)` timed out. The first assertion
(`onAddServer` not called) still passed, which is why the failure read as
mysterious rather than as a disabled button.

Run the test on fake timers end to end instead. The first validation is
landed explicitly with `advanceTimersByTimeAsync(VALIDATE_DEBOUNCE_MS)`
— imported from the hook, so the test cannot drift from the value it
depends on — and after the second paste the timers are simply not
advanced. The pending re-validation therefore cannot land at all, the
window stays open by construction, and the final assertion becomes a
synchronous `getByText`.

Verified with a throwaway probe holding both shapes side by side with a
deterministic 400ms stall injected between the paste and the click: the
old shape fails exactly as reported, the new one passes.

No timeout was widened and no production code changed.

Closes #2250

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQgwZ1kGzhkdkMJ81JVg42
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 00:57:03 -04:00
cliffhall
27fafe6acb fix(web): make the Connection Info extension sections say something
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>
2026-09-04 22:57:51 -04:00
cliffhall
63623b754b feat: support the Skills extension (SEP-2640) with digest verification
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>
2026-09-04 20:45:15 -04:00
cliffhall
4429c0c0bf fix(auth): keep the failure path total, and align the docs with the new order (#2144)
Copilot review round 14. Two code fixes and a documentation sweep.

- `buildRevocationRequest` ran outside `revokeToken`'s best-effort try.
  `encodeURIComponent` throws on a lone UTF-16 surrogate — valid JSON, so it
  can reach there from a persisted client id or secret — and every caller has
  already cleared its local state by then, so a rejection instead of a `failed`
  outcome would break the documented guarantee. Construction is inside the try
  now, and the catch reports `params.endpoint` rather than a `url` that may not
  exist on that path.
- The fixture's Basic decode turned a malformed percent escape into an Express
  500, reporting a bad credential as a server fault rather than as the
  `invalid_client` 401 the endpoint means. Decoding failure is failed
  authentication.
- Ten places still said revocation happens *before* the clear, which the
  reorder in the previous commit made false: the settings-form description, the
  `--no-revoke` help text, both READMEs, `docs/mcp-server-configuration.md`,
  three JSDoc blocks, and the PR body. Swept, and the ordering is stated where
  it is load-bearing rather than asserted in passing. Also merged the two
  adjacent JSDoc blocks on `InspectorClient.clearOAuthTokens`.

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>
2026-08-28 05:55:00 -04:00
cliffhall
65fd2b9e35 feat(auth): revoke OAuth tokens at the authorization server on clear (#2144)
Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.

core/auth/revocation.ts builds and sends the request; the three clear paths
call it, so the behavior is written once — web's "Clear OAuth state and
disconnect" (both the active and stored-only paths), the TUI's Auth tab,
and the CLI's --relogin. It runs BEFORE the local clear, because the token,
the client credentials and the discovered `revocation_endpoint` all live in
the store the clear is about to empty. The request names the refresh token
when there is one: §2.1 asks the AS to also invalidate the access tokens
issued under the same grant, so one request covers both halves.

Best-effort by construction. Every path returns a TokenRevocationOutcome
rather than throwing: no advertised endpoint means nothing is sent and that
authorization server behaves exactly as before, and a network error, a
non-2xx, a 5s timeout or an unreadable store is reported and nothing more.
Forgetting the tokens is what the user asked for. `lost_authorization_state`
recovery deliberately skips it — it clears a half-finished flow to retry it,
and an authorization that never completed has no grant to revoke.

Client authentication resolves the preregistered slot first, mirroring
BaseOAuthClientProvider.clientInformation; reading only the dynamic slot
would send no authentication for a server configured with `oauth.clientId`.
The Basic credential is byte-identical to the SDK's applyBasicAuth so this
request and the token request cannot present the same secret differently.

Opt out per server with `oauth.revokeOnClear` (default on, only `false`
written to disk) or per CLI run with --no-revoke. Read at clear time rather
than connect time, so toggling needs no reconnect. Turning it off is a
testing affordance: a client that walks away still holding live tokens is a
case a server author may want to reproduce.

test-servers now advertises `revocation_endpoint` and serves POST
/oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1
(revoking a refresh token invalidates the access tokens under the same
grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as
showcase configs.

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>
2026-08-28 00:48:57 -04:00
cliffhall
9516299d8d fix(web): refuse a raw-JSON tool argument the schema would retype (#2171)
The Tools and Apps tabs' "Edit as JSON" editor sent its draft through the
string-to-declared-type conversion `callTool` applies for the widget form, so
`{"count":"01"}` against a numeric schema left as `{"count":1}` — the editor
showed one payload and the wire carried another.

Both JSON-authoring surfaces now refuse such a draft and name the value to
rewrite. Edit-and-replay already did; this makes the Tools and Apps forms
agree with it, which is the consistency #2171's acceptance asks for.

`coercedArgumentNames` and `coercedArgumentsError` move into core/json so the
two surfaces share one predicate and one sentence — the check runs the real
conversion and compares rather than restating its rules, so it cannot drift
from what would actually be sent. Enforcement is opt-in
(`SchemaFormProps.enforceToolArgumentTypes`): an elicitation renders through
the same form and its values are never converted, so enforcing there would
refuse a draft for a reason that is not true of it.

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>
2026-08-27 22:57:29 -04:00
cliffhall
8535ae0c2f fix(core): say why a never-acknowledged subscriptions/listen ended
A modern-era `subscriptions/listen` is a long-lived request: the first
message on its stream must be `notifications/subscriptions/acknowledged`,
and the JSON-RPC `result` for the listen id is reserved as the
graceful-closure marker. A server that answers the listen request with a
bare `result` is saying "acknowledged and closed in the same breath" — the
SDK settles the subscription `graceful` and rejects the pending `listen()`.

The Inspector read that rejection as an ordinary drop: eight re-lists over
roughly a minute, the badge flickering `Reconnecting…`, and a final bare
`Stream ended` that said nothing about why. Accepting the message was
never wrong — it is a well-formed graceful-closure response. The silence
was.

- `core/mcp/subscriptionAck.ts` owns the predicate. The SDK gives this
  close no code of its own — it is the same `SdkError(ConnectionClosed)`
  any pre-ack close carries — so the predicate reads the message, since
  the alternative is refusing to retry a genuinely transient drop.
- The condition is deterministic, so the stream ends on the first
  occurrence instead of spending the whole backoff run. Both routes are
  covered: the one a subscribe/unsubscribe reaches, and the one a
  reconnect reaches when an acknowledged stream drops and the server has
  since started refusing.
- A `"never-acknowledged"` status of its own, not `"ended"` — `ended`
  means an expected close. It surfaces as the thrown error's message, an
  orange "Not acknowledged" badge, and a notice in the panel.
- `subscriptions-never-acknowledged-http.json` reproduces it. It
  acknowledges the first listen that subscribes to a resource and refuses
  every one after, which is what makes the badge reachable by hand.

Closes #2097

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 20:49:55 -04:00
cliffhall
8aa6f01505 fix(web): reject a negative-zero literal, which loses its sign when serialized
Copilot's twelfth pass. The suppressed comment is right and is fixed here; the
inline one is real too, but needs a core API change and is filed as #2171.

`-0` is represented exactly, so the digit comparison had nothing to say about
it — but `JSON.stringify` writes it as `0`, so the loss happens on the way
*out* rather than on the way in. The editor showed `-0` and the wire carried
`0`, and the viewer rewrote a received `-0` while pretty-printing.

`hasImpreciseIntegerLiteral` becomes `findUnsendableNumberLiteral`, checking
`Object.is(Number(literal), -0)` for every numeric spelling — `-0`, `-0.0`,
`-0e1` — before the integer-only digit check. Returning the offending literal
rather than a boolean lets the message name it, which is more use than either
of the two rules behind it and matches how the duplicate-key message reads.

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>
2026-08-27 06:11:23 -04:00
cliffhall
1b28fc8218 fix(web): detect duplicate object keys, which JSON.parse drops silently
Copilot's eleventh pass: three comments, one defect in three places.

`JSON.parse` accepts duplicate member names and keeps the last without
complaint, so `{"role":"user","role":"admin"}` becomes `{"role":"admin"}` —
a document that renders, and submits, as *less* than it says. In the read-only
viewer that is the Inspector hiding what the server sent; in a draft it is the
editor showing more than the wire will carry. Nothing downstream could catch
it, because by the time `JSON.parse` returns the evidence is gone.

New `findDuplicateObjectKey` scans the source, scoped per object so
`{"a":{"a":1}}` and `[{"a":1},{"a":2}]` are fine, and compares names by decoded
value so `"a"` and `"a"` collide as they should. It shares its
escape-handling with the number-literal scanner, which now reads strings
through the same helper rather than skipping them inline.

Wired into all four JSON-draft parsers and into the viewer's faithfulness
check, so the same document is refused, or shown unindented, everywhere.

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>
2026-08-27 05:53:18 -04:00
cliffhall
03bf9ece42 fix(web): resolve the replay tool from the draft, and say why a draft is stuck
Copilot's ninth pass, three comments, two of them consequences of round 8.

- The coercion check resolved the tool from the *entry's* name while the modal
  lets `name` be edited, so retargeting an `echo` replay at `add` validated
  against `echo`. The modal now takes the whole tool list and resolves from the
  draft's current name, which is the one the dispatch will look up.

- `IMPRECISE_INTEGER_ERROR` (was `ROUNDED_INTEGER_ERROR`) no longer names the
  safe-integer range, because the range is not the rule: a whole number past
  ±(2^53 − 1) that is exactly representable round-trips and is accepted. It
  talks about exact representability instead, and its doc links the helper that
  actually exists.

- `SchemaJsonField` carries the parser's reason instead of flattening every
  case to "Not valid JSON", which misdiagnosed a document that parses fine and
  holds a number this client cannot send — leaving the user reading about
  syntax while looking at syntactically fine JSON.

The last one had a bug the gate caught rather than the tests: empty text is not
a *problem*, so routing the value through the reason check let `JSON.parse("")`
throw out of the change handler. It surfaced as five unhandled errors across
four suites with every assertion still green, which is why `npm run ci` and not
`npm run test` is the gate. Excluded explicitly, with a regression test.

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>
2026-08-27 05:16:23 -04:00
cliffhall
fcdc65b289 fix(web): read the literal, not the parsed number; project cursors and tool args
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>
2026-08-27 04:56:22 -04:00
cliffhall
8f4aa7f8af fix(web): reject drafts whose digits or types would not survive being sent
Copilot's seventh pass, three comments, all the same class: the editor showing
one value while the wire carries another.

- `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so a
  draft reading `{"id":9007199254740993}` submits `…992`. New
  `hasRoundedInteger`, applied by all four JSON-draft parsers — the raw
  arguments editor, `SchemaJsonField`, the Edit-and-replay modal, and
  `parseJsonObjectDraft`, which Copilot did not flag but shares the defect.

  It is narrower than "unsafe integer": it also requires the number's shortest
  form to be written out in full, because JS stringifies from 1e21 upward in
  exponent form and such a value was typed that way — `{"n":1e308}` is a
  legitimate `_meta` value that `parseJsonObjectDraft`'s tests already pin as
  accepted. It still refuses the rare full-form integer that happens to be
  exactly representable, erring toward refusing a sendable value rather than
  misreporting an unsendable one — the trade `toNumericValue` already makes.

- `getPrompt` runs `convertPromptArguments`, which JSON-stringifies every
  non-string argument, so `{"count":2}` leaves as `{"count":"2"}` while the
  editor still shows a number. `reshapedReplayParam` now rejects a non-string
  prompt argument. Not a workaround: the spec types
  `GetPromptRequest.params.arguments` as `Record<string, string>`, so a string
  is the only thing a prompt argument can be. `tools/call` is untouched —
  `callTool` sends its arguments as given.

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>
2026-08-27 04:34:45 -04:00
cliffhall
0e9069a78d fix(web): reject a reshaped replay param, and forward a declared JSON body
Copilot's sixth pass generated no new inline comments; both suppressed ones
were right.

- `arguments` surviving the key check is not the same as being sent as
  written. `replayProtocolRequest` applies `arguments ?? {}`, so a `null` shown
  in the editor reaches the wire as `{}`; an array or scalar is not nullish and
  rides the cast into a call whose type says it is a named-argument record.
  `reshapedReplayParam` rejects both before Send. `name` and `uri` are
  deliberately not checked there — the dispatch already refuses those with a
  reason the caller shows as a toast, so they fail visibly rather than
  silently.

- `NetworkEntry` never forwarded the body's content type, so the Network tab
  could not reach the declared-JSON branch at all and a malformed
  `application/json` response read as plain text with no sign it was meant to
  be JSON. Forwarded — but only when the declared type *is* JSON: passing it
  wholesale would also route an HTML body into the sandboxed frame and a CSV
  one into a table, which is a change to what the Network tab is and not one
  this makes.

The `NetworkEntry > Auth Success` play function moved off `textContent` for
body assertions. Ace repaints its rows on an animation frame and virtualizes
them, so reading the DOM straight after Reveal returned the previous rows
under load — it passed in isolation and failed in the full run. It now reads
the editors' documents alongside the DOM text, and waits.

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>
2026-08-27 04:17:19 -04:00
cliffhall
4e7537ea76 fix(web): validate the edited replay draft, not just its seed
Copilot's fifth pass: one comment, and the completion of the previous round's
fix. Seeding the editor from `replayableParams` stopped it *offering* a key
the dispatcher would drop, but nothing stopped one being typed in — adding
`_meta` to a `tools/list` draft still let Send close the modal and discard it.

`parseParamsDraft` now takes the method and rejects any key the dispatch would
not read, so Send is disabled with the offending names rather than silently
dropping them. It asks the same `replayableParams` the seed is built from, so
the editor and the dispatcher cannot disagree about which keys survive.

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>
2026-08-27 03:50:57 -04:00
cliffhall
5abe289caa fix(web): seed Edit-and-replay from what replay sends, name each JSON editor
Copilot's fourth pass, one inline plus two suppressed. All three were right.

- The modal presented the whole captured frame as editable, but replay does
  not re-send a frame: it dispatches through the typed InspectorClient
  methods, whose signatures have no room for `_meta` (a captured `tools/call`
  carries `_meta.progressToken`) or for any list-request key but `cursor`. So
  editing `_meta` and pressing Send transmitted something other than what was
  on screen. `replayableParams` now projects the frame down to what the
  dispatcher actually reads, the modal seeds from that, and it names what was
  left out rather than leaving the difference to be discovered. The projection
  lives beside the dispatch switch because the two are halves of one contract.

- `ContentViewer` gains `jsonLabel`, so a read-only JSON editor can say what
  it holds. Every one of them announced "JSON content", and an expanded
  Protocol entry has two while the list has many pairs — leaving a screen
  reader with indistinguishable textboxes and only the visible headings to
  tell them apart. Protocol entries now name theirs per method and direction,
  Network entries per request/response body.

- `isJsonDocument` became `formatJsonDocument`, returning the formatted text
  instead of a boolean. The heuristic path parsed the payload to decide and
  then parsed it again to render; Protocol and Network hand over serialized
  bodies as untyped text and are not virtualized, so a long history paid twice
  per payload per render.

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>
2026-08-27 03:37:03 -04:00
cliffhall
b4083ee5fb fix(web): reject non-finite numbers, and keep the branch picker with the raw draft
Two more from Copilot's second pass, both real.

- Every JSON-draft parser now rejects a value `JSON.stringify` cannot write
  back. `JSON.parse("1e400")` yields `Infinity`, which serializes as `null` —
  so an editor showing `1e400` would have sent `null`, which is exactly the
  misreport `isSerializableJson` was written for. `parseJsonObjectDraft`
  already guarded; the raw-arguments editor and the Edit-and-replay modal did
  not, and neither did `SchemaJsonField`'s `parseJsonDraft` — the last
  predates this PR but is the same defect in the same family, so it is fixed
  with them rather than left as the odd one out.

- Editing a root union's discriminator in the raw document now moves the
  Variant picker. Nothing in `values` names a branch, so the index is held by
  the form and was only re-derived on `resetKey` or a rewritten union; a raw
  edit had no other signal, so `{"kind":"email"}` → `{"kind":"sms",…}` left
  the picker on Email and switching back to the widgets rendered the Email
  branch over SMS values. The current branch is kept when the values identify
  none, since the discriminator is absent every time it is retyped.

The raw editor is keyed by entity only, not by entity-and-branch like the
per-field editors: it holds the whole arguments object, so a branch move must
re-sync it rather than remount it — a remount would reformat the text being
typed and drop the caret to the top.

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>
2026-08-27 03:01:47 -04:00
cliffhall
c9fb461bf0 fix(web): address Copilot review on the shared JSON editor
- Cap the read-only editor's `maxLines` at 200. Uncapped, Ace renders every
  line of a payload and its viewport virtualization never engages — which
  matters because the Protocol and Network lists are not virtualized and keep
  each entry's payload mounted inside its `Collapse`. 200 is chosen so no real
  payload nests a scrollbar inside its host's while the pathological one stays
  bounded; the StructuredOutputPanel `Large` story now pins both halves (full
  payload in the document, only part of it in the DOM).

- Take a disabled editor out of the tab order and give it `aria-disabled`.
  Ace has no disabled state, so its hidden textarea kept `tabindex="0"` and a
  keyboard user could tab into a field every neighbouring control had dropped
  — landing somewhere that looks editable and swallows what they type. A
  merely read-only editor stays focusable, since a payload has to be
  keyboard-scrollable (WCAG SC 2.1.1).

- Reword a confusing comment in the EditReplayModal test.

The `Disabled` story additionally scopes the axe `color-contrast` rule off:
WCAG 1.4.3 exempts inactive components, and axe cannot see that the dimmed
gutter belongs to one because the disabled state lives on Ace's hidden
textarea. Scoped to that one story so the rule still covers everything else.

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>
2026-08-27 02:44:21 -04:00
cliffhall
1a2a36f0b8 feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle
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>
2026-08-27 02:24:22 -04:00
cliffhall
23088e9c69 chore(web): address Copilot review on the tab smoke
Three findings, all valid.

**The read/get stages asserted an RPC status, not a render.** #2148 asks
for the resource content and the prompt messages to *render*, and
`data-read-status` / `data-get-status` flip the moment the call resolves
— so the smoke stayed green with the panel deleted. `ResourcePreviewPanel`
and `PromptMessagesDisplay` now carry `data-testid="resource-preview"` /
`"prompt-messages"`, and each stage waits on the status **and** the
rendered panel. Verified by breaking each testid: the smoke fails with
`reading `events` did not render a preview — last observed:
data-read-status="ok"`, which is the finding stated back exactly.

**`smoke:web:tabs` followed ambient `SMOKE_BROWSER`**, so
`SMOKE_BROWSER=firefox npm run ci` would have run the supposedly
Chromium-only smoke under Firefox — contradicting both this PR's stated
scope and the gated-tier invariant in `run-engine-smokes.test.mjs`. The
npm script now pins `chromium` on **both** halves (install and launch)
and the smoke resolves through `resolveRequestedBrowser(argv, env)`, where
an explicit argument wins over the environment; a new case in that same
test file pins it, including that the smoke stays out of `ENGINE_SMOKES`.

**The AGENTS rules were not mirrored into `.github/copilot-instructions.md`.**
Correct, and worse than an omission: that file's engine bullet asserts the
headless web smokes are "engine-parameterized, not Chromium-only", which
this PR made false. It now names `smoke:web:tabs` as the deliberate
exception, and carries the genuinely review-citable new rule — assert
`data-*`, never copy, and never stop at an RPC status where the ask was
that something render.

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>
2026-08-26 23:35:58 -04:00
cliffhall
f62410a9b3 chore(web): connected-flow smoke for the Tools, Resources and Prompts tabs
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>
2026-08-26 23:09:44 -04:00
cliffhall
4b119c9a95 chore(web): address Copilot review on #2127
- Move REPLAYABLE_PROTOCOL_METHODS / isReplayableProtocolMethod out of
  components/groups/protocolUtils.ts into utils/replayableProtocolMethods.ts.
  It is a pure predicate over a string set, so by the project's own lib-vs-utils
  rule it never belonged under components/ — and keeping it there was what made
  lib/protocolReplay.ts import 'up' into the component graph. Both consumers now
  import from utils/; the predicate keeps its own test.

- Give replayProtocolRequest an exported ReplayClient = Pick<InspectorClient, ...>
  naming the nine methods it calls, so a caller (and the test) can satisfy the
  real contract instead of casting a stub into a whole client.

- Rebuild the protocolReplay fixtures as plain typed MessageEntry values. The
  double cast had been hiding a real defect: MessageEntry.timestamp is a Date
  and the fixture passed a number, so the assertion confirmed a shape
  messagesToLogEntries cannot produce.

- Replace the window casts in authToken.test.ts with Reflect.set /
  Reflect.deleteProperty. No 'as unknown as' remains in either test.

- Drop the stale 'floats top-right' sentence on ReAuthBannerBar, which moved
  verbatim and arrived contradicting the paragraph below it.

Declined: re-homing ReAuthBannerBar's transform/zIndex as a theme variant.
'styles' is the Styles API rather than an inline style, Mantine exposes neither
property as a prop, and a styling change is out of scope for a phase whose
safety argument is that nothing was edited.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 18:24:09 -04:00
cliffhall
b6ed5072f4 chore(web): move App.tsx's module-level block into files (#2127)
Phase 0 of #2126. Lines 239-668 of clients/web/src/App.tsx were already at
module scope — helpers, formatters, constants, and the toast subcomponents,
none of which close over component state — so the whole block moves verbatim
into files. App.tsx drops from 5,303 to 4,892 lines with no behavior change.

Placement follows the lib-vs-utils rule in AGENTS.md: pure computation to
utils/, anything touching the environment to lib/.

EMPTY_SETTINGS and the two loose toast ids deliberately do NOT land in a new
top-level src/constants.ts, which the issue suggested: the web coverage
include is a whitelist naming components/hooks/theme/lib/utils/server, so a
module there would have fallen out of the >=90 gate silently. They go under
utils/ instead.

ReAuthBannerBar is in the block but not in the issue's table — it was added
after the issue was written (#2108). It moves to components/groups/ReAuthBanner/,
beside the banner it wraps.

Every new module is at 100% on all four coverage dimensions. Two v8 ignores,
each justified in place: getAuthToken's typeof-window guard (a happy-dom
inherent path) and replayProtocolRequest's provably-dead default case.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 18:07:40 -04:00
cliffhall
e0ecf58df3 fix: address Copilot review round 42 — let an optional const be opted into
Making every pinned field read-only left an optional one unreachable: the
schema is equally happy with or without it, but the form displayed a value the
user could neither send nor edit. An optional `const` is a yes/no rather than
a fixed answer, so it renders as its single choice, clearable — opting in sends
the schema's own typed value, opting out leaves the property absent. A required
one is still displayed read-only, since there is nothing to decide.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 16:25:57 -04:00
cliffhall
b0b252a5c0 fix: address Copilot review round 41 — const does not demand a property, required-only branches
- `const` constrains a property that is PRESENT; it neither requires the
  property nor acts as a default. It is now supplied automatically only where
  the schema also requires the field — in `collectSchemaDefaults`,
  `applySchemaConstants` and the TUI's initial values alike — so an optional
  `dryRun: { const: true }` no longer turns a valid `{}` call into one that
  asks the server to do something. A value that IS present is still corrected
  to the constant, whatever supplied it, and the field stays read-only.
- Offerability is judged on the base+member merge rather than the raw member,
  so `anyOf: [{ required: ["email"] }, { required: ["phone"] }]` over
  root-declared properties is offered. It was declined, which left the gate
  checking the base alone and accepting `{}` — which that schema rejects.
- The TUI's branch sections keep the schema's own `required` for the seeding
  decision while still rendering every branch field non-required, which is what
  keeps a static form submittable.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 16:08:24 -04:00
cliffhall
48651956e5 fix: address Copilot review round 39 — identify branches by their schemas, cover the deep-link path
- branchesKey is built from the resolved alternatives themselves, not from
  labels and field names: two branches can share both while pinning different
  discriminators or typing a field differently, and a reorder of those went
  unnoticed — the form then displayed one branch and submitted another.
- InspectorView gains a deep-link test with root-union appArgs: they name the
  second branch, and the assertions pin the picker, the read-only
  discriminator, THAT branch's default (not the first branch's), and the
  absence of the other branch's field. The old shallow spread fails it.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 15:37:28 -04:00
cliffhall
85b030cbde fix: address Copilot review round 37 — stale constants, inherited answers, prefixed labels
- The seeding effect re-applies constants as well as filling absences: an
  in-place schema change can move a `const` the user cannot edit, and the
  read-only field then displayed the new value while `values` still held the
  old one — which is what would be submitted. Ordinary defaults are still only
  ever added, so an edited field keeps what the user put there.
- hasMissingIn checks own-property presence: a required argument legally named
  `constructor` resolved to the inherited one and read as supplied, enabling
  a submit the schema rejects.
- The TUI keeps the declared name as a branch field's fallback title. The
  prefix is an internal field NAME, and buildFields labels from its map key —
  so the showcase displayed `__b0__address` where the schema says
  `address`.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 14:50:52 -04:00
cliffhall
f78b541ce1 fix: address Copilot review round 36 — re-derive the branch when the union changes underneath
`branchIndex` is a position, and a tool refreshed in place keeps its
`resetKey` — so a union reordered or rewritten with the same number of
branches left the index pointing at a different shape than the one the values
describe: the picker showed SMS while Execute submitted the email arguments.
The selection is now re-derived from the values whenever the alternatives
themselves change, keyed by their labels and declared fields.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 14:25:07 -04:00
cliffhall
139563f720 fix: address Copilot review round 35 — reseed on an in-place schema change, label every primitive const
- The seeding effect is keyed by the fixed values themselves as well as the
  entity and branch. A tool refreshed in place keeps its `resetKey` and its
  branch while its schema changes underneath, so a newly pinned field was
  rendered read-only and never seeded — leaving a required one permanently
  unsubmittable. The schema object's identity says nothing (callers rebuild it
  every render), which is why the key is derived from what has to be seeded.
- branchLabel stringifies every primitive constant. `hasDiscriminator`
  accepts distinct booleans and nulls, so a `true`/`false` union was
  discriminated perfectly well and still labelled "Option 1"/"Option 2".

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 14:02:22 -04:00
cliffhall
ffa08d76f5 fix: address Copilot review round 33 — keep a cleared field cleared across a branch switch
The carry filter dropped a name whose value was `undefined`, but clearing a
number or JSON field leaves exactly that: the name present with no value, which
is the user's answer rather than an absence. The incoming branch's defaults
then put the field's default back and undid the clear. Own-property presence
alone is now the test.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 13:09:54 -04:00
cliffhall
67e7a4ca1a fix: address Copilot review round 30 — seed a read-only const for callers that do not
Rendering every `const` read-only assumed the caller had already seeded it,
which SchemaForm never did itself: `InlineElicitationRequest` passes its
values straight through and gates submit on hasMissingRequiredFields, so a
required `const` — a root union's discriminator among them — was displayed
and never supplied, leaving Submit disabled with no way to fix it.

The form now reports the fixed values upward once per entity, in an effect (the
parent's `onChange` cannot be called during our render), adding only names
absent from `values` — so a caller that already seeds sees no call at all and
the report cannot loop.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 12:01:08 -04:00
cliffhall
c913168c59 Merge branch 'v2/main' into v2/fix/2123-root-union-schemas
One conflict, in SchemaForm's `resetKey` reset: #2138 added the enlarge-caret
Map to clear there while this branch added the union branch selection. Both
belong in it, so the merged block clears the carets and re-derives the branch
from the incoming values.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 11:33:54 -04:00
cliffhall
d49798c91c fix(web): key enlarge carets by Map, and count maxLength in code points
Two more from review, both about field data the schema controls.

The caret record was a bare object keyed by field name, and field names
come from a server's schema. A field legitimately named `constructor` or
`toString` read back an inherited function rather than undefined, so the
documented end-of-value fallback never fired and that value reached
setSelectionRange. Use a Map, which has no such keys.

JSON Schema counts maxLength in Unicode code points; String.length counts
UTF-16 code units. A field holding one emoji measured 2, so a maxLength:2
field with room for another character was treated as full and refused its
newline. Count code points. The maxLength handed to the DOM input still
carries the HTML attribute's own UTF-16 counting; that predates this and
is left alone.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 10:23:43 -04:00
cliffhall
1d3b973742 fix(web): read the enlarge split from the control, and keep the caret
Two faults in the Enter path, both found in review.

The split read `values[fieldName] as string`, a cast over a
Record<string, unknown> fed by whatever a server's schema declares. A
string field holding a non-string default renders as text and then threw
on `.slice`, so one keystroke crashed the panel. Read the control's own
value instead: always a string, and the exact string the selection
offsets index into.

The caret was recorded only when the newline fit, so pressing Enter mid
value in a field at its maxLength enlarged without one and threw the
caret to the end — a second surprise on top of the newline that did not
arrive. Record it on both paths, `start + 1` after an insertion and
`start` otherwise.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 09:54:49 -04:00
cliffhall
c9054ec058 fix(web): reject the IME keydown that only reports keyCode 229
isComposing covers browsers that fire compositionend after the
committing keydown. WebKit is reported to fire it before, which leaves
isComposing already false and the CJK user back to a stray newline on
every committed word.

Also reject keyCode 229, the pre-isComposing sentinel for a keydown the
IME consumed, which such an event still carries. Deprecated, and kept
anyway: nothing non-deprecated distinguishes that event, and it cannot
swallow a real Enter, which reports 13 — tested both ways so the
sentinel is provably not eating ordinary keystrokes.

The ordering claim is second-hand; the guard is taken on its own merits,
since its downside is bounded at zero and it covers any browser whose
composition events land too late rather than one vendor.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 09:38:52 -04:00
cliffhall
7ec0be0d12 refactor(web): lift the enlarge key handler out of JSX
The handler carried four guards, a preventDefault and a state
transition inline in the TextInput's props, which is what AGENTS.md
rules out ("NEVER use inline code; instead extract to functions in the
same file"). Name it handleEnlargeKeyDown and put it beside
enlargeWithNewline, where the comment can enumerate why each condition
is there rather than sitting between two JSX props.

No behavior change.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 09:18:46 -04:00
cliffhall
4f19ee3238 fix(web): insert the enlarge newline at the caret, not at the end
Enter appended its newline to the end of the value regardless of where
the caret was, so pressing it in the middle of `abc|def` produced
`abcdef` with a trailing blank line rather than splitting the text, and
a selected range was kept rather than replaced. That is a rewritten
value, not just a misplaced caret, and no editor behaves that way.

Split at the field's own selection and replace any selected range, then
mount the text area with the caret just after the inserted newline —
EnlargedStringField takes an explicit caretAt for this. Pointer
activation passes none and keeps falling back to end-of-value, since a
click carries no meaningful position. The maxLength check now prices the
resulting value, so replacing a selection can free the room the newline
needs.

The two maxLength tests were asserting against a stub onChange, which
froze the value and made "it did not change" pass whatever the component
did. They now run on a seeded stateful harness, with a no-maxLength
control so the constraint is shown doing the work.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 09:04:01 -04:00
cliffhall
f02a6161e4 fix(web): don't enlarge on the Enter that commits an IME composition
Enter is also how a Japanese, Chinese or Korean input method commits the
candidate being composed. That keystroke means "accept this word", not
"new line", so the shortcut fired on every word such a user finished —
enlarging the field and inserting a stray newline each time.

Guard on event.nativeEvent.isComposing. The test pairs the composing case
with a negative control dispatching the same event with the flag off, so
a test that stopped reaching the handler cannot pass for the wrong reason.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 08:46:26 -04:00
cliffhall
36cf58fe6c fix: address Copilot review round 18 — empty-string constants, nested branch reset
- TUI missingRequiredFields treats an empty string as supplied when the
  property's own `const` pins it there. The one-option control cannot produce
  anything else, so reporting the seeded value as missing made such a branch
  permanently uncallable.
- A nested SchemaForm is reset by the branch-aware key too. Two outer
  alternatives can both carry the same nested object field, and a nested form
  left mounted across an outer switch kept displaying the inner branch chosen
  for the other one while the newly seeded values described another shape.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 06:55:20 -04:00
cliffhall
09914772ea fix: address Copilot review round 15 — unevaluable const siblings, $ref fields, per-branch draft keys
- rootUnion: a merged declaration pairing a `const` with an assertion this
  module cannot evaluate (`minimum`, `pattern`, …) is declined. Proving the
  conjunction safe is the requirement, not disproving it — `minimum: 10`
  beside `const: 1` is as unsatisfiable as a type mismatch.
- declaresAnyFields counts a `$ref`: its shape is unknown rather than empty,
  so `anyOf: [{ $ref }, { $ref }]` no longer reports an App tool as
  input-free and auto-invokes it with `{}`.
- SchemaForm keys its draft-holding fields by the active branch as well, so a
  half-typed `-` or an unparsed JSON draft cannot survive a switch into a
  same-named field of the incoming branch — with both parent values
  `undefined`, nothing else tells the field the entity changed.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 05:49:43 -04:00
cliffhall
85b8e4601c fix: address Copilot review rounds 7 and 8
- rootUnion: a `oneOf` demands that EXACTLY one alternative match, which
  flattening cannot preserve, so it is offered only when a discriminator makes
  the alternatives mutually exclusive; `anyOf` makes no such claim and is
  unaffected. An empty `additionalProperties` schema is the equivalent of
  `true` and no longer declines a union.
- selectBranchIndex: falls back to the branch whose own required fields the
  values supply, so an undiscriminated union's values open the picker on the
  shape they describe rather than on the first branch.
- admitsNull: honors a sibling `nullable: true` beside `const: null`.
- SchemaForm: a value is carried only where the incoming branch leaves the
  root's declaration as it found it; anything it declares itself is reset,
  since it may type the name differently from where the value was typed.
  `hasOwn`/`fromEntries` throughout, so `constructor` is not read as a
  supplied value and `__proto__` is not dropped into the prototype setter.
- TUI: the same prototype-safe construction on the way out of the form, and
  the chosen branch's `required` list is checked at submit — rendering a
  branch's fields optional makes the FORM satisfiable, not the call, so the
  missing names are reported rather than a known-invalid call being sent.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 03:54:24 -04:00
cliffhall
c15e30414f fix: address Copilot review round 6 — additionalProperties, annotations, prototype-safe records
- rootUnion: decline a union that adds fields under a restrictive root
  `additionalProperties`. The keyword constrains what its SIBLING
  `properties` does not name, so the original schema admits none of the
  branch fields; flattening moves them beside the keyword, where they read as
  allowed and the form would submit what the schema forbids.
- `default` joins the annotation set: it constrains nothing, so two
  declarations suggesting different initial values no longer conflict.
- Property records are read with `Object.hasOwn` and built with
  `fromEntries`/`defineProperty`. `properties` is a JSON record, so
  `constructor` is a legal argument name that `in` reported as a collision
  the root never made, and `__proto__` is one that assignment would drop
  into the legacy prototype setter instead of keeping as a field.
- SchemaForm: a value survives a branch switch unless BOTH branches declare
  the name. A root argument the outgoing branch merely specialized is still a
  root argument in a branch that inherits it, so dropping it erased a valid
  value.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 02:56:55 -04:00
cliffhall
7e4af5ed34 fix: address Copilot review round 4 — allOf faithfulness, const null, branch-aware seeding
- rootUnion: an `allOf` member is foldable only when it states nothing beyond
  what the merge applies (`type`/`properties`/`required` plus annotations),
  so a nested `anyOf`, a `not`, or an `additionalProperties` can no longer
  be erased along with the keyword; members are checked against the
  ACCUMULATED merge, so two of them contradicting each other is caught even
  when neither contradicts the root.
- admitsNull: `const: null` admits null, so a required field pinned to it is
  no longer seeded with the one value it accepts and then reported missing.
- collectSchemaDefaults takes the values a caller is about to overlay, and
  seeds the branch THEY identify — the App deep link passes its `appArgs`, so
  branch 0's defaults can no longer sit invisibly under branch 1's arguments.
- The branch inference moves into core as `selectBranchIndex`, shared by the
  seeding and the form's picker so the two cannot disagree.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 02:24:17 -04:00
cliffhall
1a271e4931 fix: address Copilot review round 3 — conjunctive keywords, unflattenable allOf, branch inference
- rootUnion: a branch restating a constraint the root states differently is
  declined (keywords at one level are conjunctive and no intersection is
  computed here), while annotations may disagree freely. An `allOf` with a
  member it cannot fold in — JSON Schema's boolean form, or a $ref whose
  constraints are unknown rather than absent — is left intact instead of
  merged-and-stripped, which would turn an unsatisfiable schema into a
  fillable form.
- rootUnion: `declaresAnyFields` counts the raw composition members, so
  `hasInputFields` reports fields for a union the resolver declines and an
  App tool is never auto-invoked with empty arguments.
- SchemaForm: the picker opens on the branch the supplied values identify
  (an App deep link overlays its args, so they can name another branch), and
  a branch switch no longer carries a value the outgoing branch declared —
  the branches may type the same name differently.
- TUI: a base property some branch redeclares is offered in EVERY branch's
  section under that branch's inherited or specialized declaration, so a
  branch that does not redeclare it can still supply a required root
  argument.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 02:01:44 -04:00
cliffhall
4c338a37e9 fix(web): take the enlarge button out of the tab order
Every string field in a tool or elicitation form carries an enlarge
button in its right section, so tabbing through a form stopped on one
between each pair of fields. Give it tabIndex={-1}, as #1487 did for the
clear button.

That button was deliberately left in the tab order by #2042, on the
grounds that clearing has a keyboard equivalent and enlarging has none.
So give it one: Enter on the focused single-line field enlarges it and
enters the newline. Nothing was listening for that key there — no
consumer renders SchemaForm inside a <form> — and it is the key a user
presses trying to type the newline the input swallows, which is the
failure #2042 exists to fix.

Enter enters the newline rather than only reshaping the field, since
consuming the keystroke leaves the next word running on from the last. A
field already at its maxLength is enlarged without one. Shift+Enter
enlarges too; the Ctrl/Cmd/Alt chords are left free for a consumer to
bind to running the tool. Clicking the button still only enlarges.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 01:13:32 -04:00
cliffhall
2782ed75d1 fix: address Copilot review — const precedence, TUI variant model, decline both-keyword unions
- rootUnion: decline a schema carrying BOTH oneOf and anyOf rather than
  reading one and silently dropping the other's constraints.
- SchemaForm: handle `const` before every type dispatch and for every
  constant type, so a numeric/boolean constant, or a schema carrying both
  `const` and `enum`, can no longer offer a forbidden value.
- collectSchemaDefaults / TUI initialValue: `const` outranks `default`,
  which is an annotation a schema may set to something its const rejects.
- convertToolParameters: resolve from the first branch's merged schema, so a
  branch's specialization of a root-declared property is what types the arg.
- TUI: ink-form keys values by field name across the whole form, so two
  branches' `kind` were one field and the later section's initial value
  decided what the earlier submitted. Branch fields now render under prefixed
  names behind a Variant select, and decodeFormValues translates back on
  submit, dropping every branch but the chosen one.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 01:01:54 -04:00
cliffhall
5933162dd0 fix: render tool schemas with root-level anyOf/oneOf composition
A tool's `inputSchema` may carry composition keywords at its root — the
2026-07-28 revision makes this explicit — but both form builders enumerate
the root's `properties` and nothing else. A schema keeping its fields on
`allOf`/`oneOf`/`anyOf` branches therefore rendered no controls at all: no
branch picker, no fields, not even the raw-JSON fallback a union-typed
*property* falls back to, so the tool could only be called with empty
arguments.

Adds `core/json/rootUnion.ts`, one flattening shared by all three clients so
they cannot drift on which schemas they can render:

- `allOf` is merged unconditionally (conjunctive — there is no choice to
  present), a root `oneOf`/`anyOf` is returned as branches.
- Web `SchemaForm` renders a Variant picker above the fields and prunes the
  outgoing branch's values on a switch, since they are no longer visible and
  would describe a shape the call is not making.
- TUI `schemaToForm` gives each branch its own section with optional fields —
  ink-form is static, and only one alternative applies to a call.
- `convertToolParameters` looks through the branches, so a CLI `--tool-arg`
  is typed by the schema that declares it rather than sent as a string.

Required-field gating asks whether *any* branch is satisfied, which is
selection-independent and never blocks arguments the schema accepts. A
`const` is seeded like a `default` and rendered read-only: it admits exactly
one value, so anything else the user could type produces a rejected call.

Adds a `root-union-schemas-http.json` showcase server.

Closes #2123

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 00:37:43 -04:00
Cliff Hall
e005055a46
Merge branch 'v2/main' into v2/feat/1005-schema-lint 2026-08-25 08:46:11 -04:00
cliffhall
68c4abf37a fix: count severities correctly, gate remote-ref on $id, order --strict checks
Copilot review round 5.

- The sidebar tooltip applied `hasError` to the TOTAL, so a tool with one
  error and three warnings announced as "4 schema portability errors" —
  a defect the previous round introduced while adding that wording, and
  worse than the vague text it replaced. Both the tooltip and the
  accessible label now carry the breakdown, formatted by a new shared
  `summarizeToolFindings` so the fourth place to phrase a severity count
  cannot drift from the other three.

- Suppress `remote-ref` when the document declares an `$id` anywhere.
  `$id` establishes a base URI, so with a root `$id` of
  `https://example.com/root` the ref `https://example.com/root#/$defs/x`
  resolves to THIS document and needs no fetch — the finding and its
  "inline the referenced schema" remediation would both be wrong.
  Classifying correctly needs full RFC 3986 resolution against nested
  embedded resources, which this module does not do, so it declines
  rather than guessing. A missed finding is the acceptable direction.

- Run the `--strict` validations before every short-circuit return.
  `servers/list`, `servers/show`, `--list-stored-auth` and
  `--print-handoff` all return from `parseArgs` before any connect, so
  the checks further down let `--strict --method servers/list` succeed
  while silently ignoring a tools/list-only flag.

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>
2026-08-25 02:54:21 -04:00