Most of this round re-reported code the previous commit already changed
(it generated 0 new comments). Two findings were genuinely open.
- SkillsScreen: give "View SKILL.md" and "Fetch with skills/get" the same
attempt ordering `verifyRow` got in round 3. Both were still
last-completion-wins within one manifest: a double click whose older
request settled last replaced the newer result. One shared `isStale`
helper now covers all three actions — a result is dropped when its
manifest was invalidated OR a later click already wrote.
- test-servers/skills.ts + docs: stop calling all three edge cases
"non-conforming". `resources: "dynamic"` is a LEGAL wire form for
generated content — it is unverifiable, not invalid, which is exactly
why the checker reports it as a warning. The prose contradicted the
code it was describing. Now: one legal-but-unverifiable case and two
actual violations.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
Two of these reverse round 4, which asked for the opposite. Where the
readings conflict I took the permissive one: a wrong tightening rejects
conforming servers, a wrong loosening only fails to report something.
- skills.ts: stop requiring the `skill:` scheme. SEP-2640 says a server
SHOULD use `skill://` and explicitly allows a domain-native scheme, so
round 4's requirement handed a conforming `github://…` skill a false
`malformed-uri` and skipped its name and root checks. Containment is
scheme-independent — it compares against THIS entry's own root — so the
traversal fix that requirement came packaged with is unaffected.
- skills.ts: the 512-entry and 16 MiB limits are warnings. Both are
SHOULD NOTs for a server and MAYs for a host, so calling them errors
contradicted the "error = a MUST was broken" contract round 4
established and told authors a permitted skill was invalid.
- skills.ts: `skillEntriesMatch` compares canonically — keys sorted,
manifest sorted by URI. `JSON.stringify` treated key order and manifest
order as differences, though neither carries meaning.
- SkillsScreen: a differing `skills/get` is "a different snapshot"
(yellow), not a disagreement (red). The SEP defines it as a fresh
point-in-time read, so a skill that changed since the listing
legitimately differs.
- useServerCommands: route `onGetSkill` through the command-scoped OAuth
recovery like every other server command. An expired authorization
rendered an error and stopped there — no reauthorization, no retry.
- test-servers/skills.ts: stamp the modern base result envelope
(`resultType` / `ttlMs` / `cacheScope`) on every skills result. The SDK
stamps it only for methods in its own codec, and `skills/*` are
consumer-owned, so a 2026-era connection was receiving results without
it. Unconditional rather than per era: the modern leg builds a server
per request, so there is no era to branch on at registration time, and
on legacy they are three members no codec inspects.
- composable-test-server: remove the `skills.directoryRead` option. It was
publicly settable and advertised a method nothing serves, producing the
exact false capability the fixture exists to help catch. `skills` is now
a plain boolean; the flag returns in phase 3 (#2248) with its handler.
Still not changed: whether a modern `skills/list` RESULT SCHEMA must
require that envelope. Stamping it server-side is free; requiring it
client-side would reject servers on a reading nobody has checked against
the normative text. #2248 owns settling it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
Three things the Skills work got wrong in that modal, all found by review
of the PR screenshots.
- The "Skills Extension" section repeated `io.modelcontextprotocol/skills`
as its value — the identifier the "Server Extensions" section two rows
above already lists, so the section added nothing. What a flat key list
*cannot* show is the extension's sub-options, which is the fact a server
author opens this modal to check. Renamed "Skills Extension Options" and
rendered as a ✓/✗ row for `directoryRead`, in the same vocabulary as the
capability columns, so it reads as the same kind of claim.
- The extension sections' contents were bold (`ValueText`, the value half
of a label/value pair) while sitting directly beneath the capability
checklists, which are plain. They are lists of items, not values, so
they now use the same `Text` the checklist rows do.
- Those lists were comma-joined into one line, which wraps mid-identifier
in a half-width column. One row per identifier.
The `skills-directory-read` tests now assert `data-supported` rather than
the copy: "Not supported" contains "Supported", so a text assertion passed
for either answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
- SkillsScreen: actually call `skills/get`. The client method and its
tests existed but no production caller invoked it, so #2234's acceptance
criterion ("skills/get retrieves a single entry") was unmet and a server
author's required handler could not be exercised. The detail pane now
fetches the selected URI on demand and reports whether the fetched entry
AGREES with the one skills/list advertised — both describe the same
skill, so a disagreement is a server bug only a side-by-side fetch shows.
- core/mcp/sha256.ts: a dependency-free SHA-256, used when `crypto.subtle`
is absent. `SubtleCrypto` needs a secure context, and this app is
documented as servable over plain HTTP on a LAN IP — where every skill
verification threw and the UI reported a read failure for files it had
fetched fine. Checked against the FIPS 180-4 vectors and differentially
against WebCrypto.
- skills.ts: require the `skill:` scheme in `normalizeSkillUri`. Checking
only that a URI was hierarchical let `https://demo/SKILL.md` pass the
name and root checks — a manifest pointing anywhere on the web, reported
as conforming.
- skills.ts: `missing-digest` and `missing-size` are errors. Both are
required fields, and an omitted `size` is what lets a server slip past
the 16 MiB pre-fetch limit while the UI reports zero errors. `warning`
is now reserved for what is legal yet unverifiable — `"dynamic"`.
- SkillsScreen: key the "Verify all" batch guard to the manifest. A global
flag left a newly selected skill's button disabled until the previous
skill's reads settled — forever, if one hung.
Not changed, deliberately: whether a modern-era `skills/list` result MUST
carry the SEP-2549 caching attributes. #2234's analysis records it as open
and the review asserts the opposite; neither reading was checked against
the normative text. Leaving the schema permissive accepts a server that
omits them, while tightening on a wrong reading would reject conforming
responses — the more expensive direction. `skillsSchemas.ts` states this
and #2248 settles it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
- skills.ts: decide root containment on NORMALIZED URIs. A raw prefix
check passed `skill://acme/billing/refunds/../other.md`, which starts
with the advertised root but resolves outside it — a traversal the
conformance report was reporting as clean.
- skills.ts: `normalizeSkillUri` also rejects a relative string, so
`demo/SKILL.md` is `malformed-uri` rather than a skill path, and an
opaque-path URI (`skill:demo/..`), which the parser leaves un-normalized
and on which containment cannot be decided.
- skills.ts: `missing-description` is an error. SEP-2640 requires it, so
an absent one must not read as "0 errors" in the conformance summary.
- managedSkillsState: call `markResponseRejected` for a decode rejection,
as every managed list does. An invalid `skills/list` result was showing
in the Protocol tab as a clean success.
- SkillsScreen: per-row attempt token. The manifest key cannot tell two
verifications of the SAME row apart, so a double click (or a row button
pressed during "Verify all") let an older read finish last and overwrite
the newer verdict.
- SkillsScreen: disable "Verify all" while a batch runs. The concurrency
cap is per invocation, so repeated clicks stacked pools — 4, then 8,
then 12.
- SkillsScreen: include the finding index in each issue alert's key. Three
identical URIs produce two `duplicate-resource` findings with the same
code and URI, and React was free to drop the extras — hiding findings in
exactly the malformed input this view exists to inspect.
- test-servers/skills.ts: register `skills/list` and `skills/get` through
the PUBLIC `setRequestHandler` with explicit param schemas. The private
`_requestHandlers` map is now reached only to wrap `resources/read`,
which has to chain onto the SDK's handler rather than replace it — the
one thing the public API cannot express, and the comment now says so
instead of citing the tasks fixture.
- test-servers/skills.ts: raise `-32602` for an unknown `skills/get` URI.
A plain Error mapped to a generic server failure, making the fixture
non-conforming outside its three documented bad cases.
- clients/web/README.md: the paragraph named four contracts while still
saying the smoke "drives all three".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
- SkillsScreen: key verdicts and React elements by manifest ROW INDEX,
not URI. The checker deliberately tolerates a duplicated URI so it can
report `duplicate-resource`; a URI key collided those rows into one
verdict, so verifying either updated both and "Verify all" raced two
different digest/size declarations into the same slot.
- SkillsScreen: hold the invalidation generation in React state keyed by
the manifest, not a ref bumped during render. `useValueChange` runs its
callback during render and requires setState-only purity — an abandoned
render left the ref incremented and silently discarded a live
verification.
- SkillsScreen: title a size disagreement "Size mismatch" and render its
reason. `verifySkillResource` catches it before hashing, so the alert
was showing "actual undefined" under "Digest mismatch".
- SkillsScreen: pass `contents` to ContentViewer so a base64 SKILL.md
renders. The text-block form substituted "" and painted a blank preview
for a file verification had just read correctly.
- skills.ts: add `malformed-size` for a size that is not a non-negative
safe integer, and exclude such values from the 16 MiB total. A negative
one could pull the sum back under the limit and hide a violation.
- managedSkillsState: make the overlap guard per-session instead of a
boolean. A disconnect during an in-flight walk left it set, so the
reconnect's own load no-oped and was never retried — permanently, if
the stale request never settled. A stale `finally` can no longer clear
the live session's guard either.
- skillsSchemas: require the `{ skill }` envelope for `skills/get`. The
accepted SEP settles it, and normalizing an inline entry would let a
non-conforming response past the one place that could report it.
- #2248 and the PR description: corrected — they said
`ReadResourceDirectoryResultSchema` was already declared, which round 1
removed. #2248 now owns defining it, and records the frontmatter
cross-check gap too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
- skills.ts: report a manifest that omits the skill's own SKILL.md
(an empty list included), duplicate URIs, entries outside the skill
root, and a missing size. `Conforms` was reachable for manifests that
break invariants SEP-2640 states.
- skills.ts: cross-check the declared byte length before hashing. A size
that disagrees fails verification on its own — the digest is taken over
the bytes the server served, so agreeing with it says nothing about
whether the manifest describes them.
- skills.ts: copy the view instead of slicing its backing store in
`sha256Digest`. `SharedArrayBuffer.prototype.slice()` returns another
SharedArrayBuffer, which `crypto.subtle.digest` rejects — the cast
claimed to handle the exact input that would have thrown. No cast now.
- skills.ts: state the one obligation NOT checked here — that an entry's
frontmatter matches the fetched SKILL.md's. The digest cannot cover it,
and closing it needs a YAML parser, so it is tracked on #2248.
- managedSkillsState: cap the walk at LIST_MAX_PAGES. The repeated-cursor
guard only catches a server stuck on one cursor; endlessly unique ones
walked forever. Raises rather than truncating, like the salvage walk.
- managedSkillsState: gate every write on a session generation, so a walk
that resolves after a disconnect or destroy cannot repopulate a cleared
store or deliver the previous session's skills into the next.
- inspectorClient: send a cursor when it is `!== undefined`, not when it
is truthy. An opaque cursor may be `""`, and dropping it re-requested
page one — which the store then reported as a repeated-cursor failure.
- SkillsScreen: key verdict invalidation on the manifest (URI + digests +
sizes), not the URI alone. A Refresh that changed the manifest left a
green badge attached to a digest nothing had checked.
- SkillsScreen: epoch-guard every read continuation, so a fetch that
resolves after the selection moved on cannot write into the new one.
- SkillsScreen: bound "Verify all" to 4 concurrent reads. A conforming
manifest may hold 512 files.
- skillsSchemas: drop the guessed `resources/directory/read` result
schema. Nothing calls it, so an unverified shape could sit wrong
indefinitely; phase 3 adds it against the normative text.
- skills-http.json: declare the extension bare. It advertised
`directoryRead: true` with no handler, so Connection Info reported
"Supported" for a method that answers -32601.
- types.ts: restore the Tasks doc comment the Skills interface displaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
Adds detection, enumeration, and conformance checking for the Skills
extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234,
which is everything its Acceptance list names.
Skills is a *server*-declared extension, read off the connecting server's
`capabilities.extensions`, so it is deliberately absent from
`ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises
and the user toggles, and an entry there would be a meaningless toggle.
`skills/list` and `skills/get` are consumer-owned extension methods that
neither era codec defines, so the SDK's era gate skips them and they go out
as ordinary `client.request` calls with explicit result schemas. The
raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is
not era-gated: a legacy-era server that declares the extension is serving it.
The checks are the point, not the list view. `core/mcp/skills.ts` reports
each obligation SEP-2640 states — the name/path invariant, the digest
format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which
means integrity cannot be verified at all. Digest verification hashes the
fetched bytes with WebCrypto and returns a mismatch with both digests
attached rather than throwing, because showing a mismatch loudly is the
whole value proposition.
Files are fetched on demand: SEP-2640 is explicit that a `resources/read`
of a `SKILL.md` is not a load and confers no standing, so none of the SEP's
host machinery is implemented.
The `skills-http` fixture serves four skills over two pages, three of them
deliberately non-conforming — without those the verification code is
untestable.
Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248.
Closes#2234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
#2222 reported that `core/auth/revocation.ts` breaks a client secret
containing `+`, on the basis that `encodeURIComponent` leaves `+` bare.
It does not — it escapes it as `%2B`. The characters it leaves bare are
exactly `!'()*-._~` and alphanumerics, every one of which a form-
urldecoder passes through unchanged, so its output decodes identically
under both algorithms; verified over every code point up to U+2FFF.
Switching to `URLSearchParams` would be the literal algorithm §2.3.1
names and a small regression: it encodes a space as `+`, which a lenient
server decoding with `decodeURIComponent` alone reads as a literal `+`,
while `%20` is understood by both. The encoder is therefore unchanged
and the reasoning is recorded on it.
The report's second half stands and is the real defect: the fixture
decoded with `decodeURIComponent`, the encoder's own inverse, so the
round trip succeeded for every input by construction and no test could
have failed on an encoding mistake. `/oauth/revoke` now runs a genuine
RFC 6749 Appendix B decode, with unit cases for `+`, a space, `:` and
`%` (asserted on the wire and through an independent decoder), an
end-to-end client whose secret is base64 with a `+` in it, and a guard
that an unencoded credential is refused — the case that separates the
two decoders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhVaufrEZAfFAXcg6456hK
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot: the catalog entry is mutable and the live client is not. A card
can be edited while connected — `onConfigSubmit` writes the catalog and
does not rebuild the client — so after A connects to X and is edited to
Y, the entry reads Y while the session is still authorized against X.
Both consumers read the entry, so both got it wrong in the same way:
- the hook compared a cleared entry against A's *entry* URL, so clearing
another entry still at X missed the match and deleted and revoked the
live client's X-keyed credentials without disconnecting it — the exact
failure this PR exists to prevent;
- `runClear` locked on the entry's key, so a clear of edited-A (`url:Y`)
and a clear of entry B at X (`url:X`) took different locks while both
performed the same X-keyed operation through the same live client.
`InspectorClient.getTransportConfig()` returns the config it was
constructed with, which is what its credentials are keyed under, so both
now resolve from that and fall back to the catalog entry only when there
is no client.
The rule moves into one exported function, `resolveOAuthClearIdentity`.
Two copies of it that disagree is precisely the class of bug #2217 is,
and the two consumers each re-deriving it is how this round's finding
came to exist at all.
Both fakes gain a `getTransportConfig`, and three tests cover the split:
the resolver's own two (the client's URL wins; an active clear locks on
the client's key) and a hook case asserting an edited active entry still
disconnects when the entry sharing the client's real URL is cleared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, and the repo's own rule against an unjustified `as unknown as`.
The `useServers` cast was the more useful catch: removing it surfaced
that the mock omitted `reorderServers` and `importSource`, so App code
calling either would have read `undefined` with this test still passing
type-check. The full result shape is supplied instead, matching the
`mockServersWith` helper above.
The client cast becomes an intersection — the instances are already typed
`EventTarget`, so naming the test-only spy on top of that expresses what
is needed without erasing the rest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1.
Refreshes are concurrent — an `oauthComplete` can start a second read while
the first is still in flight — and nothing makes them settle in order. The
new catch made that visible: a slow earlier read could reject *after* a
newer one succeeded and clear the fresh result. Guard both handlers with a
monotonically increasing sequence so only the newest read writes.
The two tests were also too weak to detect their own guards, as the review
pointed out. Both started from an undefined panel, so neither could tell a
correct handler from one that merely swallowed the error:
- The rejection test now seeds a successful read first, then rejects an
`oauthComplete` refresh and asserts the already-loaded details clear.
- The post-cleanup test now reconnects with a replacement client that
populates details, then rejects the stale read, and asserts the current
details survive.
- A third test covers the ordering guard directly.
Each of the three guards has exactly one test that fails without it,
verified by mutation: dropping the sequence check, dropping the clear, and
dropping the `cancelled` check each break one test and no others.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBkPzbuxgyqiz39ytmQzMB
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot: `oauthClearKey`'s unit tests prove the derivation, but a
regression that dropped the `has` check, added or deleted the wrong key,
or invoked the clear twice would still have passed.
Adds an App.test.tsx case that connects entry A, holds its clear open on
a deferred `clearOAuthTokens`, then drives a clear for entry B — a
different catalog id against the same URL — and asserts the underlying
clear ran exactly once, and once more after the first settles, so the
key is proven released rather than leaking a permanently dead control.
Verified to fail against the id-keyed guard.
Two supporting changes it needs:
- an `open-settings-b` control on the mocked InspectorView, the only way
to reach a settings modal for a server other than the active one;
- `@inspector/core/mcp/remote/index.js` becomes a partial mock.
`getWebProxiedFetch` reaches for `createRemoteFetch` there, and the
bare stub threw a missing-export error before the clear under test ever
ran — the failure surfaced as the generic "Could not clear the stored
OAuth state" toast.
The case carries an explicit 20s timeout and `delay: null`: it drives
three full modal interaction sequences against the whole App tree, which
runs past the 5s default when the suite is under load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
Clearing the retry from the mirror effect raced: an async command continuation
can open a prompt and install its retry before a previous commit's passive
effect flushes, and that effect's stale `pendingStepUp === null` snapshot would
then delete the new prompt's retry.
Install it in `trySetPendingStepUp` instead, in the same synchronous step that
opens the prompt — `null` for an ambient prompt, the operation for a
command-scoped one. Every prompt comes through there and `handleStepUpAuthorize`
is the only reader, so a slot cleared as a side effect can leave a stale closure
behind harmlessly: the next prompt overwrites it. A refused prompt still
installs nothing (#2165).
Copilot round 3 on #2237.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot: `runClear`'s double-click guard deduped by `server.id`, which is
the same identity mismatch this PR fixes, one layer up. Two catalog
entries against one URL share one credential blob, one grant and one
revocation, so an id-keyed guard let exactly the race it exists to
prevent through between them — concurrent store writes, concurrent RFC
7009 requests, and two contradictory teardown toasts.
The guard now keys on `oauthClearKey`, a pure helper that resolves the
OAuth storage key and falls back to the entry id for a config that has
none (stdio), where there is no shared state to collide over. The id
fallback keeps such entries distinct from each other rather than
collapsing them onto one key, and both forms are prefixed so a
user-supplied id can never impersonate a URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot: the stale-session guard deliberately skips `disconnect()` after a
switch, and `disconnect()` can also reject (which gets its own toast), so
claiming the active session "was disconnected too" is not always true.
What is always true is that its stored tokens went with the shared blob
and it must reconnect, so the message says that instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
The retry closure belongs to the prompt, but the two paths that clear the
step-up slot as a *consequence* of something else — the server-switch reset
and `resetOAuthRecoveryState` on disconnect — left `pendingStepUpRetryRef`
installed. The next prompt to open without a retry of its own would inherit
it, so authorizing an ambient step-up on server B ran the command server A was
left mid-way through.
Clear it in the existing mirror effect whenever the slot is null, so no arm has
to remember. A ref write in an effect, since the render-phase reset must stay
pure.
Copilot on #2237.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
`useOAuthRecovery` cleared its two server-scoped pending-OAuth slots — the
deferred re-auth and the step-up prompt — in a `useEffect` keyed on
`activeServerId`. An effect runs after the commit, so a server switch painted
one frame still carrying the previous server's re-auth banner or step-up
prompt: an authorization affordance attached to the wrong server.
Reset both during render with `useValueChange` instead. `activeServerId` is a
primitive, so it satisfies the helper's `Object.is` stability requirement, and
the previous value is read from the `setState` updater argument rather than
from `sessionRef.current`, keeping the render-phase callback pure.
`react-hooks/set-state-in-effect` did not fire on the original because the
effect read the previous value through a ref rather than from a prop or state.
The test records one entry per committed render and asserts both slots are
empty in the first frame after the switch — the pre-existing `waitFor`-based
tests pass against the effect version too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
The connection-details refresh effect in useOAuthRecovery voided
inspectorClient.getOAuthState() with a lone .then and no rejection
handler. In the browser that read goes through the remote OAuth store, so
it is a network round trip that can reject — a backend that is down or
restarting, a 401 on the API token, malformed stored state. void silences
no-floating-promises without terminating anything, so every such failure
became an unhandled rejection, on the initial refresh and on every
oauthComplete refresh.
Terminate the chain with a .catch that clears the panel details rather
than leaving the last successful read on screen: the panel reports the
current OAuth state, and a stale answer is indistinguishable from a fresh
one. The cancelled guard is repeated on the catch so a rejection arriving
after unmount does not write. The void stays, now with the one-line
justification AGENTS.md asks for — a synchronous useEffect body cannot
await.
Two tests cover it. Both fail against the unfixed source with unhandled
rejections, which is the failure mode itself: an unhandled rejection
fails the whole vitest run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBkPzbuxgyqiz39ytmQzMB
Signed-off-by: cliffhall <cliff@futurescale.com>
OAuth state is keyed by server URL, but `clearServerOAuthAndDisconnect`
decided "is this the active connection" from the catalog entry id. Those
are different identities and nothing keeps them in sync: `serverList`
enforces no URL uniqueness, so two entries with distinct ids against one
URL are a supported state — and a natural one, since separate entries are
how a user keeps different names, headers or per-server settings against
the same server.
Clearing the inactive entry therefore deleted the shared URL-keyed blob —
the active session's tokens, DCR client id and PKCE state — and, with
#2144 in, revoked its grant at the authorization server, while the id
check took the inactive branch: no live-client clear, no disconnect, no
session cleanup. The active session was left connected on credentials
that no longer existed, with nothing told to the user; the break then
surfaced somewhere else entirely, on the next refresh, 401 or reload.
The branch now compares the OAuth storage key of the cleared entry
against the active entry's, and treats a match as affecting the active
session — live-client clear, disconnect, session cleanup — exactly as
clearing the active entry does. The existing id and client-identity
checks stay on top: they guard the separate stale-session race from
#2144, and the snapshotted id they revalidate is now the active one
rather than the cleared entry's, which for a shared-key clear are not the
same. The toast says why the session went away.
The alternative, prohibiting duplicate URLs in `serverList`, would remove
a legitimate workflow to fix a bug in the consumer and could not repair
catalogs that already hold duplicates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
Two suppressed comments, both accurate.
- Pin zod, zod/v4 and open to the root in vitest.shared.mts, and add matching
paths to the three clients/web tsconfigs. The claim they replace — that these
resolve unambiguously by walk-up — was wrong: both still sit at the top level
of clients/web/node_modules as transitive copies (zod under
eslint-plugin-react-hooks, open under Storybook), so an unpinned bare import
from a web test resolved the client copy while core/ and the SDK packages
resolved the root's. For open that is only wasteful; for zod it is a schema
built by one copy and an instanceof check made by the other across the whole
@modelcontextprotocol/* surface. zod is deduped as well, and zod/v4 gets its
own entry because first-party code imports both specifiers and pinning one
would split the package rather than collapse it.
- Update the consumer comments that #2195 falsified. clients/web/vite.config.ts
(three places) and server/start-vite-dev-server.ts all still said the shared
aliases resolve pino/hono and friends from clients/web/node_modules; they now
state the split — react and react-dom from the client install, everything
root-declared from the repo root.
- local-dev skill: replace the wrong 'resolves unambiguously' list with the
distinction that actually matters. Deleting a declaration is not the same as
the package resolving from the root, because chokidar, zod, open and react
all sit in a client install transitively; check the install, not the manifest.
ajv, commander and undici carry no pin because no client install has a copy.
npm run local:gate passes from a cold tsc build.
Signed-off-by: cliffhall <cliff@futurescale.com>
- clients/web/tsconfig.{app,node,test}.json: repoint the pino, hono,
hono/streaming, @hono/node-server, atomically, chokidar and @napi-rs/keyring
path mappings from ./node_modules to ../../node_modules. The stale mappings
were not failing the build — an unresolvable paths entry falls back to
ordinary walk-up resolution, which finds the root copy — but chokidar is the
case where the fallback never runs: Vite's transitive copy still sits under
clients/web, so tsc was type-checking against the client's copy while Vitest
and the shipped bundle both resolved the root's. Same version today, silent
skew tomorrow. Both path prefixes now carry a comment saying which ownership
each one asserts.
- local-dev skill: the consolidation rationale no longer groups commander, open
and @hono/node-server (client-imported) with the core/ set, no longer claims
#2195 moved undici (it was already root-only), and no longer implies every
root-declared package has an alias.
npm run local:gate passes with clients/web/node_modules/.tmp cleared, so the
tsc results are from a cold build rather than an incremental cache.
Signed-off-by: cliffhall <cliff@futurescale.com>
- Complete the bundler external lists against the AGENTS.md invariant rather
than against today's reachability: chokidar, hono and react to clients/cli,
chokidar and hono to clients/tui, react to the web runner. Every root-declared
package core/ imports at runtime is now named in all three, so a new import in
core/ cannot silently inline one.
- AGENTS.md: split the single over-broad bullet in two. commander, open,
@hono/node-server, vite and @vitejs/plugin-react are root dependencies reached
only from client code, so they are not part of the core/ set and need naming
only in the external list of the client that imports them. Give the
vitest.shared.mts pins their own rule, stating the react/react-dom exception
instead of implying every root-owned package is aliased to the root.
- vitest.shared.mts: the umbrella comment now says root-OWNED rather than root
runtime dependency — express is test-only and lives in the root
devDependencies — and notes that the list is deliberately not the complete
root set, since a package that resolves unambiguously needs no pin.
- local-dev skill: document why react/react-dom stay client-pinned, which the
previous text contradicted.
npm run local:gate and npm run verify:bundle-externals pass.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1.
@vitest/browser-playwright declares an EXACT peer on vitest ("vitest":
"4.1.10"), so that package — not the root's range — decides which vitest lands in
clients/web. Dropping web's own vitest declaration left the root free to resolve
4.1.11 while web's peer stayed at 4.1.10, so web's tests ran on one vitest while
loading a @vitest/coverage-v8 provider built against another. Both still passed,
which is the bad part.
vitest and @vitest/coverage-v8 at the root and @vitest/browser-playwright in
clients/web are now pinned exactly to 4.1.10 and move together, the same
discipline as the exact prettier pin (#1790).
The docs also claimed more than the change delivers. npm auto-installs an unmet
peer into the install that needs it, and a client install cannot see the root's
tree — so web's eslint-plugin-react-refresh / -storybook and the TUI's
eslint-plugin-react-hooks each pull a client-local eslint, and web's
Storybook/Vitest stack pulls a local typescript and vitest; @types/express
hoists an @types/node into web and cli. Those copies sit nearer than the root's
and win. AGENTS.md and the local-dev skill now say so, and state what the
consolidation actually buys: one declaration and one place to bump, with
verify:dep-lockstep as the guard that the copies agree.
Signed-off-by: cliffhall <cliff@futurescale.com>
Every runtime dependency core/ imports is now declared once, in the repo-root
package.json, and nowhere else. Deleting the client-side copies does not merely
align them: a package installs only into an install root that declares it, so
with no second declaration a second copy cannot be created at all — the failure
class #1896 was opened for and #1965/#2029 built verify:dep-lockstep to detect.
- clients/{web,cli,tui,launcher}: dropped ajv, atomically, chokidar, commander,
open, pino, zod, @napi-rs/keyring, hono, @hono/node-server and react, as each
applied. clients/cli and clients/launcher now declare no runtime dependencies.
- Root hono floor raised ^4.12.18 -> ^4.13.1, keeping the higher of the two
ranges that were in the tree rather than silently lowering web's.
- tsup external lists: added ajv and zod to all three bundles, plus atomically
and open (cli) and atomically (tui). tsup externalizes only what the nearest
manifest declares, so a package that moves to the root is inlined unless it is
named explicitly.
- vitest.shared.mts: repointed the pino, hono, @hono/node-server, atomically,
chokidar and @napi-rs/keyring pins from <client>/node_modules to the repo
root, joining express, yaml and proper-lockfile. Left behind they would
resolve to a directory that no longer exists, or to a transitive copy an
unrelated dependency dragged in. react and react-dom stay client-pinned so
the pair stays version-matched within an install.
- Docs: AGENTS.md dependency placement, the root README and the local-dev skill
describe the consolidated layout.
verify:dep-lockstep candidate set: 4 -> 2 (hono and pino fell out; only
@standard-schema/spec and zod still reach one tsc program from two installs).
npm run local:gate and npm run pack:verify both pass.
Signed-off-by: cliffhall <cliff@futurescale.com>
Declare the toolchain every client uses once, at the repo root, and delete the
per-client copies: eslint, @eslint/js, typescript-eslint, globals, prettier and
typescript (all five manifests), plus vitest, @vitest/coverage-v8 and
@types/node (four). Adds the latter three to the root manifest; the first six
were already there.
Every client reaches the root copy by walk-up — npm run puts each ancestor
node_modules/.bin on PATH, and Node and TypeScript resolve modules and @types
the same way — so nothing about the client scripts changes. clients/launcher now
declares no devDependencies at all and its validate is unchanged.
The copies had already drifted: globals sat at ^17.7.0 at the root against
^17.4.0 in all four clients, and typescript-eslint at ^8.65.0 against ^8.56.1.
Consolidating takes the root's version in both cases, and makes the exact
prettier pin (#1790) a single pin rather than five that can diverge.
Tools only one client runs stay where they are — tsup, tsx, vite, vite-node,
playwright, storybook, happy-dom, ink-testing-library, and each client's own
@types/*.
Documents the rule in AGENTS.md's Dependency placement section and the reasoning
in the local-dev skill.
Signed-off-by: cliffhall <cliff@futurescale.com>
Seeding the settings draft during render made `useSettingsDraft` call
`resolveInitial` there, and App.tsx passes `lastPersistedSettings.resolve`.
That resolver was neither pure nor current:
- it could `recordsRef.current.delete(...)`, a shared-state mutation that a
render React replays under StrictMode or abandons would run an
unpredictable number of times;
- it read `serversRef`, which is mirrored in a passive effect and so trails
`servers` by one flush — so on exactly the render where a refreshed list
arrives, it answered from the previous commit's list. That render is the
one that seeds the draft.
`resolve` now reads `servers` directly and only reads. Eviction moves to an
effect: nothing depended on it happening at a particular moment, it only
keeps a dead record from being re-checked on every later write. Reading
`servers` also makes the supersession test fire a render earlier, which is
the right direction — the record is being checked against a list that has
genuinely been re-read.
`resolve`'s identity now changes with the list, so the test harness
delegates to the current hook result instead of handing back a snapshot from
the first render; a snapshot would have closed over the initial list and
made `setServers` look like a no-op. New test asserts `resolve` answers from
the list of the render it is called in, verified to fail against the ref
read.
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>
- `useEmaIdpLoginState` attributed a read to the wrong issuer. A bare
`EmaIdpLoginState` carries the previous issuer's answer over to a new one
until a fresh read lands — and if that read fails, indefinitely, so the
settings surface reports "logged in" for an IdP nobody authenticated
against. Round 1's `.catch()` made that worse by design, and the test
added with it asserted the wrong behaviour outright.
State is now the pair (issuer, value) and `loginState` derives from it, so
a mismatch reports "none". A monotonic read token — the device
`useInitialConfig` already uses — drops completions that were overtaken,
including a read still in flight when `logout` clears the session; issuer
keying cannot catch those, since both carry the same issuer. Both guards
are pinned by tests verified to fail without them.
Deriving the value also removed the last synchronous commit on that path,
so the `set-state-in-effect` suppression is gone — one fewer than the PR
started with.
- The draft-seeding change had no test that could tell it from the effect
it replaced: the regression is a committed frame, and RTL flushes passive
effects before any assertion on `result.current`. Both hooks now have a
commit-level trace recorded from inside the tree, asserting the previous
target's (or session's) draft is never committed for the new one. Both
were verified to fail against the effect implementation.
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>
- `useEmaIdpLoginState`: the effect-triggered `refresh()` was discarded with
`void`, but it does not own its failures — `getEmaIdpLoginState` awaits
`storage.getIdpSession`, which rejects when the backend is unreachable.
Terminate with `.catch()` and leave `loginState` untouched, as `logout`
already does. Covered by a new test that also asserts no unhandled
rejection escapes.
- Correct the history: the plugin was *client-local*, declared by both
`clients/web` and `clients/tui`. The root could reach neither, since Node
resolution walks up rather than down — that, not web-exclusivity, is why
`core/react/` went unlinted.
- Correct the enforcement claim: `clients/tui` registers the plugin but
deliberately enables only `rules-of-hooks` and `exhaustive-deps`, so
`set-state-in-effect` and `refs` hold for `clients/web` and `core/react/`
only. Says so in AGENTS.md and in the Copilot mirror, which would
otherwise have reviewers cite rules the TUI gate does not run.
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>
Copilot round 6. `handleCommandScopedAuthRecovery` wrote
`pendingStepUpRetryRef` before calling `trySetPendingStepUp`, which
REFUSES a second prompt while one is already open — so the refused
command's operation replaced the open prompt's, and authorizing that
prompt ran the command the user had just been told could not start.
The retry now goes in only on a prompt that actually opened, which makes
the pairing hold in both directions: the prompt and its retry are
installed together, and the EMA branch takes them out together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 5. `pendingStepUpRetryRef` is shared, and the EMA branch
dismisses the prompt *before* its awaits — so a newer command can open
its own step-up and install its own retry while the first authorization
is in flight. Every arm that read or cleared the ref afterwards was
acting on that newer operation: running it under the wrong
authorization and reporting its failure to the wrong source, or
deleting it outright.
The attempt now takes its retry out of the ref before any await, and no
arm touches the ref again. That covers all three sites at once and
removed the interactive-arm clear added last round — the value is
already out by then. The standard step-up path redirects and clears
synchronously, so it has no such window.
Also rewrites the `writeOAuthResumeSnapshot` docstring, which still
described the byte-comparison token it no longer returns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3.
"stops listening on unmount" asserted that the rendered value stopped
changing after unmount, which proves nothing: React drops a store
notification for an unmounted component whether or not the listener is
still attached, so the test passed with the leak it existed to catch.
Spy the store's add/removeEventListener and assert the exact function
attached is the one detached. Verified it discriminates by deleting the
cleanup, watching this test fail, and restoring.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 4:
- the EMA `interactive` arm kept `pendingStepUpRetryRef`, on a comment
claiming it was "still on its way to a redirect". That is wrong twice:
`prepareOAuthRedirect` returns before the navigation is resolved, so a
pre-navigation failure leaves the operation for a later, unrelated
step-up to re-run; and the full-page navigation destroys the closure on
the success path anyway. It now clears the ref, matching the standard
step-up below.
- the two surviving `void (async () => …)()` sites now state why the
caller cannot await, as AGENTS.md requires — the comments had described
only what the catch handles.
- the `randomUUID` fallback test restored an own descriptor that never
existed (it is inherited from `Crypto.prototype`), leaving an own
`randomUUID: undefined` that would push every later test in the file
onto the fallback path. It deletes the temporary property instead, and
asserts the global is a function again afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 3, four items:
- the snapshot "attempt token" was the stored serialization, which is not
unique: the snapshot records neither the authorization URL nor its
`state`, so two concurrent redirects for one server with unchanged UI
serialize identically. `OAuthResumeSnapshot` now carries an optional
`attemptId`, stamped per write and matched on clear. Optional because
an older build's snapshot, mid-redirect across an upgrade, has none —
and treating that as a match would strand a live callback.
- the resume-failure toast promised a retry even when the session had
ended and nothing was restored. Copy is now chosen by whether anything
is left pending; `authRecoveryAbandonedMessage` covers the other case.
- a connect-scoped redirect that failed left the connection at
"connecting" with the active-server lock held, since the redirect was
what would have ended the attempt. It now disconnects first, as the
sibling arm in `useConnectionLifecycle` does — scoped to that context,
because an ambient or step-up failure does not mean the session is dead.
- both terminal EMA step-up failure arms now drop the stored retry. A
later step-up does not overwrite the ref, so a retained operation could
be re-run by an unrelated authorization. The `interactive` arm keeps
it: that one is still on its way to a redirect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1.
The cache was keyed on the store and its revision alone, so a changed
`read` was answered with the previous reader's output, and with a null
store — where the revision is pinned at 0 — a changed `whenAbsent` was
ignored outright. Key on `event`, `read` and `whenAbsent` too.
This makes an unstable argument defeat the cache rather than silently
serve a value derived from the previous one, so React fails it with "The
result of getSnapshot should be cached" at first render. That is the
intended failure mode and is now documented as such: a loud error beats a
wrong list nobody can account for.
Also correct the docs, which described the helper as universal. That
misread useListError as a violation: its snapshot is the stored Error
instance, already referentially stable, so it subscribes with
useSyncExternalStore directly and needs no cache. The rule is
read-during-render; the helper is for getters that return a fresh value
per read.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 2, both findings valid:
- `resumePendingReauth`'s restore could resurrect a challenge whose
session ended during the awaits — a server switch or disconnect clears
the slot on purpose. It is now gated on the attempt still targeting
the live session, client identity included (reconnecting to the same
server builds a replacement client an id-only check would admit), and
uses a functional update so a newer deferral queued in the same batch
wins rather than being read past.
- `prepareOAuthRedirect` cleared whichever resume snapshot was current,
not the one it wrote. Nothing single-flights that callback, so a
redirect failing before navigation could delete a later attempt's
callback-routing state. `writeOAuthResumeSnapshot` now returns the
serialization it stored, and `clearOwnOAuthResumeSnapshot` removes it
only while it is still the current one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
Every state-subscription hook in core/react/ seeded local React state from
its store prop and re-synced it inside a useEffect. That shape paints one
frame of the previous store's data when the prop changes (switching
servers), and loses any event dispatched between the render that took the
snapshot and the effect that attached the listener.
Convert all 16 to useSyncExternalStore through a new shared
useStoreSnapshot, following useManagedListError (#1954).
useSyncExternalStore needs a referentially stable snapshot, and these
getters hand back a defensive copy, so TypedEventTarget now keeps a
per-event-type dispatch counter (getEventRevision) that the snapshot is
cached against. A contents comparison would not do: MessageLogState folds
a response into its request entry by mutating that entry in place, which a
shallow compare reports as unchanged. The bump lives on the dispatchEvent
override so no dispatch — including a hand-rolled CustomEvent — can bypass
it.
useInspectorClient is included though the issue did not list it; its nine
subscribed slices made the stale frame most visible. Its lastError stays
useState (accumulated from events, no getter to read back) but resets on
client swap during render rather than in an effect.
The settings-draft and async fetch hooks are deliberately untouched: the
former hold genuinely local editable state, the latter are I/O rather than
store subscriptions.
Also answers the issue's open question. react-hooks/set-state-in-effect
never fired here because eslint-plugin-react-hooks has never linted
core/react/ at all — it is a clients/web devDependency and the root config
loads no React plugin. Corrected the config comment that gave "no JSX" as
the reason; the gap itself is #2192, measured at 15 findings, none of them
in the hooks converted here.
Closes#1955
Signed-off-by: cliffhall <cliff@futurescale.com>
Three findings, all valid:
- `beginInteractiveAuthorization` navigated *before* awaiting provider
creation and flow-state recording, so a rejection could arrive after
the browser had been handed over — and the web catch clears the resume
snapshot on one. Fixed at the root: the navigation now resolves last,
after every fallible step, which makes every rejection the method can
produce a pre-navigation rejection. It is also better on its own
terms — a provider that cannot be built means the callback could not
have completed, so navigating first only bought a dead-end round trip.
- `resumePendingReauth` restored the pending slot unconditionally, which
could overwrite a newer challenge deferred while it was awaiting.
Restores only into an empty slot, so latest wins.
- the `authChallengeInteractive` catch re-read the active server rather
than the one the challenge arrived for. `serverId` is now captured
synchronously in the listener and used by both the body and the catch.
Three new tests, one per finding, each driving the interleaving rather
than the happy path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
#2153 dropped App.tsx below the React Compiler's bail-out, so the
compiler-powered rules in eslint-plugin-react-hooks v6 began reporting
nine latent findings. Each was suppressed with a directive pointing at
this issue; the later phases moved them into useConnectionLifecycle and
useMcpApps. Eight remained. Five are now fixed and three re-justified on
their own terms, so no suppression points at an open issue any more.
Fixed:
- `connectedServerId` was a useState mirrored from `connectionStatus` and
`activeServerId` by an effect. It is a pure function of both, so it is
derived. A state copy could only lag the status it copies by one
committed render, which is a frame of the wrong highlight for no gain.
- The two latest-ref writes (`sandboxUrlRef`, `listedResourcesRef`) move
from render into a layout effect, the same fix review applied to
`setupClientForServerRef` in #2153. Both refs are read by an event
handler or an async handler that runs after the commit, so neither
reader observes a different value.
- `appElicitationController` was a ref seeded during render with `??=`.
The instance is never reassigned, so `useState`'s lazy initializer
gives the same create-once semantics without a render-time ref read —
which also retires the two directives on the `useSyncExternalStore`
arguments it fed.
Kept, with the argument in place of the pointer:
- The deep-link `onToggleConnection` call. It opens a transport and
drives a handshake; the state it sets along the way is a consequence of
that I/O, not something derivable from props. An effect is the right
tool, and the one-shot `deepLinkConnectRef` guard is what bounds it.
- The two `createAppBridgeFactory` calls. `react-hooks/refs` cannot see
through the factory and warns that a ref-reading dep it is handed
(`getListedResourceMeta`) may be called during render. It is not: the
factory only stores its deps, and the sole call site is inside the
async `sandboxready` handler.
No behavior change beyond `connectedServerId` landing in the same
committed render as the status it derives from, rather than the next one.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
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>
Each of the five sites in `useOAuthRecovery` discarded a promise with
`void`, satisfying `@typescript-eslint/no-floating-promises` but not the
narrower rule AGENTS.md states — `void` only where the callee owns its
failures or the caller cannot await, with a comment saying which.
Each now picks a user-visible outcome:
- `prepareOAuthRedirect` clears the resume snapshot (so the next load
cannot read a redirect that never started as an *abandoned* one),
flags the server and raises the re-auth banner.
- the `authChallengeInteractive` handler flags and banners, where before
a rejection drew no UI response at all.
- `resumePendingReauth` restores the pending slot and toasts, so the
next trigger retries the deferred recovery instead of losing it.
- the `/oauth/callback` effect flags and banners what falls between its
existing arms — the client rebuild and the post-resume scope check.
- `handleStepUpAuthorize`'s EMA branch reports exactly as its `failed`
outcome arm does; the stored retry gets its own catch, since by then
the permissions leg succeeded and the failure is the command's.
The two surviving `void`s carry the justification AGENTS.md asks for.
Closes#2165
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>