Commit graph

3,240 commits

Author SHA1 Message Date
cliffhall
43c817c8e9 fix(auth): bound the whole teardown, and scope the TUI clear to its server (#2144)
Copilot review round 7.

- The five-second timeout was per request while grants are revoked
  sequentially, so a server with N issuer slots could block the disconnect for
  N x 5s — the "short timeout" bounded a request rather than anything the user
  feels. It is now one budget for the whole call: each grant gets the remaining
  time, and a grant reached after it is exhausted is reported as never
  attempted rather than starting a fresh five seconds. `--relogin` shares one
  budget across its two key spellings for the same reason.
- `AuthTab` called the same settle handler on rejection, so a failed local
  clear or disconnect announced "OAuth state cleared". A rejection is not a
  revocation failure — those come back as outcomes — so it now has its own
  path and reports the real error. The completion is also scoped: a clear
  carries the server it started on and an attempt number, and a switch retires
  it, so server A's confirmation cannot appear under B.
- `clearOAuthAttemptRef` is now retired by the server-switch effect alongside
  `disconnectAttemptRef`; without that an A -> B -> A round trip left both the
  token and the captured name matching again. `handleClearOAuth` also
  revalidates after `await disconnectInspector()`, since a switch during that
  second await would have put the revision bump on the new selection.

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 03:55:37 -04:00
cliffhall
56ac83051b fix(auth): RFC-encode the Basic credential; make the TUI clear await its work (#2144)
Copilot review round 6.

- The Basic credential is now form-urlencoded per RFC 6749 §2.3.1. Round 1
  matched the SDK's raw `id:secret` on the theory that a credential should be
  presented the same way here as at the token endpoint; that does not hold — a
  client authenticated with `client_secret_post` never exercised the SDK's
  Basic path, so there is no precedent to match, and the raw form is ambiguous
  for an id containing `:` and makes `btoa` throw on a non-Latin-1 secret. The
  fixture decodes each half.
- The ctx-less grant read is suppressed on the store's issuer *stamp* rather
  than on the token's value. The active slot can hold client information
  without a token, in which case the read falls back to the legacy grant — and
  a value collision with any other issuer would have dropped that grant
  unrevoked and unreported.
- The TUI announced "OAuth state cleared" on the keypress, so with a bounded
  revocation request it said so while the work was still in flight and then
  removed it when the work finished. `onClearOAuth` returns a promise;
  `AuthTab` shows "Clearing OAuth state…" until it settles, ignores repeats
  while one is in flight, and skips exactly the revision bump its own clear
  caused so the confirmation is not immediately reset by it.
- `handleClearOAuth` now claims an attempt and retires a superseded one, the
  way `handleDisconnect` does: the await can keep it suspended for seconds,
  long enough for the user to switch servers and have server A's outcome —
  including its disconnect — land on server B.

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 03:40:40 -04:00
cliffhall
e9cd678cd0 fix(auth): key grant dedup by issuer, and isolate per-slot read failures (#2144)
Copilot review round 5.

- Deduplicating grants by token string alone could conflate two grants: a token
  is only meaningful to the authorization server that minted it, so two issuers
  minting the same opaque value are two grants. The second was dropped before
  the issuer-mismatch check could report it, and `clear` then deleted it. The
  key is now issuer+token; the ctx-less read stays suppressed only when its
  token already came from a slot, which is exactly the active-issuer duplicate
  and never the legacy one.
- A read failure in one issuer slot aborted `collectGrants`, so one corrupt
  slot left every other revocable grant untouched while the clear deleted them
  all. Each slot is now read independently and its failure carried as an
  outcome alongside the grants that could still be revoked; the no-metadata and
  no-endpoint returns carry them too, so a failure is never swallowed by a skip.
- The CLI no longer deduplicates its two key spellings. That could only be done
  by pre-reading a single token, and `revokeStoredOAuthTokens` now enumerates
  every issuer slot under a key — so a shared active token skipped the second
  key entirely, taking any other issuer-bound grant under it with the local
  delete. A duplicate RFC 7009 request is harmless (§2.2 makes an unknown token
  a success); a missed one is the leak.

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 03:17:39 -04:00
cliffhall
6c499f3ea1 fix(auth): read enumerated issuers exactly, and stop the TUI tone going stale (#2144)
Copilot review round 4.

- `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that
  issuer holds none — right for a connect, wrong for `collectGrants`, which
  labelled the fallback with the issuer it happened to be iterating and paired
  it with that issuer's credentials. During a partially migrated flow the clear
  could have sent an old, unbound token to a newly discovered authorization
  server. `OAuthStorage.getIssuerTokens` is the exact, no-fallback read; only
  the ctx-less read still falls back, which is the one place it belongs.
- The TUI's warning tone was sticky. It lived in its own state while ~30 call
  sites set ordinary OAuth notes without resetting it, so one revocation
  failure left every later "Authorization updated" yellow, across server
  switches. It is now DERIVED — `oauthMessageToneFor(message, warningText)` —
  so it cannot outlive the message that earned it, with nothing to remember to
  clear. Both setters stay plain `useState` setters: a `useCallback` wrapper
  would be a value `react-hooks/exhaustive-deps` demands in seven dependency
  arrays for an identity that never changes.
- Hook-level tests for the web wiring (previously missed): the per-server
  opt-out and the backend-proxied fetch are now asserted to reach
  `clearServerOAuthState`, so handing it the page-origin fetch — which a real
  authorization server's missing CORS headers would reject — fails a test.

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 02:58:04 -04:00
cliffhall
781aa69d19 fix(auth): revoke every issuer-bound grant, not just the active one (#2144)
Copilot review round 3.

`clear(serverUrl)` deletes EVERY `byIssuer` slot, but revocation read only the
context-free (active-issuer) token. A server that had authorized against
issuers A and B therefore revoked B, then destroyed the local record of A while
A's grant stayed live at its authorization server — the same leak this feature
exists to close, one level down.

- `OAuthStorage.listIssuers(serverUrl)` is the new seam (one implementation:
  every backend extends `OAuthStorageBase`). `collectGrants` walks it plus the
  ctx-less read, deduping by token, so every grant the clear will delete is
  covered.
- Metadata is cached once per server, not per issuer, so a grant bound to an
  issuer the cached document does not describe cannot be revoked: sending its
  token to that endpoint would hand a credential to a server that never minted
  it. That grant is reported as failed — dropped unrevoked — rather than
  silently discarded or misdirected.
- `aggregateOutcomes` gives a failure precedence over another grant's success,
  so one live grant cannot hide behind another's 200. Exported for its own
  test, since `computeOutcome` returns early on the empty case.

Also from that round:

- The fixture's /oauth/revoke now verifies token ownership (RFC 7009 §2.1).
  Access tokens record their owning `client_id`; a token belonging to another
  client is left alone and still answered 200 (§2.2 — the response must not
  tell one client whether another's token exists). New e2e case proves a
  second registered client cannot revoke the first's grant.
- The TUI rendered the revocation-failure note in cyan, the informational
  tone. It is a partial success — the local clear really happened — so it is
  not an `error` status, but the grant may still be live and cyan understated
  that. `AuthTab` takes an `oauthMessageTone`; the message says so explicitly.

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 02:40:28 -04:00
cliffhall
039fc6f02e fix(auth): release discarded probe bodies, report the redirected source, proxy the CLI refresh
Three review findings.

Probe responses the loop skips — and the original failed response the
substitution replaces — were dropped without releasing their bodies. On
Node/undici that holds the connection, and this loop can run on every OAuth
attempt, so repeated discovery against a 4xx candidate could exhaust the
origin's pool. Both paths now cancel, the same discipline
`core/mcp/node/authChallengeFetch.ts` uses for a discarded 401.

`fetch` follows redirects, so the candidate URL is where the probe was aimed,
not necessarily where the document came from. The source header and the console
warning now report `probe.url`, falling back to the candidate for a synthesized
response that carries none.

The CLI's stored-token refresh built its fetch from the global rather than from
`createProxyFetch()`, so a server reachable only through `HTTPS_PROXY` was
probed directly. It now builds one proxy-aware fetch and hands it to both the
discovery probe and `refreshAuthorization`, so neither leg bypasses the proxy —
this function runs outside `InspectorClient`, so nothing else puts a proxy
under it.

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-28 02:35:27 -04:00
cliffhall
89d6af4436 fix(auth): mirror the SDK's candidate policy in the compat probe loop
The probe loop advanced to the next OIDC candidate on any failure, which meant
it could promote a later candidate over an earlier one the SDK would have
stopped at — turning a failure into a success, a worse defect than the one
being worked around. Four distinct cases: a 500 (an outage the SDK surfaces), a
2xx body that will not parse (terminal for the SDK), a network error (which the
SDK propagates outside the browser's CORS case), and a non-JSON media type.

It now continues only where the SDK's own loop does — a 4xx or 502 — and
otherwise returns the original response, leaving the SDK to make the same
request and reach its own verdict. The `content-type` gate is gone entirely,
because the SDK parses a 2xx discovery body whatever media type it carries, so
gating could skip a genuine OpenID provider document and substitute an
RFC-8414-only one from a lower-priority candidate in its place.

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-28 01:59:04 -04:00
cliffhall
4a24c4880d fix(auth): address Copilot review round 2 on the RFC 7009 revocation (#2144)
- `revokeStoredKeys`' pre-read of `getTokens` sat outside the best-effort
  catch. `getTokens` parses through `OAuthTokensSchema`, so a persisted token
  that no longer validates rejected and abandoned the local delete `--relogin`
  promises — over a grant that could not have been revoked anyway. Caught and
  retained as a failed outcome; the clear proceeds.
- The multi-key bookkeeping was wrong twice. A token is now marked spent only
  on a `revoked` outcome, so a failed or unsupported attempt no longer skips a
  duplicate entry under the other key that may hold the credentials or metadata
  that would have worked. And a failure now outranks an earlier success for
  reporting, so a stale grant left live cannot be hidden behind the first key's
  200.
- `--no-revoke` is now rejected without `--relogin`, ahead of the short-circuit
  returns, matching the parser's existing stance on accepted-but-inert flags
  (`--strict`). On its own it reads as "this run will not revoke anything",
  true only because nothing was being cleared.
- Extracted the App draft merge as `utils/serverWithDraftSettings`, so the rule
  has a test. The hook tests receive whatever they are handed and cannot tell a
  draft from a persisted entry, and `App.test.tsx`'s settings harness connects
  a stdio server where the OAuth section never renders.

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 01:47:48 -04:00
cliffhall
28c7a9ff8a fix(auth): cover the transport's own discovery, and only rewrite GETs
Two more review findings, both real.

The shim wrapped `effectiveAuthFetch` only, but the transport is constructed
with the base `this.fetchFn`, and the SDK runs its own discovery from inside
the transport on the 401/refresh leg — so a reconnect with an existing auth
provider could still hit the upstream failure. It now wraps the base fetch,
the same seam `withOAuthEndpointOverrides` uses and the only one that reaches
both paths.

That trades away the previous claim that a captured Network entry shows the
real 404: below the trackers, what they record is the substitution. Rather
than leave that silently misleading, a substituted response now carries
`x-inspector-oauth-metadata-source` naming the URL its body was actually
fetched from — the transport builds its tracker internally, so there is no
seam above it to install into instead. An integration test captures the fetch
the transport is handed and drives it against a real server, since nothing
else proves that wiring.

The wrapper also keyed on URL shape alone, but nothing stops an authorization
or token endpoint from living under `/.well-known/oauth-authorization-server/`;
a POST there returning an ordinary OAuth 4xx would have had its error replaced
by a metadata document. Metadata discovery is a GET, so anything else is now
left alone.

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-28 01:39:28 -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
7f27d7e9c7 fix(auth): address Copilot review round 1 on the RFC 7009 revocation (#2144)
- RFC 8414 §2 defaults an omitted `revocation_endpoint_auth_methods_supported`
  to `client_secret_basic`; it does not inherit the token endpoint's list.
  Inheriting made metadata advertising only `client_secret_post` there send
  POST credentials to an endpoint that never advertised the method.
- The 5s timeout was inert on the web paths: `createRemoteFetch` re-issues the
  call as a POST to `/api/fetch` and drops `init.signal`, and the backend's
  outbound fetch gets none either, so a wedged AS could hold the teardown open
  indefinitely. A wall-clock race now enforces the deadline; the signal is kept
  because it genuinely cancels the direct-fetch paths.
- The settings-modal Clear read `settingsModalTarget`, which comes from the
  persisted `servers` list, while edits live in `settingsDraft` until the save
  debounce. Toggling the checkbox and clearing immediately used the previous
  value. It now merges the draft.
- `--relogin` clears both key spellings, so it now revokes from both rather
  than stopping at the first populated one — a stale entry under the other
  spelling is a live grant. The normalised key goes first, matching
  `findStoredServerState`, and a second key holding the same token is skipped.
- The fixture's /oauth/revoke now ENFORCES RFC 7009 §2.1 client
  authentication (both RFC 6749 §2.3.1 forms), so the e2e's claim that the
  Inspector authenticates correctly is actually tested; two negative cases
  assert a wrong secret and an unidentified client are refused.
- Dropped the `as unknown as` casts from the tests: a typed `InspectorLogger`
  double, and a spy on the real storage instead of a spread-and-cast.
- Added CLI-level tests through `runCli` for `--no-revoke`, the per-server
  `oauth.revokeOnClear`, and the stderr warning, and TUI tests for the
  forwarded opt-out and the rendered failure.

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 01:30:09 -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
1db1b9be1f fix(auth): cover the CLI refresh path and tighten the well-known match
Two review findings.

The stored-token refresh path (`refreshStoredAuthToken`) calls SDK discovery
directly rather than through `InspectorClient.effectiveAuthFetch`, so a state
file holding a refresh token and client information but no `serverMetadata`
still hit the upstream OIDC-schema failure and could not refresh against the
very servers this change is for. Its default `discover` now wraps the global
fetch with the compat shim; a caller-supplied `fetchFn` is left alone. Covered
by a test that injects no `discover` — injecting one would bypass the default
under test — against a real server that 404s the RFC 8414 path and serves plain
RFC 8414 metadata at the appended OIDC path.

`oidcDiscoveryCandidates` matched the well-known path by bare prefix, so it
also claimed `/.well-known/oauth-authorization-server-backup` and would have
replaced that path's failed response with a document fetched from a derived
URL. It now requires the exact path or a `/` boundary, which is precisely the
set `buildDiscoveryUrls` can emit.

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-28 01:11:51 -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
15f381c6ad test(auth): cover the no-window guard in getWebProxiedFetch (#2144)
Mirrors `remoteOAuthStorage.test.ts`, whose identical guard is covered the
same way. Without it the file sat at 83.33% branches and failed the
per-file gate.

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 01:00:42 -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
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
7ca753bc4c fix(auth): accept RFC 8414 metadata served at the OIDC well-known path
The SDK's `discoverAuthorizationServerMetadata` picks its validation schema
from the well-known filename that resolved rather than from the document that
came back, so anything found at `/.well-known/openid-configuration` is
validated as an OpenID provider document — requiring `jwks_uri`,
`subject_types_supported` and `id_token_signing_alg_values_supported`, three
fields RFC 8414 does not define. RFC 8414 §5 permits that filename for general
OAuth metadata, so a conforming plain OAuth 2.0 authorization server is
rejected; and because the parse throws instead of continuing the candidate
loop, discovery aborts and the connection fails outright.

Filed upstream as modelcontextprotocol/typescript-sdk#2733.

`core/auth/oidcDiscoveryCompat.ts` works around it without fabricating a
field: on a failed RFC 8414 candidate it probes the OIDC candidates the SDK
would try next and, when one returns a document that satisfies
`OAuthMetadataSchema` but fails the OIDC schema, serves that body as the RFC
8414 response so the SDK picks the schema that describes it. A genuine OpenID
provider document is left to the SDK's own OIDC leg. Issuer validation runs
unchanged, since the substituted document is the one the server published.

It wraps `effectiveAuthFetch` — above the fetch tracker, the opposite of
`withOAuthEndpointOverrides` — so the Network tab still records the real 404
and the real probe rather than the substitution.

The candidate derivation mirrors the SDK's `buildDiscoveryUrls`, which a fetch
wrapper cannot call, and a test pins it against that export so the two cannot
drift.

Adds `oauth.asMetadataPath` to the composable test server (mirroring
`oauth.resourceMetadataPath`) and the `oauth-rfc8414-at-oidc-path-http.json`
showcase config that reproduces the failure.

Closes #2172

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-28 00:40:11 -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