Server Settings already has a Custom Headers editor, so adding one to the
manual add form created a second place to edit the same field. The reporter
had also conditionally withdrawn #1915 — "editing that information from the
server settings dialog is sufficient" once #1848 closed, which it has.
Reverts the add-form editor, the shared KeyValueRows element, the optional
`settings` argument on addServer / updateServer, and the App-level merge
helper. What remains are two defects found while working in
ServerSettingsForm, which this PR now closes instead (#2040): the Custom
Headers hint misstating OAuth precedence, and the key/value rows' missing
accessible names.
Signed-off-by: cliffhall <cliff@futurescale.com>
Round-4 review follow-ups on #2038.
The Custom Headers hint claimed a custom `Authorization` value is "ignored"
once OAuth is configured. Verified against the installed
@modelcontextprotocol/client: both SSE and streamable-HTTP `_commonHeaders()`
set the bearer first and then spread `requestInit.headers`, so a custom
value *overrides* the OAuth token. The hint told users the opposite of what
would happen, and could have them silently break their own auth. Corrected in
both places — the modal's new hint and the pre-existing one in
ServerSettingsForm, which carried the same wrong claim.
`utils/serverSettingsPatch.ts` imported both its shared types from
components, reversing the one-way `components -> utils` direction AGENTS.md
sets. `KeyValuePair` moves to `utils/keyValuePairs.ts` (KeyValueRows
re-exports it), and the mode is spelled as a literal union — still checked,
since `ServerConfigModalMode` must stay assignable to it at the call site.
Signed-off-by: cliffhall <cliff@futurescale.com>
Round-3 review follow-ups on #2038.
`buildHeaderSettingsPatch` treated the mere presence of stored headers as a
change, so saving an id or URL edit re-sent the modal's settings snapshot —
taken when it opened — and could overwrite a metadata or OAuth change made
in the settings form since. It now compares the submitted headers against
the stored ones and omits `settings` when they match, which is what makes
the PUT route preserve the node. Order counts as a difference: it is what
the form round-trips and what the user sees.
Row labels carried the key alone, so two rows sharing a key — mid-edit, or a
duplicate a server persisted — gave both rows' controls identical accessible
names, the thing the labelling exists to prevent. The row number is now
always part of the label rather than a blank-key fallback.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
Round-2 review follow-ups on #2038, all three from the suppressed set.
The header rows stayed editable while a save was in flight, unlike the rest
of the modal — a slow request let a user edit a row whose value had already
been captured, and the modal then closed having persisted the earlier one.
`KeyValueRows` takes a `disabled` prop and the modal passes `submitting`.
Each row's clear buttons were still named the bare "Clear", so a screen
reader saw six indistinguishable buttons across three rows. They now name
the field they empty ("Clear header name, Cookie").
The App-level headers-into-settings merge — the seam that distinguishes an
edit from a clone, and where the credential copy lived — moves into
`utils/serverSettingsPatch.ts` with its own tests. App.tsx is outside the
coverage gate, so inline it could not be covered at all.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
Adding a server by hand offered no way to set custom HTTP headers, so the
only route to a cookie or a routing header was importing a config file.
The manual "Add server" form now carries a Custom Headers key/value editor
for the sse / streamable-http transports, pre-populated when editing or
cloning and submitted alongside the config.
Headers are not part of `MCPServerConfig` — they live on the entry's
`settings` — so they travel as `onSubmit`'s third argument rather than
folded into the config, and `addServer` / `updateServer` gained an optional
`settings` parameter to carry them to the backend (`POST /api/servers`
already accepted one). `updateServer`'s omission semantics are unchanged:
with no settings passed, the route still preserves the node on disk.
`KeyValueRows` moves out of ServerSettingsForm into a shared element so the
two editors cannot drift, and each row's controls gain a row-scoped
`aria-label` ("header value, Cookie", "Remove header, Cookie") — previously
every remove button announced only "X", indistinguishable across rows.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
The Tools screen mounts the Results panel only once a call has produced a
result, but the panel's zero-content branch rendered "No results yet" — the
same wording it would use before anything had run. An empty `content` array
with no `structuredContent` is a legal `CallToolResult`, so a call that had
plainly completed reported itself as a call that never happened.
That reads worst after a multi-round-trip (MRTR) sequence, which is how it was
reported: the user answers one or more embedded elicitations, watches the
Protocol tab group the exchange and mark it COMPLETE, and is then told there
are no results yet. Nothing was lost — the result was stored and rendered; the
panel simply had no wording for "completed, returned nothing".
The panel now renders an "Empty result" alert saying the call completed and
returned no content. This is the remaining half of the same gap #1908 closed
for `structuredContent`-only results.
test-servers: new `mrtr_empty` preset (`createMrtrEmptyResultTool`) — one
elicitation round, then a complete result with `content: []` and no
`structuredContent` — added to `mrtr-showcase-http.json` so the shape is
reproducible by hand. README documents it.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
`ServerTasksCapability`'s `list`/`cancel` are objects, not booleans, so
the legacy-tasks fixture failed `tsc -b` in CI. A stale incremental
build cache hid it locally.
Signed-off-by: cliffhall <cliff@futurescale.com>
SEP-2663 moved task support from `capabilities.tasks` to the negotiated
`capabilities.extensions["io.modelcontextprotocol/tasks"]`, but the
Connection Info modal's capability grid tested only for the top-level
key. A modern server that advertised the extension in `server/discover`
therefore showed "Tasks ✗" while the very same extension id was listed
under "Server Extensions" two sections below.
Server capability rows now fall back to an extension id for the
capabilities the modern era expresses that way (tasks is the only one
today), gated on the negotiated era so a legacy connection is still
judged purely on its `initialize` capabilities. Keyed the same way
`InspectorClient.isTasksExtensionNegotiated()` gates the Tasks tab, so
the checkmark and the tab can't disagree.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review: the comments offered "a nullable number the user cleared" as a
way to get an explicit `null` into this field. Clearing the box reports
`undefined` — `toNumericValue("")` says so, and an existing test pins it — so
the only way `null` arrives is from parent state: a value received from the
server, restored from a deep link, or written by a caller for a nullable
schema. Say that in all three places instead.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review: a nullable number schema really does produce `null` for both
the raw value and the schema default, but the props declared only
`number | undefined` and the call site cast the null away. The runtime already
handled it — the initializer's `?? ""` exists for exactly that — so the types
were the part that was wrong, and a cast hiding it is how a future null bug
goes unseen by the compiler.
Widen both to `number | null | undefined` and drop the narrowing casts, keeping
the distinction the initializer encodes: `undefined` takes the default, `null`
is a value and renders as an empty box.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review: seeding with `value ?? defaultValue` also substitutes the
default when the raw value is an explicit `null` — a nullable number field the
user cleared, or one the server sent — where `resolveValue` previously kept the
null and the box rendered empty. A nullable schema with a non-null default
would have opened showing that default over a real value.
Test the `undefined` case explicitly, as `SchemaJsonField` already does, and
pin it: the new test fails with `'30'` where `''` belongs.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review: the code and test comments still said a defaulted array or
object argument *was* uneditable. It would be, but not through today's seeding
path — `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the
substitution returns the same reference and the re-sync never fires. It needs a
structurally-equal but distinct value object, which parsed wire/deep-link
values and rebuilt nested-object defaults produce, and which these tests
construct. The number field has no such protection and did revert in the app.
State the boundary in all three places, and stop claiming the distinct-object
seed is how the Tools tab seeds the field, which it is not.
Signed-off-by: cliffhall <cliff@futurescale.com>
A field whose schema declares a `default` could not be edited. Unsendable
draft text reports `undefined` by design — an inspector must not report a value
it cannot send — and `resolveValue` turned that straight back into the default.
That is a real change from the previous value, so the draft/value re-sync
rewrote the box to the default and the keystroke was lost. Almost every edit
passes through an unsendable state, which left a defaulted array or object
argument uneditable in the JSON editor, and a defaulted number field unable to
be emptied.
Hand the two draft-holding fields the raw value and the schema default
separately, and compare only against the raw one. The default now seeds what
the field opens with, which is what a `default` means, rather than being
re-imposed while the user types. Every other field keeps resolving through
`resolveValue` unchanged.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review: seeding `{ b: null }` / `{ cfg: null }` meant the schema's
`default: null` was never resolved, so a regression that dropped null defaults
would have left both #1885 tests green while claiming to pin that path. Render
the harness unseeded instead, which is also how the reporter's form arrived.
Signed-off-by: cliffhall <cliff@futurescale.com>
Three issues report one defect: the schema form's JSON fallback re-escaping
the text being typed. #1853 saw it while typing an array, #1856 on the
Backspace that first makes a valid array invalid, and #1885 on a nullable
parameter whose default `null` had to be edited in place.
The mechanism — unparseable draft text stored back as the field's value, which
the next controlled render re-JSON.stringify'd — was removed by the draft/value
split in SchemaJsonField and the nullable-union collapse (#1928, #2007, #2020).
Those fixes were made for differently-framed issues, so nothing pinned the
flows these three reports actually describe.
Verified against the pre-fix commit: four of the five new tests fail there with
the reported corruption, including '"\"\"a"' for the caret-outside-the-quotes
case and the compounding backslash cascade for the typed array.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
Copilot round 3 (both findings suppressed; both valid).
- The a11y fix in the previous commit added `align="flex-start"` to the row's
`Group`, giving it two static layout props and so tripping the .withProps()
rule in AGENTS.md — which applies to a single-use element too. Extracted as
`AuthorizationParamRow`. (The sibling `KeyValueRows` Group carries one static
prop and stays inline, correctly.)
- Added OAuthManager coverage for the config→provider bridge. The point is
sharp: the provider test proves the merge and the runner test proves the
options object, but nothing crossed the seam in `createOAuthProvider`, so
deleting the `authorizationParams:` line left the feature dead with every
test green. Verified by doing exactly that — both new cases now fail with
`expected null to be 'corp-idp'` — then restoring it. The second case also
pins the reserved-key drop through the manager path.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 2 (suppressed comments). Five of the seven findings were the
accessibility of the new row list and one stray cast; all are addressed here.
- The reserved-key reason is now the key input's `error` **string** rather than
a bare boolean plus free-standing text. Mantine renders it in the input's own
error slot and wires the `aria-describedby` association, so a screen reader
learns *why* the field is invalid; a boolean only set `aria-invalid`. This
also deletes the duplicate `ReservedParamError` text and its Stack wrapper.
- Each control carries a per-row `aria-label` — the section heading and the
placeholders are not programmatically associated with the inputs, so the key
box, the value box, and a remove button announcing only "X" were
indistinguishable to assistive technology. The label names the row by its key,
falling back to "row N" while the key is still blank.
- Dropped an unnecessary `as unknown as Record<string, unknown>` in the
servers-route integration test: `MCPConfig.mcpServers` is already typed as
`Record<string, StoredMCPServer>`, so the entry's `oauth` reads directly.
Three tests cover the new behavior (accessible names, the blank-key row-number
fallback, and the aria-describedby association), each verified to fail against
the pre-fix component. No change to the reserved-key rule or the EMA boundary.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
Copilot review round 1. With an ID Token row alongside the Access Token
row, both can render a "Decode JWT" toggle and a "Copy" control, and the
four buttons shared two accessible names — button-by-button screen-reader
navigation could not tell which token a control acted on. That collision
is introduced by this PR: before it there was only ever one token row.
- `CopyButton` takes an optional `label` naming what is being copied,
used for the accessible name only ("Copy ID Token"). The visible
tooltip stays "Copy", and the name keeps "Copy"/"Copied" as its first
word so it still contains the visible text (WCAG 2.5.3 Label in Name).
- `OAuthTokenField` qualifies its decode toggle the same way ("Decode JWT
for Access Token") and passes its row label down to `CopyButton`.
The visible UI is unchanged — this is accessible-name only.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
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>
When an authorization server returns an `id_token` alongside the access
token, the Inspector already persisted it and never showed it. Wire it
through to Connection Info as a viewer.
- Generalize `OAuthAccessTokenField` into `OAuthTokenField` (label + token),
so the ID Token row reuses the existing copy control and "Decode JWT"
toggle rather than duplicating them. Access-token behavior is unchanged.
- `oauthDetailsFromConnectionState` now surfaces `tokens.id_token` as
`idToken`, omitted entirely when absent so no empty row renders.
- Docs: update the two `specification/` references to the renamed field.
This is deliberately a viewer, not a flow: no OIDC login mode, no signature
verification, and the `id_token` is never treated as a credential. The EMA
path is untouched. `id_token` is already in `maskSecrets`'
`JSON_SENSITIVE_KEYS`, so it stays masked in the Network tab — no gap.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
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>
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>
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>
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>
Both sides added a showcase config row and section after the pagination
entry; kept both, structured output first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3