Commit graph inspector/clients/tui
Author SHA1 Message Date
cliffhall
b0b252a5c0 fix: address Copilot review round 41 — const does not demand a property, required-only branches
- `const` constrains a property that is PRESENT; it neither requires the
  property nor acts as a default. It is now supplied automatically only where
  the schema also requires the field — in `collectSchemaDefaults`,
  `applySchemaConstants` and the TUI's initial values alike — so an optional
  `dryRun: { const: true }` no longer turns a valid `{}` call into one that
  asks the server to do something. A value that IS present is still corrected
  to the constant, whatever supplied it, and the field stays read-only.
- Offerability is judged on the base+member merge rather than the raw member,
  so `anyOf: [{ required: ["email"] }, { required: ["phone"] }]` over
  root-declared properties is offered. It was declined, which left the gate
  checking the base alone and accepting `{}` — which that schema rejects.
- The TUI's branch sections keep the schema's own `required` for the seeding
  decision while still rendering every branch field non-required, which is what
  keeps a static form submittable.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #2123

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 20:26:43 -04:00
cliffhall
531cf84e60 Merge branch 'v2/main' into v2/fix/2067-proxy-undici
One conflict, in `clients/tui/__tests__/App.test.tsx`: both sides added an
`opts` capture to the test harness's `FakeClient` for different fields —
`defaultMetadata` on v2/main (#1910), `environment.fetch` here (#2067) — so the
resolution is the union of the two, keeping both rationales and both tests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Guarding it:

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

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

Closes #2067

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:13:04 -04:00
cliffhall
b33532c024 feat: allow complex JSON values in request _meta
Closes #1910

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:23:40 -04:00
cliffhall
b35db18fe8 Merge remote-tracking branch 'origin/v2/main' into v2/feat/2082-file-secret-store-lock 2026-08-23 11:56:05 -04:00
cliffhall
99a194ca69 chore(lint): fail lint on warnings (--max-warnings 0)
`lint` failed only on errors, so a warn-level rule was a channel nothing
consumed. `react-hooks/exhaustive-deps` reported two stale-closure
`useCallback` deps in App.tsx on every run, `npm run ci` passed anyway,
and the defect was caught only by a review round on #2076.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

npm run ci passes.

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

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

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

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

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

npm run ci passes.

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

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

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

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

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

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

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

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:48:36 -04:00
Cliff Hall
405d0f4d0d
Merge pull request #2039 from modelcontextprotocol/v2/chore/1959-no-floating-promises
chore(lint): enable @typescript-eslint/no-floating-promises in all five ESLint scopes
2026-08-16 22:35:52 -04:00
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
9fd5bc177c chore: drop the orphaned root override, and correct the TUI coverage note
Review follow-ups on #1983 (Copilot's suppressed comments).

Removing `ink-form` from the root dependencies left `overrides.ink-select-input`
with nothing to override: no root dependency reaches that package any more, and
the regenerated lock carries no entry for it. The override that matters lives in
`clients/tui/package.json`, where the build resolves `ink-form` — and it is what
puts `ink-select-input@6` (rather than the React-18-era v5) into the bundle.

The README paragraph above the new bundling section still described the Ink
components and `App.tsx` as an interim coverage exclusion. #1501 lifted that:
`vitest.config.ts` now gates all of `src/**`, excluding only the pure
`src/tui-servers.ts` re-export. Corrected to match the config it describes.

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:45:09 -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