Commit graph inspector/clients/tui
Author SHA1 Message Date
cliffhall
440d1aa5c1 Merge remote-tracking branch 'origin/v2/main' into v2/chore/2196-consolidate-toolchain-devdeps
Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	clients/cli/package-lock.json
#	clients/launcher/package-lock.json
#	clients/launcher/package.json
#	clients/tui/package-lock.json
#	clients/web/package-lock.json
2026-08-31 23:17:20 -04:00
cliffhall
f62cfbaeeb chore: address Copilot review on #2200
- 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>
2026-08-31 19:58:22 -04:00
cliffhall
1f5c32940d chore: consolidate duplicated runtime dependencies into the root package.json
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>
2026-08-31 19:08:24 -04:00
cliffhall
327c8d67fa chore: consolidate shared toolchain devDependencies into the root package.json
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>
2026-08-31 18:57:40 -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
b9012da988 fix: close three stale-completion holes from the round-16 suppressed block (#2144)
Copilot's round 16 reported "no new comments", but its suppressed block carried
three real findings.

- `AuthTab` released `clearInFlightRef` before checking whether the completion
  was still current, so server A settling after the user moved to B and started
  a clear there dropped B's lock and let a second B clear run concurrently
  against the same store entry. `current()` is now checked before either shared
  ref is touched: a stale clear owns nothing.
- Switching servers retired the clear but left `ownClearRef` set. The retired
  clear returns before its `oauthRevision` bump, so the marker had no bump to
  skip and would swallow the first unrelated revision on the newly selected
  server, stranding that server's banner.
- The web session check compared only the server id, which a
  disconnect/reconnect to the SAME server passes — that flow builds a
  replacement `InspectorClient`, so the old clear would have run its
  session-wide cleanup against the new session. The client identity is part of
  the check now.

Both new tests were verified to fail with their fix reverted.

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:47:31 -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
c0bcdc7e4a fix(tui): forward the clear promise from App, so AuthTab can actually await it (#2144)
Copilot review round 12, and the best catch of the review.

`onClearOAuth` was wired as `() => { void handleClearOAuth(); }`, so the
promise `AuthTab` awaits resolved instantly. Everything round 6 through 9 built
on that await was therefore inert in production while still passing its own
tests: the pending line never showed, the repeat lock released immediately, and
a rejection went unhandled instead of reaching the failure line.

Passed directly now. The regression test drives the real App rather than
`AuthTab` in isolation — the wiring is the contract, and only an App-level test
can see it. Verified it fails when the void-ing arrow is put back.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 02:58:04 -04:00
cliffhall
781aa69d19 fix(auth): revoke every issuer-bound grant, not just the active one (#2144)
Copilot review round 3.

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

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

Also from that round:

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 01:30:09 -04:00
cliffhall
65fd2b9e35 feat(auth): revoke OAuth tokens at the authorization server on clear (#2144)
Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.

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

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

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

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

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

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

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

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 14:50:52 -04:00
cliffhall
ba91db97e2 fix: address Copilot review round 34 — structured constants, non-required const controls
- A `const` may be an object or an array, with or without a `type`, and
  `String({...})` is "[object Object]" — which no CLI argument can equal, so
  the only value such a schema accepts never matched. Supplied text is parsed
  and compared structurally through the resolver's own comparison (exported as
  `sameJsonValue` so the two cannot disagree), for both branch selection and
  the substitution.
- The TUI's one-option const control is never marked required: its option may
  legitimately be the empty string, which ink-form's required gate can never
  accept, so submission would not even reach `decodeFormValues`. The value is
  fixed by the schema, and `missingRequiredFields` still validates the call.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 13:39:16 -04:00
cliffhall
ab3f8c779a fix: address Copilot review round 25 — names before constants, unrenderable properties
- selectBranchIndex asks the supplied NAMES first and falls back to a lone
  agreeing constant only when they settle nothing. A matching `const` does
  not identify a branch while another candidate leaves that property unpinned:
  `{ kind: "email", phone: "555" }` agrees with an email branch whose
  `address` is missing while satisfying a phone branch outright.
- isOfferable requires every property VALUE to be readable — JSON Schema's
  boolean form is fine, a `null` or an array is not a schema at all and the
  web form dereferences one on the way to choosing a widget. Such a branch is
  declined rather than handed on to crash a tool panel that would otherwise
  have rendered.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 09:30:36 -04:00
cliffhall
d334710d6f fix: address Copilot review round 20 — malformed applicators, const-null under anyOf
- rootUnion reads every composition keyword through `membersOf` and every
  `required` through `requiredOf`. These schemas describe the wire and each
  member arrives as `unknown`, so `anyOf: {}` or `required: "x"` really
  can arrive — and reading them as lists threw, taking all three clients down
  rather than declining one malformed tool. Both web and TUI required-field
  checks are guarded the same way.
- admitsNull evaluates a sibling `anyOf` beside a `const: null` instead of
  refusing outright: the union decides in both directions, and
  `{ const: null, anyOf: [{ type: "null" }] }` admits null as plainly as an
  all-string union rejects it. Refusing it left a read-only required field
  seeded with the only value it accepts and submit permanently disabled.

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

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 06:55:20 -04:00
cliffhall
95030cfdb6 fix: address Copilot review round 17 — shared branch narrowing, null admissibility
- The name-based narrowing is extracted as `narrowBySuppliedNames` and used
  by the CLI coercion as well as the form: an undiscriminated union whose
  branches type a shared name differently was losing the coercion for every
  argument, including the ones that identify the branch unambiguously. The
  type-agreement fallback now also polls only the const-compatible branches.
- TUI missingRequiredFields accepts `null` only where the property admits it,
  matching the web gate: branch fields are rendered optional, so a
  `default: null` on a non-nullable required field reached the check and the
  call went out with a value the schema rejects.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 06:38:46 -04:00
cliffhall
df1e39e623 fix: address Copilot review round 13 — cross-keyword contradictions, own-property required check
- rootUnion: keywords the two declarations state SEPARATELY can contradict too.
  A merged `const` is now checked against its own `type` and `enum`, so
  root `x: { type: "string" }` under branch `x: { const: 1 }` declines
  instead of seeding an immutable 1 into a string field. An integer const still
  satisfies a `number` type — the one direction JSON Schema widens.
- TUI missingRequiredFields tests `Object.hasOwn` first: an argument legally
  named `constructor` resolved to the inherited one and read as supplied, and
  the call went out without it.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 05:21:21 -04:00
cliffhall
246ee1c262 fix: address Copilot review round 9 — canonical constant comparison, name-based inference, permissive schemas
- rootUnion: schema values are compared through a key-sorted rendering, so two
  discriminator constants written as {a,b} and {b,a} are recognized as the same
  value rather than as mutually exclusive alternatives.
- selectBranchIndex: after the const and required passes, a name only ONE
  alternative declares identifies that alternative; a name several declare is
  ambiguous and says nothing.
- additionalProperties counts as restrictive only when its schema states an
  assertion — an annotation-only `{ title: ... }` is the equivalent of
  `true`, like `{}`.
- Drop the unjustified double casts from the ToolTestModal fixtures; the
  schemas are assignable to Partial<Tool> as written.

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

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

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 02:01:44 -04:00
cliffhall
84cd0c10f0 fix: address Copilot review round 2 — conjunctive merges, generated-name collisions, CLI branch selection
- rootUnion: a name both the root and a branch declare is now MERGED rather
  than replaced, so a root `minimum` survives a branch `maximum`; a branch
  that contradicts the root on a property's `type` describes an unsatisfiable
  value and is declined outright. Branch offerability also requires that
  `properties` really is an object (a malformed `properties: null` threw)
  and that the member's own `type` admits objects.
- rootUnion: `ownFields` becomes `declaredFields` — a branch commonly
  specializes a root property, and consumers must render the specialization.
- TUI: the generated select and branch prefixes step aside from any name the
  schema itself declares (JSON has no reserved namespace), decode filters them
  by exact name, a branch's specialization renders in its own section, and a
  `const` is rendered as a one-option select whose typed value is restored
  from the schema on submit.
- convertToolParameters: identify the branch from the discriminator constants
  the call supplies; when none identifies one, coerce only names every
  declaring branch types the same way rather than picking an arbitrary branch.

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

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

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

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

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

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

Closes #2123

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 00:37:43 -04:00
cliffhall
2cb23a8b21 fix: map the new exit code, keep add runnable, distinguish severity by shape
Copilot review round 4.

- Add `SCHEMA_UNPORTABLE` to `codeForExit`. `CliExitCodeError` lets a
  caller omit the envelope, so without the case an envelope-less throw
  reported the generic `error` — the same silent mislabelling the rename
  was meant to prevent, one layer down. Covered via the envelope-less
  construction that exposed it.

- Keep a local `type` (and the preset's `required`) alongside the remote
  `$ref` in the showcase config. The TUI's `schemaToForm` dispatches on
  `type`, so a `$ref`-only property fell through to a string field that
  `add`'s numeric handler then rejected — making the README's "every
  showcase tool is runnable" claim false there. The `remote-ref` finding
  is unaffected, since that rule reads `$ref` independently.

- Restrict `type-union` to well-formed unions: non-empty, unique, and
  all recognized JSON Schema type names. Its message asserts the
  construct is *legal* JSON Schema, which is false for `[]`, `[3]` or
  `["bananas"]`, and the `anyOf` it suggested for those would be invalid
  too. Malformed schemas stay the SDK parser's business.

- Drop `if`/`then`/`else` from the presence-only allowlist. They only
  constrain as a pair, so `{"if": {"const": 1}}` accepts every value and
  was evading `untyped-schema`; the pairing is now checked explicitly.

- Distinguish the web sidebar severities by SHAPE, not just colour — a
  circle for an error, a triangle for a warning — and name the severity
  in the tooltip. Colour alone left a colour-blind reader unable to tell
  them apart.

- Fix the stale "across 2 tools" count in the CLI README's hint sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:37:10 -04:00
cliffhall
2f9e0901c1 feat: flag unportable tool schemas in all three clients
Closes #1005

A tool schema can be perfectly legal JSON Schema and still be rejected by
the client the server is meant to run against — Go's `jsonschema` emits a
bare `true` for `interface{}`, which the SDK accepts and Claude Code
refuses with an opaque "Invalid input". The Inspector passed it through
silently.

Adds `core/json/schemaLint.ts`, a portability lint over a tool's
inputSchema/outputSchema, and surfaces one shared verdict everywhere:
the CLI's `--strict` report (exit 6 on an error-severity finding), the
TUI's tool list marker + detail pane, and the web Tools sidebar flag +
detail section.

Deliberately not a JSON Schema validator: a census of 617 public servers
found zero that fail the SDK's own parse, so a conformance check would
report nothing on real servers. Each rule is instead a construct that is
legal JSON Schema and known to be refused or mishandled by a shipping
client — a bare boolean in a schema position, an array-form `type`, a
remote `$ref`, a schema with no validation keyword, a non-object root.

`test-servers/configs/unportable-schemas-http.json` exercises every rule,
via a new `rawToolSchemas` override — the Zod-built presets cannot emit
any of these shapes, which is the same reason a real server only hits
this when its schemas come from another generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 01:18:53 -04:00
cliffhall
3be4230391 chore: pin esbuild past the GHSA-g7r4-m6w7-qqqr range in web/cli/tui
`npm audit` (dev included) reported a low-severity advisory against
`esbuild` in three of the four client installs. It is not in the
production tree — `npm audit --omit=dev` has been clean everywhere since
2058 — but a permanently non-clean audit trains people to skim the
channel, which is the same argument 2058 made.

`tsup@8.5.1` declares `esbuild: ^0.27.0`, and the advisory covers
0.27.3 - 0.28.0 with 0.27.7 the last 0.27.x, so there is no upward
escape inside tsup's declared range. `npm audit fix` "resolves" it by
silently downgrading to 0.27.2 across three installs — the trap hit and
reverted during 2058. An `overrides` entry forces the copy above the
range instead.

- Add `"overrides": { "esbuild": "^0.28.2" }` to clients/{web,cli,tui}.
  That also covers the tui's `tsx` copy (0.28.0), so no separate update
  is needed.
- Re-resolve each lockfile. Every changed entry is an esbuild one: the
  nested `tsup/node_modules/esbuild` and `tsx/node_modules/esbuild`
  copies collapse into the single deduped 0.28.2, and no other package
  moves.
- Document the pin in AGENTS.md, including when to drop it.

The real gate here is the build, not the audit: this puts tsup on an
esbuild major it does not declare. `npm run build` succeeds for all
three clients, and `npm run ci` passes.

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-24 20:26:43 -04:00
cliffhall
531cf84e60 Merge branch 'v2/main' into v2/fix/2067-proxy-undici
One conflict, in `clients/tui/__tests__/App.test.tsx`: both sides added an
`opts` capture to the test harness's `FakeClient` for different fields —
`defaultMetadata` on v2/main (#1910), `environment.fetch` here (#2067) — so the
resolution is the union of the two, keeping both rationales and both tests.

Everything else auto-merged. `npm run ci` passes on the result.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 23:34:07 -04:00
cliffhall
21cf1bd6c3 test(tui): drop an unjustified double cast; fix a stale mock comment
Addresses review round 6 on #2099. Both nits were correct.

- The proxy-fetch sentinel used `as unknown as typeof fetch`, which AGENTS.md
  forbids without justification — and none was needed here: an
  `async () => new Response("")` satisfies the fetch signature directly, so it is
  now annotated rather than asserted.

- The mock's comment still told readers to override `h.proxyFetchResult`, the
  name from an earlier revision; the hook is `h.proxyFetchState.current`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 22:43:38 -04:00
cliffhall
91e08067dc test(proxy): cover the CLI and TUI bottom-of-stack wiring
Addresses review round 5 on #2099 (three suppressed comments, no posted ones).

The `fetch: createProxyFetch()` line in each Node client is the whole of that
client's proxy support — it is what puts the proxy at the BOTTOM of the fetch
stack, where InspectorClient's wrappers compose over it rather than discarding
it. `InspectorClient` sets `fetchFn` unconditionally, so the transport-level
fallback never fires for these clients. Both lines could have been deleted with
every existing test still green.

- CLI: a test that runs a real `tools/list` against a real HTTP test server with
  HTTP_PROXY pointed at a real forwarding proxy, asserting the request traversed
  it. Behavioral rather than structural on purpose — the defect this replaces was
  a runtime dispatch failure no structural assertion would have caught. It clears
  all six proxy vars first so an ambient value cannot route it elsewhere.

- TUI: the mock added earlier always returned undefined, so the proxy branch was
  never exercised. `createProxyFetch`'s return is now controllable, and two tests
  assert the sentinel reaches `environment.fetch` when a proxy is configured and
  that the slot stays unset when one is not.

Both were confirmed to fail with their client's wiring line removed.

Also updates the `InspectorClientEnvironment.fetch` doc in core/mcp/types.ts,
which still told callers that Node leaves this undefined and uses the global
fetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 22:32:27 -04:00
cliffhall
96f96aad41 fix(proxy): preserve fetch semantics through the proxy adapter
Addresses review round 4 on #2099 — three posted comments and three suppressed
ones, all valid.

- A rebuilt Response reported `url: ""`, `redirected: false`, and
  `type: "default"` even after undici followed a redirect: a fetch-contract
  regression visible only to proxy users, and one `core/auth/endpointOverrides.ts`
  explicitly guards against by declining to rebuild at all. This adapter has no
  such choice — the response arrives from the wrong realm — so it now reinstates
  the three with `defineProperty`.

- Normalizing a `Request` input dropped every option the Request itself carried
  beyond method/headers/body/signal. Most visibly `redirect`: a
  `new Request(url, { redirect: "manual" })` started following redirects instead
  of surfacing them. All of them are now carried through, with an explicit
  `init` still winning per the fetch contract.

  Both are covered by tests driving a real redirect through a real proxy, and
  both were confirmed to fail without the fix.

- `@modelcontextprotocol/ext-apps` was added only to the CLI's `external` list.
  AGENTS.md requires every root-declared runtime dependency in all three, since
  which client reaches it is a function of what `core/` imports rather than of
  the client's own code. Added to the web runner and TUI lists too.

- The `BUNDLED_CLIENTS` test compared the hard-coded list against a hard-coded
  copy of itself, so a new tsup-bundled client would have left it green while
  going unchecked. It now discovers clients from disk (a client is tsup-bundled
  iff it ships a tsup config) and compares. Verified it fails when a stub client
  is added.

- AGENTS.md's project tree still described `core/mcp/node/` as only the stdio
  transport factory; it now carries `proxyFetch.ts` and its role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 22:18:43 -04:00
cliffhall
d90a955164 fix(proxy): route through undici's own fetch and keep undici external
Corporate-proxy users could not connect to any remote MCP server from v2.
Two independent defects, both reported in #2067.

1. The web and TUI bundles inlined undici. It was declared in the root and
   `clients/cli` manifests but named in no client's tsup `external` list, and
   tsup auto-externalizes only what the *nearest* manifest declares — so
   esbuild inlined 1.05MB of it into `clients/{web,tui}/build/`. undici is
   CommonJS, so the emitted chunk's `require("assert")` hit esbuild's ESM
   `__require` shim and threw `Dynamic require of "assert" is not supported`.
   The rejected `import()` surfaced as "the `undici` package is not available",
   telling users to install a package that was already present — and could not
   have helped, because the specifier had been rewritten to a relative chunk at
   build time.

2. `withProxyDispatcher` injected a *userland* undici `EnvHttpProxyAgent` into
   Node's *built-in* fetch. That couples two different copies of undici at the
   dispatcher handler interface, which is not stable across majors: Node 22
   embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8
   agent handed a built-in undici 7 handler is rejected with
   `fetch failed: invalid onRequestStart method`, and the request never leaves
   the process. So fixing the bundling alone would only have converted the
   web/TUI failure into the CLI's.

Both halves of the pair now come from userland undici — its `fetch` and its
`EnvHttpProxyAgent` — which keeps the handler inside one copy and works
unchanged from the Node 22.19 engine floor through Node 26. Deleting the
wrapper in favor of Node's native `NODE_USE_ENV_PROXY` was not an option:
measured, it is unsupported on 22.19 (the floor) and only lands mid-22.x.

Because undici's `Response` is a different class from `globalThis.Response`,
the proxied fetch re-wraps each response as a genuine global `Response`,
streaming preserved. Without that, `res instanceof Response` is false for
callers that test it — including the SDK's OAuth error formatter, which would
degrade every message to `Raw body: [object Response]`.

The proxy now sits at the bottom of the fetch stack (`environment.fetch` for
the Node clients, and the `createTransportNode` default for the web backend,
its one caller that supplies no `fetchFn`) rather than wrapping the transport's
fetch. Proxying means *substituting* the fetch, not decorating one, so wrapping
a supplied `fetchFn` would discard it; installing it underneath lets
InspectorClient's own wrappers compose over it. A side effect worth having:
OAuth discovery and token requests go through `environment.fetch` too, and were
never proxied before.

Guarding it:

- `undici` is added to all three tsup `external` lists and removed from
  `clients/cli/package.json`, so it is root-declared only, per the placement
  rule in AGENTS.md. The two installs had already drifted (root 8.5.0 vs cli
  8.9.0).
- New `npm run verify:bundle-externals` fails if any package a client declares
  external was inlined into that client's bundle anyway. It reads the *built
  output*, not the config — those two disagreed for four releases — and derives
  what to check from each client's own `external` array, so a newly
  externalized package is covered without editing the guard. Verified against
  the real pre-fix artifact, which it flags.
- The proxy tests now drive a real forwarding proxy rather than a mock. That
  matters: defect 2 was a runtime interface mismatch, which no mock can catch
  because no mock ever dispatches.

AGENTS.md's must-not-bundle rule (added by #2082) is extended with why undici
slipped past it, and notes the `node -e` trap — probing the bundled chunk with
`node -e` falsely succeeds, because it exposes a global `require` that
satisfies esbuild's guard.

Closes #2067

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 21:12:16 -04:00
Cliff Hall
06c7334b24
Merge branch 'v2/main' into v2/feat/1910-complex-request-metadata 2026-08-23 18:55:24 -04:00
cliffhall
0f628a8174 fix: address Copilot review on the _meta widening
- Widen the three `_meta` annotations the bulk pass missed, all of them
  return types rather than identifier declarations: `mergeMeta`,
  `aggregateListParams`, and the `tools/call` params object. The second was
  unsound in a way callers could see — it advertised
  `{ _meta: Record<string, string> }` while the runtime carried nested JSON.

- `normalizeStoredMetadata`: keep an explicit `null` from a legacy pair
  instead of folding it into `""` (only a missing `value` defaults now), and
  store through `Object.defineProperty` so a legacy `__proto__` key becomes an
  own property rather than hitting the prototype setter.

- Drop the unjustified `as unknown as` from the migration test; the legacy
  fixture is untyped and cast once where it crosses into the typed parser.

- The TUI settings test asserted only that the app mounted, so it could not
  have caught a pair array being forwarded as numeric `_meta` keys. The fake
  client now captures its constructor options and the test asserts the nested
  object arrives intact.

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-23 13:13:04 -04:00
cliffhall
b33532c024 feat: allow complex JSON values in request _meta
Closes #1910

`_meta` was typed `Record<string, string>` end to end in the Inspector —
on disk as a `{ key, value }[]` pair array, in the Server Settings form as
key/value rows, and in every request verb's per-call metadata parameter.
Nothing in the MCP spec restricts a `_meta` value to a string, and the SDK
models the field as a `z.looseObject`, so the restriction was ours alone.

Widen the whole path to `RequestMetadata` (`Record<string, JsonValue>`):

- Per-server default metadata is a JSON **object** on disk and in memory.
  The pre-#1910 pair array is still read (`normalizeStoredMetadata`) so an
  existing `mcp.json` keeps working; it is never written back, so a save
  migrates the entry.
- The Server Settings → Request Metadata section is a JSON editor
  (`JsonObjectInput`) instead of key/value rows — rows cannot express an
  object value. It keeps the typed text as the display source and emits
  only parsed JSON, the same draft split `SchemaJsonField` uses, so the box
  stays typeable; invalid text is flagged inline and not applied.
- The CLI stops flattening `--metadata` / `--tool-metadata` through
  `metaValueToString`. Those values were already JSON-parsed, so
  `trace={"id":"abc"}` used to reach the wire as the *string* `{"id":"abc"}`
  and now reaches it as an object.
- `servers/show` redaction replaces a sensitive key's whole value rather
  than a string, so a structured value cannot leak through its members.
- `progressTokenOf` narrows the reserved `progressToken` key back to
  `string | number` before it reaches the SDK; any other JSON still travels
  inside `_meta` but wires up no progress callback.

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-23 12:23:40 -04:00
cliffhall
b35db18fe8 Merge remote-tracking branch 'origin/v2/main' into v2/feat/2082-file-secret-store-lock 2026-08-23 11:56:05 -04:00
cliffhall
99a194ca69 chore(lint): fail lint on warnings (--max-warnings 0)
`lint` failed only on errors, so a warn-level rule was a channel nothing
consumed. `react-hooks/exhaustive-deps` reported two stale-closure
`useCallback` deps in App.tsx on every run, `npm run ci` passed anyway,
and the defect was caught only by a review round on #2076.

Add `--max-warnings 0` to all six lint scopes — each of
clients/{web,cli,tui,launcher}'s `eslint .`, plus the root's `lint:core`
and `lint:shared` — so a warning fails `lint`, and therefore `validate`
and `ci`. All six were already warning-free, so no suppressions, rule
downgrades, or ignore-list widening were needed.

Also promote `react-hooks/exhaustive-deps` to `error` in both React
scopes (web, tui). The flag makes severity irrelevant to the gate; it
does not make it read correctly in an editor.

Document the invariant in AGENTS.md as a new "Lint has no warning tier"
section, note it in the README beside the type-aware-linting paragraph,
and mirror it into .github/copilot-instructions.md.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 10:47:29 -04:00
cliffhall
04dbc36544 feat(auth): give the secrets file a real cross-process lock (#2082)
#1950 shipped FileSecretStore without cross-process mutual exclusion, by
decision: an earlier revision hand-rolled a `mkdir` election with an owner
stamp, a heartbeat and a stale-takeover, and three consecutive review rounds
found a real race in it. The last one is not closable with what Node exposes
— claiming a stale lock atomically needs compare-and-swap on a directory
entry (`renameat2(RENAME_EXCHANGE)`) — so it was replaced with optimistic
verify-and-retry and the residual documented.

#2082 settles that as "borrow, don't hand-roll". `proper-lockfile` is what
npm itself locks with, and stale-takeover is precisely the problem it has
already solved: it re-stats the lock directory after claiming it and gives
the lock up when the mtime is not the one it wrote, so the loser of a
takeover race releases instead of proceeding.

- core/auth/node/file-lock.ts: `withSecretFileLock`. `realpath: false` so a
  file can be locked into existence (the very first `set` has no
  `secrets.json`, and the library's default resolves through `fs.realpath`);
  a warning in place of the library's `onCompromised`, which throws from a
  timer and would take the session down; and a degrade-with-one-warning path
  rather than a throw when no lock can be taken — this store exists for boxes
  missing the usual mechanism (#1848, #1905) and must not gain a new way to
  be unavailable.
- FileSecretStore.mutate holds it across the whole read-modify-write. The
  optimistic verify stays underneath and is not redundant: a lock is advisory
  between the processes that take it, so the verify covers a writer outside
  this codebase and covers the degrade path. The in-process queue stays too,
  and gains a second job — proper-lockfile is not reentrant, so serializing
  per path keeps ELOCKED meaning "another process".
- absorbFileSecretsIntoKeyring takes the same lock around orphan adoption and
  the atomic claim, behind a lock-free `readdir` fast path so the common
  startup (keychain available, no file ever written) neither creates a lock
  directory nor warns about one it could not create.

proper-lockfile is a root `dependency` per the placement rule, and is named
in all three bundler `external` lists: tsup externalizes what the *client's*
manifest declares, so a root-only CJS package was being inlined into the ESM
bundles, leaving esbuild's `Dynamic require of "path" is not supported` shim
that killed `--cli` at import time. That rule was undocumented; it is now in
AGENTS.md beside the placement rule that creates it, and mirrored into
.github/copilot-instructions.md.

Tests: 7 new in file-lock.test.ts driving a real second process (the existing
suite structurally cannot — `serialize` orders in-process callers before the
lock sees them), plus two for the migration fast path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 09:32:41 -04:00
cliffhall
180b397aec chore(deps): bump nanoid to 3.3.18 to clear the high-severity advisory
`npm audit --omit=dev` reported one high-severity finding in the
production tree, on both `main` and `v2/main`:

  nanoid <3.3.18 — GHSA-2v37-7h3g-55p8
  custom generators can loop indefinitely when size is zero

It arrives transitively, via vite -> postcss -> nanoid. `postcss`
depends on `nanoid@^3.3.11`, so the fix is an in-range lockfile bump in
all five installs — no package.json changed. `npm audit --omit=dev` is
now clean at the root and in each of the four client installs.

Applied as a targeted `npm update nanoid` per install rather than
`npm audit fix`. The latter reaches the same clean audit but re-resolves
the whole tree, and here dragged in an unrelated esbuild downgrade
(0.27.7 -> 0.27.2) across web/cli/tui — roughly 700 lines of lockfile
noise for a one-package security patch.

The issue also asked whether `vite` belongs in root `dependencies`,
since that placement is why this advisory appears under `--omit=dev` at
all. It does, and deliberately: `start-vite-dev-server.ts` imports
`vite` and `@vitejs/plugin-react` at runtime for `--web --dev`, and
`tsup.runner.config.ts` lists both as `external`, so a published install
resolves them from the root manifest. Moving them to `devDependencies`
would break `--web --dev` for consumers while passing every local check.
Left in place, with the reasoning recorded in the README's
dependency-placement paragraph so it need not be re-derived.

Verified: `npm audit --omit=dev` reports 0 vulnerabilities in all five
installs; `npm run ci` passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 23:12:10 -04:00
cliffhall
1edb4f78c7 chore(deps): sync the security fixes that landed on main
Dependabot security updates always target the default branch, so #1833,
#1840, #1913, #1925 and #2004 all landed on `main`. `main` is never merged
back into `v2/main`, so those fixes had no path to the develop branch and
every install off `v2/main` still resolved the vulnerable versions —
Dependabot will not re-raise them, since it considers them fixed on the
branch it watches.

Brings v2/main to parity: hono 4.12.25 -> 4.13.1, undici 8.5.0 -> 8.9.0,
body-parser 2.2.2 -> 2.3.0, ip-address 10.1.0 -> 10.5.0, fast-uri
3.1.0 -> 3.1.5, plus qs, type-is, express-rate-limit, hasown,
side-channel and es-object-atoms, across the root, web, cli and tui
installs. Two direct declarations move with them: cli undici ^8.9.0 and
web hono ^4.13.1.

The files are taken from the reconciled v2.3.0 milestone-merge tree, so
the root version stays 2.3.0 rather than regressing to main's 2.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 22:20:10 -04:00
cliffhall
1c595e96ee fix: dedupe a repeated name in a group, and name every unmet requirement
Addresses Copilot's round-13 review on #2035. Two suppressed comments, both
real, both in the required-group work from round 7 rather than in the recent
preview rework.

1. `{a,a}` is ONE requirement named twice, but `requiredGroups` stored it as
   a two-name group, so everything downstream read it as "either of these
   will do". The TUI was left with a form it could not submit: its
   `length === 1` test marked `a` optional while ResourceTestModal's guard
   still refused a blank. The web panel offered the useless hint
   "Any one of: a, a". Names are now deduplicated within the expression --
   expansion still emits both occurrences (`x://{a,a}` -> `x://1,1`), which
   RFC 6570 requires.

2. The per-field hint used `find`, so a variable in several shared groups
   advertised only the first. With `{a,b}{b,c}{a,c}`, filling `b` satisfies
   the first two and every visible hint then looks met while Read Resource
   stays disabled on the unmet `{a,c}` -- a requirement with nothing on
   screen pointing at it.

   Fields now hint every shared group they sit in. That alone is not enough,
   because no per-field hint can say WHICH group is still outstanding, so
   the form states it directly: "Still needed: a or c", built from the same
   `unmetRequiredGroups` the submit gate uses, so the message and the button
   cannot disagree. `hasRequiredValues` is no longer called here -- the
   panel derives both from the one list.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:33:52 -04:00
Cliff Hall
63250a6c52
Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion 2026-08-16 23:16:27 -04:00
cliffhall
6acaff70ca fix: honor a defined-but-empty value, and announce the expansion error
Addresses Copilot's round-8 review on #2035 (one inline comment, one
suppressed). Both real.

1. RFC 6570 distinguishes an UNDEFINED variable, which omits its whole
   expression, from one defined as the empty string, which expands. The
   expander was calling `definedValues` on the way in, collapsing the two --
   so `x{?q}` with `{ q: "" }` gave `x` instead of `x?q=`, and there was no
   way for any caller, `readResourceFromTemplate` included, to request
   either that or `x;q`. Key presence is now honored, including §3.2.7's
   rule that the matrix operator drops the `=` for an empty value (`x{;q}`
   -> `x;q`, while `?`/`&` keep it).

   Dropping blanks is a FORM concern, so it moved to the two form
   boundaries: the web panel seeds every declared variable with "" and the
   TUI's ink-form returns "" for every untouched field, so neither can tell
   "never touched" from "deliberately empty" -- but that is a fact about a
   text input, not about the template, and it has no business deciding what
   a template means for every other caller.

   Six unit tests and one integration test encoded the old collapse and now
   assert the corrected meaning. The prototype-collision cases get stronger
   rather than weaker: a blank `{?toString}` expands to `?toString=` -- its
   OWN empty value -- where the original bug expanded the inherited
   function's body.

2. The expansion error is now `role="alert"`. It can appear while typing (a
   pasted value that cannot be encoded), and plain text is not announced, so
   a screen-reader user met a disabled Read Resource with no stated reason.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:08:30 -04:00
cliffhall
1c24eda429 fix: validate the whole varspec, and derive the TUI's message from its gate
Addresses Copilot's round-7 review on #2035. Two suppressed comments, both
real; each reproduced against this branch before acting.

1. `parseVarSpec` was a "strip the first `*`, trim, split on `:`" pass, so
   it accepted shapes RFC 6570's varspec production does not admit --
   measured on the previous commit:

     x://{*id}     name "id"  =>  "x://abcdef"   explode is a TRAILING modifier
     x://{id*:3}   name "id"  =>  "x://abc"      the two modifiers are exclusive
     x://{ id }    name "id"  =>  "x://abcdef"   whitespace is not a varchar
     x://{a b}     name "a b" =>  "x://1"        ditto -- and it emits into the URI

   `{id*:3}` is the worst of them: it silently truncated the value through a
   combination the grammar forbids. Parsing is now one anchored regex --
   a varname, then at most one trailing `*` or `:1-9999`.

   The name set is `varchar` widened by `-` and `~`, deliberately. A name is
   the one thing expansion emits into the URI WITHOUT encoding it (`;`, `?`
   and `&` write `name=value`), so the rule it has to satisfy is "needs no
   encoding" -- RFC 3986 unreserved plus pct-encoded, of which the RFC's own
   varchar is a subset. Strict varchar would refuse `{user-id}`, which real
   servers publish and which the SDK's matcher round-trips; refusing to read
   a resource that demonstrably works is a worse outcome for a debugging
   tool than a name one character outside the production. Everything now
   rejected is a shape we would otherwise have to guess at.

2. The TUI built its "Missing required template variable(s): ..." list with
   its own bare `values[name]` filter while gating on `hasRequiredValues`,
   which reads own properties only. For a variable legitimately named
   `constructor` the two disagreed: `({})["constructor"]` is `Object`, whose
   `.length` is 1, so the list came out empty and the error named no field
   at all while the submit stayed blocked. Both now come from one exported
   `unmetRequiredGroups`, with `hasRequiredValues` defined in terms of it.

Also renames a test whose title still said multi-name query expressions were
left "to the SDK, which handles them" -- expansion has been owned here since
935113d1; the behavior it asserts is naming, not delegation.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:48:36 -04:00
Cliff Hall
405d0f4d0d
Merge pull request #2039 from modelcontextprotocol/v2/chore/1959-no-floating-promises
chore(lint): enable @typescript-eslint/no-floating-promises in all five ESLint scopes
2026-08-16 22:35:52 -04:00