Commit graph inspector/clients/tui/__tests__
Author SHA1 Message Date
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
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
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
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
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
cliffhall
85e3215534 fix(tui): retire the disconnect token on a server switch, not just clear
Copilot round 3 (PR #2039): clearing the banner when `selectedServer`
changes is not enough. Start disconnecting A, switch A → B → A, and by
the time the original request rejects both guards match again — the
attempt token is unchanged and the selected server is A once more — so
the stale failure lands on the freshly re-selected A. Bump the token on
every switch, so a round trip invalidates prior disconnects the same way
a one-way switch does.

New test drives exactly that interleaving; it fails without the bump
(the one-way test does not, which is why this needed its own).

Also attaches the before/after TUI screenshots the review asked for —
the review was right that "no UI change" stopped being true once the
banner was added — and corrects the PR description accordingly.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:11:40 -04:00
cliffhall
d830213796 fix: address round-2 review — stale disconnect, lint-project gap, coverage
Copilot round 2 (PR #2039) surfaced no new visible comments but six
suppressed ones. Four were actionable.

`clients/tui/src/App.tsx` — a stale-completion race in the new banner:
start disconnecting server A, switch to B, then let A's disconnect
reject, and A's failure appears under B. Rapid same-server retries had
the same shape, and the stale attempt is the one most likely to reject.
Claim a monotonic attempt token before awaiting and publish only if it
is still current and the server has not changed; `handleConnect` bumps
the same counter so a connect retires an in-flight disconnect. The
rejection is still owned when superseded — just not published. The
regression test drives a deferred rejection across a server switch, and
fails without the guard.

`tsconfig.lint.json` — the root type-aware block matches
`test-servers/src/**/*.tsx` but the parser project omitted that
extension, so such a file would have failed with "not found in any of
the provided project(s)" instead of being linted. Added, and AGENTS.md
now states the invariant: the include must stay a superset of the lint
globs.

`clients/web/server/{server,vite-hono-plugin}.ts` — the `open()`
rejection branch was untested, and duplicated. Both were fixed by
removing the duplication: `openBrowser` now lives beside
`printServerBanner` in `web-server-config.ts`, with one implementation
to test. Two copies of a one-line `.catch` is two chances for one to
drift back to a bare `open(url)`. The dev plugin is not booted in tests
— it returns early under VITEST by design — but it calls the same
tested helper.

`core/mcp/remote/node/server.ts` — `closeAbortedStream`'s rejecting-close
path, the entire reason the helper exists, had no test. Exported it (as
this file already does for `requestIdForSendWait` / `mcpParamHeadersOnly`)
and covered both legs, asserting no unhandledRejection fires.

Also corrects AGENTS.md, which said each client lists "both" tsconfig
projects — true for cli/tui/launcher, but web lists four leaf projects.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:52:43 -04:00
cliffhall
ebc18e1d56 fix: expand each expression independently, and reject invalid varspecs
Addresses Copilot's round-5 review on #2035 — eight suppressed comments,
all of them real.

The consequential one overturns behavior I copied from the SDK and
defended in two earlier rounds. RFC 6570 expands each expression
independently; it carries no cross-expression state. The SDK tracks
whether a query expression has already emitted and rewrites a later
`{?two}`'s `?` to `&`. That looks friendlier and is wrong, and its own
matcher says so: for `x{?one}{?two}` it expands to `x?one=1&two=2`, and
`match("x?one=1&two=2")` on that same template returns **null**, while
`match("x?one=1?two=2")` returns both variables. So the rewrite emitted a
URI the advertised template cannot match — #1919 one level up. A server
that wants a continuation advertises `{?one}{&two}`, which expands to `&`
and matches; producing that shape is the server's choice to declare, not
ours to infer.

Also:

- `{id:abc}` was silently treated as `{id}`, and a test codified it. RFC
  6570's max-length is `%x31-39 0*3DIGIT`, so `{id:}`, `{id:0}`,
  `{id:abc}` and `{id:10000}` are invalid templates rather than templates
  with an ignorable modifier — and the SDK's constructor accepts them all,
  so nothing else rejects them. Strict expansion now throws; lenient
  returns the raw template. Discovery stays lenient so the panel still
  renders rather than going blank.

- The TUI could submit `{a,b}` completely blank while the web panel
  blocked the same request: ink-form has no way to express "any one of
  these", so its members are left optional and the modal needs its own
  group check. Added, with tests — it was also below the coverage gate
  without them (functions 76.92%).

- Added the integration test the config never had: it resolves the
  checked-in config and drives all four reads over a real transport, so a
  misspelt preset fails there rather than only when someone runs the repro
  by hand.

- "All three clients" was false. The CLI has no template form and its
  `resources/read` passes the already-expanded `--uri` straight to
  `readResource`, so nothing here runs for it. Corrected in the README,
  AGENTS.md, and the module comment, and dropped a docblock still
  describing the SDK-delegation path removed last round.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:38:52 -04:00
cliffhall
5d8094357d fix(tui): surface a disconnect failure independently of connection status
Review catch (Copilot, PR #2039): folding the rejected disconnect into
`connectError` meant nobody ever saw it. InfoTab renders that value only
when `serverState.status === "error"`, and a failed disconnect leaves the
status "connected" — so the message existed in state and never on screen.
Worse, it fed `connectError ?? inspectorLastError`, where a stale value
would go on masking a later, real connection error.

Give it its own `disconnectError` state and a header banner that renders
regardless of status, cleared on the next connect or disconnect attempt
and on a server switch, so it can neither linger nor be attributed to
the wrong server. The tests now assert the rendered banner rather than
noting that nothing is visible.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:16:34 -04:00
cliffhall
935113d1e0 fix: encode per RFC 3986, and derive the TUI form from the shared parser
Addresses Copilot's round-4 review on #2035 — again reported as "no new
comments" while carrying three suppressed ones. All three reproduced.

1. The TUI form still named its fields from the SDK. `uriTemplateToForm`
   read `UriTemplate.variableNames`, which mangles a name: `{;id}` yields
   ";id" and `{id:3}` yields "id:3". The form therefore submitted
   `{ ";id": "7" }` while the shared expander looked up `id`, found
   nothing, and dropped the expression — the value vanished silently.

   This is the finding that mattered most, because it falsified the claim
   this change is built on. Moving the expander into core only makes the
   clients agree if each client's FORM derives its names from the same
   parser: a form submits under the names it rendered. The TUI now reads
   `templateVariables`, and the SDK template is constructed only to
   validate, preserving the existing malformed-template diagnostic.

   Its `required` flag comes from `requiredGroups`, and only a variable
   that is the sole member of a non-omittable expression is marked: ink-
   form cannot express "any one of these", so marking every member of
   `{a,b}` required would refuse input the expander accepts.

2. `encodeURIComponent` is not RFC 3986's unreserved set — it leaves the
   sub-delims !'()* bare, which RFC 6570 requires encoded for every
   operator except + and #.

   Fixing that settled a design question left open in the previous round.
   With two encoders, the SAME value encoded differently depending on
   whether its expression happened to carry a modifier, since only the
   modifier pushed it onto our path. So delegation is gone entirely: one
   expander, one set of rules. The SDK's `UriTemplate` is still
   constructed, but only to validate a template.

3. The "Any one of: a, b" hint could contradict the disabled submit
   button. A name can sit in a singleton required group AND a shared one
   (`x://{a}/{a,b}`), where the singleton demands that exact field. The
   hint is suppressed in that case.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:04:20 -04:00
cliffhall
519ecf49ee chore(lint): enable no-floating-promises in all five ESLint scopes
A floated promise is invisible at review time — the call reads like an
awaited one minus four characters — and the unhandled rejection it
produces surfaces in a different test, in a different file, as a stack
pointing at SDK internals. In #1947 two un-held `callTool` calls made
`npm run ci` unpassable and took a full investigation to attribute; the
fix was two lines. `@typescript-eslint/no-floating-promises` catches the
class outright, so enable it at `error` everywhere.

The rule is type-aware and `tseslint.configs.recommended` is not, so each
scope's config gains a parser project. The four clients name their own
tsconfig projects (both of them — `src` and `__tests__`/`server` live in
different ones). The root scope has no tsconfig at all, so add
`tsconfig.lint.json`: a lint-only project covering `core/**`,
`test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and
gating nothing. It sets `moduleResolution: bundler` deliberately — under
NodeNext `core/`'s extensionless imports fail to resolve, every import
degrades to `any`, and the rule silently stops seeing promises at all.

Measured surface, and the fixes:

- core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono
  invokes subscribers with a bare `subscriber()`, so a promise returned
  from the listener is floated one layer up; `closeAbortedStream` owns
  the rejection where it can actually be owned.
- tui (5) — `handleDisconnect` had no catch of its own and its only
  caller is a key handler that cannot await, so a failing disconnect was
  a live unhandled rejection; it now surfaces the message the way
  `handleConnect` does. The rest are `void` at callers that genuinely
  cannot await, over callees that already own their failures.
- web (92) — 83 are Storybook `expect(...)`, which is instrumented and
  returns a promise, now awaited (`scrollAreaStoryAssertions` is async
  for the same reason). Two tests were asserting against a provider call
  they never awaited. `open(url)` in both web servers now warns instead
  of escaping a synchronous callback.
- cli, launcher — already clean.

Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s
to ~19s, inside `validate`. Narrowing the loaded projects is the lever if
that needs reducing later; dropping the rule is not.

Closes #1959

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:57:32 -04:00
Cliff Hall
2f0aa24726
Merge branch 'v2/main' into v2/fix/1942-tui-coverage-flake 2026-08-16 17:40:11 -04:00
cliffhall
ae0e9d53a3 test(tui): settle input handlers at the keypress sites too, and pin the contract
Extends the previous commit's fix rather than changing it. `waitUntil` now
settles, but the same window is open wherever a key is written without having
just polled: `press()` reaches its `stdin.write` on a turn that may still have
React's passive-effect flush queued (straight after a plain `tick`, or after an
earlier key committed a render), and `mount()`'s absorbing keypress lands one
tick after the mount commit. Both now `await settleInputHandlers()` before
writing, so "frame visible" implies "input handlers armed" at every dispatch
point, not only after a frame wait.

Adds a `test helpers` case that pins the contract: a `setImmediate` sentinel
stands in for the pending effect flush and must have run by the time
`waitUntil` resolves. Verified it fails when the yield is removed, so a future
regression surfaces here instead of as a differently-named flaky OAuth test
under coverage instrumentation.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 12:15:41 -04:00
cliffhall
8356033d58 fix: strip the enum null sentinel, and make the preset test cover its wiring
Copilot round 4: no new visible comments, two in the suppressed block. Both
valid.

1. The `type: [T, "null"]` encoding keeps its keywords at the top level, so a
   nullable enum written that way carries the null *inside* the list:
   `{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. The
   collapse left it there, which broke both renderers in different ways — the
   web dispatcher handed `null` to Mantine as option data, and the TUI's
   all-strings check rejected the whole enum and fell back to a plain text
   field. Neither is the dropdown the schema asks for.

   The collapse now strips a `null` member whenever it flattens, since that
   fact has already moved onto `nullable`. An enum of nothing but `null`
   drops out entirely: it offers no value a dropdown could show, so the plain
   widget serves the field better than an empty select. Covered in core and
   end-to-end in both dispatchers.

2. The new integration test claimed in its own header to pin the registry and
   config wiring, then called `createNullableFieldsTool()` directly — so a
   misspelt preset case or a config naming a dead preset would have left it
   green. A fair "this does not test what it says" catch. It now resolves the
   checked-in `nullable-fields-http.json` through `loadConfig` →
   `resolveConfig`, matching `duplicate-tool-names.test.ts`, and asserts the
   resolved tool names before booting the server.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 18:28:38 -04:00
cliffhall
80eb321b14 fix: stop const-less anyOf branches from crashing the form, and never select a non-string enum
Closes #2007.

Two things, both surfaced while answering "does #1928's PR also fix #2007?".

**#2007, and why it belongs in this PR.** `z.array(z.union([z.object(…),
z.object(…)]))` gives an `items.anyOf` whose branches carry no top-level
`const`, so every MultiSelect option was `String(item.const ?? "")` — the
empty string, twice. Mantine *throws* on duplicate option values, greying
out the whole tool panel.

That is a pre-existing bug, but this branch makes it reachable from a new
direction: a *nullable* array of object unions previously had no top-level
`type` and fell to the JSON fallback, and now collapses to `type: "array"`
with `items.anyOf` — straight into the crash. Shipping the collapse without
this fix would widen an existing crash, so the two go together. Both shapes
are covered by tests.

`toConstOptions` now returns `null` unless every branch yields a distinct,
non-empty value, and the field falls through to the JSON editor — which is
the honest widget for a union of object shapes anyway. Applied to the
`string`/`oneOf` path as well, which had the identical `?? ""` and the same
duplicate-throw waiting on it.

**Copilot round 2.** Round 1's fix kept "an explicit `type` is
authoritative", so `{ type: "number", enum: [1, 2] }` still collapses with
its enum hoisted. The TUI's dispatcher tests `property.enum` *before* the
type switch, and `toSelectOptions` stringifies, so that reached a select
that submits `"1"` for `1`. The select path is now gated on `isStringEnum`
(exported from core for this), and a typed non-string enum falls through to
its typed field: the enum constraint is lost, the value's type is not,
which is the safer of the two losses. Pre-existing for a plain numeric
enum; the collapse just made the nullable form reachable too.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 17:49:09 -04:00
cliffhall
17a4c78dd3 fix: centralize the nullable-union collapse in core and apply it in the TUI
Closes #2015, folded into #1928 rather than left as a follow-up.

The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its
local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no
array-valued `type` — so a `.nullish()` argument has no top-level `type` or
`enum` for the dispatcher to read and falls to the `switch` default, losing
its select. Milder than the web bug (a plain text field, not a corrupting
JSON textarea), but the same class.

Fixing it in a second place is the argument for not having the logic in a
first place. So the collapse moves to `core/json/nullableUnion.ts` as
`normalizeNullableUnion`, generic over a minimal `NullableUnionSchema`
shape — the least a caller must expose to be *recognized* as a nullable
union — so neither client has to adopt the other's schema type. Both form
builders now share one copy and cannot drift on which schemas they render.

- `core/json/nullableUnion.ts` — the shared collapse, plus tests at
  `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what
  gates `core/`).
