Commit graph

2,546 commits

Author SHA1 Message Date
cliffhall
6002c2ce33 fix: gate submission on unsendable field text, and on required tool args
A field that cannot turn its text into a value reports `undefined` — which is
exactly what an empty field reports. That made the two indistinguishable to
every caller of `SchemaForm`, so invalid text in an optional field was
submittable and the argument simply arrived absent (#2020).

`SchemaForm` now tracks per-field draft validity and reports it through
`onValidityChange`, covering both fields that hold text the form cannot send:
the JSON editor (unparseable text) and the number input (a magnitude JS cannot
hold exactly, which is dropped rather than silently rounded). The number field
also states it on itself, as the JSON editor already did. A nested object form
reports through the same channel, and both the field-level and form-level
effects report "valid" on cleanup so a draft cannot outlive the field that held
it — a remount under a new `resetKey`, or an unmounted panel.

`ToolDetailPanel` gated Execute on `isExecuting` alone, so it also had no
required-argument check at all — unlike the App and elicitation panels. It now
uses `hasMissingRequiredFields` like they do. `InlineElicitationRequest` had
neither gate and gets both.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 21:19:04 -04:00
Cliff Hall
27a4820215
Merge pull request #1976 from modelcontextprotocol/dependabot/github_actions/v2/main/github-actions-d1cfab875f
build(deps): bump the github-actions group across 1 directory with 5 updates
2026-08-15 21:07:10 -04:00
Cliff Hall
71aa4b6b1d
Merge pull request #2014 from modelcontextprotocol/v2/fix/1928-nullable-enum
fix: render nullable and union argument fields correctly in the web form and the TUI
2026-08-15 21:03:31 -04:00
Cliff Hall
2f56385b71
Merge branch 'v2/main' into v2/fix/1928-nullable-enum 2026-08-15 20:36:45 -04:00
Cliff Hall
372403d9cc
Merge branch 'v2/main' into dependabot/github_actions/v2/main/github-actions-d1cfab875f 2026-08-15 20:32:04 -04:00
cliffhall
18053d4cb2 fix: decline empty enums, drop mislabelling enumNames, treat $ref as opaque
Copilot round 11. Two of the three are the enum-strip logic this PR added
misbehaving on degenerate input; the third is one more keyword for the
fail-closed rule.

1. An empty `enum` permits nothing at all, but the strip reported "unchanged"
   (nothing was removed) and the collapse built a Select with no options — a
   required field then had no interaction able to supply a value. It now
   reaches the same conclusion as `[null]`: no selectable member, so decline.
   The outcome is renamed `unselectable`, since "only-null" no longer describes
   both routes to it.

2. Filtering `null` out of `enum` shortens it, which could make an ALREADY
   mismatched `enumNames` come out the same length — so `enum: [null, "b"]`
   with `enumNames: ["None"]` started labelling `b` as "None", turning labels
   both renderers had correctly ignored into a confident mislabel. A list that
   was not parallel before filtering is now dropped rather than kept.

3. `$ref` joins the opaque set. It is never resolved here and applies alongside
   its siblings, so `{ type: "string", $ref: "#/$defs/intOnly" }` can be
   unsatisfiable. The wrapper path already declined on it by virtue of the
   annotation allowlist; this extends the same rule to branches, the
   `type: [T, "null"]` path, and `admitsNull`.

No round 12 was requested: the review loop was stopped at the user's direction.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 20:27:19 -04:00
cliffhall
562709a79a fix: guard the surviving branch's own composition, via one shared predicate
Copilot round 10: one finding, the last position of the rule the previous two
rounds established. A *non-null* branch can carry its own `anyOf`, and the
hoist drops it — the branch's `anyOf` overwrites the wrapper's in the spread
and is then cleared, so `{ anyOf: [{ type: "string", anyOf: [{ const: "a" }] },
{ type: "null" }] }` widened into an unconstrained nullable string.

Rather than add a fourth inline condition, the four sites that ask "does this
schema compose something I cannot evaluate?" now share one predicate,
`hasUnevaluatedComposition` (an opaque applicator, or a nested `anyOf`):

- the surviving branch in the `anyOf` collapse (this round's finding),
- the null branch in `admitsNull`,
- the `type: [T, "null"]` collapse path,
- and, via `hasOpaqueApplicator`, the top of `admitsNull`.

Each of those was missed independently across rounds 8-10 for the same reason:
the guard existed but was spelled out inline at each site and drifted. Sharing
the predicate is the part that stops a fifth site from repeating it.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 20:11:59 -04:00
cliffhall
274a328abe fix: apply the fail-closed rule uniformly, and correct the admitsNull docs
Copilot round 9: four findings, all of them places where round 8's fail-closed
inversion was the right idea applied incompletely. The rule was only enforced
at the positions I happened to be editing; it now holds everywhere a schema is
inspected.

1. `admitsNull` returned early on an explicit `type`, ignoring a sibling
   `anyOf` — but the two are conjunctive, so `{ type: ["string", "null"],
   anyOf: [{ type: "string" }] }` admits no null at all. A sibling union now
   withholds the claim.

2. A null-typed branch could be unsatisfiable via its own nested `anyOf`
   (`{ type: "null", anyOf: [{ type: "string" }] }`). The branch check tested
   `not`/`allOf`/`oneOf` but not `anyOf`; it now treats a nested union as
   equally opaque.

3. The `type: [T, "null"]` collapse path had no applicator guard at all —
   `collapsed` clears `anyOf` unconditionally and the renderers ignore the
   rest, so `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` was widened
   into an unconstrained string field. That path now declines the same way the
   `anyOf` path does.

4. The `admitsNull` docstring still advertised `oneOf` support that round 8
   deliberately removed. Rewritten to state what is recognized, what is
   declined and why, and the asymmetry that settles the direction.

The three code fixes share one helper (`hasOpaqueApplicator`) rather than
repeating the condition, so the next position that needs it cannot drift.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:58:07 -04:00
Cliff Hall
b74c0b5d42
Merge pull request #1994 from modelcontextprotocol/v2/fix/1940-gitattributes-eol
fix: add eol=lf policy to .gitattributes so Windows checkouts stay clean
2026-08-15 19:47:05 -04:00
cliffhall
2fdbd1905f fix: make the collapse and null-admission fail closed on unevaluated schemas
Copilot round 8 opened the same conjunctive-semantics seam a third time, so
this stops patching cases and inverts the defaults instead. Both guards now
fail CLOSED: anything this module cannot evaluate sends the field to the JSON
editor rather than to a widget built from a partial reading.

1. The wrapper-conflict check enumerated the *validation* keywords and declined
   when two of them disagreed — which meant any keyword nobody listed
   (`oneOf`, `allOf`, `not`, `$ref`, a future vocabulary) was silently treated
   as safe to drop. That whitelist fails open, and open means rendering a
   widget for a constraint that is not the schema's. It is replaced by an
   allowlist of *annotation* keywords: the collapse declines whenever the
   wrapper carries anything beyond annotations and the union itself. Stricter
   than the old equality test — a wrapper restating its branch's `type` now
   declines too — which costs a dropdown on a rare redundant shape and buys a
   rule with no gap in it.

2. `admitsNull` returned true for shapes it had not actually evaluated:
   `not: { type: "null" }` rules null out, `allOf` can add a member that does,
   `oneOf` requires *exactly one* branch to match (so a null branch does not
   by itself mean null validates), and a branch like `{ type: "null",
   const: "x" }` is unsatisfiable. All now answer false. Under-claiming
   nullability costs a clear button; over-claiming lets the form emit a value
   the schema rejects, so the asymmetry decides it.

Note the third finding — draft validity never reaching the submit controls —
is NOT fixed here, and it is worse than I described last round: `ToolDetailPanel`
gates Execute on `isExecuting` alone and never calls `hasMissingRequiredFields`,
so my earlier claim that required fields were already covered was wrong for the
Tools tab. Corrected on #2020, which is where the fix belongs.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:44:09 -04:00
cliffhall
987279b5d3 fix: stop the hoist dropping wrapper constraints, and let an explicit type decide
Copilot round 7: both findings are the same principle applied one level
further — JSON Schema siblings are conjunctive, and this code was treating
them as alternatives.

1. The hoist is a spread, so a branch keyword *replaced* the wrapper's. That
   is not a conjunction: a wrapper `enum: ["a"]` around a branch
   `enum: ["a", "b"]` means `"a"`, but the spread produced `["a", "b"]` and
   the dropdown would have offered — and submitted — a `"b"` the schema
   rejects. `type`, bounds, and the object keywords fail the same way.

   The collapse now declines when wrapper and branch carry different values
   for the same validation keyword, and the field renders through the JSON
   editor with its schema intact. Intersecting them properly is a much larger
   surface, and a subtly wrong intersection would be worse than none.
   Identical values are not a conflict, so the ordinary redundant-`type`
   shape still collapses; metadata (`title`, `description`, `default`,
   `enumNames`) never conflicts, since nothing validates against it.

2. `admitsNull` let a `{ type: "null" }` branch outvote an explicit
   non-null top-level `type`. `{ type: "string", anyOf: [..., { type:
   "null" }] }` rejects null — the value must satisfy the type *and* the
   union — so an explicit `type` now decides instead of falling through to
   the branch scan.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:26:14 -04:00
cliffhall
86ba42633c fix: honor sibling constraints on nullability, guard numeric item enums
Copilot round 6: no visible comments, three suppressed. All valid.

1. JSON Schema keywords at one level are conjunctive, so a syntactic null does
   not by itself mean the schema accepts null. `{ type: ["string", "null"],
   enum: ["envio"] }` names "null" and still rejects it — yet the collapse
   marked it `nullable: true`, giving the field a clear button that emits a
   value the schema forbids, and `admitsNull` let required-field gating accept
   it. Both now consult `nullExcludedBySiblings`.

   The distinction that makes this safe: a *branch's* `enum` is not a sibling.
   In `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]` — the #1928
   shape — the enum constrains that branch alone and the union still permits
   null, so `nullable` is read off the ORIGINAL schema rather than the hoisted
   merge. Reading the merge would have called the headline case non-nullable.
   Flattening is unaffected either way; only the nullability claim is withheld.

2. The collapse newly routed a nullable array of numeric item enums into the
   string-only `MultiSelect` path, which would submit `["1"]` for `[1]`. Gated
   on `isStringEnum`, matching `toConstOptions`; `isStringEnum` is now a type
   predicate so the narrowing is real rather than asserted.

3. Invalid JSON and an empty field both report `undefined`, so an optional
   complex argument could be dropped from the submission while invalid text sat
   visible in the box. The field now says so via `error`. Note a *required*
   field was already gated — its value is `undefined`, which
   `hasMissingRequiredFields` treats as missing — so this is specifically the
   optional case. Disabling Execute/Open/Submit on an invalid optional draft
   needs a validity channel out of `SchemaForm` into all four call sites, which
   is wider than this fix; filed separately.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:11:49 -04:00
cliffhall
32d6f80bc1 fix: align enumNames when stripping null, and make the JSON fallback typeable
Copilot round 5: one inline comment, two suppressed. All three valid, and two
were defects from round 4's own fix.

1. Stripping the `null` sentinel from an `enum` left the parallel `enumNames`
   untouched. Both renderers discard labels outright when the two lengths
   disagree, so `{ enum: ["a", null], enumNames: ["Alpha", "None"] }` lost the
   `Alpha` label entirely — a worse outcome than the sentinel it was fixing.
   `enumNames` is now filtered by the same indices, and only when it was
   positionally parallel to begin with (a mismatched list is already ignored
   by both renderers, so re-indexing it would invent an alignment the server
   never declared).

2. `enum: [null]` became `enum: undefined`, which widened a schema permitting
   *only* `null` into a plain string field accepting arbitrary text — the form
   would then invite values the schema forbids. Trading a cosmetic problem for
   a correctness one, which was the wrong call. The collapse now declines
   entirely for that case and the field renders through the JSON editor.

3. That JSON editor was still carrying #1928's original mechanism: it stored
   unparseable text back as the *value*, which the next render
   re-`JSON.stringify`d, so typing `[` gave `"["` then `"\"[\""`. Routing
   nullable fields to real widgets removed the common way of landing there but
   never fixed the editor — and #2007's fix deliberately sends object unions to
   it, so a fallback nobody can type into is not a fallback. `SchemaJsonField`
   now holds the draft text locally (the split `SchemaNumberInput` already
   uses) and reports parsed JSON, or `undefined` while the text is mid-edit —
   an inspector must not report a value it cannot send. Keyed by `resetKey`
   like the number field, since it now holds state.

Also replaces an `as unknown as string[]` in the round-4 test, which violated
the repo's TypeScript rules and papered over `InspectorFormSchema.enum` being
unable to model a valid `["a", null]`. The fixture is wire data, so it now goes
through `toFormSchema` — the same wire→form narrowing boundary every production
call site uses.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 18:54:58 -04:00
Cliff Hall
52661187df
Merge branch 'v2/main' into v2/fix/1928-nullable-enum 2026-08-15 18:39: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
Cliff Hall
548aa53ef7
Merge pull request #2017 from modelcontextprotocol/v2/fix/1990-modern-era-logging-setlevel
fix: gate the connect-time logging/setLevel on the negotiated era (#1990)
2026-08-15 18:28:32 -04:00
cliffhall
34ba8256a4 fix: address Copilot review round 3 — typed consts, null admission, preset coverage
Three findings (one inline, two in the suppressed block), all valid.

1. `toConstOptions` still stringified every non-null `const`, so
   `items: { anyOf: [{ const: 1 }, { const: 2 }] }` submitted `["1"]` where
   the schema says `[1]`. Same wrong-type-on-the-wire problem that keeps a
   numeric `enum` off the select path, so it gets the same answer: only a
   string `const` is selectable, anything else falls to the JSON editor
   where the value keeps its type.

2. `hasMissingRequiredFields` derived null admission from the *renderer's*
   collapse, which is narrower on purpose — it only flattens a two-member
   union. So `anyOf: [string, number, null]` renders through the JSON
   fallback (where `null` is typeable) yet was still marked missing,
   disabling submit on a value the schema accepts. Null admission is a
   validity question, not a rendering one, so it is now its own predicate:
   `admitsNull` in core, recognizing `nullable`, `type: "null"`,
   `type: [..., "null"]`, and a null branch anywhere in an `anyOf`/`oneOf`
   of any size.

3. Nothing exercised the `record_shipment` preset — the web and TUI tests
   build the `anyOf` shape by hand, which verifies the renderers but
   *assumes* the premise the whole fix rests on: that Zod's `.nullish()`
   emits `anyOf: [<branch>, { type: "null" }]` with the enum on the branch.
   A Zod output change or a registry typo would leave every unit test green
   while the documented reproduction server quietly stopped reproducing.
   Added an integration test that boots the preset over HTTP, asserts the
   wire shape, and runs it back through the collapse — following the
   `duplicate-tool-names.test.ts` precedent, which exists for the same
   reason (#1957).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 18:15:38 -04:00
cliffhall
e746665eb6 docs: mark initialLoggingLevel as legacy-era only (#1990)
Copilot review: the option's doc still promised the level is set "if server
supports logging" — after the era gate that is exactly the case which now
silently does nothing, since a modern server can advertise `logging` and still
ignore the option.

Document it as legacy-only and point modern callers at the per-request
`io.modelcontextprotocol/logLevel` opt-in (`modernLogLevel` /
`setModernLogLevel()`).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 18:10:16 -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
2b2e157511 fix: gate the connect-time logging/setLevel on the negotiated era (#1990)
`logging/setLevel` is a legacy-era method, but connect() called it on any
server advertising the `logging` capability, era regardless. The modern wire
era rejects it outright, and because the call sits in the post-handshake
sequence that rejection failed the *connection* — so the CLI (the one caller
passing `initialLoggingLevel`, hardcoded to "debug") could not run any method
at all against a modern server that advertised logging.

Gate it on `protocolEra !== "modern"`. Modern has no session-scoped level to
set: the equivalent is the per-request `io.modelcontextprotocol/logLevel`
`_meta` opt-in, already driven by `modernLogLevel` from the server settings.
So modern skips the call rather than translating it. Legacy is unchanged.

The guard lives next to the capability check in inspectorClient.ts rather
than in the CLI, so any future caller passing `initialLoggingLevel` is
covered too.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 17:39:43 -04:00
cliffhall
e4e16bb3e6 fix: address Copilot review on the nullable-union collapse
Four findings, all valid:

1. Returning `T` was unsound. The collapse genuinely changes two fields —
   `type` stops being an array, `anyOf` becomes `undefined` — so a caller
   whose `T` typed either that way could keep treating it so afterwards.
   The result is now `T | NormalizedNullableUnion<T>`, which states exactly
   that: `Omit<T, "type" | "anyOf">` plus a `RenderableType` `type`, a
   cleared `anyOf`, and `nullable`. The one assertion left is isolated in a
   `collapsed()` helper, needed only because `Omit` stays deferred while `T`
   is open.

2. A typeless `enum` does not imply strings. JSON Schema allows
   `enum: [1, 2]`, and inferring `"string"` for it would hand numbers to a
   `Select` declared `string[]`, and make the TUI submit `"1"` for `1`. Now
   only an all-string enum earns the inference; anything else stays on the
   JSON fallback. An explicit `type` is still authoritative.

3. `hasMissingRequiredFields` counted every null as missing, so clearing a
   *required* nullable enum — newly possible via the clear button — would
   disable submit on a value the schema permits. `required` constrains
   presence, not content, so null now counts as present exactly when the
   field's schema admits it.

4. `collectSchemaDefaults` read the raw union, so a default nested inside a
   nullable object's branch was displayed by the form but never seeded into
   the submitted values. It normalizes each property first, like the
   renderer does.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 17:24:46 -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
cliffhall
937f1dd51b fix: render nullable enum fields as a Select instead of a raw JSON textarea
A tool argument that is optional AND explicitly nullable — Zod's
`.nullish()` / `.nullable().optional()` — compiles to
`anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch
of the tool-call form's field dispatcher tests a single `type` string, so
such a field matched nothing and fell through to the raw-JSON fallback,
whose onChange stores the unparseable in-progress text back into state and
re-`JSON.stringify`s it on the next render — compounding a fresh layer of
escaping on every keystroke.

`normalizeUnionType` already existed for the FastMCP `string|null` shape,
but it only copied the surviving branch's `type` and dropped everything
else, so the `enum` never reached the dispatcher; and nothing called it on
a tool input schema — only elicitation requests, via
`resolveRefsInMessage`. So:

- Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords
  (`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema,
  as v1.x did. This also collapses nine near-identical blocks into two and
  picks up the `object`/`array` cases the `type: [X, "null"]` form was
  missing.
- Normalize each field in `SchemaForm.renderField`, so tool, elicitation,
  and app forms all get it rather than just elicitation.
- Give a nullable enum `Select` a clear affordance, so a value can be set
  back to `null`; a non-nullable enum keeps none, since it would reject it.

Adds a `record_shipment` preset and `nullable-fields-http.json` showcase
config serving all four nullable scalar shapes, for manual verification.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 16:10:17 -04:00
Cliff Hall
ebeaa142df
Merge pull request #2011 from modelcontextprotocol/v2/chore/2010-version-sync
chore: sync v2/main to 2.2.0 + guidance to bump on v2/main before the milestone merge
2026-08-15 15:23:21 -04:00
cliffhall
d0143ef843 docs: tag origin/main explicitly so a release can't point at a local commit
git checkout main && git pull resolves through the maintainer's configured
merge/rebase strategy, so a divergent local main can produce or replay local
commits. Tagging HEAD there tags a commit that is not on origin/main, and
pushing only the tag leaves a release whose commit was never published.

git tag <x> origin/main pins the tag to what the remote branch actually points
at, regardless of local state.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 15:04:34 -04:00
cliffhall
4d567c97e4 docs: make the release commands copy-pasteable and correct the invariant
Copilot review:

- `<x.y.z>` / `<major|minor|patch>` inside a bash block is shell redirection,
  so both documented commands were syntax errors if copied. Verified: `bash -n`
  rejects `git tag <x.y.z> && ...` and accepts `git tag 2.3.0 && ...`. Use a
  concrete 2.2.0 -> 2.3.0 example and say what to substitute.
- "agree on the version at every point" / "never disagree" overstated it. The
  procedure deliberately bumps v2/main first, so between the bump and the
  milestone merge the branches differ — v2/main reads the version being built,
  main the one released. Describe the real invariant: they converge when the
  release merge lands, and v2/main is never left behind main.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 14:47:33 -04:00
cliffhall
9048e69758 docs: drop the v prefix from the release tag step
This repo's release tags are bare x.y.z (2.2.0, 2.1.0, 2.0.0), not v-prefixed.
Also notes why the manual tag matters: npm's tag-version-prefix defaults to 'v'
and the repo sets no .npmrc, so a bare `npm version` would have produced a tag
that doesn't match the convention.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 14:39:30 -04:00
cliffhall
a9ed087f25 chore: sync v2/main to 2.2.0 and bump on v2/main before the milestone merge
v2/main has been at 2.0.0 since the v2.0.0 release while main and npm `latest`
are at 2.2.0. The bump was created on the milestone-merge branch, which is cut
from main, so it only ever existed downstream of v2/main and nothing carried it
back — `chore: 2.1.0` and `chore: 2.2.0` are both on main and neither is on
v2/main.

That is not cosmetic. A branch cut from a milestone-merge branch silently
carries the bump into an unrelated PR — #2009 arrived with a 2.0.0 -> 2.2.0 diff
on a container bugfix — and anything reading the version in development
(readInspectorVersion, --version, GET /api/config) reported a version two
releases old.

Move the bump to v2/main, before the milestone merge, so it flows into main with
the rest of the milestone's work and the branches never disagree. The release
tag is applied to the resulting main commit instead. The publish workflow needs
no change: its tag-vs-package.json assertion runs at the release commit on main,
which still carries the bump.

Deliberately not a back-merge of main into v2/main: main holds the whole pre-v2
v1 history (~230 commits v2/main lacks, retained through `chore: replace main's
tree with v2`), so merging would graft all of it into the develop branch's log
permanently to deliver a two-file change.

Also sets v2/main to 2.2.0 as a one-time catch-up. Nothing in the repo asserts a
specific version value, so this is a safe two-file change.

Closes #2010

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 14:16:03 -04:00
Cliff Hall
b57859c44f
Merge pull request #2009 from modelcontextprotocol/v2/fix/1951-container-loopback-bind
fix: bind loopback by address and pin the sandbox port for containers
2026-08-15 13:31:12 -04:00
cliffhall
1ea1e30f63 fix: address Copilot review — real latch coverage, consistent docker docs
- The `retriedDynamic` latch test never made the retry fail: a real `listen(0)`
  always succeeds, so it produced one EADDRINUSE and passed with the latch
  deleted. Mock `node:http` so every listen fails, asserting exactly two listen
  attempts and a settled result. Verified by mutation: removing the latch now
  hangs the test instead of passing it.
- The migration guide's Docker notes still described a dynamic sandbox port and
  told readers to pin a custom one, contradicting the new fixed 6275 two
  sections later.
- Narrowed "or any container" in the troubleshooting entry, which overstated the
  scope and contradicted the PR's own scope note. It now names the IPv4-forwarder
  case and explicitly excludes the official image and a plain loopback -p.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 13:17:08 -04:00
cliffhall
adde9ccf94 fix: bind loopback by address and pin the sandbox port so containers work
Two defaults that each break the Inspector inside a container.

#1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted
to the NAME `localhost`, and `server.listen(port, host)` resolves a name
through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17
the default result order is `verbatim`, so on a glibc Linux host — whose
/etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The
server bound IPv6 loopback only and refused every IPv4 client, including the
dev container port forwarder. It presents as an endless spinner rather than a
connection error because the forwarder accepts on the host side before failing
to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit
HOST=localhost is still honored as typed.

#2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox
listened on an OS-assigned port, so it changed every run and could never be
named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default
it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an
OS-assigned port (loudly) if it is taken — the collision a dynamic default
never had.

Verified in the reporter's exact image
(mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners
come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox.

Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both
defaults in the web README, the migration guide, and the root README's Docker
section.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 12:33:27 -04:00
olaservo
cf834633ba fix: add eol=lf policy to .gitattributes so Windows checkouts stay clean
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 08:56:24 -07:00
Cliff Hall
5ec2ecf956
Merge pull request #1981 from modelcontextprotocol/v2/docs/1980-inspector-roadmap
docs: six-month Inspector roadmap (Aug 2026 → Feb 2027)
2026-08-12 11:47:24 -04:00
Cliff Hall
57d6e0da2e
Merge pull request #1989 from modelcontextprotocol/v2/chore/1988-sdk-2.0.0
chore: upgrade the MCP TypeScript SDK from 2.0.0-beta.5 to 2.0.0
2026-08-12 10:57:02 -04:00
cliffhall
55878297d9 docs: correct the EMA spec's stale SDK-dependency claim
Review caught that v2_auth_ema.md contradicted itself. Its "TypeScript SDK
(implemented)" section claimed Inspector depends on `@modelcontextprotocol/sdk`
v1.x only (`^1.29.0`) and that there is "no `@modelcontextprotocol/client` v2
dependency in the tree today" — while the subsection immediately below it
documents which v2 client helpers the EMA legs have adopted.

Checked rather than assumed: every module the section's table names imports the
v2 client (emaFlow, idpOidc, resourceContext, tokenEndpoint, transportProvider,
wire, and providers.ts), and there are zero v1 SDK imports in first-party code.
The v1 SDK is present in the tree only as a peer pulled in by ext-apps, which
AGENTS.md already states must never become a direct dependency.

Rewrote the paragraph to say what is true and name the version, so it stays
consistent with the two auth specs corrected in the previous commit.

The per-row "v1 SDK" attributions in the table below it are wrong for the same
reason, but correcting each one means auditing which SDK symbol each leg calls
today — a real audit, not a version-string refresh, and out of scope for a
dependency bump. Left for a follow-up rather than guessed at here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-12 09:46:45 -04:00
cliffhall
3e76498366 chore: upgrade the MCP TypeScript SDK from 2.0.0-beta.5 to 2.0.0
The SDK shipped 2.0.0 final on 2026-07-27. Shipping the Inspector against a
prerelease of its core protocol library is a supply-chain and reproducibility
liability we no longer have a reason to carry: a beta is eligible for unpublish
and deprecation in a way a stable release is not.

Bumped in the root package.json only — client, core, server, server-legacy.
Node resolution walks up, so the root install already serves every client; a
per-client declaration would install a second copy that drifts from it (#1970).
Verified after install: one copy of each at 2.0.0, no clients/*/node_modules/
@modelcontextprotocol/* at all. ext-apps is a separate package on its own ^1.7.4
line and is untouched.

The beta.5 → 2.0.0 delta is seven commits. Reviewed each for call-site impact:

  - core/src/schemas.ts (+16/-16) is doc-comment only — every hunk rewrites a
    spec URL from a commit-pinned GitHub blob to the published 2026-07-28 page.
    No schema surface change, so nothing in core/mcp or core/json is affected.
  - client 2.0.0 wants zod ^4.2.0; we are on ^4.4.3, so no zod move and no risk
    of the #1896 dual-copy tsc heap blowup. verify:dep-lockstep stays green and
    the lockfile delta is exactly the four packages with no transitive churn.
  - fix(validators) honors declared draft-07/06 JSON Schema dialects instead of
    rejecting them, which can only widen the set of tool schemas we accept.
  - The SSE keep-alive and legacyWrap changes are server-side, exercised by the
    test servers rather than by client code.

The one change with real behavioral reach is #2564: the negotiation probe now
classifies HTTP 401/403 as an auth failure instead of legacy-era evidence. That
is the upstream fix #1807 is blocked on, and it is left for that issue — the
directAuthRecovery clause in inspectorClient.ts still intercepts the challenge
before the classifier sees it, so this bump does not change connect behavior.
#1807 can now delete the workaround and lean on the SDK verdict, but doing it
here would smuggle an auth change into a dependency bump.

Also corrected the two auth as-built specs, which still claimed beta.4 —
already stale before this change. v2_new_spec_impact.md is deliberately left
alone: it is a dated pre-upgrade analysis, and editing its premises would
rewrite the record rather than update it.

npm run ci passes end to end: validate, the per-file >=90 coverage gate,
verify:build-gate, all six smokes (including web:app driving connect -> open
app -> data-app-status="ready"), and 472 Storybook tests.

Closes #1988

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-12 08:34:08 -04:00
Cliff Hall
532ad82791
Merge pull request #1982 from modelcontextprotocol/v2/docs/1979-dco-signoff
docs: document the DCO signoff requirement
2026-08-12 07:44:24 -04:00
Cliff Hall
f9d893f3d7
Merge pull request #1986 from modelcontextprotocol/v2/fix/1984-mantine-transition-timer-leak
fix(test): clear leaked timers so one cannot fail an unrelated CI run
2026-08-12 07:41:06 -04:00
Cliff Hall
95d4799926
Merge pull request #1987 from modelcontextprotocol/v2/fix/1985-dead-session-race
fix(test): wait for the dead transport instead of sleeping 300ms
2026-08-12 07:38:45 -04:00
cliffhall
c0e4b89c8d fix(test): treat timer handles as opaque; the numeric guard never matched
Review was right, and I had assumed the handle type rather than checking it.
Probed both under happy-dom:

  setTimeout            -> typeof "object", constructor Timeout
  requestAnimationFrame -> typeof "object", constructor Immediate

So `typeof id === "number"` in the clearTimeout wrapper never matched, and an
explicitly-cleared timer was never untracked. Both `Set<number>` annotations and
`const id: number` were fictions TypeScript could not catch, since the DOM lib
DECLARES number while happy-dom RETURNS an object.

The effect was benign — the teardown sweep re-clears them and clearTimeout is
idempotent — but the sharper problem is that the test asserting this passed for
the wrong reason. "clearTimeout doesn't throw" holds whether or not tracking
works, so it proved nothing and hid the broken guard.

Hold handles as `unknown`, route cancellation through two small helpers that
carry the one justified cast each, and drop the numeric guards. The test now
asserts the net's own bookkeeping through an exported pendingTimerCount(), and
is mutation-checked: restoring the numeric guard fails it (1 failed | 6 passed).

That is the second test in this PR that passed without proving its claim. Both
had the same tell — the assertion would have held with the feature removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-12 01:23:21 -04:00
cliffhall
b451949c9e docs: the renderWithMantine rule no longer rests on timer safety
Review caught a second-order effect of the fix: making the leaked-timer net
global invalidated the rationale of a neighbouring rule.

"Do not hand-roll a bare MantineProvider — that reintroduces the leak class"
stopped being true the moment the net moved into setup.ts, which covers every
unit test regardless of how it renders. The rule itself is still right, so keep
it and replace the justification: a hand-rolled provider skips the project theme
and the helper's options and drifts from every other test. Both copies say
explicitly that the old reason no longer holds, so nobody re-derives it.

Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md
requires for review-relevant guidance — it carried the identical stale claim.

That makes four places this one belief had propagated to. A fix can create stale
docs by making a previously-true statement false, which is easy to miss when
only the changed lines get read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-12 01:11:05 -04:00
cliffhall
b70aed6914 test(fix): probe a single-source route when waiting for the dead transport
Review feedback on #1987: `waitForDeadTransport` polled `/api/mcp/send`,
which answers `transport_error` from two branches — the `isTransportDead()`
short-circuit and the `catch` around a rejected `send()`. Only the first
implies the transport is marked dead, so the stop condition could not tell
them apart from the response alone.

That was safe in practice: `StdioClientTransport.send` rejects only when
`_process` is undefined, and the transport clears `_process` and calls
`onclose` in one synchronous block, so the `catch` branch is not observable
before `markTransportDead()` has run (confirmed empirically — 0/25 probes
returned early). But the safety rested on an SDK-internal ordering this test
neither states nor controls, and if it changed the send test would silently
cover the `catch` branch instead of the short-circuit it names — a regression
with no failing run attached to it.

Poll `/api/mcp/auth-state` instead. For a session connected without an
authState there is no auth provider, so `setAuthState` always throws and
`transport_error` there has exactly one source: the dead-transport
short-circuit. The route never awaits a transport response, so it cannot
hang either. Same cost, exact condition, no dependence on SDK internals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
2026-08-12 01:10:03 -04:00
cliffhall
c4e8ed992b fix(test): cancel queued frames before sweeping timers
Review found the net still had the exact hole it was written to close.

The afterEach is synchronous, so a queued rAF callback cannot run until it
returns — at which point it registers a fresh setTimeout AFTER the sweep has
already drained. On a file's last test that timer survives environment
teardown. That is the rAF race described in the previous commit message, left
open by the fix for it.

Track animation frames too, and cancel them BEFORE sweeping timers. Order is
the fix: JS is single-threaded and nothing between the two loops yields, so no
frame callback can run in between. Mutation-checked — without the cancel pass
the new ordering test fails (1 failed | 6 passed); with it, 7 pass.

Also from review:

- renderWithMantine.tsx still documented the disproven env="test" behavior. It
  is the file contributors are told to use, so leaving it would have preserved
  the wrong belief exactly where it does most damage. That was the third copy of
  this claim in the repo; all three now corrected.
- Dropped an unjustified `as unknown as` in the test helper, which broke the
  repo's own double-cast rule. Inference keeps the handle type clearTimeout
  accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-12 00:58:44 -04:00
Cliff Hall
10793d79fa
Merge pull request #1983 from modelcontextprotocol/v2/fix/1952-tui-form-crash
fix(tui): bundle React-rendering deps so a consumer install can't split React
2026-08-12 00:49:11 -04:00
cliffhall
24d89d7d0b fix(test): wait for the dead transport instead of sleeping 300ms
connectDeadSession spawned a real node subprocess whose only job is to die,
then slept a flat 300ms for the death to propagate. That budget has to cover a
cold spawn(), node's startup, its exit, the parent observing the close, and
onclose marking the session dead — comfortable on an idle box, not under CI
contention or a machine running several agents.

When the budget was missed the session was still live, so isTransportDead() was
false and /api/mcp/send fell through to `await responseWait` for a tools/list
reply the dead child can never send. Nothing bounds that wait, so the test hung
to the project's full 30s ceiling. The timeout IS the symptom of losing the
race, which is why it never presented as a clean assertion failure.

Poll the observable condition instead. Two design points that are load-bearing:

  - The probe is a NOTIFICATION (method, no id). requestIdForSendWait returns
    undefined for it, so the route never awaits a response and cannot hang.
    Probing with a request would reproduce the exact bug being fixed.
  - Not /api/mcp/events, which looks like the natural "transport died" signal:
    opening that stream on a dead transport calls sessions.delete(sessionId),
    so the send under test would then answer 404 instead of transport_error.

Both terminal branches of the route answer transport_error — the
isTransportDead() short-circuit and the catch around a send() that threw on the
dead pipe — and either proves the subsequent real send cannot reach the hanging
path, so both are valid stop conditions.

Verified by reproducing the bug deterministically: with the wait reduced to 0
(the budget always missed) the test times out at 30s; with the poll it passes
10/10. Fixing the helper rather than the test also covers its second call site,
the auth-state test, which sits on the same race and had simply not lost it yet.

connect-crash.test.ts:147 is deliberately left alone. #1985 proposed giving it
the same treatment, but readSseEvents stops at transport_error, which arrives
whenever the child dies — so its 200ms sleep cannot hang, and changing it would
add risk for no gain.

Closes #1985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-12 00:28:34 -04: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
1e0f94bb31 fix(test): clear leaked timers so one cannot fail an unrelated CI run
A window.setTimeout that outlives its test file fires after happy-dom disposes
that file's window; React's dispatchSetState then throws an uncaught
ReferenceError: window is not defined. Vitest attributes it to whichever file
was running, so a single leak fails the ENTIRE run with every test passing —
landing on an innocent file under a step named "Enforce per-file coverage gate."
Seen twice in CI, both on docs-only branches.

Not a Mantine bug, and not a badly-written test. Both hooks do clear on unmount
(useTransition's clearAllTimeouts, useLockScroll's effect cleanup). It is a rAF
race: clearAllTimeouts cancels the *pending* rAF, but the transition schedules
rAF -> rAF -> setTimeout, so if the inner callback is already in flight when the
unmount lands, cancelAnimationFrame is a no-op and that callback then schedules
a timer after cleanup has run. Nothing owns it. Needs a loaded machine, which is
why it only ever appeared in CI.

Track every timer and clear whatever is still outstanding after cleanup(). The
ordering is load-bearing: legitimate unmount cleanups get their turn first, so
only true leaks are dropped. Fake timers pass through untracked, which is
correct — a fake timer cannot outlive the environment.

Also corrects the claim, in AGENTS.md and in setup.ts's own comment, that
env="test" prevents this. It does not: env is read only by Transition.mjs at its
render branch, while useTransition runs before that check (hooks cannot be
conditional) and still schedules real timers. Measured — opening a <Modal>
through renderWithMantine schedules three 200ms timers. Believing that guarantee
is why this went unexamined.

The regression test is mutation-verified: with the net disabled it fails, with
it restored it passes. It deliberately does not assert that Mantine schedules no
timers — it does, and that is normal; the contract is that none survive.

Closes #1984

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-12 00:16:05 -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
cliffhall
c158ad809f fix(docs): correct the DCO rules against the validator source
Round 5 was right on all three counts. Read dcoapp/app/lib/dco.js this time
rather than the README, which is where the earlier errors came from:

  const authors = [commit.author.name, commit.committer.name]
  const emails  = [commit.author.email, commit.committer.email]
  if (isMerge || ...) continue          // parents.length > 1
  else if (author && author.type === 'Bot') continue

So, corrected:

- Scope. Merge commits and bot-authored commits are skipped, not gated. "Every
  commit" overstated it and could prompt a needless history rewrite.
- Matching. The trailer may match the author OR the committer, not the author
  alone. A cherry-picker's own signoff is accepted.
- Certification. The DCO expressly covers submitting work created by others
  that you have the right to submit, so signing off a cherry-pick is legitimate.
  My "never sign off someone else's commit" would have told a maintainer not to
  do a correct thing. The real prohibition is narrower: never fabricate a
  trailer in another person's name.

This also retracts the mechanical argument in bd6c1072 for dropping the hook —
it does satisfy the check, since you are the committer. The recommendation
against it stands on honest grounds now: the trailer is a certification, a hook
makes it for every commit automatically including work merely applied on
someone else's behalf, and it provably cannot tell the difference. A judgment
about deliberateness, not a claim that it breaks.

Mirror corrected to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-11 23:22:22 -04:00