Commit graph

3,129 commits

Author SHA1 Message Date
Cliff Hall
a5efb326f1
Merge pull request #2185 from modelcontextprotocol/v2/fix/2140-streamable-http-cancellation
fix: cancel a Streamable HTTP call by closing its stream (#2140)
2026-08-28 08:24:04 -04:00
Cliff Hall
2d34999e82
Merge branch 'v2/main' into v2/fix/2140-streamable-http-cancellation 2026-08-28 08:23:18 -04:00
Cliff Hall
18fe35478b
Merge pull request #2182 from modelcontextprotocol/v2/chore/2130-inspectorview-prop-bundles
chore(web): collapse InspectorView's prop wall into domain bundles
2026-08-28 08:16:00 -04:00
cliffhall
cd5dde839c test: drive the cancellation showcase through its checked-in config
The two cancellation tests each built their own `slow_task`, so the
documented showcase — the config file, its preset registration, and the
fixture's own abort loop — was covered by nothing. A misspelt preset name
or a regression in that handler would have surfaced only when someone ran
the repro by hand.

Resolve `cancellation-modern-http.json` through `loadConfig`/`resolveConfig`
and drive the real preset, following the pattern the nullable-fields and
MRTR showcase tests already use.

It asserts on the handler's own return value rather than on progress
notifications. Counting ticks is a false negative here: the SDK drops a
cancelled request's progress handler locally, so the client stops seeing
ticks the moment it cancels whether or not the server ever stopped —
verified, that version passed with the fix reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 01:31:58 -04:00
cliffhall
0139fd74f8 chore(web): address Copilot's second review on #2130
Two findings, both valid.

`onCallTool`, `onGetPrompt`, `onReadResource` and `onOpenApp` await
`handleCommandScopedAuthRecovery` from inside their catch blocks, and a
rejection thrown from a catch is not caught by that same catch — so it
escapes the handler. That helper awaits `checkAuthChallengeSatisfied` and
`pushRemoteAuthState`, both of which reach the backend and can reject, so
the bare `void` on those four could still leak.

Rather than patch the four and keep a comment enumerating which handlers
are safe, all seven wrappers now terminate the same way: a reporting
`.catch` via `reportDispatchFailure`. Whether a handler "owns its
failures" turned out not to be reliably knowable by reading — the
previous two attempts at that judgement were both wrong — so the code no
longer depends on getting it right. A handler that does surface its own
failure resolves and never reaches the catch, so nothing double-toasts.

The reorder callback lifted out of the JSX had no App-level coverage.
The `InspectorView` double grows a reorder control, with tests for both
forwarding the ordered ids and reporting a rejected reorder.

Adds the auth-recovery escape test the review asked for. All four new
rejection tests were confirmed to fail against the bare-`void` code, so
they pin the fixes rather than merely passing beside them.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 01:18:56 -04:00
cliffhall
56d3c04643 fix(test-servers): address review on the cancellation showcase
- `slow_task` sleeps through a helper that removes both listeners on
  whichever outcome wins and returns early on an already-aborted signal.
  `{ once: true }` detaches on the event firing, not on the waiter losing
  interest, so the old loop left one listener per second attached and a
  60s run tripped MaxListenersExceededWarning.
- Report the outcome on stderr. A successful cancel closes the stream the
  result would travel on, so the tool's return value is undeliverable by
  construction — the README now points at the server's terminal rather
  than at a result panel that cannot show it.
- Drop a double cast the file's own next hunk shows is unnecessary, and a
  floating observer promise whose assertion was false anyway: `postSend`
  rethrows before awaiting the SSE wait, so the send rejects on abort
  whether or not that wait was cancelled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 01:11:30 -04:00
cliffhall
85c5206895 fix: cancel a Streamable HTTP call by closing its stream (#2140)
The Cancel button POSTed `notifications/cancelled` on every transport. On
a 2026-07-28 Streamable HTTP connection that is the wrong signal — the
spec makes closing the request's SSE response stream the cancellation
signal there and says the notification is neither required nor expected —
so a spec-compliant server acknowledged it 202 and dropped it while the
tool ran to completion.

The SDK already implements the fork, off `transport.hasPerRequestStream`.
Every Inspector connection is wrapped in `MessageTrackingTransport`, which
did not forward that flag, so the SDK read `undefined` and took the stdio
branch on every client — the CLI and TUI included, whose real
`StreamableHTTPClientTransport` advertises it correctly.

The web client needed three more links: its browser transport answers for
the upstream transport that lives on the backend, so it advertises the
flag for `streamable-http` and applies the SDK's `requestSignal` to its
`POST /api/mcp/send`; and `/api/mcp/send`, held open for the whole call,
forwards the browser's disconnect to the upstream `transport.send`.

The test fixture's `toWebRequest` built a Web Request with no `signal`, so
no fixture server could observe a disconnect at all — without that, a test
asserting the cancel reaches the server passes vacuously.

stdio and SSE keep `notifications/cancelled`: they multiplex every request
over one shared channel and have no per-request stream to close. So does
the legacy era, which predates the mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 00:55:29 -04:00
cliffhall
4e0f4e5460 Merge branch 'v2/main' into v2/chore/2130-inspectorview-prop-bundles 2026-08-28 00:32:39 -04:00
Cliff Hall
e2de318698
Merge pull request #2181 from modelcontextprotocol/v2/fix/2180-render-smoke-startup-independent
test(scripts): anchor the render-smoke overlap paint to the parent's clock
2026-08-28 00:21:13 -04:00
cliffhall
37add967cd chore(web): address Copilot review on #2130
Three findings, all valid on verification.

`onToggleConnection` and `onDisconnect` do not own every rejection, so a
bare `void` on them turns an ordinary Connect/Disconnect click into a
global unhandled rejection with nothing shown to the user. The toggle has
three escape paths outside its own try/catch — awaiting
`initialConfigSettledRef`, constructing the client via
`setupClientForServer`, and the already-connected disconnect branch,
whose `try/finally` runs `finalizeExplicitDisconnect()` and then lets a
failed transport close propagate — and `onDisconnect` is that same
`try/finally` on its own. Both are now terminated with a `.catch` that
toasts the failure.

The blanket comment claiming all seven wrappers own their failures was
wrong. The five command handlers (`onCallTool`, `onGetPrompt`,
`onReadResource`, `onCancelTask`, `onOpenApp`) do each end in a `catch`,
so `void` stays correct for them; the comment now splits the two groups
and says which is which.

Adds the two tests the review asked for, covering the rejected-close path
through both the toggle and the explicit Disconnect. Each fails against
the previous `void` code, so they pin the fix rather than merely passing.
The `InspectorView` double grows a `disconnect` button, since the
standalone `onDisconnect` was otherwise unreachable from it.

Also restores the `PropOverrides` doc example, which the scripted
regrouping had rewritten inside its own JSDoc into a triple-nested shape
that would not type-check if copied.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 00:18:03 -04:00
Cliff Hall
aacfef3ee2
Merge branch 'v2/main' into v2/chore/2130-inspectorview-prop-bundles 2026-08-28 00:07:46 -04:00
cliffhall
2e4e76aa1a
test(scripts): anchor the render-smoke overlap paint to the parent's clock
The case reproduces an overlap — the render deadline expiring while the
survival window is still open — and it had to buy that overlap with a
startup allowance. `paintAt` is measured from the child's first JS tick
while `renderTimer` is armed in the parent at spawn, so a plain
`setTimeout(paint, N)` lands at `N + startup` and the deadline has to
cover whatever startup turns out to be. #2177 widened that allowance
(150ms to 950ms), which moves the threshold rather than removing it: the
case still fails, as "did not render", on any machine loaded enough to
blow the new number.

The child is now told the wall-clock instant to paint at and sleeps for
whatever is left of it on arrival, so startup is absorbed by the sleep
instead of added to it and the paint lands at ~300ms from spawn however
slow the boot was. `timeoutMs: 600` / `surviveMs: 900` then satisfies
the right-hand side of the overlap for ANY paint time including 0, so a
boot slow enough to swallow the anchor entirely still reproduces the
shape rather than degrading into a different case. The one startup
assumption left is that boot completes before the deadline at all, an
order of magnitude over the 46-59ms measured idle here.

Passing is no longer taken as evidence on its own: the verdict's
first-paint time is read back and the overlap asserted, so a paint
landing early enough to put the deadline outside the survival window
fails loudly instead of passing while testing nothing.

Verified by mutation — removing the `clearTimeout(renderTimer)` this
case guards fails it at 607ms, i.e. the timer still fires inside the
survival window. Verified under load — 8/8 with 16 busy loops pinning
all 8 cores. Runtime drops from ~1.75s to ~1.2s.

Closes #2180

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:51:36 -04:00
cliffhall
7083c6c7aa
docs(scripts): correct the attribution in the render-smoke margin comment
Copilot round 1. The comment blamed `script(1)` PTY allocation, but this
test's `stub()` spawns `process.execPath` directly — the PTY wrapper
lives in `smoke-tui.mjs`, the caller — so the fixture never pays that
cost and the 66-114ms figure (measured through the wrapper) was not
this path's.

Re-measured without it: Node boot alone is 46-59ms idle, which fits the
old 150ms budget comfortably. That is *why* it read as a flake rather
than a bug — what actually exhausted the budget was scheduling delay
under a full gate run, on both the child's boot and the parent's timer.
The fix is unchanged; only the reasoning was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:51:35 -04:00
cliffhall
0f845a319a
fix(scripts): give the render-smoke overlap test room for process startup
The test gave itself a 150ms budget that had to cover `script(1)` PTY
allocation plus Node boot: `paintAt` is measured from the child's first
JS tick, while `renderTimer` is armed in the parent at spawn. Measured
idle, that startup is 66-114ms — ~36ms of slack at best — and under the
load of a full `local:gate` run it goes over, so the render timer fires
before the paint and the run is reported as "did not render", which is
precisely the misdiagnosis this test exists to catch. It failed three
gate runs in a row while passing standalone every time.

The constants are widened rather than the assertion changed: the shape
under test is the overlap (paint < timeoutMs < paint + surviveMs), and
1200/1500 keeps it with real margin on both sides. Verified by mutation
— removing the `clearTimeout(renderTimer)` this test guards makes it
fail at 1204ms, i.e. the timer still fires inside the survival window.

Closes #2177

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:51:35 -04:00
Cliff Hall
baff393675
Merge pull request #2175 from modelcontextprotocol/v2/fix/2171-raw-json-no-coercion
fix(web): refuse a raw-JSON tool argument the schema would retype
2026-08-27 23:48:15 -04:00
Cliff Hall
904cbf9014
Merge branch 'v2/main' into v2/fix/2171-raw-json-no-coercion 2026-08-27 23:37:33 -04:00
cliffhall
61f58f9ae0 test(web): pin that a refused raw-JSON draft dispatches nothing (#2171)
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>
2026-08-27 23:32:32 -04:00
Cliff Hall
667ec74fb5
Merge pull request #2179 from modelcontextprotocol/v2/fix/2174-subscribe-error-recovery
fix(web): surface failed resource subscribe/unsubscribe
2026-08-27 23:21:08 -04:00
cliffhall
0425516fa2 chore(web): collapse InspectorView's prop wall into domain bundles (#2130)
`<InspectorView>` took ~130 flat props, which made ~170 of App.tsx's JSX
lines a prop wall rather than a component tree. It now takes 12 domain
props and the call site is 14 lines.

Two conventions keep the change reviewable. No field is renamed — every
field inside a bundle carries the exact name it had as a flat prop, so
this is a regrouping rather than a rename. And the bundles stop at
`InspectorView`: it destructures each one back into the same locals its
body already used and passes the same individual props down, so nothing
below the view knows they exist.

Three props have no single screen and are placed deliberately:
`onCompleteArgument` / `completionsSupported` are read by both the
Prompts and Resources screens, so they sit in `connection` rather than
being duplicated; `malformedListItems` is one array three screens filter
for their own entries, so it sits in `shell`; `erroredServerId` /
`connectedServerId` are connection outcomes the Servers screen renders,
so they sit in `connection`.

Every multi-line closure declared inline in the JSX is now a named
`useCallback` above the return, including `onServerReorder`'s `.catch`
and the seven `void`-discarding wrappers, which carry their
`no-floating-promises` justification once rather than at each call site.

The `InspectorView` double in App.test.tsx is now typed with the real
`InspectorViewProps` instead of a hand-written structural mirror, so it
cannot drift from the component it stands in for. Stories keep one
constant per bundle, since Storybook merges args only at the top level.

No behavior change.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:20:58 -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
Cliff Hall
4d69dbf999
Merge pull request #2176 from modelcontextprotocol/v2/fix/2097-never-acknowledged-subscription
fix: say why a never-acknowledged subscriptions/listen ended, instead of retrying it eight times
2026-08-27 22:45:32 -04:00
cliffhall
37e156c035 fix(web): surface failed resource subscribe/unsubscribe (#2174)
Both handlers discarded their promise with a bare `void`. Nothing owned
those failures: `subscribeToResource` throws when the client is
disconnected, when the server declares no subscription support, and
(wrapped) on a failed request — so a subscribe that failed did nothing
visible and surfaced only as an unhandled browser rejection, while every
neighbouring command in the hook already routed through the shared
recovery.

Both now go through `runCommandInBackground`, so a mid-session 401
recovers the way it does on a refresh and any other failure toasts.

The source is `ambient`, not `resource`: a `resource` step-up failure is
routed into `readResourceState` — the read-preview panel — which
describes a read of whatever is selected rather than this subscribe, so
marking it errored would contradict a read that succeeded. Subscribing
has no panel of its own, the same position the refreshes and the
pagination toggle are in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 22:42:56 -04:00
Cliff Hall
31ce682c3d
Merge branch 'v2/main' into v2/fix/2097-never-acknowledged-subscription 2026-08-27 22:27:16 -04:00
Cliff Hall
2856f82343
Merge pull request #2173 from modelcontextprotocol/v2/chore/2155-use-server-commands
chore(web): extract App.tsx's server commands into useServerCommands
2026-08-27 22:23:56 -04:00
Cliff Hall
222b65d8c2
Merge branch 'v2/main' into v2/chore/2155-use-server-commands 2026-08-27 22:13:20 -04:00
cliffhall
9136ab6268 fix(test-servers): describe and test after-first's resource-subscription counting
Copilot review on #2176. All three findings were the same gap seen from
three angles: `"after-first"` counts *resource-subscription* listens, not
listens, and both the README and the option's own doc comment said
"the first listen".

That distinction is load-bearing rather than incidental — the Inspector
opens a listen at connect time whenever a list-change opt-in is live
(#1920), so counting listens would spend the allowance there and refuse
the user's very first Subscribe, leaving the documented repro describing
a trace nobody sees. It is also what a reader of the option would
otherwise get wrong.

- README: say "the first listen that subscribes to a resource" in both
  places, and name the connect-time listen as the reason.
- `ServerConfig.modern.neverAcknowledgeSubscriptions`: document the
  list-change exemption and contrast it with `true`.
- Integration test for the mode itself, with every list-change opt-in on
  so the connect-time listen really is opened: it must be acknowledged
  without consuming the allowance, the first resource subscription
  acknowledged, the next refused with the stream left `active`.

Closes #2097

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 21:14:30 -04:00
cliffhall
71cd1130ee chore(web): fix a duplicated word in a moved comment
Copilot round 3, from its suppressed set: "a paginated paginated fetch".
The line came across verbatim with `onLoadMoreTools`, so the typo
predates this PR — fixed here since the comment is the thing being
moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 21:02:40 -04:00
cliffhall
f7fe4bc9e9 test(web): make the harness's recovery wrapper actually recover
Copilot round 2. The stand-in `runWithCommandAuthRecovery` was a bare
pass-through while its comment claimed it reproduced the real one, so
`onCompleteArgument` and `onCancelTask` — whose only auth handling *is*
that wrapper — would have passed every test here while bypassing the
shared recovery entirely. That routing is the integration this
extraction exists to preserve.

The stand-in now matches `useOAuthRecovery`'s: it catches an
`AuthRecoveryRequiredError`, hands it to `handleCommandScopedAuthRecovery`
with the call site's `source` and a `retryOperation`, retries once when
the recovery reports satisfied, and rethrows anything else.
`runCommandInBackground` is built on it, as the real one is. Three tests
pin the source and the retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 20:50:59 -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
5c7c5184cf chore(web): address Copilot review on #2155
Give the test's paginated-list fixture a precise return type instead of
an unjustified double cast. The cast was hiding that the fixture omitted
`error`, so an interface change would not have been type-checked there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 20:35:33 -04:00
cliffhall
00f1733abe chore(web): extract App.tsx's server commands into useServerCommands
Phase-2 step 4 of the App.tsx decomposition (#2155, under #2129/#2126).

Every command the screens issue against the live server moves into
`useServerCommands`: calling a tool, getting a prompt, reading and
subscribing to a resource, completing an argument, cancelling a task or
a call, setting either log level, refreshing or paging a list, and
flipping the pagination mode. All of them route through the
command-scoped auth recovery `useOAuthRecovery` publishes, which is why
this cluster cannot precede the OAuth one.

The three in-flight result panels move with them, behind a small
`useResultPanels` in the same file — split out only because of call
order, the same reason `useHandshakeTelemetry` sits beside
`useConnectionLifecycle`: `useOAuthRecovery` drops those panels on a
session reset and routes a step-up failure into whichever one issued
the command, and it runs before the commands hook.

The move is inert — no behavior change, #2095's per-server pagination
override included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 20:29:23 -04:00
Cliff Hall
6bc3251962
Merge pull request #2170 from modelcontextprotocol/v2/feat/2151-ace-json-editor
feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle
2026-08-27 20:19:56 -04:00
Cliff Hall
ccd99083ea
Merge branch 'v2/main' into v2/feat/2151-ace-json-editor 2026-08-27 20:07:11 -04:00
Cliff Hall
7a3a30b148
Merge pull request #2169 from modelcontextprotocol/v2/chore/2154-use-connection-lifecycle
chore(web): extract App.tsx's connection lifecycle into useConnectionLifecycle
2026-08-27 20:04:08 -04:00
Cliff Hall
592446fc8e
Merge branch 'v2/main' into v2/feat/2151-ace-json-editor 2026-08-27 10:30:52 -04:00
Cliff Hall
6bd2841afc
Merge branch 'v2/main' into v2/chore/2154-use-connection-lifecycle 2026-08-27 10:24:40 -04:00
Cliff Hall
df53d718c1
Merge pull request #2167 from modelcontextprotocol/v2/chore/2146-rename-local-gate
chore(scripts): rename the local pre-push gate off `ci`, and guard it out of CI (#2146)
2026-08-27 10:16:05 -04:00
cliffhall
684eb9c7bc fix(web): reject an underflowing literal, and route embedded JSON resources
Copilot's thirteenth pass, two comments, both real.

- `1e-400` has a nonzero mantissa and parses to `0`. The digit comparison skips
  it for being exponent-form and it is not negative zero, so nothing saw a
  document that says one number and sends none of it. Caught now by pairing
  `Number(literal) === 0` with a nonzero digit in the mantissa, which leaves a
  literal written as zero (`0e-400`) and a subnormal that still parses to
  something (`1e-320`) alone.

- The `resource` content-block branch rendered every embedded resource in a
  plain code block, ignoring `resource.mimeType`, so a prompt message carrying
  JSON did not inherit this migration even though the text-block branch did. An
  embedded *text* resource declaring JSON now goes through the same dispatch.

Narrowed to JSON, as the Network tab's bodies are: routing every declared type
through would also put an embedded `text/html` resource into the sandboxed
frame and a `text/csv` one into a table. The Resources screen already renders
resource contents that way, so doing it here too is defensible — but it is a
change to what a tool result *is*, and belongs to whoever decides that.

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:26:23 -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
390e0c45ed fix(web): stop the read-only viewer reformatting a payload into a different one
Copilot's tenth pass generated no new inline comments. Of its two suppressed
ones, this fixes the first and declines the second.

`ContentViewer` pretty-prints by `JSON.parse` then `JSON.stringify`, which is a
round trip through JavaScript's number type and is not lossless. A server
sending `{"id":9007199254740993}` was shown `…992`, and one sending
`{"limit":1e400}` was shown `null`. Neither is a formatting difference: both
are the viewer telling the reader something untrue about the payload it
received, which for an inspector is the worst kind of bug. It predates this PR
— `formatJson` has always round-tripped — but this PR is where the read-only
JSON path was rebuilt, and the same two predicates the draft parsers use answer
it exactly.

Both formatters now display the payload as it arrived when reindenting would
alter a value: unindented, but honest. The copy button already copied the
original text either way, so this makes the screen agree with it.

Not changed: a literal whose *spelling* is normalized but whose value is not.
`0.10000000000000001` and `0.1` are the same double, as are `1e2` and `100`, so
those still format. Rejecting them would mean refusing any literal not already
in JS's shortest form — `1.50`, `1e2`, `0.5000` — which blocks drafts that send
exactly what they mean. The integer guard stays because there the digits name a
value the double is not.

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:34:33 -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
12c00d8c63 fix(web): re-apply the editor's accessible name, and correct the README's scope
Copilot's third pass generated no new inline comments; all three of its
suppressed ones were right.

- `ariaLabel` is now re-applied from the wiring effect, not only at mount.
  `setOptions` updates Ace's option when the prop changes, but Ace recomputes
  the textarea's `aria-label` from it inside `TextInput.setAriaLabel()`, which
  it calls when the cursor moves — so a name that changed while the editor
  stayed mounted (a tool refreshed in place under a new field title, which
  `SchemaJsonField` passes straight through) left the visible label and the
  accessible name disagreeing until the user clicked into the box. `onLoad`
  keeps doing it too, since an effect runs after paint and this is the
  control's only name.

- The README claimed this editor is on "every surface where JSON is typed or
  displayed", which is false in two places it now names instead:
  `ExperimentalFeaturesPanel`'s JSON-RPC *response* is still a read-only
  `Textarea` (only its request box moved), and `OAuthTokenField` renders a
  decoded JWT in a `Code` block beside the raw token. Both bypass
  `ContentViewer` and so are not reached by its JSON branch. Documenting them
  as exceptions is what keeps the section from sending a maintainer to the
  wrong abstraction.

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:16:44 -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
0c0d5faac6 chore(scripts): cover PowerShell's Env: drive, and correct two stale scope summaries (#2146)
Copilot, eleventh review:

- `Set-Item Env:SMOKE_BROWSER firefox` — PowerShell's Env: DRIVE, the other
  standard way to write the environment there — matched none of the patterns.
  Covered now, positional or via -Path/-Value, for Set-Item/New-Item and their
  -ItemProperty forms. A `Get-Item` READ is still not a finding, which has its
  own negative case: reading the variable is not setting it, the same
  distinction as round 5's `echo "SMOKE_BROWSER: firefox"`.

- Two scope summaries had drifted behind the traversal, both saying the guard
  reads only `run` and `env`/`with`: the module header's "ONLY EXECUTABLE
  POSITIONS" paragraph, and the copilot-instructions mirror. Both list the real
  set now — `run`, the `shell` command template at all three levels, and
  `env`/`container.env`/`with`. The mirror one is the AGENTS.md rule this repo
  states about itself, so it is a rule violation rather than a typo.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:44:41 -04:00