- `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and
  `resolveRefsInMessage` call the core function directly.
- `clients/tui`: `schemaToForm` normalizes each property before dispatch,
  and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the
  encoding uses.

Verified by driving the built TUI under a pty against the
`nullable-fields-http.json` showcase server. Before, every argument of
`record_shipment` rendered as a plain text field; after, `direction` is a
select ("No value"), `quantity` an integer, `express` a boolean
("[Not set]").

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 16:59:37 -04:00
olaservo
9c6e4aeb4e fix: settle ink input-handler re-subscription after frame waits in TUI App tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 08:57:44 -07:00
cliffhall
94a227b9c1 fix(tui): widen the root react range so an external ink can't split React
Review follow-up on #1983. Copilot was right, and about a hazard this repo
had already proven and then documented away: the claim that ink's ">=19"
peer "keeps npm from hoisting it next to a React the Inspector couldn't
also use" is false. A consumer pinning React 19.0 satisfies ">=19" while
`^19.2.4` nests underneath, so ink renders through their React and the
bundle through ours — the #1952 crash, one level up, breaking TUI startup
rather than just its forms.

Bundling ink closes it (verified: with a `createRequire` banner for the
inlined CJS, since react-reconciler calls `require("react")` and
signal-exit@3 `require("assert")`), but costs ~1.4MB — react-reconciler
and yoga-layout come along. The root `react` range closes it for free
instead: `^19.0.0` is open to the whole major, so npm can satisfy our
React and a consumer's pinned React 19 with one copy, and an external ink
resolves the same copy the bundle does.

Verified against both consumer trees — the reporter's (React 18 + ink 5)
and a pinned React 19.0.0 + ink 6.8.0, which splits under `^19.2.4`.

That makes the range load-bearing while looking like an ordinary version
bump, so the tests pin it: the root range must equal ink's own declared
peer floor. They also assert each exempt package is both external and a
root dependency — external means consumers install it, the mirror image
of the inlined ones, which must not be root-declared.

The docs in all four places now say ink is exempt on cost and never on a
peer range, and name that claim as one we got wrong.

Closes #1952

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
2026-08-12 00:21:43 -04:00
cliffhall
39575ff060 test(tui): drive the ink-form patch through the plugin, and drop the esbuild type import
Review follow-ups on #1983.

The patch's tests called the string helper directly, so nothing exercised
the esbuild `onLoad` filter that decides whether the helper runs at all. A
filter that stopped matching would leave the build green, the tests green,
and the typo shipped — the silent no-op the throwing helper exists to
prevent, reintroduced one level up. The plugin is now driven the way
esbuild drives it (register, then invoke) against the real `ink-form`
module path, and a second test pins the filter's scope: it matches that
path and a Windows-separator one, and rejects `Form.js`,
`ink-select-input`'s own `SubmitButton.js`, and a first-party
`src/SubmitButton.jsx`.

The plugin type came from `import type { Plugin } from "esbuild"`, but
`esbuild` is tsup's transitive dependency, not one this client declares —
it typechecked only while npm happened to hoist it, and would break on a
lock change that nested it. Derive it from tsup's own
`Options["esbuildPlugins"]` instead of adding a dependency for a type.

Closes #1952

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
2026-08-11 23:29:13 -04:00
cliffhall
3c1bbbcb5f fix(tui): correct ink-form's misspelled incomplete-form hint
The hint under a form with unfilled required fields reads "you have not
competed yet". The string is upstream's, hardcoded in
`ink-form/lib/SubmitButton.js` with no prop to override it, and `ink-form`
was last published in 2024 — but it renders in the Inspector's tool,
prompt, and resource test forms, so it is ours to fix in practice.

Bundling `ink-form` (previous commit) is what makes it patchable: an
esbuild `onLoad` hook rewrites the label as the file enters the bundle.
The hook throws when the string is absent rather than passing the source
through, so an `ink-form` upgrade cannot silently retire the patch or
leave it aimed at a string that no longer exists — the tests drive it
against the real dependency source for the same reason.

Reported upstream as lukasbach/ink-form#14.

Closes #1952

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
2026-08-11 23:15:35 -04:00
cliffhall
eb5bba62ed fix(tui): bundle React-rendering deps so a consumer install can't split React
`ink-form` and `ink-scroll-view` were externalized by the TUI bundler and
both declare a loose `react` peer (">=18"). Installed under a project that
has its own React 18, npm hoists them to that project's root — where the
peer is satisfied — while the Inspector's React 19 nests underneath. The
bundle then renders through React 19 while those two call hooks on React
18, whose dispatcher is null, so opening a tool test form (or any scroll
view) dies with "Cannot read properties of null (reading 'useState')".

Inline them instead: their `import "react"` is emitted into the bundle and
so resolves from the build directory exactly like the bundle's own, with
npm's placement no longer part of the decision. This also pins their
transitive deps to what this repo resolved — notably `ink-select-input@6`
via `overrides`, which npm ignores for a package installed as a dependency,
so consumers were getting the React-18-era v5.

`ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails
ESM interop with `Dynamic require of "assert"`) and doesn't need to be —
its ">=19" peer keeps npm from hoisting it next to an unusable React.

Both packages are dropped from the root `dependencies`, since the tarball
now ships their code rather than having consumers install them.

`clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the
failure is invisible in this repo and in every smoke (a dev install has
one React) and only appears once the package is installed under another
project, so the invariant is asserted against the config instead — every
dependency declaring a `react` peer must be bundled unless exempted.

Closes #1952

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
2026-08-11 23:00:01 -04:00
Cliff Hall
d9e4f444a2
test(cli,tui): typecheck the __tests__ dirs (#1791) (#1799)
* test(cli,tui): typecheck the __tests__ dirs (#1791)

The cli/tui `typecheck` scripts only checked `src` (the src-only tsconfig
excludes `**/*.test.*`), leaving the `__tests__` dirs — which do the most
`as` casting — with no tsc pass. Add a `tsconfig.test.json` per client that
includes `__tests__` with the test-exclusions dropped and the test-only path
aliases (`@modelcontextprotocol/inspector-test-server`, the `@inspector/core/*`
deep paths, express/vitest) resolving what vitest resolves, then fold
`tsc -p tsconfig.test.json` into each `typecheck`.

Fixes the 76 errors it surfaced:
- cli: a wrong `@modelcontextprotocol/inspector-core/...` module specifier
  (should be `@inspector/core/...`; only a type import so vitest never caught
  it), `r.headers` optional-access guards, and partial OAuth mocks replaced
  with typed `makeFakeCliOAuthClient()` / `makeFakeServerSettings()` factories
  instead of `as` casts. Adds `@types/express` (devDep) so the transitively
  aliased test-server source typechecks, mirroring clients/web.
- tui: rest-param signatures on the App OAuth spies (fixes the spread-arg and
  narrow-return errors), a widened callback param (`iss?`), a `makeFakeServer-
  Settings()` factory for the tuiOAuth tests, required OAuth-metadata/client
  fields in AuthTab, and a loosened `entry()` message param for the
  deliberately-malformed wire messages HistoryTab's defensive branches render.

Gate coverage only; no runtime behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(cli,tui): address review — real spy signatures, config typecheck, nits

Responds to the @claude review of #1799:

- App.test.tsx: type the OAuth spies against the real InspectorClient method
  signatures (`vi.fn<InspectorClient["…"]>()`) and forward `Parameters<…>`
  tuples from the FakeClient wrappers, so `toHaveBeenCalledWith(...)` on the
  six OAuth-lifecycle spies stays argument-checked under the new gate rather
  than accepting anything via `unknown[]`. `getOAuthState` regains its wide
  `OAuthConnectionState | undefined` return.
- Typecheck the client config files too: add `vitest.config.ts` / `tsup.config.ts`
  (and tui `dev.ts`) to each src `tsconfig.json` include — they were the one
  first-party surface still getting no tsc pass.
- Trim the redundant `src/**/*` from each `tsconfig.test.json` include (tsc
  pulls in the src the tests import; the src-only config already checks it).
- Rename the tui settings helper to `oauth-test-fakes.ts` to match cli's, and
  soften both factories' docstrings to stop naming a loader they don't mirror
  field-for-field.
- Fix a stale `@modelcontextprotocol/inspector-core/...` specifier in a
  createRemoteTransport JSDoc example (the same wrong package name the gate
  caught in the cli tests, surviving in a comment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-2 review — correct claims, tighten types, typecheck launcher

Responds to the round-2 @claude review of #1799:

- Correct the toHaveBeenCalledWith claim: vitest doesn't type-check
  `toHaveBeenCalledWith(...)` arguments against the mock's signature, so the
  App.test.tsx comment no longer claims it does — the typed spies buy checked
  implementations/return payloads (the stale-literal fix), not arg-checking.
- authenticate spy now uses the real `InspectorClient["authenticate"]`
  signature (returns a `URL`), so it no longer diverges from production; the
  one mockResolvedValue is updated to a URL.
- makeFakeCliOAuthClient members are typed against the real CliOAuthClient
  signatures (not bare vi.fn()), so a wrong-signature override is rejected.
- HistoryTab: revert the `entry()` `message: unknown` widening (it un-checked
  the ~15 well-formed fixtures) and instead scope a named `MALFORMED` cast to
  the two intentionally-malformed messages.
- Typecheck the launcher `__tests__` too (the same gate hole in the one client
  the PR hadn't touched): new clients/launcher/tsconfig.test.json + a typecheck
  script folded into validate. Completes the "every client's __tests__ is
  typechecked" invariant.
- Doc the new test-typecheck + config-file typecheck in AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-3 nits — launcher config typecheck, callTool typed, docs

Responds to the round-3 @claude review of #1799 (all non-blocking):

- launcher: add `vitest.config.ts` to tsconfig.test.json's include (it was in
  neither project — the build config's rootDir: ./src rejects it). Now
  typechecked.
- App.test.tsx: type `callTool` against `InspectorClient["callTool"]` (+ the
  Parameters<…> wrapper) so its `mockResolvedValue` payloads are checked, and
  give `clearOAuthTokens` an `async () => {}` impl so the mock actually returns
  the `Promise<void>` its type promises.
- AGENTS.md: correct the per-client alias-set description (cli's is widest,
  tui's is core+react only, launcher's has no paths) and note launcher's config
  file lives in its test project, not the src project.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-4 — durable typecheck-coverage guard + launcher/doc nits

Responds to the round-4 @claude review of #1799 (all non-blocking):

- Add `verify:typecheck-coverage` (scripts/verify-typecheck-coverage.mjs), the
  typecheck-coverage analog of verify:format-coverage and the durable guard for
  the invariant this PR establishes: for each Node client it runs the tsconfig
  projects its `typecheck` names with `tsc --listFiles`, unions them, and fails
  on any tracked .ts/.tsx that lands in no project. This closes the class the
  review kept surfacing by hand (launcher/__tests__ in round 3,
  launcher/vitest.config.ts in round 4) — a new top-level file can no longer go
  untypechecked silently. Wired into `npm run ci` and the GitHub workflow.
- launcher: reorder `validate` to run `typecheck` before `build` (matches tui
  and the documented order), and make `typecheck` self-contained — it now runs
  both `tsconfig.json` and `tsconfig.test.json`, so running it standalone means
  the same thing it does in cli/tui.
- makeFakeCliOAuthClient: note on the comment that the typing covers the
  defaults, not `overrides` (a bare vi.fn() override is Mock<(...args)=>any>).
- Docs: README table + AGENTS.md updated for the new guard and the corrected
  launcher typecheck/validate ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-5 — wire-check the guard, --listFilesOnly, into validate

Responds to the round-5 @claude review of #1799:

- Finding 1 (the pre-merge one): the guard now ports verify:format-coverage's
  reachability checks — it asserts each client's `typecheck` is reachable from
  its own `validate` and that the root chain runs each client's `validate`, so
  it can't stay green while measuring a typecheck nothing invokes. Verified:
  stripping `npm run typecheck` from launcher's validate now fails the guard.
- Nit 3: switch `tsc --listFiles` → `tsc --listFilesOnly` (identical file list,
  ~4× faster: whole guard 23s → ~4s). Now cheap enough for the inner loop, so
  it moves into `validate` (right after verify:format-coverage) instead of a
  standalone ci step — matching its sibling. Removed the separate ci-chain entry
  and GitHub workflow step (validate covers it).
- Nit 4: `npx --no-install tsc` so a missing local tsc fails fast instead of
  reaching for the registry (matches verify-build-gate.mjs).
- Nit 5: corrected projectFiles' JSDoc to describe the filter that's actually
  there (drops repo-external + node_modules paths; core/ + test-servers/ stay
  but are harmless).
- Finding 2: fixed the 2-space misindent on the new root package.json line
  (prettier --write). Docs (README/AGENTS) updated for the validate placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): address round-6 guard nits — dedupe wiring, robust diagnostics

Responds to the round-6 @claude review of #1799 (all guard-ergonomics nits):

- Nit 2: extract the shared reachability logic (`reachableScripts`,
  `rootRunsClientValidate`) into scripts/lib/npm-scripts.mjs and consume it from
  BOTH verify-format-coverage.mjs and verify-typecheck-coverage.mjs, so the
  "is this gate actually run?" wiring can't drift between them (same rationale
  as scripts/lib/prod-web-server.mjs).
- Nit 1: typecheckProjects now harvests `-p`/`--project` from every script
  reachable from `typecheck` (not the single string), so a delegating
  `typecheck` (`npm run typecheck:src && …`) is handled instead of failing with
  a false "names no -p project" diagnosis. Also accepts `--project`.
- Nit 3: on a tsc config error, echo the `error TS…` diagnostic (scanning both
  streams — tsc prints config errors to stdout) so a broken tsconfig is
  self-explaining rather than surfacing only as a misleading "file in no
  project" report.
- Nit 4: refreshed the CI workflow's stale "Validate" step name/comment (it now
  runs the coverage guards + validate:core + per-client typecheck).
- Nit 5: reworded the `.d.ts` carve-out comment to read as pre-emptive (there
  are none under cli/tui/launcher today).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-7 — guard .mts/.cts too; drop dead export

Responds to the round-7 @claude review of #1799:

- Finding 1: verify-typecheck-coverage now requires a tsc pass for tracked
  `.mts`/`.cts` (not just `.ts`/`.tsx`), matching verify-format-coverage's
  extension set — `.mts` is already the shared-config idiom here, so a client
  `vitest.config.ts` → `.mts` rename can no longer drop out of both the tsconfig
  include and the guard's required set. No-op on today's tree (no client
  `.mts`/`.cts`); confirmed a tracked `.mts` with a type error is now caught.
  Ambient `.d.{ts,mts,cts}` stay excluded.
- Nit 2: drop the unused `export` on `rootReachedCommands` (internal to the lib;
  nothing imports it, and scripts/ isn't eslint-gated to catch dead exports).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-8 — single casts in HistoryTab, uniform wrappers, doc scope

Responds to the round-8 @claude review of #1799:

- Finding 1: reduce the `MALFORMED` helper and `entry()`'s return cast from
  `as unknown as` to a single `as` (both compile clean — the malformed shapes
  are comparable to the JSONRPCMessage union), and delete the false "only
  option" sentence. Fixes the AGENTS.md avoid-double-casts violation.
- Nit 3: write all seven FakeClient OAuth wrappers as
  `(...a: Parameters<InspectorClient["…"]>)` (the three zero-arg ones were
  hand-written `()` forwarders), so the block comment's "forward the same
  Parameters<…> tuple" is literally true and a future added parameter can't be
  silently dropped.
- Nit 2: update the five places that still described the guard as `.ts`/`.tsx`
  only to `.ts`/`.tsx`/`.mts`/`.cts` (script header + trackedSourceFiles JSDoc,
  AGENTS.md ×2, README.md).

Nits 4 (js config files out of the typecheck guard by construction) and the
footnoted callTool/ancestor-tsc items left as agreed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-9 — drop entry()'s harmful cast; guards vouch for each other

Responds to the round-9 @claude review of #1799:

- Finding 1: remove `entry()`'s `as MessageEntry` return cast in
  HistoryTab.test.tsx. It was unnecessary (the spread of Partial<MessageEntry>
  over complete defaults is directly assignable) and harmful — it silenced type
  errors in the helper's OWN default fields (a `direction`/`method` typo now
  surfaces TS2820/TS2353). MALFORMED's single cast stays (load-bearing). This
  leaves MALFORMED as the file's only cast, matching what the comments describe.
- Nit 2: add `rootReachesScript` to scripts/lib/npm-scripts.mjs and have each
  coverage guard assert its SIBLING is still wired into the root `validate`. A
  guard can't detect being unrun itself, but the two now vouch for each other,
  so dropping either from `validate` is caught by the other (only deleting both
  slips through). Verified: removing verify:typecheck-coverage from validate
  now fails verify:format-coverage, and vice-versa.

Nits 3 (git-index visibility) and 4 (pre-existing as-never/as-unknown casts in
tests — out of scope, none added by this PR) left as agreed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-10 — reject non-checking typecheck passes; pre/post hooks; docs

Responds to the round-10 @claude review of #1799:

- Finding 1: verify-typecheck-coverage now rejects a `-p <project>` whose own
  command carries `--noCheck` or `--listFilesOnly` — such a pass lists files
  without type-checking them, which previously satisfied both the wiring and
  file-coverage halves while checking nothing. (Splits each script on
  `&&`/`||`/`;` so a flag on one command can't taint another.) Verified a
  `typecheck` using `tsc … --noCheck` now fails with a clear message; `--noCheck`
  is a live idiom here (cli's test-servers:build), so this is a real copy-paste
  hazard.
- Nit 2: reachableScripts (shared lib) now also follows npm's implicit
  `pre<name>`/`post<name>` lifecycle hooks, so moving a gate into e.g.
  `prevalidate` is recognized instead of producing a false "not reachable"
  failure (cli already uses a `pretest` hook). Verified.
- Nit 3: AGENTS.md "Test placement" now states the Node clients (cli/tui/
  launcher) keep all tests in `__tests__/`, enforced by verify:typecheck-coverage
  (a co-located src test lands in no tsconfig project).
- Nit 4: noted on the factory comments that `Partial<…>` overrides admit an
  explicit `undefined` for a required field (exactOptionalPropertyTypes off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* fix: round-11 — launcher build excludes tests; guard catches all noCheck vectors

Responds to the round-11 @claude review of #1799:

- Finding 1 (shipping bug): clients/launcher/tsconfig.json now excludes
  `**/*.test.{ts,tsx}` (matching cli/tui). launcher's `build` is an EMITTING
  tsc over src/**, and clients/launcher/build is in the root `files` allowlist,
  so a co-located `src/**/*.test.ts` would have been compiled into the published
  tarball. Excluding it also makes such a test land in no tsconfig project, so
  verify:typecheck-coverage catches it — making the AGENTS.md placement rule
  literally true for all three clients.
- Finding 2: verify-typecheck-coverage now catches both remaining ways a
  typecheck could check nothing: (a) case-insensitive `--noCheck`/`--nocheck`/
  `--listFilesOnly` on the command, and (b) `noCheck` set in the tsconfig itself
  (detected via `tsc --showConfig`, which surfaces it from the config or its
  extends chain). Verified both fail the guard.
- Nits 3 & 4: restructured into an explicit gate-integrity phase (wiring +
  neutered + config-noCheck + no-projects) that reports under its own accurate
  header and exits BEFORE the file-coverage pass — so an inert gate no longer
  prints file-remediation advice, nor buries the real cause under an
  "in no tsconfig project" line for every file that gate covered.

The one CIMD OAuth E2E integration test that flaked in the coverage run passes
32/32 in isolation and on re-run; unrelated to these script/config changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* fix: round-12 — exclude .mts/.cts tests from build; drop false integrity line

Responds to the round-12 @claude review of #1799:

- Finding 1 (tarball, again): the round-11 test exclude only covered
  `.ts`/`.tsx`, so a co-located `src/*.test.mts` still compiled into launcher's
  shipped `build/`. All three clients now exclude `**/*.test.*` (one glob,
  covering .ts/.tsx/.mts/.cts) — which is also exactly what AGENTS.md already
  claims. Verified a `src/probe.test.mts` is no longer emitted and now fails the
  guard.
- Finding 2: the gate-integrity phase no longer prints a false "names no
  `-p <project>`" line when config-`noCheck` disables projects — the condition
  now keys off `projects.length` (harvested), not `checking.length` (post-
  filter), so a client whose projects were all config-disabled shows only the
  accurate per-project lines.
- Nit 3: recorded the boundary on the phase comment — it does not detect
  shell-level failure suppression (`… || true`, `; exit 0`).
- Nit 5: the file-coverage remediation now leads with "for a co-located test,
  move it to `__tests__/`" (adding it to the src `include` would reintroduce the
  build emit) before the general add-to-include advice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-13 — implicit-tsconfig + derived clients; diagnostics

Responds to the round-13 @claude review of #1799:

- Finding 1: verify-typecheck-coverage now understands a `tsc` command with no
  `-p`/`-b` project flag — it resolves the implicit `./tsconfig.json` (tsc's own
  default), so the idiomatic `tsc --noEmit` form counts as coverage instead of
  producing false "in no tsconfig project" failures. Also harvests `-b`/`--build`
  project paths. Verified `tsc --noEmit && tsc --noEmit -p tsconfig.test.json`
  now reports OK.
- Finding 2: CLIENTS is no longer hardcoded — it's derived from the `cd
  clients/<x> && npm run validate` entries in the root `validate` chain (minus an
  explicit web exclusion), so a new Node client is auto-required, matching the
  repo-wide reach of its sibling verify-format-coverage. Added a
  guard-would-check-nothing bail if the derivation yields an empty set. Verified
  a new `clients/newclient` is picked up and its uncovered file flagged.
- Nit 3: the phase-2 remediation now leads with the general add-to-`include`
  advice and only appends the "move it to __tests__/" note when a failing path
  is actually a test file (was misdirecting plain files).
- Nit 4: AGENTS.md no longer says the test config includes "only" __tests__
  (launcher's also includes its vitest.config.ts).

(Two different web integration tests each flaked once under parallel load during
the coverage runs — both pass in isolation and on a clean re-run; timing-
sensitive and unrelated to these script/doc-only changes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-14 — enumerate clients from disk (fail-closed)

Responds to the round-14 @claude review of #1799:

- Finding 1: replace the root-chain `CLIENTS` derivation (round 13) with a
  disk enumeration — every `clients/<name>` whose package.json has a `typecheck`
  script. The chain derivation was fail-open: a root-chain edit that kept
  running a client but didn't match the strict regex silently dropped it from
  the required set (112→53 files, still green). Disk is fail-closed — a client
  can't be silently dropped, a new client with a typecheck is auto-required, and
  web (no typecheck script; builds via `tsc -b`) is excluded on its own, so the
  explicit exclusion is gone. The existing `rootRunsClientValidate` wiring check
  still verifies the root chain runs each — now LOUD when it doesn't. Verified:
  `cd ./clients/tui && …` now hard-fails the integrity phase instead of silently
  shrinking coverage; `cd clients/tui && npm run build && npm run validate` stays
  gated. Removed the now-unused `clientsRunByRootValidate` from the shared lib.
- Nits 2 & 3: documented the two unreachable-today limitations on
  typecheckProjects — a solution-style `tsc -b` (empty `files` + `references`)
  would under-report, and the implicit-tsconfig fallback assumes no file operands.
- Nit 4: refreshed the file header to mention `-b`/`--build` + the implicit
  `./tsconfig.json` (only the JSDoc had been updated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-15 — enroll-or-exempt enumeration; normalize `cd ./`

Responds to the round-15 @claude review of #1799:

- Finding 1: the disk enumeration keyed on the presence of a script named
  `typecheck`, so renaming it silently dropped the client (112→53, still green)
  — contradicting the docstring. Now every `clients/*` with a readable
  package.json must EITHER declare a `typecheck` (→ enrolled) OR be in an
  explicit EXEMPT map (`clients/web`, reason: `tsc -b`); anything else is a hard
  gate-integrity failure. This is fail-closed on the rename axis too, and web's
  skip is a stated decision rather than an accident of which scripts it declares
  — so web gaining a `typecheck` no longer auto-enrolls it into the solution-`-b`
  false failures. Verified: renaming tui's `typecheck` now hard-fails; a `tsc -b`
  on web stays exempt.
- Nit 3: `rootRunsClientValidate` normalizes a leading `./`, so
  `cd ./clients/tui && npm run validate` (which genuinely runs it) is no longer a
  false "no longer runs" alarm.
- Nit 2: README/AGENTS/script-header prose no longer names a fixed
  cli/tui/launcher scope — they describe the disk-discovered "every clients/*
  with a typecheck script (web exempt)" rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-16 — anchor wiring match; close enumeration axes

Responds to the round-16 @claude review of #1799:

- Finding 1 (only false-PASS): rootRunsClientValidate matched the client dir as
  a bare substring, so a prefix-sibling (`clients/tui` vs `clients/tui-next`)
  satisfied the wiring check for the shorter name — dropping a client from the
  root chain went green if a sibling was present. Now an anchored regex (escaped
  dir + optional leading `./` + a trailing boundary). Verified the sibling case
  now hard-fails.
- Nit 2: a `clients/*` dir holding tracked TS but no readable package.json is
  now a gate-integrity failure (was treated as "not a client"), so the
  "fail-closed on every axis" claim holds. Verified.
- Nit 3: readdirSync is wrapped, so a missing `clients/` dir yields the intended
  "found no clients/* dir" message rather than a raw ENOENT stack.
- Nit 4: EXEMPT's reason is now surfaced in the success line ("clients/web
  exempt: typechecks via tsc -b …"), so the exemption is visible at runtime, not
  only in source.

(Also moved `isRequiredSource` above nodeClients — nit 2's new tracked-TS check
calls trackedSourceFiles at module-init, which referenced it before init.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-17 — share quote-aware tokenizer between guards

Responds to the round-17 @claude review of #1799:

- Finding 1: the two coverage guards parsed shell strings differently —
  verify-format-coverage had a quote-aware tokenize(); verify-typecheck-coverage
  used a raw regex. So a quoted `-p "tsconfig.test.json"` (a form tsc accepts)
  made the typecheck guard report every file that project covers as uncovered,
  and a quoted `cd "clients/tui"` in the root chain tripped a false wiring
  failure. Moved tokenize() (now single+double quote) into the shared lib,
  imported it into both guards, rewrote typecheckProjects to walk tokens
  (`-p`/`--project`/`-b`/`--build` → next token), and strip quotes before the
  `cd` match in rootRunsClientValidate. Verified both quoted forms now pass.
- Nit 2: a stale EXEMPT key (a dir that no longer exists) was asserted in the
  success line unchecked. nodeClients now fails if an EXEMPT key isn't a real
  `clients/*` directory. Verified.
- Nit 3 (build/dist filter belt-and-braces) left as a recorded boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-18 — match tsc by basename; strip quotes on both halves

Responds to the round-18 @claude review of #1799:

- Finding 1 (regression from round 17): `tokens.includes("tsc")` didn't match a
  path-invoked binary (`node_modules/.bin/tsc`), which the previous `\btsc\b`
  regex did — so such a `typecheck` was falsely reported as naming no project.
  Now matches on the token basename (`isTsc`), keeping round-17's quoting fix
  while restoring the tolerance. Verified a `node_modules/.bin/tsc` typecheck
  reports OK.
- Nit 2: `rootRunsClientValidate` stripped quotes for the `cd` match but tested
  `npm run validate` against the unstripped string, so `npm run "validate"`
  false-failed. Both halves now test the stripped string.
- Nits 3 & 4: documented on typecheckProjects — the contrived `--noCheck false`
  (checking on) is still treated as disabling, and the `&&`/`||`/`;` split runs
  before tokenizing (a quoted operator inside an arg would split mid-token,
  unreachable for project paths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-19 — typecheck test-servers' unimported bin; --prefix wiring form

Responds to the round-19 @claude review of #1799:

- Finding 1: `test-servers/src/server-composable.ts` (a `#!/usr/bin/env node`
  bin entry nothing imports) got no tsc pass — the barrel alias covers the other
  13 test-servers/src files transitively, but not this one, and its own build
  runs `tsc --noCheck`, so its errors were emitted into `test-servers/build`
  unchecked. Added it explicitly to `clients/cli/tsconfig.test.json`'s include
  (zero source changes; verified a bad line in it now fails cli's typecheck).
  Softened the AGENTS.md clause that implied the whole test-server source is
  transitively covered.
- Nit 2: `rootRunsClientValidate` now also recognizes the
  `npm --prefix <dir> run validate` invocation form (was `cd`-only).
- Nit 3 (stderr spy drops the completion-callback overload): left as-is —
  latent, not live (no exercised path awaits a stderr drain), and adding typed
  overload params would reintroduce the write-overload complexity the round-1
  vi.spyOn refactor removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-20 — anchor validate tail; guard shared-source class

Responds to the round-20 @claude review of #1799:

- Finding 1 (false PASS): the wiring matcher's tail `/\brun validate\b/` also
  matched `run validate:fast` (`:` is a word boundary) — a DIFFERENT script that
  may skip typecheck. So the root chain could run a client's `validate:fast`
  while the guard kept measuring its `validate` (unrun), green. Anchored the
  tail to a boundary (`/\brun validate(?=$|[\s&;])/`). Verified `validate:fast`
  now hard-fails.
- Finding 2: durably close the test-servers/src class instead of point-fixing.
  Added a SHARED_ROOTS check — every tracked TS under `test-servers/src` + the
  root `vitest.shared.mts` (the `format:check:shared`/`lint:shared` surface) must
  land in the GLOBAL union of client projects. So a NEW unimported bin entry is
  caught (not just server-composable.ts, named in round 19). Verified a fresh
  `server-orphan-bin.ts` now fails. Zero source changes — the union already
  covers 15/15.
- Nit 4: the integrity message and rootRunsClientValidate docstring now mention
  the `npm --prefix` form too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-21 — deny-by-default non-client coverage

Responds to the round-21 @claude review of #1799 (findings 1 & 2, one fix):

- Replaced the `SHARED_ROOTS` allowlist with a deny-by-default non-client set:
  all tracked TS minus `clients/*` (per-client pass) minus a reasoned `core/`
  exemption (covered by web's `tsc -b`). This closes BOTH round-21 findings at
  once:
  - Finding 1 (stale allowlist fail-open): a rename of `test-servers/src` no
    longer silently drops it — its files reappear in the required "other" set
    and hard-fail (verified: `git mv test-servers/src test-servers/source` now
    fails instead of going 127→113 green). The subtractive `core/` exemption is
    fail-closed on staleness for the same reason.
  - Finding 2 (allowlist vs deny-by-default): a new top-level TS location is now
    auto-required (verified `tools/probe.ts` is flagged), matching the repo-wide
    reach of the sibling verify-format-coverage.
- Nit 3: added the shared-source clause to the README table row (AGENTS got it
  last round).

Header comment updated to describe the non-client coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-22 — narrow core/ exemption to what web's tsc -b covers

Responds to the round-22 @claude review of #1799:

- Finding 1: the `core/` exemption was whole-tree, but web's `tsc -b` only
  covers `core/**/*.ts` MINUS co-located `*.test.ts(x)` (its app project's
  include). So a `core/*.tsx`/`*.mts`/`*.cts` or a co-located `core` test got no
  tsc pass from anything. Narrowed the exemption to exactly that shape
  (`isExemptCoreFile`): a core `.ts` that isn't a co-located test. So a core
  `.tsx`/`.mts`/co-located test is now required in some project. Zero-fix today
  (0 such files); verified `core/mcp/probe-widget.tsx` and a co-located core
  `.test.ts` are now flagged.
- Nit 2: `EXEMPT_ROOTS` is now a Map (dir → reason) and its reason is surfaced
  in the success line alongside the client exemption.
- Nit 3: the non-client miss message is now the general "in no client's tsconfig
  project"; the `test-servers` remediation clause is conditional on an actual
  `test-servers/` path (tracked via `nonClientMisses`, not string-sniffing the
  composed message).
- Nit 4: success line says "(N clients + non-client)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-23 — core exemption mirrors both web projects

Responds to the round-23 @claude review of #1799:

- Finding 1: round 22's `isExemptCoreFile` mirrored only web's app project, but
  web's `tsc -b` also builds `tsconfig.test.json`, which includes
  `core/**/__tests__/**/*.{ts,tsx}`. So a `core/**/__tests__/*.test.ts` (a live
  layout — `core/mcp/__tests__/fakeInspectorClient.ts` already sits there) was
  flagged though web checks it. Now mirrors the UNION: under `__tests__/` →
  exempt `.ts`/`.tsx`; otherwise `.ts` minus co-located `*.test.ts(x)`. Verified
  a core `__tests__` test now passes while a core `.tsx` outside `__tests__`
  stays required.
- Finding 2: `EXEMPT_ROOTS` was a Map whose keys nothing read (round 22 replaced
  its filter with hardcoded `isExemptCoreFile`) — a phantom registry. Collapsed
  to a single `CORE_EXEMPT_REASON` const used in the OK line.
- Nit 3: README/AGENTS scope descriptions updated (core is no longer wholesale
  excluded; the non-client set is deny-by-default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-24 — enroll clients/web via tsconfig references

Responds to the round-24 @claude review of #1799:

- Finding 1: `clients/web` was whole-tree EXEMPT, but web's `tsc -b` only reaches
  what its four referenced projects include — a file at web's package root
  (e.g. `playwright.config.ts`) got no tsc pass and passed EVERY gate, fully
  silently. This was the last whole-tree exemption wider than the gate it
  defers to. Now web is ENROLLED: a client with no `typecheck` script but a
  `tsconfig.json` `references` array is measured through those reference
  projects (`tsc -p <ref> --listFilesOnly`), and is wired iff its `validate`
  runs `tsc -b` (`validateRunsTscBuild`). EXEMPT is now the empty escape hatch.
  Verified: a web-root `playwright.config.ts` with a type error is now flagged,
  and dropping `tsc -b` from web's build hard-fails the integrity phase.
  Zero-fix on the tree — the union of web's 4 projects covers all 625 tracked
  web files.
- Nit 2: assert `core/` exists (the OK line unconditionally states its
  exemption) — parity with the EXEMPT stale-key check.
- Nit 3: a `core/` miss now gets core-specific remediation (widen web's
  `tsconfig.app.json` include, not a cli/tui project).
- Nit 4: README/AGENTS scope descriptions corrected — web is enrolled (not
  exempt), and the exact core-exempt shape (app `*.ts` minus co-located tests +
  test `__tests__/**/*.{ts,tsx}`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-25 — drop redundant core exemption; guard tsc -b --noCheck

Responds to the round-25 @claude review of #1799:

- Finding 1: enrolling web (round 24) made the hand-maintained `core/` exemption
  both redundant and fail-open — web's measured `tsc -b` projects already cover
  core, and the mirror could go stale (narrowing web's include let an unimported
  core error pass). Deleted `isExemptCoreFile`, `CORE_EXEMPT_REASON`, the core
  presence assertion, and the exemptNote core clause. `core/` is now required
  like everything else and covered by measurement (127→894 files; a `core/*.tsx`
  web doesn't reach is now caught by measurement, not a hand-model). Kept the
  core-specific remediation clause.
- Finding 2: the reference (`tsc -b`) path skipped the non-inertness check — a
  `tsc -b --noCheck` build gated nothing while the guard stayed green. Turned
  `validateRunsTscBuild` into `tscBuildStatus` (ok/neutered/none); a neutered
  `tsc -b` now hard-fails, matching the typecheck-script path. Factored the
  disabling-flag predicate (`isDisablingFlag`) so both paths share it.
- Nit 3: `clientTsconfigReferences` now strips block comments too (every other
  tsconfig in the repo uses `/* */`).
- Nit 5: README/AGENTS wiring clause covers web's `tsc -b` (not just the
  `typecheck` script), and core is described as measured, not exempt.

Nit 4 resolves itself: EXEMPT is empty and the exemptNote/ternary now collapse
cleanly. Nit 6 (tsc -b bound to a specific tsconfig) left as recorded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-26 — expand solution configs wherever harvested

Responds to the round-26 @claude review of #1799:

- Finding 1: the reference (`tsc -b`) path was selected only by "no typecheck
  script", so adding `typecheck: "tsc -b"` to web (the natural uniformity move)
  flipped it to the typecheck-script path, where the harvested solution config
  lists nothing → 665 false failures. Fixed at the measurement layer:
  projectFiles now expands a solution config (`{"files":[], "references":[…]}`
  — lists nothing under --listFilesOnly) to its references and measures those
  recursively, so a `tsc -b` project is measured no matter which path harvests
  it. Generalized the references reader (tsconfigReferences(path)). Verified web
  with `typecheck: "tsc -b"` now passes. Dropped the stale "web … is out of
  scope" JSDoc clause.
- Nit 2: a `core/**/__tests__/**` miss now points at `tsconfig.test.json` (its
  actual owner), not `tsconfig.app.json`.
- Nit 4: AGENTS.md's one-line summary now mentions the non-client (`core/`,
  `test-servers/src`) coverage, matching the detailed bullet.

Nit 3 (EXEMPT empty, dormant stale-key check) left as the intended escape hatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-27 — non-inertness follows references on both paths

Responds to the round-27 @claude review of #1799:

- Finding 1: round 26 made COVERAGE follow a solution config's references, but
  the non-inertness (`noCheck`) check on the typecheck-script path still only
  inspected the harvested solution (empty compilerOptions) — so a `noCheck` in
  a REFERENCED project was invisible there (caught only on the reference path).
  Factored a shared `resolveLeafProjects` (a solution → its real leaf projects,
  cached rawProjectFiles) used by BOTH coverage (`projectFiles`) and a shared
  `checkingLeaves` disable-check, so a `noCheck` in a referenced project is now
  caught on either path. Verified: `typecheck: "tsc -b"` + `noCheck` in
  tsconfig.app.json now hard-fails (was green). Caching also cut the guard from
  ~20s to ~11s.
- Nit 2: directory-form references (`{ "path": "./packages/a" }`) now resolve to
  `<dir>/tsconfig.json`, so a nested-solution dir reference is expanded.
- Nit 3: reworded the empty-listFiles comment (it's "solution config OR errored
  config", both of which the reference expansion handles).
- Nit 4: script header notes a `tsc -b` solution is reduced to its references on
  either enrollment path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-28 — dedup shared leaf in checkingLeaves

Responds to the round-28 @claude review of #1799 (which found no new gate hole):

- Finding 1: `checkingLeaves` dedup'd the `checking` array but ran
  projectDisablesChecking + integrity.push on every visit, so a leaf reachable
  from two harvested projects produced a duplicate `noCheck` line (and a
  redundant `tsc --showConfig`). Now tracks visited leaves in a Set and skips an
  already-seen leaf entirely. Verified a duplicate harvested project yields one
  line, not two.
- Nit 3 (record-completeness): noted in AGENTS that the one tier the guard
  structurally can't see — a per-file `// @ts-nocheck` — is owned by
  `@typescript-eslint/ban-ts-comment` across lint:core/lint:shared/each client.

Nit 2 (the ~11s perf claim isn't reproducible on a CI-class runner) and nit 4
(hybrid-solution / composite boundaries, both tsc-rejected → unreachable) left
as recorded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(scripts): unit-test the typecheck-coverage guard's parsers

Responds to the round-29 @claude review of #1799 (no new gate hole; the finding
is that the guard itself has no tests):

- Made the guard's pure helpers importable: exported `typecheckProjects`,
  `tscBuildStatus`, `tsconfigReferences`, `clientTsconfigReferences`,
  `resolveLeafProjects`, `isTsc`, `isDisablingFlag`, `isRequiredSource`, and
  moved the module-init + phase execution behind an exported `main()` run only
  when the file is executed directly (importing it for tests no longer runs the
  guard). Behavior when run directly is unchanged (894 files).
- Added table-driven tests (node's built-in `node --test`; the root has no
  vitest harness by design) for the shared lib (`tokenize`, `reachableScripts`
  incl. pre/post hooks, `rootRunsClientValidate` across cd/--prefix/quoted/
  prefix-sibling/`validate:fast`, `rootReachesScript`) and the guard's parsers
  (`isRequiredSource`, `isTsc`, `isDisablingFlag`, `typecheckProjects`,
  `tscBuildStatus`) — one case per rule a review round found (r7/r10/r13/r15/
  r16/r17/r18/r19/r20/r25). 12 tests. Wired `test:scripts` into `validate`.
- Nit 2: a `scripts/*.ts` miss now gets a scripts-specific remediation (keep it
  `.mjs`, or give `scripts/` its own tsconfig project).
- Documented the guard's test suite in AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(scripts): make test:scripts self-guarding; fill parser test gaps

Responds to the round-30 @claude review of #1799 (mutation testing killed 5/5;
findings are about the new test gate + suite completeness):

- Finding 1: `test:scripts` lacked the two properties the guard enforces for
  every tsc pass. verify-typecheck-coverage now asserts, in phase 1, that
  `test:scripts` is reachable from the root `validate` (via the already-imported
  rootReachesScript) AND that its tracked `scripts/**/*.{test,spec}.*` file set
  is non-empty and every file is matched by the command's glob (a new exported
  `globToRegExp`). So dropping it from validate, or renaming a test to
  `*.spec.mjs`/`*.test.mts` (which node --test silently skips), now hard-fails.
  Verified both.
- Nit 2: extracted a pure `parseTsconfigReferences(raw)` from tsconfigReferences
  and table-tested the JSONC tolerance (block/line comments, trailing comma, no
  references, malformed, no path) — the r17/r26 rules had no cover.
- Nit 3: added `-b`/`--build`/bare-`tsc -b` and explicit `--project` cases to
  typecheckProjects's test so its title matches its table.
- Nit 4: rewrote the rootRunsClientValidate test to put `cmd` in a script
  reached from validate, and added the reachability cases (an orphan cd-validate
  must NOT count; validate:tui indirection must) — the r5 property.
- Nit 5: added test:scripts to AGENTS's validate ordering + the README table.

15 guard tests now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-31 — glob harvest follows delegation; brace/? globs

Round-31 review findings, both false failures in the new `test:scripts`
self-guard:

1. The glob half read `rootPkg.scripts["test:scripts"]` literally while the
   wiring half two lines above used `reachableScripts`, so a delegating
   `test:scripts` (`npm run test:scripts:lib && npm run test:scripts:guard`)
   — which genuinely runs all 15 tests — reported every tracked test file as
   "not matched by the glob", advising a rename the files already satisfy.
   Now harvested across every script reachable FROM `test:scripts`, the same
   fix round 6 applied to the `typecheck` harvest; `pretest:scripts` counts
   for free.

2. `globToRegExp` escaped `{`/`}` as literals and left `?` as a regex
   quantifier, both divergent from `node --test`'s glob. `*.{test,spec}.mjs`
   is the natural widening here (the guard's own file scan already treats a
   `.spec.` file as a test) and false-failed; `?` silently meant "optional
   previous char" and threw outright when segment-leading. Adds `?` → `[^/]`
   and a brace-alternation pass, with an unbalanced brace falling back to a
   literal.

Nit 3: `resolveLeafProjects`'s two path-resolution rules (the r26
directory-form reference, and refs resolving against the referring config's
dir) extracted as `projectConfigFile`/`refToProject` so they are one-row
tables like the other parsers, rather than untestable behind a `tsc` spawn.

Nit 4: AGENTS.md + README document that the guard now enforces `test:scripts`
on three axes (wired, non-empty, glob-covered).

Verified: probes C and D exit 0; probe B (rename to `*.spec.mjs`) still
caught; `test:scripts` 18/18; `verify:typecheck-coverage` OK 894 files;
`verify:format-coverage` OK 919; `npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-32 — measure globs with node's own matcher

