Commit graph inspector/clients
Author SHA1 Message Date
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
935113d1e0 fix: encode per RFC 3986, and derive the TUI form from the shared parser
Addresses Copilot's round-4 review on #2035 — again reported as "no new
comments" while carrying three suppressed ones. All three reproduced.

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

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

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

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

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

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

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

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

Measured surface, and the fixes:

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

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

Closes #1959

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:57:32 -04:00
cliffhall
9aab945964 feat(web): custom headers on the manual server-config form (#1915)
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>
2026-08-16 20:57:02 -04:00
cliffhall
7a29a21618 test(web): drop the double cast from the CRLF SSE stream mocks
Both new #2006 regressions faked the events response with an object cast
`as unknown as Response`, which AGENTS.md rejects — it erases the check
that the mock actually satisfies the API the hook consumes.

Each now builds a real `ReadableStream` inside a real `Response`. `pull`
runs once per chunk the hook's reader consumes, so awaiting a promise
inside it gates delivery exactly the way the hand-rolled reader did,
while the mock stays type-checked against the Response contract. The
chunk counter replaces the read counter as the "the reader has consumed
the previous chunk" signal.

Both still fail against the pre-fix parser, verified.

Raised by Copilot on PR #2036.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:47:01 -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
cliffhall
1d29e4e71c Merge remote-tracking branch 'origin/v2/fix/1919-rfc6570-uri-template-expansion' into v2/fix/1919-rfc6570-uri-template-expansion 2026-08-16 19:35:48 -04:00
cliffhall
e7d1246c86 fix(core): scope requiredness to expressions, and encode + / # per RFC 6570
Addresses Copilot's round-3 review on #2035. It again reported "no new
comments" while carrying two suppressed ones; both reproduced.

1. Requiredness could not live on a variable at all. Deduplication kept the
   first occurrence's group, so in `x{?a}{?b}{a,b}` both names ended up
   required with singleton groups and the form demanded both -- while the
   SDK expands that template with only `a` to "x?a=11". Widening the stored
   group would not have been enough either: in `{a,b}{a,c}`, filling `b` and
   `c` satisfies both expressions, which no per-variable flag can express.

   So requiredness is now returned per expression by `requiredGroups`, and
   `hasRequiredValues` asks that each group be satisfied by any one of its
   names. `TemplateVariable.groupNames` is gone rather than left as a field
   that quietly means something narrower than it reads; `required` remains,
   documented as driving the "Optional" marker and nothing else.

2. `encodeURI` is not the allow-reserved encoder `+` and `#` call for. It
   escapes `[` and `]`, which are reserved and must survive, and it escapes
   `%`, so an already-encoded value is double-encoded. Measured:
   "[::1]" -> "%5B::1%5D" and "%2F" -> "%252F". Both corrupt the URI rather
   than merely over-escaping it -- an IPv6 literal or a pre-encoded path
   reaches the server altered, which is the same class of defect #1919 is
   about.

   Added an RFC 6570 3.2.1 encoder that preserves reserved characters and
   existing pct-triplets, splitting on `%XX` so a lone `%` is still encoded
   to `%25`, and matching with the `u` flag so an astral character is
   encoded whole. `+` and `#` expressions are now taken over even for a
   single name, so both expansion paths agree on what those operators mean.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 19:35:33 -04:00
Cliff Hall
539475ca6a
Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion 2026-08-16 19:31:24 -04:00
Cliff Hall
d0c38b6bf7
Merge branch 'v2/main' into v2/fix/1860-mrtr-results-panel 2026-08-16 19:21:37 -04:00
cliffhall
25c3ad272e fix(core): support ; and :length, and scope requiredness to the expression
Addresses Copilot's round-2 review on #2035. It reported "no new comments"
but carried three *suppressed* ones; all three reproduced against the
pinned SDK.

1. The `;` path-parameter operator is absent from the SDK's operator list,
   so `{;id}` parsed as a variable literally named ";id" and expanded to
   nothing. Added the operator and its named expansion (`;a=1;b=2`).

2. An RFC 6570 prefix modifier was folded into the variable name: `{id:3}`
   yielded a variable called "id:3" and expanded to nothing. Varspecs are
   now parsed properly and the value truncated before encoding. Truncation
   is by code point, since `String.prototype.slice` counts UTF-16 units and
   would split an astral character into a lone surrogate.

   For both of these the wrong URI is the lesser problem: a form has to
   *name* the variables it asks the user to fill, so the panel was
   rendering fields labelled `;id` and `id:3` that nobody could use.

3. Requiredness was applied per variable, but it is a property of the
   *expression*: RFC 6570 drops undefined names from a multi-name
   expression, so `{a,b}` with only `a` filled expands to `a`'s value --
   the SDK does this too. The panel was refusing input the expander would
   have accepted. `hasRequiredValues` now encodes "any one name in a
   required group suffices", and such a field reads "Any one of: a, b"
   rather than falsely claiming each is mandatory.

Two structural consequences, each pinned by a test:

- Takeover is now per TEMPLATE rather than per expression. Splicing
  corrected fragments into a template the SDK re-expands would leave its
  cross-expression `?`-to-`&` rewrite blind to the fragments already
  resolved.

- Expansion is split into a strict variant that throws and a lenient one
  that returns the raw template. `readResourceFromTemplate` wraps the
  thrown error with the template name -- three pre-existing integration
  tests assert that -- while the form must not throw on a server-supplied
  template, since that would take out the panel on render. The strict
  variant constructs the SDK template unconditionally, because that
  construction is what validates syntax: otherwise `x://{;a}{b,c` would
  take the own-expansion path and its unclosed tail would pass as literal
  text with nothing objecting.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 19:17:05 -04:00
Cliff Hall
7cd8d3e4c7
Merge branch 'v2/main' into v2/fix/1860-mrtr-results-panel 2026-08-16 19:13:26 -04:00
cliffhall
409dce37be fix(core): share URI-template expansion and correct the SDK's multi-name branch
Addresses Copilot's review on #2035. All three findings reproduced against
the pinned SDK before acting.

1. `#` was misclassified as required. Measured: `x://a{#frag}` with no
   `frag` expands to exactly `x://a`, a well-formed URI naming a real
   resource -- unlike `{+path}` (`x://a/`) or a simple `{userId}`
   (`file:///users//profile`), which leave an empty path segment. Moved `#`
   into the omittable set; the required cases now assert what the URI
   *becomes* when blank, so the rule is checked rather than asserted.

2. Delegating to the SDK did not actually give RFC 6570 expansion.
   `UriTemplate.expandPart` takes an early `names.length > 1` branch that
   raw-joins values, skipping both `encodeValue` and the operator prefix:
   `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` -- the very
   unencoded-slash defect this PR is about -- and `x://a{/p,q}` to
   `x://ax y,z`. Only `?`/`&` are correct, being dispatched earlier.

   Fixing that in the web client alone would have left the TUI and CLI
   wrong, since `readResourceFromTemplate` expands through the same class.
   So parse/classify/expand now live in `core/mcp/uriTemplate.ts` and both
   call sites use it. The correction is surgical: a multi-name non-query
   expression is expanded here and spliced in as literal text before the
   SDK sees the template (safe -- both encoders escape `{`/`}`), while
   every single-name and query expression still goes through the SDK
   untouched. The preview applies the same correction, so it cannot
   promise a URI that submitting would not send.

3. The showcase promised a blank `{?topic}` read that did not work.
   `UriTemplate.match()` compiles `{?topic}` to a *required*
   `\?topic=([^&]+)`, so `match("foobar://events")` returns null and the
   read 404s. A real server exposes the unfiltered collection as its own
   resource; the showcase now registers `foobar://events` so the
   documented step resolves. Verified end to end through the CLI.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 18:51:12 -04:00
cliffhall
f5757c0f95 test(integration): drive the mrtr_empty reproduction through the showcase config
Copilot review: the unit test covered the panel's rendering, but nothing
executed `mrtr_empty`, so the handler, its preset-registry branch, and the
showcase config entry could regress with CI green.

Adds a live modern-era test that loads `mrtr-showcase-http.json` through
`loadConfig`/`resolveConfig` — the same artifacts the README's manual
reproduction uses, so the config entry and the registry branch are covered
rather than just the fixture factory — answers the embedded elicitation, and
asserts the terminal result really is an empty `CallToolResult`: that is the
input the Tools panel must render as "completed, returned nothing".

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 18:41:36 -04:00
cliffhall
83658db2dd fix(web): expand resource templates per RFC 6570
The Resources screen discovered and substituted template variables with a
bare `/\{(\w+)\}/g` regex, which is wrong two ways: it cannot see an
expression carrying an operator, so `foobar://events{?topic}` rendered no
input at all; and it splices the raw value in, so a `/`, `?`, `#`, `%`,
space or non-ASCII character in a simple `{topic}` landed unencoded and
changed the URI's structure -- `foo/bar` produced an extra path segment
that a conforming matcher rejects with `-32602 Resource not found`.

Delegate expansion to the SDK's `UriTemplate`, the same implementation
`InspectorClient.readResourceFromTemplate` (and so the TUI) already
expands through, so the two clients cannot disagree about what a template
means. The new `utils/uriTemplate` supplies only what that class does
not: which variables to render an input for, which of them a read cannot
proceed without, and a partially-expanded preview.

Required-ness follows the operator. Under `?`, `&`, `.` or `/` the whole
expression is omitted when the variable is undefined, so those fields are
marked Optional and reading with them blank is a legitimate request for
the unfiltered resource; under `""`, `+` or `#` the variable sits mid-URI,
so it stays required. Blank fields are dropped before expanding so an
untouched optional field reads as undefined rather than as the empty
string, which would expand to a valueless `?topic=`.

Adds the `rfc6570_templates` preset and a `rfc6570-templates-http.json`
showcase server serving the two templates from the issue.

Closes #1919

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 18:31:35 -04:00
cliffhall
b3aedf62cd fix(web): name an empty tool result instead of "No results yet" (#1860)
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>
2026-08-16 18:31:01 -04:00
cliffhall
0b54cdc4e1 test(web): use the object shape for the legacy tasks capability
`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>
2026-08-16 18:25:49 -04:00
cliffhall
73913ef09f fix(web): read modern tasks support from the extension map
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>
2026-08-16 18:11:29 -04:00
Cliff Hall
138e07fab2
Merge branch 'v2/main' into v2/fix/1941-cli-vitest-teardown-crash 2026-08-16 17:43:24 -04:00
Cliff Hall
2f0aa24726
Merge branch 'v2/main' into v2/fix/1942-tui-coverage-flake 2026-08-16 17:40:11 -04:00
cliffhall
ae0e9d53a3 test(tui): settle input handlers at the keypress sites too, and pin the contract
Extends the previous commit's fix rather than changing it. `waitUntil` now
settles, but the same window is open wherever a key is written without having
just polled: `press()` reaches its `stdin.write` on a turn that may still have
React's passive-effect flush queued (straight after a plain `tick`, or after an
earlier key committed a render), and `mount()`'s absorbing keypress lands one
tick after the mount commit. Both now `await settleInputHandlers()` before
writing, so "frame visible" implies "input handlers armed" at every dispatch
point, not only after a frame wait.

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

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 12:15:41 -04:00
cliffhall
a945fb71c6 docs: correct how a null reaches the number field
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>
2026-08-16 01:08:40 -04:00
cliffhall
27fe15976f fix(web): admit null in the number field's value and default prop types
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>
2026-08-16 01:00:48 -04:00
cliffhall
d3b70a6329 fix(web): keep an explicit null out of the number field's default fallback
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>
2026-08-16 00:53:16 -04:00
cliffhall
c6edc13a9f docs: mark the JSON half of #2026 latent, not reproducible today
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>
2026-08-16 00:44:53 -04:00
cliffhall
a24413af6f fix(web): stop a schema default from reverting the edit in progress (#2026)
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>
2026-08-16 00:27:56 -04:00
cliffhall
325ac536cd test(web): resolve the null defaults from the schema, not a seeded value
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>
2026-08-15 23:59:31 -04:00
cliffhall
4676af4e39 test(web): pin the JSON editor's reported escaping reproductions
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>
2026-08-15 23:51:21 -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
298510c793
Merge pull request #2023 from modelcontextprotocol/v2/feat/2019-id-token-viewer
feat: surface and decode the OAuth id_token in Connection Info
2026-08-15 23:03:30 -04:00
Cliff Hall
6fdc12424f
Merge branch 'v2/main' into v2/fix/1941-cli-vitest-teardown-crash 2026-08-15 22:50:58 -04:00
Cliff Hall
ccb0b9b443
Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items 2026-08-15 22:40:48 -04:00
Cliff Hall
c28165fd48
Merge branch 'v2/main' into v2/feat/2019-id-token-viewer 2026-08-15 22:40:35 -04:00
Cliff Hall
55dbac5e95
Merge branch 'v2/main' into v2/feat/2018-custom-authorization-params 2026-08-15 22:40:26 -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
97b25da026
Merge branch 'v2/main' into v2/feat/2019-id-token-viewer 2026-08-15 22:27:53 -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
f36278538e fix: extract the param row constant and cover the manager bridge (#2018)
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>
2026-08-15 21:42:32 -04:00
cliffhall
55734f3776 fix: accessible names for the authorization-parameter rows (#2018)
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>
2026-08-15 21:28:59 -04:00
cliffhall
6002c2ce33 fix: gate submission on unsendable field text, and on required tool args
A field that cannot turn its text into a value reports `undefined` — which is
exactly what an empty field reports. That made the two indistinguishable to
every caller of `SchemaForm`, so invalid text in an optional field was
submittable and the argument simply arrived absent (#2020).

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

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

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 21:19:04 -04:00
cliffhall
362e2f419d test: build a real OAuthStorage in the EMA leak test (#2018)
Copilot round 1: the new EMA regression test constructed its inner provider's
storage with `as unknown as OAuthStorage`, an unjustified double cast — the
rule in AGENTS.md is that such a cast must carry a justification or be replaced.

Replaced with the genuine article: `OAuthStorageBase` over an
`OAuthMemoryStore` and a no-op persist backend, so the stub type-checks against
the real interface the provider is handed and a future `OAuthStorage` contract
change cannot pass silently. Reusing `emaConfig.storage`, as suggested, would
not have addressed the objection — that value is itself a cast, and typed as
`EmaFlowConfig["storage"]` rather than `OAuthStorage`.

The test's assertion is unchanged and was re-verified to fail against pre-fix
code (`expected 'corp-idp' to be null`).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 21:14:21 -04:00
cliffhall
3a22d4eedc fix: disambiguate the two token rows' controls for screen readers (#2019)
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>
2026-08-15 21:13:11 -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
abaa39e4a2 Merge remote-tracking branch 'origin/v2/main' into v2/feat/2019-id-token-viewer 2026-08-15 20:45:03 -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