Commit graph inspector/core
Author SHA1 Message Date
Cliff Hall
4e4aa49db1
Merge pull request #2037 from modelcontextprotocol/v2/feat/1906-auth-token-url-overrides
feat(auth): Auth URL and Token URL overrides in Server Settings
2026-08-16 22:49:19 -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
8d6803953c perf(auth): gate the override wrapper on the discovery request, and guard disk values
Two suppressed review findings.

With an override configured, the wrapper cloned and parsed every
successful application/json response — every tools/call result and every
resource payload on the direct CLI/TUI transports — only to conclude it
was not a metadata document. It now tests the request URL for the RFC
8414 / OIDC well-known paths first, so ordinary traffic costs one string
test and is returned untouched.

The read path also trusted StoredMCPServer's `string` type for the two
override fields, which a hand-edited mcp.json does not honor: the CLI and
TUI read the file directly and then `.trim()` these, so a number would
crash the server load. Guarded with cleanEndpointOverride, mirroring
cleanAuthorizationParams.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:11:16 -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
e5c20608bf fix(auth): never read a stream, and say what the issuer override does not do
Two review findings on the fetch wrapper:

The content-type test was a substring match for "json", which also
matches `application/x-ndjson` — how the streamable-HTTP transport can
serve its long-lived server-push channel (`isLongLivedStreamResponse`).
Awaiting `.json()` on a clone of an unbounded stream never resolves, so
the MCP connection would hang for as long as an override was configured.
It now matches the media type exactly (`application/json` or a `+json`
suffix), with a regression test driven by a stream that never closes.

The overrides also leave `issuer` as discovery returned it, so pointing
one at an authorization server that advertises a different issuer is
rejected on callback by the RFC 9207 / SEP-2352 mix-up defense. That
check is deliberately not weakened; the limit is now stated on the
module, in the field description, and in the configuration guide.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:52:09 -04:00
cliffhall
ac8a08e42f fix(auth): only rebuild the response when an override is actually applied
Two review findings on the fetch wrapper:

Reading the body with `response.text()` meant every successful JSON
response was rebuilt while overrides were configured, even when it was
not a metadata document. That dropped native properties a synthesized
`Response` cannot carry (`url`, `redirected`, `type`) from responses the
wrapper has no business altering, and threw outright on a body-less
204/205, whose status Fetch forbids a body on — so an unrelated transport
response could fail. It now parses a clone and returns the caller's own
response unless a document is actually patched.

`new URL` also accepts embedded credentials while Fetch rejects a request
URL carrying them, so `https://user:pass@host/token` passed both the form
and the runtime and then failed deep in the OAuth exchange.
`oauthEndpointUrlError` now rejects a username or password.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:30:32 -04:00
cliffhall
05698647f8 fix(auth): never apply endpoint overrides on the enterprise-managed leg
The overrides are per-server configuration for the *resource* server's
authorization server, but the fetch wrapper that applies them is shared
with the enterprise-managed (EMA) flow, whose IdP OIDC discovery runs
through the same fetch and satisfies the metadata predicate. An
authorization override could therefore redirect the IdP login to the
resource AS, and a token override could send the IdP code or refresh
token there.