Round-32 review; both findings point at deleting code.

1. `globToRegExp` hand-modeled `node --test`'s glob and was still one form
   short — character classes (`*[.]test.mjs`, `*.test.[mc]js`) hit the escape
   set and became literals, so the guard false-failed on files node genuinely
   runs. Replaced the whole translator (and its brace balance pre-scan) with
   node's `path.matchesGlob` — 22.5.0+, and the repo floor is >=22.19.0. Same
   *measure, don't model* move round 25 made when it deleted `isExemptCoreFile`
   in favor of measuring web's projects; being exact for every form node
   accepts is the point, since node's matcher is the thing being modeled.

2. The round-31 glob harvest was inline in `main()`, so mutation-reverting it
   to the literal single-script read left the suite green — the commit's
   headline fix was its one untested change. Extracted as `testScriptGlobs`,
   with rows for the direct form, the delegating form (both child globs, not
   `npm`/`run`/`<name>`), a `pretest:scripts` hook, and an unreachable script
   contributing nothing.

Nit 3: `resolveLeafProjects`' JSDoc moved back onto it (the extraction left it
stranded above `projectConfigFile`, which had two stacked doc blocks).

Nit 4a: a `test:scripts` naming no path/glob harvested nothing, and `.some()`
over an empty list blamed every test file with unfollowable advice. Now one
explicit message naming the real problem.

