Commit graph inspector/core
Author SHA1 Message Date
cliffhall
4789cb954e fix: preserve CIMD registration provenance across SDK issuer binding (#2242)
`BaseOAuthClientProvider.saveClientInformation` treated every save without an
explicit `registrationKind` as DCR. The SDK reaches it from three places in
`auth()` and only one is a dynamic registration, so the issuer-binding write
overwrote the `cimd` provenance our own pre-registration had just stored — and
Connection Info reported `Dynamic (DCR)` for a connection that never issued a
`POST /oauth/register`.

Recover the kind instead: a `client_id` equal to the configured
`clientMetadataUrl` is CIMD (which also covers the SDK's own CIMD write), and
otherwise a stored registration with the same `client_id` carries its recorded
kind forward. Matching on `client_id` keeps stale CIMD provenance from leaking
onto a later DCR registration for the same server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 00:04:40 -04:00
cliffhall
3e2546b040 feat: seed the MCP org example server as a remote first-run card (#2201)
Adds a third entry to `DEFAULT_SEED_CONFIG`, `example-server-default`, a
Streamable HTTP card pointing at the MCP org's own feature-reference server
(https://example-server.modelcontextprotocol.io/mcp). Until now every seeded
server was a local stdio process and every remote example in the docs was a
placeholder, so trying a remote connection meant finding or standing up a
server first.

The endpoint is protected by OAuth, but its authorization server advertises
Dynamic Client Registration and fronts a *mock* upstream IdP, so the whole
round trip completes with no account, no API key and no pre-registered client
— which is what makes it viable as a shipped default. Verified end to end
against a prod build: the card connects, negotiates MCP 2025-11-25 and lists
the reference server's tools.

The seed carries no `protocolEra`. That is not a default-by-omission: the
server answers the plain `initialize` handshake and rejects `server/discover`,
so it has no modern (SEP-2663) era to negotiate — `"auto"` would only buy a
failed probe and `"modern"` would fail the connection outright.

Docs updated alongside: the seeded-catalog block and per-client table in
`docs/mcp-server-configuration.md`, the Streamable HTTP example there (which
now leads with the live endpoint and keeps a placeholder to illustrate
`headers`), and the seed description in `clients/launcher/README.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012x3rC3JXU17aW3uSwjPoi5
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 22:57:53 -04:00
Cliff Hall
665e8aa84a
Merge branch 'v2/main' into v2/fix/2224-union-required-undeclared 2026-09-06 17:57:52 -04:00
cliffhall
3b4eb447c9 fix: walk past an empty-string cursor in the legacy task refresh (#2220)
`ManagedRequestorTasksState.refresh()` drives the legacy `tasks/list` walk
itself, and both of its cursor tests were truthiness checks — so the fixed
adapter was never reached. `while (cursor)` ended the walk on a `""`
`nextCursor`, leaving the user-visible task list on page one, and
`cursor ? [...this.tasks, ...page] : page` would have replaced the
accumulated list rather than extended it had the walk continued.

Both now test `=== undefined`. The other four paged/managed stores already
did (`pagedToolsState`, `pagedPromptsState`, `pagedResourcesState`,
`pagedResourceTemplatesState`, `pagedRequestorTasksState`,
`managedSkillsState`); this was the last walk on the truthiness side.

Covered by a new case in `managedRequestorTasksState.test.ts` that paginates
`"" → "c2" → end` and asserts the cursor each call received, so a walk that
terminated correctly but re-requested page one still fails. Reverting either
guard on its own fails exactly that case.

Reported by Copilot on #2277.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 13:59:51 -04:00
cliffhall
9b35e530fc fix: send an empty-string pagination cursor verbatim on every list adapter (#2220)
An MCP cursor is an opaque string: the spec constrains neither its content
nor its length, so `""` is a `nextCursor` a server may legitimately return
and the client is required to send back verbatim.

`listPrompts`, `listResources`, `listResourceTemplates` and
`listRequestorTasks` built their request params with `cursor ? { cursor } : {}`,
a truthiness check that cannot distinguish "no cursor" from "the cursor is
the empty string". Against a server that paginates with `""` they dropped it
and re-requested page one — a list that silently stops at the first page, or
a walk that loops on it forever. Nothing surfaced an error, because the
request was well-formed; it just asked the wrong question. `listTools` (and
the later `listSkills`) already used `cursor !== undefined`; this brings the
other four into line.

`getReplayableParams` in `protocolReplay.ts` existed partly to mirror the
asymmetry — its `method === "tools/list" || params.cursor !== ""` special
case collapses to `typeof params.cursor === "string"` now that the adapters
agree, and its long comment is updated rather than left describing a state
that no longer exists.

Coverage:

- `inspectorClient-list-cursor.test.ts` asserts the outbound params of all
  five adapters — `""` carried, an absent cursor sending no `cursor` key,
  a non-empty cursor forwarded unchanged. `listTools` is the control.
- `test-servers/configs/empty-cursor-http.json` (`emptyStringCursor`) hands
  out `""` as the cursor for page two, the shape no other fixture produces,
  and `empty-cursor.test.ts` walks all three of its lists end to end.
- Removing the fix from any one adapter fails exactly its two cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 13:28:44 -04:00
cliffhall
81f2115e8b fix: decline a union branch requiring a name it never declares (#2224)
`isOfferable` in `core/json/rootUnion.ts` checked that a merged branch carries
at least one property and that every property value is readable, but never that
each name in `required` resolves to a property a form can render. A branch like
`{ properties: { kind }, required: ["kind", "payload"] }` was therefore offered
in the picker, rendered no control for `payload`, and had the submit-time check
report it missing permanently — the exact dead end the function's own comment
already reasons through for a `false`-schema required field, one case short.

Judge the merge, so a branch requiring only names the ROOT declares —
`anyOf: [{ required: ["email"] }, …]` — stays offerable as before. `hasOwn`
rather than `in`, since an argument legally named `constructor` would otherwise
resolve through the prototype and read as declared.

Declining every branch of a union leaves the web form with neither a picker nor
a field, which is a different dead end. `SchemaForm` now seeds the raw-JSON
editor (#2151) in that one case — a root union that resolved to no branches and
no properties — so the arguments stay expressible. Seeded rather than forced, so
the switch still works, and re-seeded during render (never in an effect) because
the form is reused across tools rather than remounted. A tool that genuinely
takes no arguments is untouched. The TUI has no such editor; there the union is
simply not offered, which is already better than a section that can never be
submitted.

Adds a `record_shipment_by` preset and wires it into the
`root-union-schemas-http.json` showcase so the shape is reproducible by hand,
with docs/test-servers.md covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYT4eKdnx1yRw7x4bW7Ts
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 12:59:52 -04:00
Cliff Hall
5af54301a6
Merge branch 'v2/main' into v2/feat/2234-skills-extension 2026-09-05 12:50:10 -04:00
cliffhall
1ee958091e feat(skills): address Copilot review round 16 on #2234
Round 16 reported no inline comments and three suppressed findings, all
valid:

- `getSkillsExtension` treated a non-object extension value (`false`,
  `"skills"`) as a declaration. SEP-2133 declares an extension as an
  object of sub-options, so a primitive is not one — now rejected,
  matching `appElicitation.ts`.
- The `onGetSkill` prop doc claimed the two responses "must agree" and
  that a difference means a broken server, contradicting the
  implementation, which reports a difference as a finding rather than a
  fault. Reworded to what the code does.
- No real-transport test exercised the fixture's `skills/*` handlers.
  Added `src/test/integration/mcp/inspectorClient-skills.test.ts`,
  parameterized over both protocol eras: capability advertisement, the
  paged `skills/list` walk (direct and through `ManagedSkillsState`),
  `skills/get`, the `-32602` for an unknown URI, `resources/read` of a
  skill file, and delegation of an ordinary resource through the
  fixture's `resources/read` wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 02:42:47 -04:00
cliffhall
a561cd1daa fix: address Copilot review round 15 on #2251
Round 15 generated no new inline comments; all five suppressed findings
were real and unaddressed.

- SkillsScreen: claim the attempt BEFORE issuing the request, for both the
  SKILL.md preview and `skills/get`. Recording it only on settle left a
  window where an older request that happened to resolve first was still
  considered current and published while a newer one was in flight —
  `verifyRow` already claimed its row up front, these two did not. Tests
  answer the OLDER request first and assert nothing is published.
- SkillsScreen: "No skills listed", not "No skills". SEP-2640 lets a server
  return an empty or partial catalog and says an empty result is not proof
  it has none — an unlisted skill is still fetchable by URI — so the old
  copy had the tool asserting something the protocol explicitly does not.
- SkillsScreen: each row's Verify button gets an `aria-label` carrying its
  URI. Every row's visible text is "Verify" and the URI cell is not
  programmatically associated with the button, so a screen-reader user had
  no way to tell which file each control checked.
- inspectorClient: attribute a rejected `skills/get` envelope with
  `markResponseRejected`, so the Protocol tab stops rendering it as a clean
  success while the screen shows an error. In the client rather than a
  store because `skills/get` has none. Gated on `isClientDecodeRejection`,
  with a test that a transport failure is NOT attributed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 02:21:41 -04:00
cliffhall
11ac134975 feat: require the modern list envelope on skills/list (review round 14)
The review finally quoted the normative text, which is what I asked for
across five rounds before declining: SEP-2640's `skills/list` section says
"In protocol versions 2026-07-28 and later, the result also carries …
`ttlMs` and `cacheScope`." So the era-aware validation is in.

- skillsSchemas: `ModernListSkillsResultSchema` = the page plus the base
  list envelope, with field shapes mirroring `ModernResultEnvelopeSchema`
  in `listSalvage.ts` so this repo's two statements of a modern envelope
  cannot drift. `InspectorClient.listSkills` picks it from the negotiated
  era; the legacy shape stays permissive, because those are 2026-era
  attributes a legacy server has no business sending.
  Nothing else catches this: `skills/*` is consumer-owned, so it is absent
  from the SDK's cacheable-method registry and a modern server answering
  `{ skills: [] }` was reaching the conformance UI as a clean list.
- skills.ts: count `frontmatter.description` (and `name`) length in Unicode
  CODE POINTS. `String.length` counts UTF-16 code units, so 600 non-BMP
  characters measured as 1200 and a perfectly valid description was
  reported `malformed-description` — a conforming server failed by an
  off-by-encoding, which is the direction this module works hardest to
  avoid. The Agent Skills reference validator uses Python `len()`.
- #2248 narrowed: the `skills/list` half is settled here; what remains is
  whether `skills/get` carries the same attributes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 02:01:18 -04:00
cliffhall
84a65f28d8 fix(auth): floor the shared revocation budget instead of racing zero
The shared teardown deadline was decided on `remainingMs > 0` against
`Date.now()`. Both halves of that are noise-sensitive: the deadline is
enforced by a `setTimeout`, and a wall clock at millisecond resolution
can still read a hair short of it when the loop comes round, so the next
grant inherits a fractional budget and spends it on a request that cannot
possibly complete. The same run then issues one request or two depending
on scheduling — which is what makes the "shares one deadline across
grants" test intermittent on CI (#2252).

Measure the deadline with `performance.now()` (monotonic, so an NTP step
or a suspend cannot expire or extend the budget, and sub-millisecond, so
none is lost to rounding), and skip any grant reaching the loop with less
than MIN_REVOCATION_REQUEST_BUDGET_MS left. The skipped grant is already
reported as `failed`, so nothing is silently dropped — the needless call
to the authorization server is.

The CLI's outer per-plan budget in `sendPlans` shares both the shape and
the defect, so it gets the same treatment.

One consequence worth naming: a monotonic clock hands `revokeToken` a
fractional budget, and Node's `AbortSignal.timeout` throws
`ERR_OUT_OF_RANGE` on a non-integer delay — before the fetch, so the
request would never be sent and the revocation would report that as its
failure. `revokeToken` now rounds its own budget to whole milliseconds,
which also covers any other caller passing a fractional one.

Tests: the epsilon skip is pinned by a grant that lands inside the floor
but above zero (removing the floor fails it, verified); the fractional
budget by a `revokeToken` call at 19.996ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wEXtbs8UEHUMxDEAxbs99
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 01:25:25 -04:00
cliffhall
bd28a36f23 fix: address Copilot review round 12 on #2251
All three findings were the same underlying inconsistency: URI comparisons
on raw strings, in a flow that elsewhere deliberately treats normalized
equivalents as the same resource. One `skillUriIdentity` helper now backs
every comparison, so they cannot disagree.

- skills.ts: `manifest-missing-self` compares identities, so a manifest
  listing the RFC-equivalent `skill://demo/%53KILL.md` is recognized as the
  entry's own file — it is fetchable as that file, so reporting it missing
  was the tool disagreeing with itself.
- skills.ts: `skillEntriesMatch` normalizes the entry URI and every manifest
  URI before comparing, so a server that canonicalizes an escape between the
  listing and the fetch is not reported as a changed snapshot.
- SkillsScreen: the `wrongUri` check uses the same identity, so a
  canonicalizing server is not accused of answering for a different skill —
  the read path already accepts exactly that equivalence.
- skills.ts: apply the Agent Skills name grammar to the RAW value. Trimming
  first let `" demo "` pass, and whitespace is not in the grammar — so an
  entry could report "Conforms" with a name that can never equal its URI
  path segment. The trimmed copy now only distinguishes absent from invalid.
- skills.ts: add `malformed-description` for a description above the
  1024-character Agent Skills limit, with boundary coverage.
- inspectorClient-skills.test: justify the `as unknown as` per AGENTS.md —
  both fields are `private` with no public setter, the asserted shape is
  exactly what the class declares, and the alternative is a live connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 00:46:18 -04:00
Cliff Hall
63f89fae10
Merge branch 'v2/main' into v2/feat/2234-skills-extension 2026-09-05 00:26:12 -04:00
cliffhall
d0302078d0 fix: address Copilot review round 11 on #2251
- skills.ts: apply the two RFC 3986 §6.2.2 percent-encoding normalizations
  the URL parser does not — decode an escape standing for an unreserved
  character, and upper-case the hex of every escape that remains. `URL.href`
  leaves `%72eference.md` encoded, so a server echoing an RFC-equivalent
  form of the URI we asked for was rejected by `onReadSkillFile` as a
  different resource, and an encoded name segment produced a false
  `name-path-mismatch`. Both are the tool calling a conforming server wrong.
- oauthResume.test: assert the Skills restore path. It was wired into the
  setters but never checked, and the comprehensive present-value and
  undefined-fallback cases omitted Skills entirely. Adds it to both, plus a
  case restoring a REAL saved selection rather than the EMPTY value — a bug
  that always wrote the default would have passed the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 00:21:00 -04:00
cliffhall
9d162f36c6 fix: address Copilot review round 10 on #2251
- skills.ts: validate `frontmatter.name` against the Agent Skills format
  (1–64 lowercase alphanumerics and hyphens, no leading, trailing or
  consecutive hyphen). Checking only for non-emptiness let `Bad Name` read
  as "Conforms" — and the name is not decorative: it must equal the URI
  path segment, so a name that cannot appear in a URI is a contradiction
  the entry cannot satisfy. Suppressed when the name is absent entirely,
  which `missing-name` already reports.
- skills.ts: detect duplicates on the NORMALIZED identity. Containment and
  the read that fetches the bytes both treat normalized-equivalents as one
  resource, so `skill://demo/SKILL.md` and `skill://demo/x/../SKILL.md`
  were passing as two distinct files while naming one. The finding still
  reports the raw URI, and two different unparseable URIs stay distinct.
- SkillsScreen: run `checkSkillConformance` on the fetched `skills/get`
  entry and treat an error — or an entry answering for a different URI —
  as invalid (red), not as "a different snapshot" (yellow). A fresh
  point-in-time read excuses a CHANGE; it does not excuse a violation, and
  answering with another skill is never a refresh of the one requested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 00:09:56 -04:00
cliffhall
1f33ba93be fix: address Copilot review round 7 on #2251
- skills.ts: stop sorting `resources` arrays recursively. Only the entry's
  OWN manifest is a set; `frontmatter` is verbatim arbitrary JSON, so a
  custom `frontmatter.metadata.resources` array was being sorted too and
  two genuinely different frontmatters compared equal. The manifest sort is
  now done once, at the top level, and every other array keeps its order.
- SkillsScreen: invalidate on the whole selected entry, not just its
  manifest. The `skills/get` comparison covers `frontmatter`, so a refresh
  that changed only a description left a stale "matches skills/list"
  verdict on screen — computed against an entry that no longer existed.
  Re-verifying after a metadata-only refresh is the cheap direction to be
  wrong in; showing a match against a different entry is not.
- SkillsScreen: give each "Verify all" invocation a token, so a finalizer
  can only ever clear its own batch rather than any batch with a matching
  manifest key.
- SkillsScreen.stories: supply `onGetSkill`. Every story rendered the
  "Fetch with skills/get" button, and clicking it threw.

On the batch token: the A → B → A sequence the review describes is not
actually reachable, because the button is disabled whenever the in-flight
batch's key matches the selection — returning to A finds it disabled, not
clickable. The token is defence in depth for the same property, and the
test asserts what is really guaranteed rather than the unreachable path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 23:36:35 -04:00
cliffhall
754ce5bd95 fix: address Copilot review round 5 on #2251
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>
2026-09-04 23:11:46 -04:00
cliffhall
1cb05ca573 fix: address Copilot review round 4 on #2251
- 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>
2026-09-04 22:38:03 -04:00
cliffhall
6a08c77c92 fix: address Copilot review round 3 on #2251
- 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>
2026-09-04 22:06:36 -04:00
cliffhall
bfeebd440d fix: address Copilot review round 2 on #2251
- 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>
2026-09-04 21:45:49 -04:00
cliffhall
da8cc166ce fix: address Copilot review round 1 on #2251
- 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>
2026-09-04 21:23:58 -04:00
cliffhall
63623b754b feat: support the Skills extension (SEP-2640) with digest verification
Adds detection, enumeration, and conformance checking for the Skills
extension (`io.modelcontextprotocol/skills`) — phases 1 and 2 of #2234,
which is everything its Acceptance list names.

Skills is a *server*-declared extension, read off the connecting server's
`capabilities.extensions`, so it is deliberately absent from
`ADVERTISABLE_EXTENSIONS` — that registry is what the Inspector advertises
and the user toggles, and an entry there would be a meaningless toggle.

`skills/list` and `skills/get` are consumer-owned extension methods that
neither era codec defines, so the SDK's era gate skips them and they go out
as ordinary `client.request` calls with explicit result schemas. The
raw-wire channel modern `tasks/*` needs is not used, and the Skills tab is
not era-gated: a legacy-era server that declares the extension is serving it.

The checks are the point, not the list view. `core/mcp/skills.ts` reports
each obligation SEP-2640 states — the name/path invariant, the digest
format, the 512-entry and 16 MiB limits, and `resources: "dynamic"`, which
means integrity cannot be verified at all. Digest verification hashes the
fetched bytes with WebCrypto and returns a mismatch with both digests
attached rather than throwing, because showing a mismatch loudly is the
whole value proposition.

Files are fetched on demand: SEP-2640 is explicit that a `resources/read`
of a `SKILL.md` is not a load and confers no standing, so none of the SEP's
host machinery is implemented.

The `skills-http` fixture serves four skills over two pages, three of them
deliberately non-conforming — without those the verification code is
untestable.

Phase 3 (CLI, TUI, `resources/directory/read`, paged mode) is #2248.

Closes #2234

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 20:45:15 -04:00
cliffhall
403352357b test(auth): make the revocation fixture a real form-urldecoder
#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>
2026-09-04 19:29:02 -04:00
Cliff Hall
5bc5977682
Merge branch 'v2/main' into v2/chore/2192-lint-core-react 2026-08-30 00:46:38 -04:00
cliffhall
546dacffe2 chore: address Copilot review round 2 (#2192)
- `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>
2026-08-30 00:22:28 -04:00
cliffhall
7e6d6c0bc1 chore: address Copilot review round 1 (#2192)
- `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>
2026-08-29 23:50:18 -04:00
cliffhall
9ced0fd150 chore: lint core/react with eslint-plugin-react-hooks (#2192)
`eslint-plugin-react-hooks` was a `clients/web` devDependency only, and each
client's `eslint .` is scoped to its own directory — so the whole hook rule
set had never looked at `core/react/`, a directory of nothing but React
hooks. "Neither surface has JSX" was the stated reason and was never a good
one: the rules judge hooks, not JSX.

Add the plugin at the root and apply it in a `core/react/**` block (scoped
there rather than to all of `core/**`, since the rules key off the `use`
prefix), with `exhaustive-deps` promoted to `error` as `clients/web` does.

Resolve the 15 findings it then reports:

- `useSettingsDraft` / `useClientSettingsDraft` seed their draft during
  render instead of in a `targetId`/`opened`-keyed effect, so the previous
  target's values never reach the DOM — the same rule `clients/web` spells
  `useValueChange`. Their "latest value" refs move from render-time writes
  into an effect; every reader (the debounce timer, `flush`, `onChange`)
  runs after a commit, so a post-commit write is what they need.
- The three async loaders keep their effects behind justified inline
  disables: `set-state-in-effect` follows a call into an async function
  without modelling the `await`, verified against a minimal hook whose only
  setState is in a continuation.
- `useInitialConfig`'s cleanup bump keeps reading `generation.current` at
  teardown — the rule's "copy it into a variable" advice would restore a
  stale request token and let a superseded response commit.

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-29 23:17:45 -04:00
Cliff Hall
26fdc48bf1
Merge branch 'v2/main' into v2/fix/2165-oauth-recovery-floating-promises 2026-08-29 22:40:08 -04:00
cliffhall
8f3619f618 docs(core): correct the useStoreSnapshot caller contract
Copilot review round 2.

The contract claimed an unstable `read`/`whenAbsent` trips React's "The
result of getSnapshot should be cached" error. It does not. Measured on
React 19: an inline `read` closure throws nothing, logs nothing, and forces
no extra render pass. React's dev check calls getSnapshot twice within a
single render, where the closure is unchanged, so the cache answers both
calls identically and the warning never fires.

The real cost is quiet — a fresh value every render, defeating downstream
useMemo/React.memo/effect deps — which is a better reason to state the
contract than an error that never arrives.

Also stop requiring module scope for every fallback. A primitive
`whenAbsent` (`false`, `undefined`, `"disconnected"`) is stable under
Object.is and is passed inline throughout these hooks, so the rule
contradicted the code it was describing. Only object/array fallbacks need a
constant, which is what NO_TOOLS and NO_PAGINATION are.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 02:42:03 -04:00
cliffhall
a46a8a6cf8 fix(web): per-attempt snapshot ids, honest recovery copy, connect teardown
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>
2026-08-29 02:32:05 -04:00
cliffhall
f6105773f0 fix(core): key the snapshot cache on every input it derives from
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>
2026-08-29 02:26:42 -04:00
cliffhall
50502269f4 fix(core): read state stores during render instead of re-syncing in an effect
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>
2026-08-29 02:07:53 -04:00
cliffhall
ecad587afe fix(web): address Copilot review on the OAuth recovery failure arms
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>
2026-08-29 01:54:53 -04:00
cliffhall
f74aa757d2 fix(web): terminate the five void-discarded OAuth recovery promises
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>
2026-08-29 01:34:40 -04:00
cliffhall
2eeab05d00 Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat
Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	README.md
#	core/mcp/inspectorClient.ts
#	test-servers/src/test-server-oauth.ts
2026-08-28 10:10:57 -04:00
cliffhall
c7b18adabf Merge remote-tracking branch 'origin/v2/feat/2144-oauth-revocation' into v2/feat/2144-oauth-revocation 2026-08-28 08:37:15 -04:00
cliffhall
eede9c17ba fix: use Node storage in the revocation e2e, and drop the now-dead storage API (#2144)
The CI coverage job failed with `ReferenceError: sessionStorage is not defined`.
`revocation-e2e.test.ts` used `BrowserOAuthStorage`, but the `integration`
vitest project runs in the node environment, not happy-dom. Node 22 gates Web
Storage behind a flag while newer Node exposes it by default, so it passed
locally and failed in CI. It uses `NodeOAuthStorage` with a temp dir now,
matching the other OAuth integration tests, with a comment naming the trap.

Also from Copilot review round 21: `listIssuers` and `getIssuerTokens` became
dead production API when `takeRevocationSnapshot` took over — a grep finds them
only in their own implementations and tests. Keeping them would widen the
exported `OAuthStorage` contract and force every implementation and mock to
carry methods nothing calls. Removed, along with their tests and the mock
entries in four suites.

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 08:36:58 -04:00
Cliff Hall
5ba2fadd3b
Merge branch 'v2/main' into v2/feat/2144-oauth-revocation 2026-08-28 08:28:57 -04:00
cliffhall
1f2e3e1744 fix: address the four suppressed findings from review round 20 (#2144)
Copilot reported "no new comments" again, with four real findings in its
suppressed block.

- The web in-flight lock was global, so a pending clear for server A silently
  swallowed a click on server B's — even though the callback explicitly
  supports clearing a non-active server. Keyed by server id now.
- `else if (!isActive)` skipped the resume-snapshot cleanup when the target IS
  active but has no client yet, which is reachable while a session is being
  built or torn down. The clear then emptied storage and left stale OAuth
  recovery state pointing at credentials that no longer existed.
- `revocationAuthMethods` returned `[]` for an omitted
  `revocation_endpoint_auth_methods_supported`, which does not enforce RFC
  8414's default: an empty list means "the metadata said nothing" to
  `selectClientAuthMethod`, which then honors the registration's own
  `token_endpoint_auth_method`. The default is named literally now.
- The `v8 ignore` on the CLI budget branch was wrong, and the challenge to it
  was right: an injected `budgetMs` of zero reaches it directly, through a seam
  I had added myself the round before. Removed, and covered by a test that
  asserts no request is made and the unattempted key is reported.

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 08:20:42 -04:00
cliffhall
579a3eeeb9 fix(auth): salvage a malformed fallback registration, and correct the stale API names (#2144)
Copilot review round 19.

- Parsing the preconfigured client registration sat outside the per-slot
  salvage, so one malformed fallback aborted the whole plan and grants carrying
  their own valid credentials were never revoked. It is a *fallback*; a bad one
  is now recorded and skipped, matching every other slot.
- `sendPlans` preserved an earlier success when a later key was never
  attempted, contradicting the failure-first rule two lines below — the
  unattempted key's grant may still be live. That branch turns out to be a
  bound rather than a path (`withDeadline` fails any plan that reaches the
  deadline, so a preceding plan cannot both succeed and leave zero budget), so
  it carries a justified `v8 ignore` saying exactly that.
- `clearStoredAuthForRelogin` takes an optional `budgetMs`, which is what lets
  the shared-budget behavior be tested without a five-second test.
- Three stale references to `planOAuthRevocation`, which no longer exists —
  two JSDoc links and the `AGENTS.md` entry, which additionally still told
  callers to make a separate `storage.clear()` call. Following that text would
  have reintroduced the second clear the atomic step exists to remove.

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 07:57:45 -04:00
cliffhall
463227c947 fix(auth): make snapshot-and-clear one atomic storage step (#2144)
Copilot review round 18. Three of its findings were the same check-then-act:
`planOAuthRevocation` is asynchronous, so the gap between its reads and the
following `storage.clear()` is still a window — shorter than the network one
closed in round 13, but the same shape. An OAuth completion landing at any of
those awaits saved a grant the clear then destroyed.

`OAuthStorage.takeRevocationSnapshot(serverUrl)` reads what revocation needs
and clears the server in ONE synchronous pass over the in-memory state, with
the persist after the mutation. `clearAndPlanRevocation` replaces
`planOAuthRevocation`, and there is no separate `clear` call left in any
caller — the clear cannot be forgotten or reordered.

The snapshot returns values unparsed, deliberately: schema validation is pure
and belongs after the mutation, since running it inside would reintroduce the
`await` this exists to remove.

Two consequences worth naming. A failure of the take-and-clear now REJECTS
rather than returning an outcome, because the state was not cleared and the
caller's contract is that it was; both clients already have a rejection path.
And cross-process atomicity is explicitly not claimed — nothing in this store
takes a lock, every mutation is a read-modify-write over a loaded snapshot, and
`clear` alone always had that property.

Also from round 18:

- The CLI's budget check ran before considering `plan.outcome`, so a key that
  needed no network was reported as budget-exhausted — warning that a grant may
  be live when that key held none.
- A disconnect failure after a successful clear was reported as "could not
  clear OAuth state" in both clients. It now goes to the disconnect channel,
  and the success is still reported.

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 07:40:52 -04:00
cliffhall
0ded02b34c fix(auth): revoke with the registration bound to the grant, not the current one (#2144)
Copilot review round 17.

`resolveClientInformation` mirrored `BaseOAuthClientProvider`, preferring the
preregistered entry. That order answers "who should I authenticate as now";
revocation asks "who minted this token", and the two diverge — the store lets a
preregistered client and an issuer-bound dynamic registration coexist, so after
a server is switched from DCR to a configured `oauth.clientId` an older DCR
grant was revoked with the configured client's credentials. RFC 7009 §2.2
answers 200 for a token the server does not recognise as the caller's, so that
reported `revoked` while the grant stayed live and the local record was already
gone — the worst available outcome.

The registration bound to the grant's issuer now wins, with the preregistered
entry as the fallback, which covers both directions: a token minted with the
configured client leaves the issuer slot empty, since the SDK writes it only
after DCR.

Stated in the comment rather than implied: this is best available evidence, not
proof. The store records client information per issuer, not per token, so
binding identity at save time remains the real fix and is a storage-shape
change beyond this PR.

Also fixed a CLI test comment still describing the pre-round-13 ordering.

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 07:01:20 -04:00
cliffhall
a4ee263365 fix(auth): refuse a grant whose authorization server cannot be established (#2144)
Copilot review round 15.

The issuer check still failed open for the legacy unkeyed grant. "Unkeyed"
means its authorization server is UNKNOWN, not that the cached endpoint is
proven to own it: server metadata is a single slot a later discovery
overwrites, while a pre-SEP-2352 token survives until the first issuer-stamped
save. So a clear could post an old bearer credential to an authorization server
that never minted it.

Such a grant is now cleared without being revoked, and reported as such with a
message saying re-authorizing makes future clears revocable. The cost is that
a pre-issuer-binding entry gets no revocation — which is exactly the behavior
it had before this feature existed, so nothing regresses — and it earns
revocation back on the next authorization. Not sending is the only answer that
cannot be wrong.

Every seed in the suite is now issuer-bound, which is what a real flow writes;
the two tests that were about the legacy grant now assert the refusal, since
that is the behavior worth pinning.

Also renamed the CLI test that still described the pre-round-13 ordering.

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 06:18:58 -04:00
cliffhall
4429c0c0bf fix(auth): keep the failure path total, and align the docs with the new order (#2144)
Copilot review round 14. Two code fixes and a documentation sweep.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 05:55:00 -04:00
cliffhall
69fcdd364c fix(auth): clear before waiting on the network, not after (#2144)
Copilot review round 13. Three of its four findings were one root cause, and
it is a design flaw in the premise this PR started from.

Revoking *before* the clear meant the clear waited up to five seconds on the
network — and anything that wrote OAuth state in that window was then deleted
by a clear reasoning about the grant it replaced. A same-server reauthorization
in the TUI (whose Disconnect/Connect accelerators stay live), a stored-only web
clear for a server that becomes active mid-flight, and another CLI/TUI process
writing to the shared file-backed store all hit it.

`revokeStoredOAuthTokens` is therefore split into `planOAuthRevocation` and
`executeOAuthRevocation`, and every caller runs plan → `storage.clear()` →
execute. The snapshot still precedes the clear, because the store is where the
token, the credentials and the endpoint live; but the clear no longer waits on
anything. There is deliberately no single-call wrapper left, so no caller can
reintroduce the racy order by accident.

Fourth finding, same file: `handleConnect` now retires `clearOAuthAttemptRef`
alongside `disconnectAttemptRef`. The server-name check does not cover
disconnect/reconnect to the SAME server — the name still matches on the other
side of it — so a clear still in flight could tear down the session the connect
had just established.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 05:38:30 -04:00
cliffhall
5170cc484b fix(auth): close the fail-open issuer check and the remaining clear races (#2144)
Copilot review rounds 8 and 9.

- A cached metadata document with no `issuer` was letting every issuer-bound
  token go to its single revocation endpoint. Absence establishes nothing, so
  that is now a mismatch: an issuer-bound grant must match, and only a legacy
  unkeyed grant — which nothing binds to a different authorization server —
  proceeds without the comparison. This was a credential-disclosure risk, not
  just a correctness one.
- The ctx-less grant read was skipped whenever it carried an issuer stamp, on
  the assumption that slot had already been enumerated. If `listIssuers` fails
  there is no enumerated slot behind the stamp, so the one readable grant went
  unrevoked while `clear` deleted it. Both reads now share one issuer+token
  key, and the stamped issuer is retained rather than discarded.
- `AuthTab`'s repeat guard read `clearState`, which is not updated until React
  re-renders, so two `s` events in one input turn both started a clear against
  the same store entry. It is a ref now, set before the call.
- A rejected clear left `ownClearRef` set — `handleClearOAuth` never reached
  its `oauthRevision` bump — so the next unrelated revision was swallowed and
  the failure banner stranded. Cleared on that path.
- The web `clearServerOAuthAndDisconnect` had the staleness the TUI path was
  already fixed for: `isActive`/`inspectorClient` are captured before a
  five-second await, so a switch during it disconnected the session the user
  had just moved to and ran the session-wide cleanup against it. Revalidated
  against `sessionRef` after each await.

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 04:38:38 -04:00
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