OAuthManager.getEndpointOverrides() now returns nothing when the server
is enterprise-managed — the same boundary redirectToExternalAuthorization
draws for the custom authorization parameters (#2018) — with a regression
test, the note surfaced on both form fields, and the rule documented.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:13:51 -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
cliffhall
364d8c9dbc feat(auth): per-server authorization and token URL overrides
Adds two optional per-server fields — `oauth.authorizationUrl` and
`oauth.tokenUrl` — that override the endpoints authorization-server
metadata discovery resolved, so a server can be pointed at a development
or staging authorization server without changing what it advertises.

SDK v2 routes both endpoints through the discovered metadata document and
neither through OAuthClientProvider, so the override is applied by
wrapping the client's base fetch and patching the metadata document in
flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather
than only the auth fetch — also covers the discovery the SDK runs from
inside the transport on the 401/refresh path.

The fields round-trip through mcp.json, the /api/servers routes, the
runner (CLI/TUI), and the Server Settings → Authorization panel, where a
value that is not an absolute http(s) URL is flagged inline; at runtime
such a value is dropped with a single warning rather than failing the
connection.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:36:24 -04:00
cliffhall
07a7b0beb5 fix(web): accept CRLF-delimited SSE frames on the servers events stream
The `/api/servers/events` reader in `useServers` searched for `\n\n` and
consumed exactly two characters as the separator. SSE permits CRLF line
endings, so a `event: change\r\n\r\n` frame stayed buffered forever:
`isSseDataFrame()` was never reached and no background refresh fired,
leaving the server list stale until a manual refresh or an LF-delimited
event arrived.

Frame boundaries are now located with a CRLF-aware matcher that reports
the separator's real length, and frame lines split on either terminator.

A bare CR is deliberately not accepted as a terminator: it cannot be
distinguished from the first half of a CRLF straddling a chunk boundary,
so a buffer ending in `…\r\n\r` would read as a completed frame and the
`\n` arriving next would open a bogus one.

Two regressions cover it, both verified failing against the old parser.
Each uses a referentially stable `fetchFn` and holds the frame behind a
promise released after the disk write — with an inline `fetchFn` the hook
re-subscribes every render and that re-subscription's own mount refresh
picks the edit up regardless, so the test would pass unfixed.

Closes #2006

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:07:16 -04:00
Cliff Hall
0f31de83af
Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items 2026-08-15 23:05:31 -04:00
Cliff Hall
23847a61c7
Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items 2026-08-15 22:36:50 -04:00
Cliff Hall
9c4356fb8c
Merge branch 'v2/main' into v2/feat/2018-custom-authorization-params 2026-08-15 22:26:42 -04:00
cliffhall
e6e4600a73 fix: validate authorizationParams off disk; note the EMA exception (#2018)
Copilot round 5 — three of four findings implemented.

- `cleanAuthorizationParams` validates the field as it comes off disk. Only the
  web client's /api/servers route checked the shape; the CLI and TUI read
  mcp.json directly, so `authorizationParams: "oops"` enumerated as the
  character-indexed pairs 0=o, 1=o, … and `{ audience: 5 }` reached
  URLSearchParams.set, which stringifies it — either way the Inspector sent
  query parameters the user never wrote. Now validated in shared code so all
  three clients enforce the documented string→string contract, warning on each
  rejection. Follows `cleanRoots`, which guards the same class for `roots`.

- The form's description now states that these parameters are not sent while
  Enterprise-managed authorization is on. The EMA leg authorizes against the
  enterprise IdP — a different authorization server — and deliberately sends
  none of them, so the copy was presenting a configured-but-inert value as
  active.

- The /api/servers malformed-`oauth` warning listed an expected shape omitting
  `onInsufficientScope`, which the validator accepts and the docs describe, so
  it told users a valid config was malformed.

Ten new cases, all verified to fail against the pre-fix sources.

Declined: recording the authorize URL in the Network tab (repeat of round 2) —
that URL is reached by a top-level browser navigation rather than a tracked
fetch, so it has never appeared there for any authorization request; adding a
synthetic entry changes every OAuth flow and is a product decision for a
maintainer, not a change to make inside this PR.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 22:10:36 -04:00
cliffhall
d95a6e629c fix: stop dropping oauth.onInsufficientScope when extracting the client secret (#2018)
Copilot round 4 (one suppressed finding, and a real bug).

`extractSecretsFromStored` rebuilt the stripped `oauth` object from an
allow-list of the non-secret fields. That list had gone stale: it never named
`onInsufficientScope`, so a server carrying both a client secret and a
non-default SEP-2350 policy silently lost the policy on save, falling back to
`reauthorize`. This predates #2018 — the PR only extended the same list with
`authorizationParams` — but the list is the defect, so fix the class rather
than add a third entry to it.

The stripped entry now subtracts the one secret instead of enumerating what to
keep, so any field added to `oauth` later is preserved by construction.

Regression test verified to fail against the allow-list version:
`expected { clientId: 'cid' } to deeply equal { clientId: 'cid', onInsufficientScope: 'throw' }`.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 21:54:16 -04:00
cliffhall
866c32f629 Merge remote-tracking branch 'origin/v2/main' into v2/feat/2018-custom-authorization-params 2026-08-15 20:45:40 -04:00
cliffhall
77ff30bbc9 fix: stop the exclusion scan writing the rendered list's report (#1909)
Copilot round 10.

The scan no longer publishes its malformed entries into the tools/list report
at all. Round 9 only stopped it overwriting the aggregate's; the remaining
case is unsalvageable for a stronger reason than 'two different requests':

- a FAILED strict aggregate runs salvage, which writes the report itself;
- a SUCCESSFUL one means every entry parsed, so the rendered list contains no
  malformed entries.

So by the time the scan finds something, either the aggregate already reported
it with indices matching the screen, or the aggregate succeeded and what the
scan found is provably not in the list being displayed. Publishing could only
ever name entries the user cannot see.

Nothing is lost: listToolsForScan still marks its own refused response, so the
finding surfaces on the Protocol entry for the exchange that carried it, where
it is true. The per-page accumulation and raw-entry offset existed only to feed
the removed publish, and go with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 20:38:10 -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
efd0bf2b7c fix: let the aggregate own the tools/list report, and harden the scan (#1909)
Copilot round 9.

The exclusion scan is a separate, uncached tools/list exchange, so its entries
need not be the ones rendered — the server can answer differently, and a
cache-served aggregate is a different list version. It was overwriting the
aggregate's report wholesale, replacing accurate indices with indices into a
list nobody is looking at. The aggregate now owns the report; the scan
publishes only when the aggregate left nothing, which is the cache-served case
that made it publish at all (round 2).

A conforming re-fetch no longer swallows the mark: the list can change between
the strict page and the lenient one, and a clean second response says nothing
about the first, which was refused. It is marked with the original decode
error when there is nothing per-item to name.

The scan's fallback re-fetch now goes through invokeMcpClient, like the strict
call beside it and like salvageList's walk, so an auth challenge on that
request can still be recovered instead of being swallowed by listAllTools's
best-effort scan catch.

Also fixes listTools dropping an empty-string cursor (truthiness rather than
!== undefined) — its own fallback leg already preserved it, so the two legs of
one scan page disagreed about which page they had fetched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 20:23:59 -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
7db807b0de fix: return the era-parsed tool, and hide aggregate reports in paginated mode (#1909)
Copilot round 8.

The era contract validated with the wire schema but returned the raw input,
so the wire schema's normalization was discarded — both era schemas are
z.object, which strips unknown top-level fields as the strict codec does, so
salvaged tools could carry fields the strict path drops. The parsed output is
now what gets normalized and returned. This also simplifies the bridge: issues
come straight from the wire parse with their paths intact, so the custom-issue
forwarding (and its early describeIssue application) is gone.

The malformed-entry report is written by the aggregate walk's salvage, but in
paginated mode the tools/prompts/resources panels render the paged stores,
which neither write nor clear it — leaving a stale warning above a freshly
fetched page it does not describe, with indices into an aggregate the user is
no longer looking at. Those reports are now suppressed in paginated mode;
resource templates keep theirs, having no paged store. Suppressing rather than
adding per-page salvage: pointing at the wrong entry costs more than staying
quiet, and per-page salvage is a feature, not a display fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 20:08:03 -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
cliffhall
dbda70795b fix: make the salvage item contract match each era in both directions (#1909)
Copilot round 7.

The era contract was derived from the neutral ToolSchema plus one override,
which diverged from the real wire schemas both ways. Measured against SDK
2.0.0 on a real connection:

- modern accepts `inputSchema.properties: 5` and `required: [1]`; ours
  rejected them, so a malformed sibling made salvage DROP a conforming tool
  and report it as malformed — a false accusation from the screen whose job
  is to say which entry is wrong.
- legacy rejects `outputSchema.properties: 5` / `required: [1]`; ours
  accepted them, readmitting entries the strict path refused.
- modern rejects a non-string `inputSchema.$schema`, legacy accepts it;
  ours accepted on both.

Each era is now expressed as its own wire schema, bridged to Tool by
`eraToolItemSchema`. The bridge exists because both eras accept values the
exported Tool type cannot describe (the SDK returns them typed as Tool
anyway), so the wire schema's output type is irrelevant and z.custom<Tool>
supplies the caller's type — which is what avoids an `as unknown as` here.
Forwarded issues keep their path so the report still names the field.

The full measured matrix is pinned table-driven in listSalvage-era.test.ts:
each row asserts the SDK's verdict AND that our contract agrees, so a
divergence that closes or widens fails loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:52:50 -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
67972adc4e fix: bound the salvage walks and mark the scan's refused response (#1909)
Copilot round 6.

Bound both fallback walks with LIST_MAX_PAGES, which is now also passed to
the SDK client as `listMaxPages` so the strict aggregate and its fallback
share one value by construction. A server emitting endlessly UNIQUE cursors
never trips the repeated-cursor guard, so the salvage re-walk and the
SEP-2243 exclusion scan would page and accumulate without bound. Hitting the
cap aborts rather than returning a truncated list as a complete one.

Mark the exclusion scan's refused response on every fallback-failure path.
`salvageList` can rethrow bare because ManagedListState marks from the error
it receives; the scan has no such backstop — listAllTools catches and logs a
failing scan by design — so an unmarked rethrow left a response the client
demonstrably refused rendering as a clean exchange.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:33:27 -04:00
cliffhall
b25ece8ded feat: per-server custom OAuth authorization-request parameters (#2018)
Real deployments gate IdP selection, prompt behavior, or audience on a query
parameter the OAuth/OIDC core specs never standardized (Keycloak's
`kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's
`audience`), and none of them could be driven from the Inspector.

The Inspector never builds the authorize URL — the SDK's `auth()` does, and it
exposes no hook for extra parameters — so the merge happens at the one seam
that sees the finished URL: `BaseOAuthClientProvider.redirectToAuthorization`.
It lives in `core/`, so web, CLI, and TUI all inherit it.

- `core/auth/authorizationParams.ts`: the reserved-key list (`client_id`,
  `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`,
  `response_type`, `scope`, `state`), the key validator the form renders
  inline, and the merge itself. Reserved keys are dropped with a warning
  rather than silently overriding PKCE / the state binding / RFC 8707;
  blank-key rows are skipped; the caller's URL is never mutated.
- Applied to the authorization request only — nothing wires it into the token
  request.
- Persisted as `oauth.authorizationParams` (a string record) on the per-server
  config beside `scopes`, edited as key/value rows in Server Settings ->
  Authorization ("Additional authorization parameters"), with an inline error
  per offending row plus a section-level warning.

- The enterprise-managed (EMA) leg redirects to a *different* authorization
  server (the enterprise IdP), so it goes through the explicit
  `redirectToExternalAuthorization` entry point, which skips the merge — a
  `kc_idp_hint` or `audience` meant for the MCP server's AS must never be
  appended to the IdP's authorize URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:31:41 -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
cliffhall
23fe62ec5c Merge remote-tracking branch 'origin/v2/fix/1909-salvage-malformed-list-items' into v2/fix/1909-salvage-malformed-list-items 2026-08-15 18:47:02 -04:00
cliffhall
71cbe27d83 fix: address Copilot round 5 on the list-salvage path (#1909)
Both findings are the same shape: the fallback accepting what the strict path
would not.

- `ModernResultEnvelopeSchema` typed `ttlMs` as any number, where the codec
  requires a non-negative integer. `ttlMs: -1` (or `0.5`) plus one bad entry
  would salvage, reporting the entry and hiding the envelope violation. Tightened
  to `z.int().min(0)`.

- The item schema was the SDK's era-NEUTRAL `ToolSchema`, and the negotiated
  era's wire schema is stricter in ways the SDK does not export. Verified
  against SDK 2.0.0 on a real connection: on legacy, a tool with
  `outputSchema: { type: "array" }` is refused by the strict aggregate but
  accepted by the neutral schema; on modern, `execution` is STRIPPED by the
  strict path but kept by the neutral one. Either way a salvaged list could show
  — and let the user call — a tool the connection's era says isn't valid.

  `toolItemSchemaForEra` closes both. Replicating rules that live in the SDK's
  internals is a real cost; `listSalvage-era.test.ts` is what makes it safe to
  pay, pinning each rule to the strict path's OBSERVED behavior so a divergence
  that closes or widens fails loudly instead of rotting.

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-15 18:46:54 -04:00
Cliff Hall
52661187df
Merge branch 'v2/main' into v2/fix/1928-nullable-enum 2026-08-15 18:39:41 -04:00
Cliff Hall
166e0a9444
Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items 2026-08-15 18:39:34 -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
573b14e14e fix: address Copilot round 4 on the list-salvage path (#1909)
Two findings, both about the fallback accepting more than it should.

- The lenient page schema validated pagination and nothing else, so a response
  carrying a malformed entry AND an unrelated top-level violation salvaged: the
  entry was reported and the other violation vanished with the strict error that
  caught it. The page schema is now DERIVED from the method's real result schema
  with only its item array relaxed (`lenientListPageSchema`), so every other
  field — `_meta`, `nextCursor`, anything a future revision adds — is still
  validated. The modern era envelope the raw-wire path bypasses (`resultType`,
  `ttlMs`, `cacheScope`) is checked explicitly for the same reason.

  Salvage is also narrowed to `InvalidResult`: `UnsupportedResultType` means the
  codec doesn't recognize what KIND of frame arrived, so it isn't a list with a
  bad entry and reporting an item-level reason would hide a protocol failure.
  Marking the Protocol entry still applies to both, hence two predicates.

- On every rethrow path the re-walk had already answered the method, so the
  caller's own `markResponseRejected` (in `ManagedListState.refresh`) landed on
  the lenient page that succeeded and left the refused response clean. The
  correlation is restored before rethrowing — deleted when nothing had answered
  before, so the outer mark is a no-op rather than a lie.

`nextCursorOf` / `rawItemsOf` narrow the page's fields instead of casting: the
runtime-keyed `extend()` loses the shape, and asserting it back would be exactly
the unjustified cast the last round 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-15 18:22:10 -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
aea3f6d4c2 fix: address Copilot round 3 on the list-salvage path (#1909)
The visible review was clean; all five findings were in the suppressed block,
two of them real defects.

- The exclusion scan's aggregate offset was wrong twice over: `excluded.length`
  counts only invalid-header tools, and on page one it also folded in the
  current page's valid count. A reported index therefore pointed at a position
  that doesn't exist — worst for an unlabeled entry, whose index is all the user
  has. Replaced with a raw-entry offset advanced by each page's valid + malformed
  counts.

- The scan's own refused response was never marked. It is a distinct exchange
  from the aggregate's, and the lenient re-fetch moves the method correlation
  off it, so its Protocol entry rendered as a clean success the client had in
  fact refused. Marked with the captured id, as `salvageList` already does.

- Three double casts in the test fixture are gone: `ListPage` now models raw
  wire data (`items: unknown`, `nextCursor?: unknown`), which is what a server
  built to emit spec-forbidden shapes actually serves. Declaring the validated
  type and casting malformed values through it asserted the opposite of what
  these tests exist to check. The cursor map narrows to `string` at the one
  place a cursor addresses a page.

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-15 18:07:12 -04:00
cliffhall
1889e04135 fix: address Copilot round 2 on the list-salvage path (#1909)
All four findings taken (one visible, three in the suppressed block), plus the
`excludedTools` sibling leak the round-1 reply had deferred:

- `rawItemsOf` read a missing or non-array list member as an empty page, so a
  top-level violation (`{ tools: "invalid" }`) on a later page was discarded
  and the call returned a silently truncated list. It now returns `undefined`
  for "not a list at all", and the walk rethrows the original strict error.

- A failure of the re-walk ITSELF (a malformed `nextCursor`, a connection lost
  mid-walk) escaped in place of the original error, whose response is the one
  `rejectedResponseId` was captured for. The walk is now wrapped: the original
  surfaces, the secondary is logged so it stays diagnosable.

- The exclusion scan's lenient fallback discarded its malformed entries despite
  the comment claiming they were reported. Observable when the aggregate is
  served from cache: the salvage never runs, so nothing else reports them. The
  scan now returns them and `refreshExcludedTools` reports them — only ever
  adding, so a clean scan can't clear what the salvage path just wrote.

- An empty-string cursor is a valid cursor, and `cursor ? {cursor} : {}` dropped
  it, re-requesting page one and duplicating its entries until the
  repeated-cursor guard fired. Both walks now test `!== undefined`.

- `excludedTools` had the same session leak fixed for `malformedListItems` last
  round: cleared only on an explicit `disconnect()`, so the `onerror`-with-no-
  `onclose` route carried the previous server's set into the next connection.
  Cleared on the start-clean path too — safe because every `listAllTools`
  recomputes it, so this can only drop a stale set.

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-15 17:52:36 -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
54c6088114 fix: address Copilot review on the list-salvage path (#1909)
Three real defects it caught, plus the tests it asked for:

- The strict `tools/list` aggregate is filtered by the SDK for invalid
  `x-mcp-header` annotations (SEP-2243), but the salvage path was not: one
  ordinary malformed tool would trip the fallback and readmit every
  invalid-header tool beside it. Salvaged tools now go through the same scan.

- `markResponseRejected` resolves "the last response received for this method",
  and the re-walk answers that same method — so the mark landed on a salvage
  page that succeeded while the actually-invalid response rendered clean. The
  correlation id is now captured before the re-walk.

- The report was cleared only on an explicit `disconnect()`. The route out that
  tears down nothing (`onerror` with no `onclose`) carried it into the next
  connection, so it is now cleared on the start-clean `resetSessionState()` path
  as well.

Fixing the first exposed two more, both found by the new test:

- The modern (2026-07-28) codec validates a result against the spec schema
  before the caller's, so a lenient `client.request` is rejected exactly as the
  strict one was — there was no salvage on the modern leg at all. It now goes
  through the raw-wire channel that exists for the modern `tasks/*` methods.
  `withModernEnvelope` is split out of `withModernTaskEnvelope` so a list
  request doesn't claim the tasks extension the user may have disabled.

- A malformed tool also blanked the SEP-2243 excluded set, leaving a tool that
  vanished for an invalid header with no explanation — #1909's own complaint one
  level down. The scan walk now tolerates a malformed sibling.

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-15 17:30:25 -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
fe5b622fd1 fix: salvage a list result's valid entries when one is malformed (#1909)
The SDK validates a list result as a whole, so a single non-conforming entry
rejected the entire response and the panel showed "Couldn't load resources"
with zero items — the exact opposite of what a debugging tool should do with a
server that is 90% right. Reported against a PHP server whose empty
`annotations` object reaches the wire as `[]` rather than `{}`.

`listAll*` now falls back to a lenient re-walk when — and only when — the SDK
rejects a result it received (`InvalidResult` / `UnsupportedResultType`), keeps
the entries that validate, and reports the rest. A conforming server takes the
strict, cache-aware path exactly as before.

Salvaging is not accepting: the dropped entries are named with their reason in
a warning above the list, and the Protocol entry is still marked rejected, so
the wire truth survives. When every entry validates, the original error is
rethrown rather than swallowed — the rejection was about something the
per-item pass cannot explain.

`isClientDecodeRejection` moves to the new module and is now shared with
managedListState, which had the only copy.

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-15 16:52:11 -04:00
Cliff Hall
9e074aa6f2
Merge pull request #1946 from modelcontextprotocol/v2/fix/firefox-sse-stream-hang
fix: prime SSE streams so Firefox resolves the events fetch
2026-08-11 11:22:34 -04:00
Cliff Hall
75f4597281
Merge pull request #1967 from modelcontextprotocol/v2/fix/modern-listen-stream-opens-on-listchanged
fix: open the modern listen stream whenever the filter is non-empty (#1920)
2026-08-11 11:20:29 -04:00
cliffhall
2a4e93c621 fix: only announce the connection if it is still live after the stream open
Copilot review round 4: every await before the `connect` dispatch is a window
for a `disconnect()` or a transport close to overtake the connect, and the
listen round-trip widened it. Announcing anyway restarts every managed list
refresh against a session being torn down or already dead.

The dispatch is now gated on `status === "connected" && !disconnecting` — the
second because an explicit teardown claims ownership before it awaits
`client.close()`, leaving the status untouched until that block finishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-11 00:05:56 -04:00
cliffhall
b2dfd4099a docs: correct three comments the connect-ordering change invalidated
Copilot review round 3 (no new code findings, three stale-comment ones):

- `openModernListenStreamOnConnect`'s generation guard was justified by a
  `connect` listener racing it — which the previous commit made impossible by
  dispatching `connect` after this call. The guard is still right, for reasons
  that survive the reorder: `statusChange` has already fired, and a concurrent
  `subscribeToResource` or `disconnect()` (whose `resetSubscriptionStream` bumps
  the generation) can supersede it.
- `reconcileModernStreamStateAfterFailedRefresh`'s JSDoc still described its
  branches as "nothing subscribed" vs "URIs live"; they are the empty vs
  non-empty *filter* now, which differ exactly on the listChanged-only path.
- The superseded-failure test carried the same stale `connect` rationale.

Comments only; no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-10 23:56:24 -04:00
cliffhall
d08a53027a fix: dispatch the connect event after the listen stream is up
Copilot review round 2: opening the stream after `dispatchTypedEvent("connect")`
left a real gap. The managed list states start their initial `refresh()` from
that event, so `tools/list` could go out ahead of `subscriptions/listen` — and a
list the server changed in that window would notify nobody, leaving the UI
stale with no way to notice.

The handler registration, the initial logging level, and the stream open now all
run before the `connect` dispatch, so no consumer can act on the connection
until the notification channel is established (or its retry armed). Costs one
listen round-trip on a modern connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
2026-08-10 23:49:20 -04:00