Verified: probes for the direct, delegating, brace, `[.]` and `[mc]` forms all
exit 0 (the last two were false failures before); probe B (rename to
`*.spec.mjs`) still caught; bare `node --test` reports the new single message;
`test:scripts` 18/18; `verify:typecheck-coverage` OK 894; `npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-33 — scope the glob harvest to `node --test` segments

1. False pass. `testScriptGlobs` kept every non-flag token from every reachable
   script, so a glob belonging to a NEIGHBOURING command was attributed to the
   test runner — and since `.some()` needs only one glob to match, a broader
   one suppressed a real miss rather than adding an inert pattern. A
   `pretest:scripts` of `prettier --check "scripts/**/*.mjs"` (reachable via
   the pre-hook round 31 added) was enough: the probe-B rename to `*.spec.mjs`
   then passed while `node --test` silently ran 12 of 18 tests. Now scoped per
   command segment to the ones invoking `--test`, exactly as
   `typecheckProjects` gates on `tokens.some(isTsc)`. The JSDoc paragraph
   claiming a surplus token can't suppress a miss is corrected — it was false.

2. The gate-integrity block is extracted as `testScriptProblems(scripts,
   testFiles)`, pure and outside `main()`. The nit-4a branch added last round
   was the third consecutive commit whose newest branch sat outside the test
   seam and mutation-survived; extracting the block ends the pattern rather
   than pinning one more condition from the outside.

Verified. The finding-1 probe (prettier pre-hook + `*.spec.mjs` rename) now
exits 1 where it exited 0; the hook alone still exits 0. Mutation testing over
the whole helper — empty-harvest branch to `if (false)`, segment gate removed,
wiring axis removed, non-empty axis removed, segment split removed,
`reachableScripts` to `Object.keys`, `matchesTestGlob` to equality — kills
7/7, including the branch that survived the last three rounds.
`test:scripts` 21/21 · `verify:typecheck-coverage` OK 894 ·
`verify:format-coverage` OK 919 · `npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-34 — harvest only positional args; normalize `./`

Both findings are the axis round 33 fixed, one level in.

1. False pass. The segment gate stopped a neighbouring COMMAND's glob, but
   within a kept segment the filter was still "any non-flag token", so a
   glob-valued flag's VALUE was harvested as a positional arg.
   `--test-coverage-include "scripts/**/*.mjs"` is broader than the test glob
   and matches a renamed `*.spec.mjs`, so it vouched for the very file the
   runner skips — and it isn't hypothetical: `scripts/` sits outside the ≥90
   coverage gate, so that flag is the obvious next thing added here. Tokens
   preceded by a value-taking `--test-*` flag are now dropped.

2. False failure. `./scripts/**/*.test.mjs` — which `node --test` accepts, and
   the common npm-script idiom — matched nothing, because `git ls-files` emits
   no `./`. Both files were then blamed for a rename that never happened, the
   unfollowable-remediation shape fixed for the bare-runner case last round. A
   leading `./` is normalized off, as `rootRunsClientValidate` already does.

Nit 4: the gate-integrity footer's typecheck-wiring advice is now conditioned
on at least one issue being about the typecheck wiring — a `test:scripts`
failure carries its own per-line remediation, and appending `--noCheck`/`tsc
-b` advice under a renamed-test-file message sends the reader after the wrong
thing.

Verified. The coverage-include probe with the rename now exits 1 where it
exited 0, and exits 0 without the rename; the `./` probe exits 0 where it
exited 1; a `test:scripts`-only failure no longer prints the typecheck footer.
Mutation sweep 9/9 killed, including both new rules. `test:scripts` 22/22 ·
`verify:typecheck-coverage` OK 894 · `verify:format-coverage` OK 919 ·
`npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-35 — fourth axis: flags that narrow the run

1. False pass on a new axis. The three axes ask whether the tests are run,
   whether there are any, and whether the runner is told about each — none asks
   what it then DOES with them. `--test-shard 1/2` keeps every glob intact and
   executes 6 of 22 tests, exiting 0, with the guard green; `--test-only` runs
   2, as does a non-matching `--test-name-pattern`. This is `--noCheck` for the
   test runner, one domain over. Adds `NARROWING_TEST_FLAGS` and a fourth axis
   in `testScriptProblems`. Three of those flags were already in
   `VALUE_TAKING_TEST_FLAGS` — the guard knew them well enough to drop their
   values but not that they shrink the run. Both `--flag value` and
   `--flag=value` report as the bare flag name.

2. The footer conditioning added last round was itself inside `main()` — the
   pattern the same commit's comment documented, recurring in the commit that
   documented it. Extracted as `integrityAdvice(problems, nonTypecheck)`.

   Doing that fixes nit 3 in the same move: the old condition keyed off
   `testScriptIssues` specifically, so it covered one of three non-typecheck
   categories. A sibling-vouch failure ("the root validate no longer runs
   verify:format-coverage") and client-enrollment problems still got
   `--noCheck`/`tsc -b` advice about a typecheck pass they have nothing to do
   with. All three sets are now excluded.

Verified. `--test-shard 1/2`, `--test-only` and `--test-name-pattern zzz` each
now exit 1 naming the flag, while node runs 6/2/2 of 22. A sibling-vouch
failure no longer prints the typecheck footer. Mutation sweep 12/12 killed,
including all three new rules and the branch this commit replaces.
`test:scripts` 24/24 · `verify:typecheck-coverage` OK 894 ·
`verify:format-coverage` OK 919 · `npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-36 — discover test files by content, not name

1. False pass on the axis underneath the other four. The required test-file set
   was built with the same name pattern the `test:scripts` glob uses, so a
   rename escaping BOTH was invisible: the file left `testFiles` entirely, so
   nothing asked whether the runner was told about it, and the survivor
   satisfied the non-empty axis. A dot→hyphen slip
   (`npm-scripts.test.mjs` → `npm-scripts-test.mjs`) dropped 6 of 24 tests with
   every gate green — and the file that stops running is the one pinning the
   r16 prefix-sibling and r20 tail anchors, the series' two other false passes.

   Round 30 fixed "renamed to a form the glob misses" by broadening the name
   pattern; a fifth name pattern only moves the boundary again. `node --test`
   decides what a test is by whether it registers tests, so `isScriptsTestFile`
   reads that instead — the same measure-don't-model move as r25 and r32. It
   also covers what no name pattern can: a NEW test file added under a name the
   glob misses, never run and never noticed.

Nit 2: `enrollmentProblems` no longer suppresses the footer. "declares no
`typecheck` script …" IS about the typecheck pass and the footer's first clause
is its canonical fix; excluding it lost the advice for the r15 rename scenario.
The criterion is "is this about the typecheck pass at all", not "does it carry
per-line advice" — several typecheck problems carry their own and still want
the footer. JSDoc corrected accordingly.

Nit 3: the complementary-shard boundary (`1/2` + `2/2` across two reachable
scripts genuinely runs everything, and is flagged anyway) recorded on the
helper, alongside the guard's other documented boundaries.

Nit 4: the three `--test-coverage-{lines,branches,functions}` thresholds added
to VALUE_TAKING_TEST_FLAGS. Their values are numeric and so inert rather than
suppressing, but model completeness is cheaper than re-deriving that later.

Verified. The hyphen rename now exits 1 naming the file; the `.spec.mjs` rename
is still caught; an enrollment failure prints the footer again; the guard is
unchanged at 894 files OK (exactly the 2 tracked test files reference
`node:test`, none of the other 12 `scripts/` files do). Mutation sweep 14/14.
`test:scripts` 25/25 · `verify:format-coverage` OK 919 · `npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-37 — register-not-mention; guard the discovery read

Both findings were introduced by round 36's content predicate.

1. False failure. The predicate matched a bare `node:test` mention, so a shared
   helper importing the runner's `mock` API — registering no tests — was
   enrolled as a required test file and told to rename itself to `*.test.mjs`,
   which would only make node run an empty file. `scripts/lib/` is already the
   repo's shared-helper location, so this is the plausible next file there.
   Tightened to node's actual criterion: an import/require OF `node:test` AND a
   test registration.

2. Regression. `readFileSync` was passed as an argument, so it ran for every
   tracked `scripts/` file before the predicate's extension gate could reject
   it — a file in the index but not the worktree (`rm` without `git rm`) took
   the guard down with a raw ENOENT stack, aborting phase 1 before any real
   finding printed. Discovery did no reads at all before round 36. Extension is
   now gated first and the read wrapped: an unreadable tracked file warns and
   is skipped, the round-6 precedent where a failed `tsc` echoes its diagnostic
   instead of dying.

Verified: the mock-helper probe exits 0; the tracked-but-deleted probe exits 0
with a one-line skip warning instead of a stack; the dot→hyphen rename is still
caught. Mutation sweep over the predicate 3/3 (import form, registration,
extension). `test:scripts` 25/25 · `verify:typecheck-coverage` OK 894 ·
`verify:format-coverage` OK 919 · `npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-37 nit 3 — one definition of the JS-extension check

Finding 2's fix hoisted the extension gate into `main()` (it must run before
the read), leaving a second copy of the regex inside the predicate that reads
as protection but can never fire there. Extracted as `isJsFile` and used in
both positions, so the two can't drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 12:05:35 -04:00
Bob Dickinson
d009fcca09
Fixed tab naming to correspond to the web client. (#1780) 2026-07-25 20:11:52 -04:00
Cliff Hall
373f346e5f
chore: close quality-gate coverage gaps (#1689) (#1766)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Closes #1689. Typecheck cli/tui src, add a core/ format+lint gate, and gate core/json + core/client coverage. See PR #1766 for the full review history.
2026-07-24 17:20:12 -04:00
Cliff Hall
c2b866110d
tui: detect array-of-enum on items.enum alone (#1752)
* tui: detect array-of-enum on items.enum alone (#1751)

The TUI schemaToForm array-of-enum branch was nested under an outer
`if (property.enum)` guard, so a standard array-of-enums schema —
`{ type: "array", items: { enum: [...] } }` with no top-level `enum` — never
reached it and fell through to a plain string input. The web SchemaForm already
guards on `items?.enum` alone; this aligns the TUI with it.

Restructure the enum handling so the array-of-enums case keys on
`property.type === "array" && property.items?.enum` independently of a top-level
`enum`, falling back to the single-select `property.enum` branch otherwise. The
enumNames labeling from #1691 applies unchanged.

Tests: the array-of-enum cases now use the standard `items.enum`-only shape
(dropping the redundant top-level `enum` the old nesting required), plus a case
asserting a redundant top-level `enum` still works.

Closes #1751

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs(pr-screenshots): TUI array-of-enum before/after proof shots (#1751)

Before/after of the TUI tool form for an array-of-enums field with no top-level
`enum`: before the fix it renders as a plain text field (raw "m"); with the fix
it renders as a select resolving the enumNames title ("Medium").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:12:45 -04:00
Cliff Hall
8c41dcd383
web+tui: honor enumNames in enum forms (#1750)
* web+tui: honor enumNames in enum forms (#1691)

Legacy titled enums (`enum` + non-standard `enumNames`) rendered their raw
wire values instead of the human-readable titles in both the web SchemaForm
and the TUI schemaToForm builder, so a user picking "pet-1" had no idea it
meant "Cats".

Web (SchemaForm.tsx): extract a `toEnumData` helper that zips `enum` with
`enumNames` into `{ value, label }[]` when the lengths match, falling back to
bare values otherwise. Use it in both the string-enum and array multi-select
branches (the latter previously inlined the same zip).

TUI (schemaToForm.ts): add `enumNames` / `items.enumNames` to the property
interface and a `toSelectOptions` helper used by the single-select and
array-of-enum branches, labeling by `enumNames[i]` when length-matched and
falling back to `String(val)`.

Both guard on the arrays being the same length: a wrong-length zip would
mislabel options, which is worse than showing raw values. Raw values are
always submitted; only the display label changes.

Closes #1691

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs(pr-screenshots): TUI enumNames before/after proof shots (#1691)

Before/after captures of the TUI tool-test form (schemaToForm -> ink-form)
against a legacy titled enum: with the fix the field resolves pet-1 -> "Cats";
before, it showed the raw "pet-1".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:21:53 -04:00
Cliff Hall
2f80958d3f
Extensions: first-class advertised-extensions map (Phase 1 of #1633) (#1742)
* Extensions: first-class advertised-extensions map (Phase 1 of #1633)

Collapse the two ad-hoc `capabilities.extensions` spreads in the
InspectorClient constructor (EMA, gated on enterpriseManaged; the modern
Tasks extension, always-on) into a single builder that is the source of
truth for what the client advertises.

- core/mcp/extensions.ts: `ADVERTISABLE_EXTENSIONS` registry (id + label +
  default-on flag), the `EMA_EXTENSION_KEY` constant, and
  `buildClientExtensions()`. The registry is the shared list the Server
  Settings toggle UI (Phase 2, #1739) will drive; EMA stays a conditional
  built-in (auth-mode driven, not a free toggle).
- InspectorClientOptions.advertisedExtensions: per-extension overrides
  (present key wins over the registry default) so a future UI can toggle
  advertised extensions as a debugging knob.
- Behavior preserved: tasks defaults to advertised, so the map is non-empty
  and rides every modern request envelope; EMA advertised iff enterpriseManaged.

The raw modern-task envelope re-stamp is left untouched (it fires only on
the raw tasks channel, which requires the extension anyway).

Tests: extensions.test.ts covers the registry and every builder branch
(defaults, EMA on/off, override enable/disable, unknown key) at 100%.
The tasks-era and EMA integration suites still assert the same wire
advertisement. `advertisedExtensions` is plumbed but not yet wired to the
form (that lands in Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: address Phase 1 review (EMA-override test + envelope comment)

- extensions.test.ts: add an EMA-specific assertion that an override for
  EMA_EXTENSION_KEY cannot advertise it (auth-mode only) — documents intent
  and guards against EMA being added to ADVERTISABLE_EXTENSIONS by mistake.
- inspectorClient.ts: rewrite the withModernTaskEnvelope tasks-stamp comment
  so it stays accurate once Phase 2 lets a user disable general tasks
  advertisement — the force-stamp is load-bearing (the raw tasks/* channel
  requires the extension), not a redundant re-add.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* TUI: de-flake step-up OAuth frame assertions (raise poll ceiling)

The step-up authorize tests intermittently timed out on the final
"Step-up authorization succeeded" frame. Root cause is a test-infra
timing budget, not product code: waitForFrame/waitUntil polled only 25×
(25ms tick ≈ 625ms), and the step-up flow adds an extra async OAuth-runner
hop plus React commits that can exceed that under CI load with v8 coverage.

Hoist the poll count into a named POLL_TRIES = 100 (~2.5s ceiling). A poll
exits the instant its predicate is true, so a higher ceiling never slows a
passing assertion — it only widens the margin for the slow path. Verified
5× green (3 plain + 2 under --coverage), including the previously-flaky
step-up cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* TUI: prettier-format the wrapped waitForFrame signature

The POLL_TRIES change pushed waitForFrame's signature past the print width;
Prettier wraps it. (Root has no aggregate format script — format is
per-client, so this was missed locally.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:25:45 -04:00
cliffhall
f4fb4b50c9 Forward RFC 9207 iss to the v2 SDK (fixes OAuth + EMA leg 1)
SDK v2 added RFC 9207 §2.4 authorization-response issuer validation
(`validateAuthorizationResponseIssuer` / `IssuerMismatchError`) as a mix-up
attack defense. It is strict: when AS metadata advertises
`authorization_response_iss_parameter_supported: true` and no `iss` reaches
`exchangeAuthorization`/`auth()`, the exchange throws.

v1.29.0 had no `iss` parameter and no RFC 9207 validation at all, so dropping
the callback `iss` was harmless. The migration swapped in the stricter SDK
without feeding it the new input, breaking the authorization-code exchange
against any RFC 9207 authorization server (Okta/xaa.dev advertise it):

    Issuer mismatch in authorization response (RFC 9207):
    expected "https://idp.xaa.dev", received undefined

Found by smoke-testing EMA against live xaa.dev staging; not caught by CI
because the mock IdP never advertised the parameter, so the SDK took its
lenient path (a missing `iss` is only fatal when advertised).

`iss` was dropped in three places, with the plumbing half-built —
`oauthManager`/`inspectorClient` already accepted `iss`, but nothing supplied
or forwarded it:

- `parseOAuthCallbackParams` never extracted `iss` from the callback query
- `mcpAuth` never forwarded `options.iss` to SDK `auth()` — its own comment
  ("forwarded on v2 upgrade; ignored by v1 SDK auth()") predicted this exact
  gap, so standard OAuth was broken identically, not just EMA
- EMA leg 1 (`completeIdpOidcAuthorization`) never passed `iss` to
  `exchangeAuthorization`

Thread `iss` end to end: callback query -> CallbackParams -> resumeAfterOAuth
-> completeOAuthFlow -> {mcpAuth | EMA leg 1} -> SDK. The Node callback server
and runner carry it for CLI/TUI.

Tests: `ema-mock-servers.ts` now advertises
`authorization_response_iss_parameter_supported: true` so the mock is at least
as strict as real IdPs — with it absent the SDK silently takes the lenient path
and a dropped `iss` goes unnoticed. Flipping it made three existing tests
reproduce the failure. Adds regression coverage for forwarding and for
rejecting a mismatched `iss` before any network call.

Verified end to end against live xaa.dev staging: EMA legs 1-3 (IdP SSO ->
ID-JAG mint -> resource token) now complete and the MCP session initializes.

web 3099, cli 212, tui 274 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjMemXZohSR2EndBQYif2b
2026-07-15 20:16:43 -04:00
cliffhall
19a6df7ad3 SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624)
Migrate all three clients + core + test-servers from
@modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior
identical against legacy (2025-11-25) servers. Foundation for the SDK V2 +
New Spec stack.

Packages
- Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client,
  transports, OAuth, error taxonomy, protocol types) + core (Zod schemas);
  stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers ->
  @modelcontextprotocol/server (+ server-legacy/sse). Deterministic import
  rewrite (schemas -> core, everything else -> client); the codemod misroutes
  client code to /server so it was not used for path routing.
- new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing
  2026-era goes on the wire.

Runtime (core/mcp/inspectorClient.ts)
- Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode;
  RequestTimeout -> SdkError/SdkErrorCode.
- Handlers take method strings; task methods (tasks/*, notifications/tasks/*)
  use the 3-arg custom form (excluded from v2's spec-method set).
- Legacy tasks kept working (per project requirement): requestor tasks re-
  implemented over raw client.request + explicit deprecated schemas;
  experimental.tasks.callToolStream replaced by a local pollTaskToolCall
  async generator (with _progressHandlers revival so task-execution progress
  still reaches the caller). Receiver (server-initiated) tasks: the v2 Client
  validates a sampling/elicit handler result and rejects the legacy { task }
  response with -32602 — installReceiverTaskResponseBypass swaps the wrapped
  entry in the private _requestHandlers map for the task-augmented branch so
  { task } reaches the wire (fixes server-initiated sampling-via-task).
- Pagination: list verbs use raw client.request({method}, List*ResultSchema)
  for single-page behavior (v2's client.listX() auto-aggregate all pages).

Web / auth / apps
- Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema()
  structural narrowing at the SDK-schema -> form boundary.
- ext-apps still peers on SDK v1: one compat boundary with documented casts
  (remove when ext-apps#702 lands a v2 peer).
- OAuth provider saveClientInformation reconciled with v2's
  OAuthClientInformationContext; InvalidClientMetadataError re-homed as a
  local class (server-legacy/auth is not browser-safe).
- vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so
  vi.mock intercepts the SUT's import.

Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call
rejects with -32602; cancellation is stream-abort (no guaranteed
notifications/cancelled frame); output-schema strictness restored via the
Inspector's own Ajv check on the default path.

npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5),
coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook
(410).

Closes #1624

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:44:24 -04:00
cliffhall
1f7ef9184c test(tui): truly assert the non-selected-server auth guards
Addresses the @claude review of #1611: the event registry is now keyed by
(client, fn) rather than event name alone, and a new `fireClientEventFor(client,
…)` helper fires a single client's listeners. The "ignores auth lifecycle events
from a non-selected server" test now fires ambient/recovered/oauthError for ONLY
the non-selected client and asserts no message appears — so removing the
`selectedServerRef.current !== serverName` guard would fail the test — then fires
for the selected client to confirm it does act. Coverage of App.tsx is unchanged
(92.23 / 90.20 / 92.85 / 92.68); existing fire-all `fireClientEvent` behavior is
preserved.

Part of #1610.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-04 19:08:44 -04:00
cliffhall
f7ab287f6a test(tui): cover App.tsx mid-session auth flows to the ≥90% gate
Bring clients/tui/src/App.tsx from 79% to ≥90 on all four dimensions
(92.23 / 90.20 / 92.85 / 92.68) by exercising the mid-session / step-up auth
machinery that the existing suite didn't reach:

- runOAuthAuthentication result branches (success / already_authorized /
  insufficient_scope / unsupported), driven deterministically via a new opt-in
  runRunnerInteractiveOAuth override (defaults to the real runner so the
  existing callback-driven OAuth tests are unchanged).
- The per-client auth-lifecycle listeners (authChallengeAmbient / Recovered /
  Interactive / oauthError), fired through a new client-event registry on the
  FakeClient, including the non-selected-server early-return guards.
- handleAuthRecoveryRequired step-up-confirm and reauth paths, handleConnect's
  OAuth-on-401 branches (AuthRecoveryRequired + EmaClientNotConfigured thrown
  mid-flow), handleClearOAuth's connected/disconnect path, and the AuthTab
  onAuthorizeStepUp handler (EMA satisfied/interactive/failed/throw + standard
  non-EMA authorize, cancel).

Part of #1610.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-04 17:24:10 -04:00
cliffhall
9a3604d01d test(tui): cover AuthTab step-up up-arrow and a/c shortcuts
Bring clients/tui/src/components/AuthTab.tsx to ≥90 on all four dimensions
(now 97.43/90.81/100/100) by exercising the previously-untested step-up
keypress paths in the `useInput` handler: up-arrow navigation back to the
"authorize" choice, the `a` (authorize) and `c` (cancel) shortcuts, and the
fall-through for an unrelated key while the prompt is pending.

Part of #1610.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-04 16:59:51 -04:00
Bob Dickinson
562bdd00f1 All of the mid-session and step-up auth work 2026-07-02 16:35:31 -07:00
Bob Dickinson
13dafe457b
Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)
* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:35:51 -04:00
Cliff Hall
f43fafc08b
test(tui): extend coverage gate to Ink components + App.tsx (closes #1501) (#1538)
* test(tui): cover useSelectableList hook + leaf Ink components (#1501)

Add ink-testing-library and renderer-based tests for useSelectableList,
SelectableItem, and Tabs. Widen the vitest include glob to .test.tsx and
lift these files from the interim React-surface coverage exclusion. The
blanket src/components/**/*.tsx exclude is expanded into an explicit
per-file list so remaining components can be lifted incrementally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): add ink-scroll-view passthrough mock for renderer tests (#1501)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): add ink-form passthrough mock for modal renderer tests (#1501)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): cover all Ink tab + modal components via renderer tests (#1501)

Add ink-testing-library tests for the 12 Ink components (InfoTab, AuthTab,
ResourcesTab, PromptsTab, ToolsTab, NotificationsTab, HistoryTab,
RequestsTab, and the Tool/Resource/Prompt test modals + DetailsModal),
mounting them through the ink-scroll-view / ink-form passthrough doubles
and driving keypresses via stdin. Modal components render position:absolute
(empty frame under the test renderer) so their tests assert on behavior —
injected InspectorClient fakes and onClose — while the passthrough
ScrollView still mounts the inner JSX for coverage.

Lift these 12 components from the interim React-surface exclusion in
vitest.config.ts; only App.tsx remains excluded. All files clear the
uniform 90/90/90/90 per-file gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): add App.tsx renderer-test harness + foundation tests (#1501)

Mock the full @inspector/core surface (InspectorClient, the 7 managed-state
classes, the 8 react hooks, transport + auth) behind a controllable harness,
plus the ink-scroll-view/ink-form passthroughs, so App renders under
ink-testing-library and its keyboard state machine can be driven via stdin.
Concurrent ink mounts share raw-mode stdin, so each render is unmounted in
afterEach. Foundation covers server-list nav, tab accelerators, connect/
disconnect, OAuth-tab gating, and connected-status rendering (~50% baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): cover App.tsx via mocked-core renderer tests; lift final exclusion (#1501)

Extend App.test.tsx to 67 tests driving the keyboard state machine, tab
content, modals, OAuth flows, and connect/disconnect across the controllable
@inspector/core mock. App.tsx now clears the 90/90/90/90 per-file gate
(94.6/90.6/94.5/96.6).

Remove the last React-surface exclusion (src/App.tsx) from
clients/tui/vitest.config.ts. The whole clients/tui/src tree is now under
the uniform per-file coverage gate with no React-surface exclusions,
completing the interim-scope lift from #1484.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): raise stdout max-listeners in App test to silence warning (#1501)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): raise App.tsx branch coverage >90 and de-flake coverage runs (#1501)

Add targeted App.tsx tests for the OAuth handlers' non-Error throw arms
(quick onCallback, guided start/advance, run-to-completion) and a re-entrant
quick-auth guard, lifting App.tsx branch coverage 90.6% -> 91.48% for a
safer margin above the 90 gate.

Harden the App renderer tests against the heavier v8 coverage instrumentation,
where React+ink's multi-macrotask render scheduling races fixed ticks:
- press() now settles two ticks per key so focus/tab changes flush before the
  next key is routed (ink re-subscribes useInput on re-render);
- expectFrame()/waitUntil() poll for frame content and async flow state
  (callbackServer opts) instead of asserting after a single fixed tick;
- ResourcesTab's content-clear test polls for fetched content likewise.

Plain 'npm test' (the CI path) was already stable; this makes the local
coverage gate stable too (13/13 clean runs vs ~2/8 flaky before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): de-flake component renderer tests under slow CI (#1501)

CI flaked on AuthTab's 'renders quick auth results' under the plain test run:
its oauthFlowState is populated by a post-mount useEffect reading the
inspectorClient, and a single-macrotask tick can race that effect -> setState
-> re-render chain on a slow/loaded CI runner.

Make each component test file's tick() flush several macrotask cycles so such
chains settle deterministically before assertions (no test relies on precise
tick timing — no fake timers / debounce). App.test.tsx already polls via
expectFrame/waitUntil and passed CI, so it's unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tui): address review — shared setupFile for max-listeners, consume-once ink-form global (#1501)

Review follow-ups on #1538 (both non-blocking cleanups):

- Hoist process.std{out,in}.setMaxListeners into a shared vitest setupFile
  (__tests__/setup.ts) wired via setupFiles, so every renderer test file
  inherits it and the MaxListenersExceededWarning is silenced across the
  whole suite — not just App.test.tsx. Remove the per-file call.
- Make the ink-form double consume (clear) __INK_FORM_SUBMIT_VALUE__ on
  submit, eliminating the latent cross-test coupling on the shared global
  regardless of how the modal test files are organized.

No production code changed; suite + coverage gate stay green (App.tsx
94.8/91.5/94.5/96.6), and the warning no longer prints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:19:12 -04:00
Cliff Hall
c96db0402e
test: raise per-file coverage gate to 90 across all clients (closes #1271) (#1532)
* test(web): audit branch-coverage outliers, raise per-file gate to 70 (#1271)

The web per-file coverage gate had drifted red: the recently-landed EMA
auth feature (core/auth/ema/*) and ClientSettings components shipped
undertested, and the prior 50% branch floor let business-logic branches
go untested. CI does not run the coverage gate, so this accumulated.

Add tests to bring every failing file above the gate and push branch
coverage well past 70 across the board, then raise the per-file branches
threshold 50 -> 70 in clients/web/vite.config.ts.

- EMA core (emaFlow, resourceContext, jwt, wire): 50-88% -> 86-100% branch
- EMA OIDC/transport (idpOidc, tokenEndpoint, transportProvider): -> 97-100%
- oauthManager / discovery: 73-83% -> 88-92%
- ClientSettingsModal/Form, ConnectionInfoContent: 0-88% -> 96-100%
- useClientSettingsDraft / useEmaIdpLoginState hooks: 20% -> 100%
- TaskStatusBadge / SortableServerCard outliers: 50% -> 100%

Coverable branches reached by mocking useComputedColorScheme (dark theme),
dnd-kit useSortable (isDragging), and fetch/OIDC paths. Remaining sub-100
branches are genuinely-unreachable defensive guards or happy-dom-inherent
(portal / useMediaQuery / typeof window) and documented in test comments.

No source files changed. Global web branch coverage 88.73% -> 90.63%.
AGENTS.md updated; cli/tui/launcher stay at 50 (out of scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: raise per-file coverage gate to 90/90/90/90 across all clients

Follows the #1271 web branch-coverage audit (50 -> 70) by raising the
whole per-file gate to >=90 on all four dimensions — lines, statements,
functions, branches — for web, cli, tui, and launcher (core/ is measured
under the web suite). A codebase-wide audit added real tests for every
outlier and brought global coverage up across the board.

Config: all four vitest/vite coverage configs set to 90/90/90/90.
Two pure-type modules (core/mcp/import/types.ts, clients/web/server/
types.ts) added to the web coverage exclude list, matching the existing
type-only exclusions.

Tests: ~50 test files extended/created across core (auth, ema, mcp,
remote, state, storage, react), web components/screens/server, and the
cli/tui clients. Highlights: full EMA OAuth flow + transport coverage,
inspectorClient/remote server backfill, every Mantine control's handler
branches, and the cli unsupported-method / argv-fallback paths.

Genuinely-unreachable branches are annotated at the source with justified
`/* v8 ignore -- <reason> */` comments rather than lowering the gate:
happy-dom-inherent paths (Mantine portal, useMediaQuery, typeof window
SSR guards), React StrictMode effect replay, and provably-dead defensive
guards (?? fallbacks the types make impossible, Select.onChange values
outside the allowed list). No runtime logic changed; the only structural
edits are two behavior-preserving refactors (ResourceTemplatePanel hoists
the field value to a local so the ignore applies in JSX; ToolsScreen
swaps a ternary for an if) and prettier line-wrapping.

Also fixed a flaky real-timer debounce assertion in PromptArgumentsForm
(asserted an exact sibling-typing interleaving that raced the 300ms
debounce; now asserts the load-bearing fact — a non-empty fire-time
sibling capture).

AGENTS.md updated to describe the uniform 90 gate and the v8-ignore
policy. All four client coverage gates, root validate, storybook (370),
and smoke pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): make connect-crash SSE reader stop on the terminal event

`readSseEvents` looped until the server closed the stream (`done`). The
server closes the events stream immediately after writing the terminal
`transport_error`, but under the heavy parallel scheduler pressure of a
full `coverage` run (v8 instrumentation across all workers) that close
can lag far enough behind the event to trip the 30s test timeout — a
hang, not a wrong result. This made the test intermittently fail under
`npm run coverage` (it passed in isolation and under CI's lighter
`test:integration`).

Stop reading as soon as the terminal `transport_error` event arrives
(cancelling the reader) instead of waiting only for `done`. That event is
always the last one the session queue emits, so nothing is lost, and the
test no longer depends on stream-close timing. The `done` break is kept
for any stream that closes first.

Verified green across two consecutive full `npm run test:coverage` runs
(3094 tests) where it previously hung, plus isolation (~0.5s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:26:03 -04:00
Cliff Hall
388ea5a7bd
test(cli,tui): add in-process source coverage gates (#1484) (#1502)
* test(cli,tui): add in-process source coverage gates (#1484)

CLI and TUI source had no coverage gate: CLI tests spawned the built
binary out-of-process (coverage never saw clients/cli/src) and the TUI
had only two smoke tests. Regressions there were invisible to CI.

CLI:
- Rewrite __tests__/helpers/cli-runner.ts to invoke runCli() in-process
  (capture process.std*.write, map thrown errors to an exit code),
  preserving the CliResult shape so all existing tests run unchanged and
  clients/cli/src is now measured under v8 instrumentation.
- cli.ts: program.exitOverride() so a commander usage error throws
  (catchable in-process) instead of tearing down the worker; --help /
  --version still exit cleanly. Drop the dead, never-imported main().
- Add a thin out-of-process e2e.test.ts that spawns the built binary for
  the shebang / process.exit paths the in-process runner can't reach.
- Add @vitest/coverage-v8, a per-file gate (90/85/80/50) excluding only
  src/index.ts (binary bootstrap), and pool: 'forks' for capture
  isolation. cli.ts lands at ~98% lines / 100% functions.

TUI:
- Add @vitest/coverage-v8 and an interim per-file gate over the
  non-React logic (logger.ts, components/tabsConfig.ts, utils/*); the
  Ink components, App.tsx, and hooks/ are excluded with a documented
  follow-up (#1501). Add tests covering the included modules (100%).

Wiring + docs:
- CI runs each client's test:coverage; root test:cli / test:tui (used by
  validate) and root test:coverage now enforce the gates.
- gitignore clients/**/coverage; update AGENTS.md and client READMEs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): clarify in-process runner footguns from PR #1502 review

Review notes on the in-process CLI test runner (no behavior change):
- Warn in runCli's docblock that --help/--version must NOT go through the
  in-process runner: exitOverride only re-throws non-zero exits, so exitCode
  0 falls through to process.exit(0) and would kill the vitest worker. Those
  belong in the out-of-process e2e.test.ts layer.
- Document that a timeout orphans the uncancellable invokeCli promise (its
  stdio subprocess stays alive; later writes hit the restored stdout) — a
  known, acceptable trade-off on the hang-only failure path.
- Fix a stale reference to the removed main() (errors now route through
  index.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): fix remaining stale main() reference in vitest exclusion comment

PR #1502 re-review caught a second stale `main()` mention (the first was
fixed in 52c23e41). src/index.ts inlines the bootstrap as
runCli(process.argv).then(...).catch(handleError) — there is no main().
Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: compose root test:coverage from per-client scripts

Replace the brittle inline cd-chain with a composition of the existing
test:cli / test:tui scripts plus a new matching test:web. Behavior is
unchanged (web → cli → tui coverage gates) but there's a single source of
truth for each client's coverage invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:05:58 -04:00
Cliff Hall
73b651ff21
feat(cli,tui): normalize --catalog / --config with web; update --help (#1347) (#1499)
* feat(cli,tui): normalize --catalog / --config with web; update --help (#1347)

Bring the CLI and TUI in line with the web catalog vs read-only session
model introduced in #1481/#1483, and close the outstanding --help item
from #1347.

Core (core/mcp/node/config.ts):
- Add `catalogPath` to ServerConfigOptions (writable catalog slot).
- `resolveServerSource()` picks the active source: `--catalog` (writable,
  seed-if-missing) wins over `--config` (read-only, error-if-missing).
- Writable sources seed an empty `{"mcpServers":{}}` on first run (matches
  the web backend) instead of erroring; read-only `--config` still errors.
- `serverSourceConflict()` shared conflict matrix (mirrors run-web):
  --catalog/--config mutually exclusive; neither combines with an ad-hoc
  target.
- `readServerListFile()` public loader for runners (TUI) needing the raw map.
- Rename `withDefaultConfigPath` → `withDefaultCatalogPath`; the no-flag
  default now fills the writable catalog slot, not --config.

CLI / TUI:
- Add `--catalog <path>` (+ MCP_CATALOG_PATH) and re-document `--config` as a
  read-only session in --help.
- Enforce the shared conflict matrix before resolution.

Full writable persistence/CRUD threading remains deferred to #1482 / #1432
(CLI/TUI are read consumers today; the split surfaces as seed- vs
error-on-missing).

Tests: core resolution matrix (source/conflict/seed/read-only), CLI catalog
+ conflict integration tests, TUI loadTuiServers unit tests. READMEs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(launcher): add smoke:cli + smoke:tui e2e launcher smokes (#1347)

The launcher smokes only checked `--help` for --cli/--tui; nothing
exercised the launcher → CLI/TUI → core path on the built artifact (only
smoke:web did, for web). Add two end-to-end smokes wired into
validate:launcher between smoke:launcher and smoke:web:

- smoke:cli (scripts/smoke-cli.mjs): runs `--cli` against the bundled
  stdio test server via a temp --catalog and asserts tools/list returns
  the tools, plus the #1347 resolution paths — default-catalog
  seed-on-missing, read-only --config error-without-seed, and the
  --catalog/--config conflict.
- smoke:tui (scripts/smoke-tui.mjs): launches `--tui --catalog <temp>`
  and asserts the Ink app renders its first frame ("MCP Servers") within
  a timeout, then SIGTERMs it (shallow boot/render check).

Both build test-servers/build on demand if missing. Docs updated
(AGENTS.md validate description, launcher README).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: address review on #1499 (comment accuracy, CLI/TUI flag parity)

Review feedback from @claude on PR #1499 (all non-blocking):

- Align CLI catalogPath resolution with the TUI: `options.catalog?.trim()
  || process.env.MCP_CATALOG_PATH` so an explicit empty `--catalog ""`
  falls back to MCP_CATALOG_PATH identically in both front-ends (CLI used
  `??`, which only guards undefined/null).
- Clarify the `serverSourceConflict` comment: it mirrors only the
  source-selection portion of web's matrix and deliberately omits web's
  `--header` rejection, since CLI/TUI merge --header into per-server
  settings.
- Note the deliberate --header broadcast semantics in loadTuiServers.
- Share an EMPTY_CATALOG_CONTENT constant between seedEmptyCatalog and
  readServerListContent instead of two separate literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): trim --config to match --catalog and the TUI (#1499 review)

Cosmetic uniformity follow-up from the re-review: configPath now uses
`options.config?.trim() || undefined`, matching the catalog slot above it
and the TUI. Behavior is unchanged (resolveServerSource/serverSourceConflict
already trim downstream).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:30:26 -04:00
Bob Dickinson
2488ca5e38
feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452)
Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).

Closes #1246.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:27:49 -04:00