Commit graph inspector/core
Author SHA1 Message Date
cliffhall
4e149719bd fix(auth): route clearClientInformation through clearAfterHydration too
Round-2 @claude review of #1592 caught a seventh member of the clear
family that the first pass missed: clearClientInformation still wrote to
the store synchronously, carrying the same pre-hydration clobber risk the
clearAfterHydration helper was written for (a clear before hydration lands
persists a near-empty blob, overwriting the whole on-disk oauth.json).
Wrap its mutation the same way and add a clear-before-hydration test
proving a sibling token (and thus every other server's blob) survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 19:26:25 -04:00
cliffhall
8c10b8eb8e fix(auth): make clear* hydration-safe, migrate raw-key blobs, doc keepalive/memo caveats
Address round-1 @claude review of #1592:

- OAuthStorageBase.clear* no longer mutate the pre-hydration (empty) store —
  a clear issued before hydration is deferred until it lands, so it merges
  onto the real persisted state instead of (a) being resurrected by the late
  rehydrate merge or (b) persisting a near-empty blob that clobbers every
  other server's on-disk credential. Already-hydrated clears stay synchronous.
- store.setServerState migrates a pre-normalization raw-key blob onto the
  canonical key on first partial write (was: shadowed it with a fresh
  canonical entry, orphaning the raw blob's other fields). clearServerState
  now also drops the raw-key orphan.
- Document the keepalive 64KB combined-body ceiling on the remote-storage
  write, the getRemoteOAuthStorage memo key intentionally omitting fetchFn,
  and the real ordering invariant behind the sync getScope.
- Tests for the clear-before-hydration and raw-key-migration paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 19:20:49 -04:00
cliffhall
c299b6e9d8 feat(web): migrate OAuth store to shared /store API (RemoteOAuthStorage parity) (closes #1548)
Migrate the web client's OAuth storage from BrowserOAuthStorage
(sessionStorage) to RemoteOAuthStorage, which POSTs through the backend's
/api/storage/oauth route to ~/.mcp-inspector/storage/oauth.json (mode 0600).
A credential obtained in the browser is now the same blob the TUI/CLI read
on the same host, giving web ⇄ TUI ⇄ CLI parity (Wave 1 of #1579).

Core (shared with TUI/CLI):
- OAuthStorageBase now drives a single async hydration (`ready()`,
  `getHydrationError()`); every post-redirect read (getClientInformation,
  getTokens, getCodeVerifier, getServerMetadata, getIdpSession) and every
  save awaits it so a late hydration merge cannot clobber a fresh write.
  getCodeVerifier/getServerMetadata become async; the store is created with
  skipHydration:true so there is no auto-hydration to race the explicit one.
- store.ts adds normalizeServerUrl so a token saved under
  https://Example.com/mcp is found when the CLI asks for
  https://example.com/mcp/; getServerState falls back to the raw key.
- remote-storage adapter POSTs with keepalive so the write survives the
  OAuth authorize redirect, surfaces swallowed persist failures, and gives
  richer read/write error messages.
- generateOAuthState throws instead of silently degrading to Math.random.
- NodeOAuthStorage honors MCP_INSPECTOR_OAUTH_STATE_PATH for isolated
  fixtures. Provider codeVerifier()/getServerMetadata() + the EMA idpOidc and
  connection-state readers await the now-async storage.

Web:
- environmentFactory + a new shared lib/remoteOAuthStorage accessor
  (memoized per {baseUrl, authToken}) wire RemoteOAuthStorage into the
  connection path, the EMA IdP hook, and the per-server "clear OAuth" action
  so all three share one in-memory view of oauth.json.

Tests: async-hydration + normalizeServerUrl suite, keepalive/error-path
coverage, and the sync→async migration across existing storage/provider/EMA
tests. Web validate + coverage gate + integration suite green; CLI/TUI/
launcher validate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:39:17 -04:00
Bob Dickinson
13dafe457b
Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)
* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:35:51 -04:00
Cliff Hall
4a927d3978
fix(web): defer issuer validation to onBlur, block silent-drop on close, require a real host URL (closes #1544) (#1545)
* fix(web): defer IdP issuer URL error to onBlur in Client Settings (#1544)

The Enterprise-Managed Authorization Issuer field validated its URL inline on
every render, so the 'Must be an http(s) URL' error nagged while the user was
still mid-typing (e.g. before 'https:/…' is complete).

Gate the issuer error display on an issuerTouched flag set onBlur; once
touched it tracks the value live, so the error clears as soon as a valid URL
is entered. The persist gate (canPersistClientSettingsDraft → validateClientSettings)
is unchanged and still validates independently, so an invalid issuer is never
written to the backend regardless of touched state.

Closes #1544

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): reveal issuer error and block close on invalid issuer (#1544)

Extend the on-blur deferral so an invalid issuer can't be silently dropped on
modal close (the auto-save flush would otherwise discard it with no feedback).

ClientSettingsModal now intercepts its close (X / Esc / overlay): if the issuer
is invalid it reveals the field error (via the form's revealIssuerError prop,
overriding on-blur gating) and keeps the modal open instead of closing. The
user can fix the URL or clear the field — an empty issuer is valid to leave.
The block lives in the modal (unit-tested) rather than App, so App.tsx stays a
thin flush+close that only runs when the close is allowed; the persist gate is
unchanged. Reveal state resets via the modal's open/close remount key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): require a real host in isAbsoluteHttpUrl, not just an http(s) scheme (#1544)

The issuer validation used URL.canParse + an http(s) protocol check, which the
WHATWG parser accepts for bare/degenerate values that aren't actually usable
URLs — e.g. https://foo, https://example, https://. , https://.. all passed.

Tighten the shared rule (used by both the Zod config schema and the form's
inline validation): after parsing and the http(s) scheme check, require the
host to actually look like a host — localhost, an IPv6 literal, or a dotted
domain/IPv4 with at least two non-empty labels. Single-label, bare-dot, and
empty-label hosts are now rejected. Add core tests for the accepted
(dotted/IPv4/IPv6/localhost) and newly-rejected cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:56:27 -04:00
Cliff Hall
1dc0c744cf
fix(web): validate IdP issuer URL inline in Client Settings (#1533) (#1534)
The Enterprise-Managed Authorization "Issuer" field accepted arbitrary
non-URL text and only failed at save time, surfacing a raw Zod issue array
in "Failed to save client settings" toasts.

- Inline field-level validation: the Issuer input shows a friendly error
  ("Must be an http(s) URL, like https://idp.example.com").
- canPersistClientSettingsDraft gates on issuer validity (deferring to
  validateClientSettings) so an invalid value is never sent to the backend.
- Extract isAbsoluteHttpUrl in core/client/config-parse so the Zod schema
  and the form share one http(s)-only URL rule.
- Harden the fallback save-error toast via formatClientConfigLoadError.

Closes #1533

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:08:22 -04:00
Cliff Hall
c96db0402e
test: raise per-file coverage gate to 90 across all clients (closes #1271) (#1532)
* test(web): audit branch-coverage outliers, raise per-file gate to 70 (#1271)

The web per-file coverage gate had drifted red: the recently-landed EMA
auth feature (core/auth/ema/*) and ClientSettings components shipped
undertested, and the prior 50% branch floor let business-logic branches
go untested. CI does not run the coverage gate, so this accumulated.

Add tests to bring every failing file above the gate and push branch
coverage well past 70 across the board, then raise the per-file branches
threshold 50 -> 70 in clients/web/vite.config.ts.

- EMA core (emaFlow, resourceContext, jwt, wire): 50-88% -> 86-100% branch
- EMA OIDC/transport (idpOidc, tokenEndpoint, transportProvider): -> 97-100%
- oauthManager / discovery: 73-83% -> 88-92%
- ClientSettingsModal/Form, ConnectionInfoContent: 0-88% -> 96-100%
- useClientSettingsDraft / useEmaIdpLoginState hooks: 20% -> 100%
- TaskStatusBadge / SortableServerCard outliers: 50% -> 100%

Coverable branches reached by mocking useComputedColorScheme (dark theme),
dnd-kit useSortable (isDragging), and fetch/OIDC paths. Remaining sub-100
branches are genuinely-unreachable defensive guards or happy-dom-inherent
(portal / useMediaQuery / typeof window) and documented in test comments.

No source files changed. Global web branch coverage 88.73% -> 90.63%.
AGENTS.md updated; cli/tui/launcher stay at 50 (out of scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: raise per-file coverage gate to 90/90/90/90 across all clients

Follows the #1271 web branch-coverage audit (50 -> 70) by raising the
whole per-file gate to >=90 on all four dimensions — lines, statements,
functions, branches — for web, cli, tui, and launcher (core/ is measured
under the web suite). A codebase-wide audit added real tests for every
outlier and brought global coverage up across the board.

Config: all four vitest/vite coverage configs set to 90/90/90/90.
Two pure-type modules (core/mcp/import/types.ts, clients/web/server/
types.ts) added to the web coverage exclude list, matching the existing
type-only exclusions.

Tests: ~50 test files extended/created across core (auth, ema, mcp,
remote, state, storage, react), web components/screens/server, and the
cli/tui clients. Highlights: full EMA OAuth flow + transport coverage,
inspectorClient/remote server backfill, every Mantine control's handler
branches, and the cli unsupported-method / argv-fallback paths.

Genuinely-unreachable branches are annotated at the source with justified
`/* v8 ignore -- <reason> */` comments rather than lowering the gate:
happy-dom-inherent paths (Mantine portal, useMediaQuery, typeof window
SSR guards), React StrictMode effect replay, and provably-dead defensive
guards (?? fallbacks the types make impossible, Select.onChange values
outside the allowed list). No runtime logic changed; the only structural
edits are two behavior-preserving refactors (ResourceTemplatePanel hoists
the field value to a local so the ignore applies in JSX; ToolsScreen
swaps a ternary for an if) and prettier line-wrapping.

Also fixed a flaky real-timer debounce assertion in PromptArgumentsForm
(asserted an exact sibling-typing interleaving that raced the 300ms
debounce; now asserts the load-bearing fact — a non-empty fire-time
sibling capture).

AGENTS.md updated to describe the uniform 90 gate and the v8-ignore
policy. All four client coverage gates, root validate, storybook (370),
and smoke pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): make connect-crash SSE reader stop on the terminal event

`readSseEvents` looped until the server closed the stream (`done`). The
server closes the events stream immediately after writing the terminal
`transport_error`, but under the heavy parallel scheduler pressure of a
full `coverage` run (v8 instrumentation across all workers) that close
can lag far enough behind the event to trip the 30s test timeout — a
hang, not a wrong result. This made the test intermittently fail under
`npm run coverage` (it passed in isolation and under CI's lighter
`test:integration`).

Stop reading as soon as the terminal `transport_error` event arrives
(cancelling the reader) instead of waiting only for `done`. That event is
always the last one the session queue emits, so nothing is lost, and the
test no longer depends on stream-close timing. The `done` break is kept
for any stream that closes first.

Verified green across two consecutive full `npm run test:coverage` runs
(3094 tests) where it previously hung, plus isolation (~0.5s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:26:03 -04:00
Bob Dickinson
5de4e911e3
feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)
Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
2026-06-24 22:15:12 -04:00
Cliff Hall
5ee347571f
feat(web,core): persist & edit STDIO cwd/env in Server Settings (#1515) (#1518)
* feat(web,core): edit STDIO cwd/env in Server Settings (#1515)

Persist and edit a STDIO server's working directory and environment
variables through the Server Settings modal.

- Add `env` (pair-array, mirrors headers) and `cwd?` to
  `InspectorServerSettings`.
- `storedFieldsToInspectorSettings` lifts the stdio config's `env`/`cwd`
  into settings so the modal can show them; they stay on `config` for the
  transport. Add `envRecordToPairs` / `envPairsToRecord` helpers.
- `env`/`cwd` remain config-owned on disk (the standard mcp.json location):
  the `/api/servers` PUT route writes an edited settings.env/cwd back onto
  the stored stdio config on a settings-only patch (supporting clear), and a
  config-providing PUT keeps config authoritative. `validateSettings`
  accepts `env`/`cwd`.
- Server Settings modal: Working Directory field in Options and a dedicated
  collapsible Environment Variables section — both STDIO-only.
- Registry server.json import sets no `cwd` (the schema has no
  working-directory concept); documented in serverJson.ts.

Tests: serverList converter round-trip, route write-through (set/clear,
config-precedence, non-stdio no-leak, validation), import preservation
(client-config cwd, registry cwd skip), and form/modal component coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): preserve config env/cwd when a settings PUT omits them (#1515 review)

Address Claude's review of #1518.

- `/api/servers` PUT now distinguishes an *absent* `env`/`cwd` (preserve the
  stored value) from an explicit empty list / blank string (clear). Previously
  `validateSettings` coerced a missing `env` to `[]`, so an env-unaware caller
  patching only a timeout would silently wipe the server's env/cwd. The
  integrated web client always resends the full GET-rehydrated env, so it was
  unaffected — this protects the raw HTTP contract.
- `validateSettings` reports `envProvided`/`cwdProvided`; the write-through only
  touches `config.env`/`cwd` for fields the caller actually sent.
- On the omit path, rehydrate the untouched env's keychain values onto the
  rebuilt entry so the secret reconcile re-persists them rather than sweeping
  the blanked-on-disk keys out of the keychain.
- Minor: guard the stdio cast in `mcpConfigToServerEntries` on the transport
  type instead of casting unconditionally.
- Test: settings-only PUT omitting env/cwd preserves config keys + cwd + the
  keychain secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:15:43 -04:00
Bob Dickinson
c60a128df3
Test/1396 harden managed state connect tests (#1508)
* test(state): wait for connect refresh before asserting list RPC gate (#1396)

Replace fixed double-microtask flushes in Managed*State connect-path tests with *Change event waits so the capability gate is asserted after refresh completes.

* test(state): fail fast with a readable message if a connect refresh never dispatches its change event (#1396)

The hardened connect-path gate tests now depend on the gated (capability-less /
empty-list) refresh still emitting its *Change event. A future optimization that
skipped that redundant dispatch would make these awaits hang to the vitest
per-test timeout with no clue why.

Extract the per-file waitFor*Change helpers into a shared, generically-typed
waitForChangeEvent() that races the listener against a short timeout and rejects
naming the event that never fired — turning a future hang into a fast, readable
failure. This also removes the duplicated listener/Promise boilerplate across
the five state test files. No production code changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(state): cover void-detail event resolution in waitForChangeEvent

Add a case asserting the helper still resolves for a signal-only (void-detail)
event — CustomEvent normalizes an absent detail to null, so the listener fires
and resolves rather than hanging. Closes the coverage gap noted in review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(core): correct TypedEventGeneric void-detail comment to null

CustomEvent normalizes an absent detail to null, so listeners receive null
(not undefined) for void events — as the new waitForChangeEvent void case
establishes. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:46:09 -04:00
Cliff Hall
97b1a7fbff
feat(web,core): import client configs and registry server.json (#1348) (#1511)
* feat(web,core): import client configs and registry server.json (#1348)

Wire up the two server-import affordances that were previously todoNoop:

- Import config: source picker (Claude Desktop / Cursor / Cline / VS Code)
  read on the backend via a new GET /api/import-source, plus a file-upload
  fallback. Per-server review with import/skip on new servers and
  overwrite/skip/rename on id conflicts, then a per-server outcome summary.
- Import server.json: parse the MCP registry single-server format
  (npm/pypi/oci packages + remotes, env vars, package/runtime args, URL
  template vars) into a runnable config, with a file picker and debounced
  live validation; Add Server is disabled until validation passes.

Core gains an isomorphic import strategy layer (core/mcp/import) with a
strategy registry, client-config + server.json parsers, a strategy-agnostic
merge, and a dependency-injected source resolver. useServers exposes
importSource(). Modal headers are bold and the redundant server.json section
header is folded into the modal title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): collapse the server.json textarea into a File Contents disclosure

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-collapse File Contents disclosure after content loads

Once content is pasted or a file is loaded, collapse the File Contents
disclosure after ~1s with Mantine's built-in accordion animation, signaling
the input was accepted and surfacing the validation/env-var sections. The
disclosure becomes controlled by the modal; clearing the textarea re-opens it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): flash File Contents hover highlight before auto-collapse

Before the File Contents disclosure auto-collapses, paint it with its hover
background for 250ms, then run the collapse over a 325ms animation, so the
collapse reads as intentional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): rename "Import server.json" to "Import from registry config"

Rename the Add Servers menu entry and the modal header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): rename "Import config" to "Import client config"

Rename the Add Servers menu entry and the modal header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web,core): move import orchestration into core helpers + web hooks

Make the import logic reusable by CLI/TUI and keep the modals display-only:

- core/mcp/import: add parseClientConfig (mcpServers→servers fallback),
  resolveServerId, selectServerJsonOption, and buildServerConfigForSelection,
  so the full import (parse → select → build → merge) is callable from any
  environment.
- core/storage: extract the pure validateStoreId into a Node-free store-id
  module (store-io re-exports it) so the isomorphic import layer can validate
  ids without pulling Node deps into the browser bundle.
- clients/web/hooks: useServerJsonImport and useImportClientConfig own all the
  wizard/parse/debounce/apply state; ServerImportConfigModal and
  ServerImportJsonModal are now thin display shells over the dumb panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): rename "Import client config" to "Import from client config"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): remove the Cancel button from the registry import modal

The modal header X (and Escape) still dismiss it; drop the redundant Cancel
button (and the panel onCancel prop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): hide validation results + name override until content is present

In the registry import modal, the Validation Results section and the Server
Name override field stay hidden until something has been pasted/loaded into the
textarea.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): make Validation Results a collapsible disclosure

Wrap the registry import modal's validation results in a Mantine accordion
disclosure (default open), matching the File Contents section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): Server Name disclosure with read-only derived name + override

Make Server Name a collapsible disclosure containing a read-only "From
configuration" field (the id derived from the server.json name) and the
editable Override field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): scroll to and highlight a freshly-added server

After a manual add/clone or an import (client config / registry), the new
server's card scrolls into view and draws an animated border (Mantine Border
Animate); clicking anywhere on the card dismisses it.

- App tracks highlightedServerId; addServerHighlighted sets it (edits and
  conflict-overwrites don't). Threaded through InspectorView -> ServerListScreen
  -> ServerCard.
- ServerCard scrolls into view on highlight and wraps in <BorderAnimate>;
  onClick clears the highlight.
- Pin @gfazioli/mantine-border-animate@2.0.3 (Mantine 8 compatible; 2.0.4+
  requires Mantine 9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): highlighted card fills its cell and its controls work on first click

- The BorderAnimate wrapper is display:flex, so the card was shrinking to
  content width while the border spanned the cell. Stretch the card with
  w=100% (and the wrapper).
- Always render BorderAnimate and toggle show/animate instead of conditionally
  wrapping: swapping the element type on clear remounted the card, swallowing
  the click that triggered it (e.g. the connect toggle fired clear but not
  connect, needing a second click). Verified: one click now connects + clears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web,core): address review — OCI env, untrack fixture, rename validation

- Untrack the stray inspector-v1-multi.server.json (accidentally committed) and
  gitignore root *.server.json fixtures.
- core/mcp/import/serverJson: forward declared env vars into OCI containers via
  '-e KEY' docker args (they previously only set the docker CLI process env);
  add a test. Clarify buildServerConfig (destructure env out instead of
  spread-then-delete) and document value-vs-default env handling.
- useImportClientConfig: validate 'rename' targets inline (invalid charset /
  collision with existing, additions, or other renames) and gate the Import
  button on canImport; surface the error on the field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): highlight all servers from a batch import, not just the last

Track highlighted servers as a set of ids instead of one. addServerHighlighted
accumulates each id (a multi-server client-config import highlights them all);
opening an add/import modal starts a fresh batch. Clicking a card clears only
that card's highlight; only the first highlighted card scrolls into view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:52:08 -04:00
Cliff Hall
e72d4307f3
feat(cli): lift config-file headers/timeouts/OAuth into serverSettings (#1482) (#1500)
* feat(cli): lift config-file headers/timeouts/OAuth into serverSettings (#1482)

The CLI resolved file-sourced servers via resolveServerConfigs(), which
returns bare MCPServerConfig and dropped disk-level headers, timeouts, and
OAuth — CLI connections from a --catalog/--config file silently ignored
them. The TUI already lifted these via mcpConfigToServerEntries().

Extract the TUI's catalog/config -> per-server-settings resolution into a
shared core module (core/mcp/node/servers.ts: loadServerEntries +
selectServerEntry) and route both CLI and TUI through it, so the two share
one file->settings path. --header still overrides the file's headers for
the run while preserving the file's timeouts/OAuth.

- core: add loadServerEntries/selectServerEntry/headersToServerSettings
- tui: tui-servers.ts re-exports the shared core helpers (no logic dup)
- cli: resolve via loadServerEntries + selectServerEntry; drop the
  CLI-only conflict check and bare-config path
- tests: core servers.test.ts (settings lifting, header merge/override,
  stdio overrides, seed/error/conflict, selection); CLI integration tests
  asserting a config header is sent over HTTP and --header overrides it
- docs: CLI README note on file settings being applied

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): address #1500 review — dedup overrides, drop dead wrapper, clearer error, spec refresh

- Remove the now-unused resolveLaunchServerConfigs() wrapper (both CLI and
  TUI route through loadServerEntries now); migrate its default-catalog
  launch tests to compose withDefaultCatalogPath + resolveServerConfigs.
- Deduplicate applyStdioOverrides against config.ts applyOverrides: export
  the latter (now trims cwd) and reuse it in servers.ts.
- selectServerEntry's unknown-name error is source-agnostic ("not found"
  not "not found in config file") since entries may be a single ad-hoc
  default, where "config file" would mislead.
- Refresh specification/ docs (v2_cli_tui_launcher.md,
  v2_catalog_launch_config.md): CLI/TUI resolution now via
  loadServerEntries/selectServerEntry; mark gap G1 resolved (#1482);
  withDefaultConfigPath → withDefaultCatalogPath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): extend smoke:cli to cover #1482 lift→serverSettings

Adds end-to-end smoke coverage for the config-file headers/timeouts/OAuth
lift introduced in #1482:

- unknown --server now errors source-agnostically ("not found")
- multi-server catalog selection via --server (and the omit-it error)
- --header merge does not break the stdio connect path
- HTTP transport: a config-file `headers` object is lifted onto the wire,
  and a CLI --header overrides it (verified against an in-process test
  server's recorded requests via a non-blocking spawn)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:33:21 -04:00
Cliff Hall
73b651ff21
feat(cli,tui): normalize --catalog / --config with web; update --help (#1347) (#1499)
* feat(cli,tui): normalize --catalog / --config with web; update --help (#1347)

Bring the CLI and TUI in line with the web catalog vs read-only session
model introduced in #1481/#1483, and close the outstanding --help item
from #1347.

Core (core/mcp/node/config.ts):
- Add `catalogPath` to ServerConfigOptions (writable catalog slot).
- `resolveServerSource()` picks the active source: `--catalog` (writable,
  seed-if-missing) wins over `--config` (read-only, error-if-missing).
- Writable sources seed an empty `{"mcpServers":{}}` on first run (matches
  the web backend) instead of erroring; read-only `--config` still errors.
- `serverSourceConflict()` shared conflict matrix (mirrors run-web):
  --catalog/--config mutually exclusive; neither combines with an ad-hoc
  target.
- `readServerListFile()` public loader for runners (TUI) needing the raw map.
- Rename `withDefaultConfigPath` → `withDefaultCatalogPath`; the no-flag
  default now fills the writable catalog slot, not --config.

CLI / TUI:
- Add `--catalog <path>` (+ MCP_CATALOG_PATH) and re-document `--config` as a
  read-only session in --help.
- Enforce the shared conflict matrix before resolution.

Full writable persistence/CRUD threading remains deferred to #1482 / #1432
(CLI/TUI are read consumers today; the split surfaces as seed- vs
error-on-missing).

Tests: core resolution matrix (source/conflict/seed/read-only), CLI catalog
+ conflict integration tests, TUI loadTuiServers unit tests. READMEs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(launcher): add smoke:cli + smoke:tui e2e launcher smokes (#1347)

The launcher smokes only checked `--help` for --cli/--tui; nothing
exercised the launcher → CLI/TUI → core path on the built artifact (only
smoke:web did, for web). Add two end-to-end smokes wired into
validate:launcher between smoke:launcher and smoke:web:

- smoke:cli (scripts/smoke-cli.mjs): runs `--cli` against the bundled
  stdio test server via a temp --catalog and asserts tools/list returns
  the tools, plus the #1347 resolution paths — default-catalog
  seed-on-missing, read-only --config error-without-seed, and the
  --catalog/--config conflict.
- smoke:tui (scripts/smoke-tui.mjs): launches `--tui --catalog <temp>`
  and asserts the Ink app renders its first frame ("MCP Servers") within
  a timeout, then SIGTERMs it (shallow boot/render check).

Both build test-servers/build on demand if missing. Docs updated
(AGENTS.md validate description, launcher README).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: address review on #1499 (comment accuracy, CLI/TUI flag parity)

Review feedback from @claude on PR #1499 (all non-blocking):

- Align CLI catalogPath resolution with the TUI: `options.catalog?.trim()
  || process.env.MCP_CATALOG_PATH` so an explicit empty `--catalog ""`
  falls back to MCP_CATALOG_PATH identically in both front-ends (CLI used
  `??`, which only guards undefined/null).
- Clarify the `serverSourceConflict` comment: it mirrors only the
  source-selection portion of web's matrix and deliberately omits web's
  `--header` rejection, since CLI/TUI merge --header into per-server
  settings.
- Note the deliberate --header broadcast semantics in loadTuiServers.
- Share an EMPTY_CATALOG_CONTENT constant between seedEmptyCatalog and
  readServerListContent instead of two separate literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): trim --config to match --catalog and the TUI (#1499 review)

Cosmetic uniformity follow-up from the re-review: configPath now uses
`options.config?.trim() || undefined`, matching the catalog slot above it
and the TUI. Behavior is unchanged (resolveServerSource/serverSourceConflict
already trim downstream).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:30:26 -04:00
Cliff Hall
91f02fd6db
feat(web): catalog vs read-only session launch model + apply --header (#1481, #1483) (#1496)
* feat(web): catalog vs read-only session launch model + apply --header (#1481, #1483)

Per specification/v2_catalog_launch_config.md, `mcp-inspector --web` now selects
the backend's server-list source and a session-wide writability, instead of
seeding an inert `initialMcpConfig` the UI never read:

- `--catalog <path>` (and `MCP_CATALOG_PATH`) → active WRITABLE catalog
  (seed/CRUD/watch), like the default catalog.
- `--config <path>` → READ-ONLY session file: served in the UI but never
  written, seeded, or plaintext-secret-migrated — safe to point at a foreign
  config (e.g. claude_desktop_config.json).
- ad-hoc `--server-url`/command (+ `--header`) → one server held IN MEMORY
  (no file written); `--header` is lifted onto the entry so the seeded
  connection actually uses it (closes the #1483 warn-only no-op).
- nothing → default catalog (writable), unchanged.

Backend (`createRemoteApp`): new `writable` + `initialServers` options. GET
/api/servers branches three ways (in-memory / read-only file with no
seed+no migrate / writable catalog); the four mutating routes return 403 when
read-only; `/api/config` carries `writable`; the file watcher is skipped for
in-memory sessions. `--catalog`/`--config` are mutually exclusive and neither
combines with an ad-hoc target or `--header`; `--header` requires an ad-hoc
HTTP/SSE server (clear errors otherwise — no silent no-op).

UI: `useServerListWritable` reads `writable` from /api/config; the Servers
screen hides add/edit/clone/remove/reorder/settings and shows a read-only
banner when false. All new props default to writable=true (back-compatible).

Docs: spec gaps G2/G3/G4 updated; launcher README documents the flags.

Verified end-to-end against the built launcher: --config (writable:false,
file byte-stable, mutations 403), ad-hoc --header (in-memory, header lifted),
and --catalog (writable:true, POST persists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): address review — Mantine <Code> in read-only banner, clarify watcher comment

- Replace raw <code> with Mantine <Code> in the read-only-session Alert
  (repo convention: prefer Mantine primitives over raw HTML).
- Add a comment near `watchable` clarifying that a read-only `--config` file
  is still watched on purpose (read-only blocks UI writes, not external
  observation; `writable` does not gate watching).

Deferred (per reviewer): consolidating useSandboxUrl + useServerListWritable
into one useConfig hook to avoid the duplicate /api/config fetch — optional
follow-up. The redundant ad-hoc header type-guard is kept as defensive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:24:58 -04:00
Cliff Hall
dc31c28102
fix(core): make "error" the canonical terminal status on a mid-session crash (#1490) (#1493)
* fix(core): make "error" the canonical terminal status on a mid-session crash (#1490)

On a real mid-session transport crash many SDK transports fire BOTH
`onclose` and `onerror` in a transport-dependent order. The `onclose`
guard (`if status !== "disconnected"`) let a trailing `onclose`
downgrade a crash's `"error"` status back to `"disconnected"`, so the
persistent terminal status differed by ordering:

- onerror-then-onclose → "disconnected"
- onclose-then-onerror → "error"

`onclose` now treats `"error"` as terminal (only `"connected"`/
`"connecting"` → `"disconnected"`), so `"error"` is the canonical resting
status in both orderings. It still emits the `disconnect` event exactly
once so session-teardown consumers fire identically either way.

Because the state managers gated list/message/subscription/task teardown
on `getStatus() === "disconnected"`, holding the status at `"error"`
would have left them stale in the onerror-first ordering — swapping one
ordering-dependence for another. Introduce `isTerminalStatus()`
(disconnected || error) and key every teardown guard off it so they
clear identically regardless of crash ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): single disconnect event when explicitly disconnecting from "error" (#1490 re-review)

Addresses the non-blocking observation from the PR review: explicitly
calling disconnect() from a held-"error" status could fire the
`disconnect` event twice. close() can trigger the transport's onclose
synchronously; onclose (holding status at "error") fired `disconnect`,
then disconnect()'s own guard saw status still != "disconnected" and
fired it again — a timing-dependence on whether the SDK closes sync.

Add a `disconnecting` ownership flag: onclose defers its status set +
`disconnect` event to disconnect() while it is set, so the
explicit-disconnect path emits exactly one `disconnect` regardless of
sync/async close. Crash-path onclose (flag unset) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(core): deterministically pin the sync-close disconnect double-fire (#1490 re-review)

The real-stdio test asserts the single-disconnect invariant but may not
exercise the sync-close branch (the one that double-fired) if the SDK
stdio transport closes asynchronously. Add a test that overrides the SDK
client's close() to invoke the transport's onclose synchronously,
forcing the exact timing the `disconnecting` flag guards. Verified it
asserts 2 (fails) without the flag and 1 (passes) with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:15:29 -04:00
Cliff Hall
b4716a1405
feat(core): surface mid-session transport failures via lastError (#1489)
Closes #1323. Adds an `error`-event-driven `lastError` on useInspectorClient and a mid-session failure toast in App.tsx; the transport onerror is scoped to non-handshake (status !== connecting) so handshake failures stay on the connect() rejection path. Follow-up: #1490.
2026-06-15 08:46:59 -04:00
Bob Dickinson
2488ca5e38
feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452)
Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).

Closes #1246.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:27:49 -04:00
Cliff Hall
53dc4549be
fix(core): trace dropped fetch body update when entry rotates out (#1390) (#1480)
* fix(core): trace dropped fetch body update when entry rotates out (#1390)

FetchRequestLogState.onFetchRequestBodyUpdate silently dropped a deferred
response-body update when the matching entry had already been evicted by
log rotation (>= maxFetchRequests newer requests appended between the
append and the fire-and-forget body read). The user saw an empty Response
Body with no way to distinguish "body never came back" from "body dropped
after eviction".

Plumb an optional pino logger into FetchRequestLogState (defaulting to the
silent logger) and emit a logger.debug at the early-return site so the drop
is traceable. The web client wires the existing remote logger from
createWebEnvironment into the state manager.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): emit dropped-body trace at warn level so it is observable (#1390)

Review feedback: the web remote logger (createRemoteLogger) defaults to
level 'info' with no override at the environmentFactory call site, so a
logger.debug at the drop site is filtered out by both the pino level gate
and transmit.level — the diagnostic never reaches /api/log and the drop
stays invisible in the shipped web app, defeating the issue's goal.

Promote the trace to logger.warn (>= info, so it transmits by default). A
response body lost to log rotation is unexpected data loss, not routine
diagnostic noise, so warn is the appropriate severity. Update the comment
to explain the level choice and switch the test stub from debug to warn,
extracting a small typed makeWarnLogger helper per review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): configurable Network Log Size per server + body-dropped toast (#1390)

Turn the otherwise-invisible "response body dropped after rotation" case into
an actionable signal:

1. Per-server `maxFetchRequests` setting (default 1000, 0 = unlimited),
   editable as "Network Log Size" in the first (Options) accordion section of
   the Server Settings form. Plumbed through the full settings pipeline:
   InspectorServerSettings + StoredMCPServer types, the serverList disk
   converters (read defaults to 1000, write omits the default to keep diffs
   minimal — 0 is a meaningful non-default), and the node server's GET-sanitize
   + write-side validateSettings (non-negative number). Applied live to the
   active connection on settings-modal close via a new
   FetchRequestLogState.setMaxFetchRequests (shrinking trims immediately), and
   at connect time for the next session.

2. FetchRequestLogState now emits a `fetchRequestBodyDropped` event, but only
   for genuine rotation drops (log at capacity with a finite cap) — not for
   benign post-clear stragglers or unlimited logs, which previously also hit
   the warn path. App shows a deduped, persistent toast (stable per-server id,
   no auto-close) explaining what happened and the likely causes, with a link
   that opens this server's settings on the Options section (modal remounts via
   key) so the user can raise the limit if it's just a high-traffic server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): don't treat a cleared Network Log Size field as unlimited (#1390)

Review feedback: clearing the NumberInput emits "", and `parseInt("") || 0`
coerced that to 0 (unlimited). Since the value applies live on modal close, a
user who cleared the field to retype and then closed the modal would silently
switch the log to unlimited. Fall back to the current value on an empty/NaN
parse and reserve 0 for an explicit numeric entry. Adds a cleared-input test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:35:52 -04:00
Cliff Hall
3220d6239a
fix(web): send a cancellation notification when cancelling an ordinary tool call (#1458) (#1463)
* fix(web): send a cancellation notification when cancelling an ordinary tool call (#1458)

The Tool detail panel's Cancel button was a silent no-op for ordinary
(non-task) tool calls: App.onCancelToolCall found no captured taskId and
did nothing, leaving the button looking actionable while the call ran on.

Wire request cancellation through the MCP cancellation flow. callTool now
tracks the in-flight call with an AbortController whose signal is threaded
into the SDK request options; aborting it makes the SDK send a
notifications/cancelled for that request and reject the pending call.
cancelToolCall() performs the abort. callTool surfaces the cancellation as
a ToolCallCancelledError (without recording a failed call), which App
clears as a clean cancel plus a short confirmation toast.

Task-augmented calls are unchanged: they have a server-side task and are
still cancelled via cancelRequestorTask (#1457).

Closes #1458

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): address review — distinguish disconnect from cancel; tidy callTool params

Review follow-ups on #1463:

- Disconnect no longer surfaces as "Tool call cancelled". The catch in
  callToolWithRetries now throws ToolCallCancelledError only when the abort
  reason matches the deliberate user-cancel reason (TOOL_CALL_CANCELLED_REASON);
  a disconnect aborts the same controller with a different reason and falls
  through to the normal error path, so no misleading toast.
- Note the activeToolCallAbortController slot is shared (last-writer-wins)
  across all callTool callers.
- Fold callToolWithRetries/attemptToolCall's long positional parameter list into
  a single ToolCallRequest object; move the URL-elicitation loop state into
  callToolWithRetries.

Adds an integration test that a disconnect mid-call rejects as an ordinary
error (not ToolCallCancelledError), using a slow send_progress call over HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:23:02 -04:00
Cliff Hall
6bb49e55ba
fix(web): cancel the underlying task when cancelling a task tool call (#1455) (#1457)
* fix(web): cancel the underlying task when cancelling a task tool call (#1455)

The ToolDetailPanel Cancel button fired onCancel, but App never supplied
an onCancelToolCall handler, so clicking Cancel did nothing — a tool
running as a task kept running on the server.

App now captures the in-flight task-augmented call's taskId from the
`toolCallTaskUpdated` event callToolStream dispatches, and the new
onCancelToolCall handler cancels that task via the existing tasks API
(reusing onCancelTask). The id is reset at the start of every call, so an
ordinary (non-task) call's Cancel stays a no-op (no task to cancel, no
request-abort channel yet) — preserving prior behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): show a toast when a task is cancelled

Cancellation goes out as a `taskCancelled` event (not a status
notification), so a running task's open 'Task <status>' toast would
otherwise hang with no confirmation. Subscribe to `taskCancelled` and
replace that toast (or show a fresh one) with a short auto-closing
'Task cancelled' toast, pruning the dead progress entry. Covers both
cancel paths — the Tasks screen and the Tool detail panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): report a cancelled task as cancelled, not failed

When a task is cancelled, the in-flight callToolStream rejects with a
generic -32603 error, whose error path reported the task as "failed" —
so the Tasks screen flashed FAILED until a manual refresh fetched the
server's true "cancelled" state. InspectorClient now records cancelled
task ids (set in cancelRequestorTask before the await, since cancelling
unblocks the stream) and the stream's error path labels the terminal
task "cancelled" with a matching status message. Cleared on disconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: address review nits on task-cancel wiring

- Prune the consumed taskId from cancelledTaskIds in the stream error path
  so the set can't grow across a long session (disconnect-clear remains the
  backstop for cancels whose task completed before the cancel landed).
- Clear activeToolCallTaskIdRef before the cancel resolves so a rapid second
  Cancel click doesn't re-cancel the terminating task (which would surface a
  spurious 'Failed to cancel task' toast). Add a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:00:48 -04:00
Bob Dickinson
62d442b073 Revert "First pass at pulling the v1.5 CLI, TUI, and Launcher clients into v2."
This reverts commit 467b01edd6.
2026-06-09 21:56:38 -07:00
Bob Dickinson
467b01edd6 First pass at pulling the v1.5 CLI, TUI, and Launcher clients into v2. 2026-06-09 17:38:00 -07:00
Cliff Hall
d5f12b2a82
feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369) (#1447)
* feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369)

Let users control the order of servers on the Servers screen instead of
hand-editing mcp.json. Each ServerCard gets a grip handle (in the header,
left of the name) that drives an @dnd-kit sortable; pointer and keyboard
sensors both work, with live-region announcements for screen readers.

- Backend: PUT /api/servers/order rewrites mcp.json in the supplied order
  inside the write lock, reusing the atomic-write + watcher-notify path.
  Registered before /:id so "order" isn't captured as a server id. Rejects
  with 409 when the id set no longer matches disk and 400 on duplicate /
  malformed bodies, so a reorder racing an external edit can't drop or
  duplicate an entry.
- Hook: useServers.reorderServers(orderedIds) — optimistic local reorder,
  PUT, refresh-to-disk-truth on failure.
- UI: SortableServerCard wrapper owns the dnd-kit concerns; ServerListScreen
  wraps the grid in DndContext + SortableContext (rect strategy) with
  pointer + keyboard sensors and announcements. Reorder math and a11y copy
  extracted to reorderIds.ts / serverReorder.ts for direct unit testing.
  Renders plain cards (no grip) when no onReorder is wired.
- Wired onServerReorder through InspectorView -> App.tsx.

Tests: pure-helper units, hook (persist / optimistic / 409 revert), 9
backend integration tests (incl. id-set-mismatch rejection and the
route-registration guard), ServerCard/ServerListScreen units, and a
real-browser Storybook keyboard-reorder play (the dnd-kit keyboard sensor
needs layout rects that happy-dom doesn't provide).

Adds @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): notify on reorder failure; skip reorder glue when not reorderable

Address PR #1447 review:
- App.tsx: catch reorderServers rejection (409 from a racing external edit,
  or a network error) and show a red toast, matching the error-handling
  pattern used by every other mutation — previously the rejection was
  discarded with `void` and the drag silently bounced back.
- ServerListScreen: only build handleDragEnd / announcements when
  `reorderable`, so the four-closure announcements object isn't allocated
  every render when no onReorder is wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:15:58 -04:00
Cliff Hall
721dae08c6
feat: apply auto-refresh setting live + diff-aware list-changed indicator (#1444) (#1445)
* feat: apply auto-refresh setting live + diff-aware list-changed indicator (#1444)

Two follow-ups to #1402.

1. Live setting application — InspectorClient gains setServerSettings()
   (on the protocol + fake); App pushes edited settings onto the live
   client when the Server Settings modal closes for the connected server,
   so toggling auto-refresh-on-list-changed takes effect without a
   reconnect (the managed state reads getServerSettings() at notification
   time).

2. Diff-aware indicator — in flag-only mode, on */list_changed the managed
   Tools/Prompts/Resources state now fetches the new list and compares it
   to the current one, lighting the indicator only when it actually
   differs. The displayed list stays put until the user pulls via Refresh
   (pull-on-demand preserved). Servers like everything re-send identical
   lists on list_changed; this suppresses the false-positive indicator.
   Refactors each manager's refresh into fetch + apply helpers shared with
   the peek path.

Trade-off: item 2 reintroduces a fetch on */list_changed (to compare), but
unlike the pre-#1402 behavior it never replaces the displayed list under
the user — only the indicator reflects a real change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): make the diff-aware peek clear the indicator on revert (#1444 review)

peekForChange now sets the list-changed flag to whether the server list
differs from the displayed one (setListChanged(differs)) instead of only
ever setting it true. So a later notification that reverts the server back
to the displayed list clears the indicator — nothing left to pull. Adds a
clear-on-revert regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): consolidate managed-list state managers onto a shared base (#1444)

The four per-primitive list managers (tools, prompts, resources, resource
templates) were near-identical copies of the same fetch/apply/peek/connect/
disconnect/list_changed machinery, a standing drift risk. Extract a generic
ManagedListState<T, M> base parameterized by a config (change event,
list_changed notification, capability key, page fetcher, and whether the
list drives an indicator). Each manager is now a ~40-line subclass: its
config plus a typed getter alias.

Public contracts are unchanged — same per-manager *Change / listChangedChange
events and get*/refresh/getListChanged/clearListChanged methods — so the
hooks and all existing tests pass untouched. Templates opt out of the
indicator (supportsIndicator: false): a list_changed auto-refreshes only
when the server opts in, else does nothing. Net -245 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(core): coalesce bursts of list_changed peeks (#1444 review)

A rapid burst of */list_changed notifications fired one concurrent
paginated fetch per notification. Guard peekForChange with an in-flight
flag: while a peek is fetching, a new notification queues a single re-run
instead of launching a concurrent fetch, and the running peek loops once
more on completion to capture the latest server state. Collapses a burst
into sequential peeks. Adds a coalescing test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): debounce list_changed bursts into a single fetch (#1444)

The in-flight guard only coalesced fetches that actually overlapped — when
a server (e.g. everything) emits a burst of */list_changed spaced just far
enough apart that each list call returns before the next notification, we
still fired one list call per notification. Add a real debounce in the
ManagedListState base: a notification (re)starts a timer and the
refresh/peek runs once the burst settles, collapsing N notifications into
one fetch. The in-flight coalescing guard stays as a second line of defense
for a post-debounce notification landing during a slow paginated fetch.

The delay is injectable (DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; subclasses
take an optional constructor arg) so tests run with 0 for fast, determinist
ic behavior. Timer is cleared on disconnect, unsubscribe, and destroy. Adds
debounce + in-flight regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): extend the in-flight guard to the auto-refresh path (#1444 review)

The overlap guard was only on the peek path; a burst-during-a-slow auto-
refresh could let a slower older fetch clobber a newer list (applyItems is
last-write-wins). Lift the guard from peekForChange up to runListChanged so
it wraps both the auto-refresh and peek actions: while one is fetching, a
new post-debounce notification queues a single re-run. Adds an auto-refresh
coalescing test alongside the peek one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): light the indicator without fetching when auto-refresh is off (#1444)

Per maintainer feedback: with auto-refresh off, a list_changed should not
issue any list call. The diff-aware peek (fetch-to-compare) meant one
tools/list went out per settled burst even with auto-refresh off, which the
maintainer doesn't want. Revert flag-only mode to lighting the indicator
blindly on a (debounced) list_changed — no network — and the user pulls the
new list via Refresh.

Trade-off (accepted): the indicator lights even when the server re-sent an
identical list (the everything-server case), since we no longer fetch to
compare. Keeps the debounce (one indicator light per burst), the
auto-refresh path + its in-flight guard, the live setServerSettings, and the
ManagedListState consolidation. Removes peekForChange and its tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(core): scrub stale peek docstrings after the blind-indicator revert (#1445 review)

The diff-aware revert removed peekForChange's body but left its docstring,
which orphaned onto destroy() and described fetch-to-compare/clear-on-revert
behavior that no longer exists. Replace it with a one-line destroy() doc, and
update clearListChanged's doc (peek -> blind light).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(core): finish scrubbing stale peek references after the revert (#1445 review)

Round-7's scrub missed four more peek/fetch-to-compare mentions in
managedListState.ts (supportsIndicator, fetchAll, the debounce-default and
onListChanged comments) plus six 'async peek' comments in the state tests.
Update them all to the blind-indicator semantics. Doc/comment-only; no
behavior change. grep -i peek over core/mcp/state now returns nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:00:28 -04:00
Cliff Hall
b0a024269e
feat(web): list-changed handling — indicator, pull-on-demand default, and per-server auto-refresh opt-in (#1402) (#1443)
* feat(web): wire the listChanged indicator across all screens (#1402)

The per-screen list-changed indicator was hardcoded false in
InspectorView for Tools, Apps, Prompts, and Resources, so it could
never light up. Source it from the managed-state layer instead.

Each ManagedXState now tracks a listChanged flag: set true when its
*/list_changed notification arrives (the existing auto-refresh leaves
it set), exposed via getListChanged()/a listChangedChange event, and
cleared by clearListChanged() or on disconnect. The useManagedX hooks
surface the flag and clear it on a user-initiated refresh (the
indicator's Refresh button = acknowledge). App threads the three flags
to InspectorView, which maps them to the screens — Apps shares the
tools flag since apps are a filtered view of tools.

Tests cover set/clear/disconnect at the state-manager layer, the hook
subscription + refresh-clears behavior, and the InspectorView routing
(including Apps sharing the tools flag and no cross-wiring between
screens).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): close clear-after-await race in managed-list refresh (#1402 review)

The useManagedX refresh() callbacks cleared the listChanged flag AFTER
awaiting the fetch. If a */list_changed arrived mid-fetch, the state
re-set the flag (and auto-refreshed), then the trailing clear wiped that
genuinely-new signal. Clear BEFORE the await instead: the user's refresh
acknowledges only the change in hand, and a mid-fetch change re-sets the
flag and survives. Adds a race regression test on the tools hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): drop duplicate Refresh on the Apps screen (#1402)

AppControls uniquely had a standalone toolbar Refresh button AND the
ListChangedIndicator on a separate row, so once the indicator could
actually appear (this PR), the Apps screen showed two Refresh buttons.
Match the other three controls (Tools/Prompts/Resources): the indicator
is the single header-right refresh affordance. Updates AppControls and
AppsScreen tests to assert exactly one Refresh (and none when the list
hasn't changed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): stop auto-invoking */list on list_changed; user pulls via Refresh (#1402)

With auto-refresh on, the list was already current by the time the
list-changed indicator appeared, making its Refresh button moot. Now the
managed Tools/Prompts/Resources state managers only flag the change on
*/list_changed (they no longer auto-refresh); the list stays as-is until
the user clicks Refresh, which fetches the new list and clears the
indicator. The connect-time initial load is unchanged.

Resource templates (no own indicator, part of the Resources screen) also
stop auto-refreshing; onRefreshResources now refreshes both resources and
templates, since a single resources/list_changed covers both. Tasks are
left auto-refreshing — they have no list-changed indicator and need live
status updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(core): correct ManagedResourceTemplatesState header comments (#1402 review)

The class no longer subscribes to resourceTemplatesListChanged; update
the stale file/class docstrings that still claimed it syncs on that
notification to reflect connect + statusChange only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): add per-server 'Auto Refresh on List Changed Notifications' option (#1402)

New Options section in ServerSettingsForm with a single checkbox. When
checked, the managed Tools/Prompts/Resources (and resource templates)
state auto-refreshes on */list_changed notifications; when unchecked
(default) the notification only lights the list-changed indicator and the
user pulls via Refresh.

Plumbing: new optional autoRefreshOnListChanged on InspectorServerSettings
and StoredMCPServer, round-tripped through serverList (lifted with a false
default, persisted only when true, added to the exhaustive field-key map);
surfaced from InspectorClient via a new getServerSettings() on the protocol;
the state managers read it live at notification time to decide auto-refresh
vs flag-only. Resource templates re-subscribe to their list_changed but only
auto-refresh when the option is on (they have no indicator of their own).

Tests cover the round-trip (true persists, false omitted), the form
section (render/checked/toggle), and each state manager's on/off branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): persist autoRefreshOnListChanged through validateSettings (#1402)

The PUT /api/servers write path runs settings through validateSettings,
which rebuilds the value from explicitly-named fields and silently drops
unknown keys — so autoRefreshOnListChanged never reached disk (the
serverList round-trip test passed because it calls
inspectorSettingsToStoredFields directly, bypassing this path). Validate
it as an optional boolean and include it in the built value; false is
omitted on disk by inspectorSettingsToStoredFields as before.

Adds integration tests over the real PUT path: true persists, false is
omitted, and a non-boolean is rejected (400).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): default the Server Settings modal to the Options section (#1402)

Open 'Options' instead of 'Custom Headers' when the Server Settings modal
first opens, so the new Auto Refresh toggle is the first thing surfaced.
Updates the modal default-expanded section, the modal test's initial-state
assertions, the three header tests (now expand 'Custom Headers' first since
it's no longer open by default), and the story defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop accidentally-committed Inspector history dump; gitignore the class

83076441 swept an exported Inspector message-history JSON (manual-testing
artifact at the repo root) into the tree via git add -A. Untrack it and
extend the existing repo-root debug-artifact .gitignore block (alongside
/*.png) to cover /inspector-history-*.json so these can't be re-added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:58:21 -04:00
Cliff Hall
9b558afee1
feat(web): display negotiated MCP protocol version on ServerCard (#1324) (#1442)
* feat(web): show negotiated MCP protocol version on ServerCard (#1324)

Add optional protocolVersion to ConnectionState and render it as a
dimmed "MCP <version>" label at the end of the transport-badge/
description row on ServerCard, shown only when connected. The value
is plumbed through useInspectorClient as part of #1324.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core,web): surface negotiated protocolVersion end-to-end (#1324)

Plumb the MCP protocol version negotiated during initialize from the
InspectorClient through to the ServerCard so the active connected card
displays "MCP <version>".

The SDK Client doesn't expose protocolVersion; it stamps it onto the
transport via setProtocolVersion. MessageTrackingTransport now captures
it (and forwards to the base transport when present), so it works for
stdio too — whose base transport has no setProtocolVersion.

- core: MessageTrackingTransport captures + exposes protocolVersion;
  InspectorClient stores it, exposes getProtocolVersion(), dispatches a
  protocolVersionChange event, and clears it on disconnect.
- core: useInspectorClient subscribes and returns protocolVersion.
- web: App reads it from the hook (drops the hard-coded "2025-06-18")
  and InspectorView splices it onto the active server's connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): decouple initializeResult from protocolVersion (#1324 review)

Gate the initializeResult memo on serverInfo only, not protocolVersion.
initializeResult also drives the connected ViewHeader and Connection Info
modal, so requiring the version risked hiding the whole connected surface
in the edge case where it's absent. protocolVersion now flows through as
the optional field it already is everywhere downstream ('' reads as
unknown; the ServerCard label and modal value both tolerate it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): drop redundant connected-check in InspectorView splice (#1324 review)

initializeResult is derived from connectionStatus in App's memo, so a
truthy initializeResult.protocolVersion already implies a live
connection — the extra connectionStatus === 'connected' guard in the
splice was redundant. ServerCard keeps its own status guard (dumb-
component robustness). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:40:02 -04:00
Cliff Hall
121f875a0e
feat(web): History — Pin & Replay, plus two history-status fixes (#1438) (#1441)
* fix(web): don't show a permanent PENDING badge on history notifications (#1439)

`extractStatus` derived the history status badge purely from whether a
`response` was attached, so a fire-and-forget notification (no id, no
response ever) — and any unmatched standalone response — was labelled
"Pending" forever. The pending → OK/Error lifecycle only applies to
request entries (messageLogState attaches the response by JSON-RPC id),
so scope the status to `direction === "request"` and render no
request-style badge for notifications/responses. The method badge already
labels them.

Part of the History-screen work on #1438.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): track outgoing responses so server→client requests resolve in history (#1440)

MessageTrackingTransport.send only tracked outgoing requests, so the
client's response to a server→client request (roots/list, sampling,
elicitation) was never recorded. The inbound request entry never got its
response folded in, leaving it stuck "Pending" with no response body.

Make send symmetric with onmessage: track outgoing responses (by id +
result/error) as well as outgoing requests. The response now correlates
to its request entry, which resolves to OK/Error with the body. Outgoing
notifications remain untracked (separate gap).

Part of the History-screen work on #1438.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): wire up History Pin and Replay (#1438)

Both HistoryEntry actions were rendered but stubbed to todoNoop in App.

- Pin: App now owns a session-scoped Set<string> of pinned entry ids,
  toggled by onTogglePinHistory and passed down as pinnedHistoryIds.
  HistoryListPanel already sorts pinned entries to the top. The set resets
  with the rest of the per-screen state on disconnect/server-switch.
- Replay: onReplayHistory re-issues the entry's original request by method
  (tools/call → callTool, prompts/get → getPrompt, resources/read →
  readResource). The call flows through InspectorClient → tracked
  transport → message log, so the fresh request+response surface as a new
  History entry (history-local) without touching the Tools/Prompts/
  Resources panels. Unsupported methods / a removed tool surface a toast.

Tests: pin toggle propagation, replay dispatch for each supported method,
unsupported-method and missing-tool toasts.

Closes #1438.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): make history Replay support list/discovery requests

Replaying a tools/list entry reported "Replay isn't supported". Extend
replayHistoryRequest beyond the call-style requests to the read/discovery
methods, re-issuing each via its InspectorClient method (preserving the
pagination cursor):

- tools/list → listTools, prompts/list → listPrompts,
  resources/list → listResources,
  resources/templates/list → listResourceTemplates, ping → ping.

Tests: tools/list replay (cursor preserved) and an unsupported-method
toast (logging/setLevel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): replay tasks/list; reorder + conditionally show History buttons

- Add tasks/list to the replayable set (re-issued via listRequestorTasks),
  completing the client-issued */list methods.
- Reverse the HistoryEntry actions to Pin then Replay, and hide Replay for
  entries whose method can't be replayed.
- Introduce a single source of truth — REPLAYABLE_HISTORY_METHODS /
  isReplayableHistoryMethod in historyUtils — used by HistoryEntry to
  show/hide the button and by App's replayHistoryRequest to gate dispatch,
  so the two can't drift.

Tests: tasks/list replay, Replay hidden for a non-replayable method, and
Pin-before-Replay ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): add client↔server direction badge to History and Network (#1437)

A dual-state badge at the top-left of each entry shows which way it
traveled: "client → server" (green) for inspector-originated, "client ←
server" (yellow) for server-originated.

- New shared element MessageDirectionBadge (Elements).
- History: MessageEntry gains an `origin` ("client" | "server") set at
  tracking time — MessageTrackingTransport tags `send` as client and
  `onmessage` as server, threaded through the track callbacks. HistoryEntry
  maps origin → badge. (direction stays the request/response/notification
  message-type used for response correlation; origin is the flow.)
- Network: fetches are always inspector-originated, so NetworkEntry renders
  the badge as outgoing.

Tests + stories for the badge; HistoryEntry direction tests; transport
test asserts the origin argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): use purple (not yellow) for the client ← server direction badge

Yellow + green read as caution/ok status; purple vs green reads as
direction. Incoming (server → client) is now violet; outgoing stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): make History Pinned/History sections collapsible toggles

The "Pinned Requests (N)" and "History (N)" section headers are now
clickable toggle buttons that expand/collapse their entries — styled like
the LogControls level toggles (UnstyledButton listItem variant with the
active background) and wrapping the entries in a Collapse. Both default
open and collapse independently.

Tests assert the per-section aria-expanded toggling and independence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): replace Expand/Collapse text button with an icon toggle

History, Network, and Task cards now use a shared ExpandToggle ActionIcon
instead of the "Expand"/"Collapse" text button: LuArrowDownToLine when
collapsed (expand), LuArrowUpFromLine when expanded (collapse). The
aria-label stays "Expand"/"Collapse" so the control reads the same.

- New shared element ExpandToggle (+ test + stories).
- HistoryEntry: actions split into a left group (Pin/Replay) with the
  toggle pushed right via space-between.
- NetworkEntry / TaskCard: drop their now-unused SubtleButton const.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): pin icon toggle on HistoryEntry, moved to the right

Replace the Pin/Unpin text button with a shared PinToggle ActionIcon
(TiPinOutline when unpinned, TiPin when pinned; aria-label stays
Pin/Unpin). Move it to the right action group, just left of the
expand/collapse toggle — Replay stays on the left.

- New shared element PinToggle (+ test + stories).
- HistoryEntry action row: Replay left; Pin + expand toggle right.
- Drop the now-unused formatPinLabel helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): per-section Clear/Export on the Pinned and History headers

Each section header now shows Clear and Export links on the right, but
only when both sections are present (a single section is already covered
by the panel-level Clear/Export). They act on just that section's entries:

- Clear: removes that section's entries by pin membership (clearing the
  Pinned section also drops the now-stale pinned id set).
- Export: downloads just that section's entries
  (inspector-history-pinned/-unpinned-...json).

Threaded onClearSection/onExportSection App → InspectorView → HistoryScreen
→ HistoryListPanel; App computes each section from messages + pinned ids.
Extracted a CollapsibleSection subcomponent and a SectionActions links
group. Extended ExportKind with history-pinned/history-unpinned.

Tests cover both-sections visibility gating and per-section dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): Filter by Message Direction on the History controls

Add a "Filter by Message Direction" section to HistoryControls, modeled on
LogControls' "Filter by Level": a Select/Deselect All control plus two
listItem toggles — "client → server" and "client ← server" — that show or
hide entries by origin.

- HistoryUiState gains visibleDirections (Record<MessageOrigin, boolean>),
  defaulting to both visible; persists with the other per-screen state.
- HistoryScreen wires the toggles (single + all) through onUiChange and
  passes the filter to HistoryListPanel, where matchesFilters drops entries
  whose direction is off (entries without a recorded origin are never
  filtered out).

Tests cover the controls (render, single toggle, select/deselect all), the
HistoryScreen onUiChange wiring, and the list-level direction filtering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): section Clear/Export as subtle buttons, not link anchors

Match the Select/Deselect All control in HistoryControls: the per-section
Clear/Export on the History/Pinned headers now use a subtle Button
(variant subtle, size xs) instead of an Anchor link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): color the direction filter labels to match the badge

The "client → server" / "client ← server" toggles in HistoryControls now
color their labels green / violet, matching the MessageDirectionBadge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): track outgoing client notifications in history too

Review follow-up (#1441): MessageTrackingTransport.send now also tracks
the client's own notifications (initialized, progress,
roots/list_changed) as origin "client", making send fully symmetric with
onmessage. Previously only id-bearing outgoing messages (requests,
responses) were tracked, so client notifications never reached History
while incoming server notifications did.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): Filter by Message Direction on the Network screen

Add the same "Filter by Message Direction" section to NetworkControls as
on HistoryControls. Extracted the shared section into a MessageDirectionFilter
group component (used by both HistoryControls and NetworkControls) so they
stay identical — a Select/Deselect All control plus green/violet
client → server / client ← server toggles.

- NetworkUiState gains visibleDirections (default both visible), persisted
  with the rest of the per-screen state; NetworkScreen wires the toggles and
  NetworkStreamPanel filters on them.
- Network fetches are always inspector-originated, so all entries are
  client → server: the client toggle gates them all and the server toggle
  matches nothing (documented in NetworkStreamPanel). The section mirrors
  History for parity.

Tests cover the shared filter, both controls' direction sections, the
Network list filtering, and the NetworkScreen wiring; ambiguous "Deselect
All" selectors (now two sections) disambiguated by order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* revert(web): drop message direction from the Network tab

Network traffic is always client → server — even SSE is a client-initiated
stream tagged outgoing — so a direction badge/filter there is meaningless.
Remove the MessageDirectionBadge from NetworkEntry and the Filter by Message
Direction section (and its NetworkUiState.visibleDirections wiring) from the
Network screen.

History keeps its direction badge + filter; the shared MessageDirectionBadge
and MessageDirectionFilter components stay (still used by History).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): a lone History section isn't a collapsible accordion

When only one of Pinned / History is present, its header is now a plain
title and its entries always show — instead of a `listItem` accordion
toggle that could hide them based on a stale collapsed state from when
both sections were on screen. The accordion (and per-section Clear/Export)
only applies when both sections are present.

Example fixed: collapse History while pins exist, then unpin everything —
History becomes the sole section and shows its entries under a plain
header rather than staying collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): put section Clear/Export on the header pleat itself

The per-section Clear/Export sat to the right of the collapsible header
bar, on the panel background. Make the whole header row the "pleat":
a new Group `sectionHeader` theme variant (rounded + hover, active bg
passed per instance) wraps a flex-1 toggle button (the title) on the left
and the Clear/Export buttons on the right — so the actions live on the
pleat. The toggle is a separate button since the action buttons can't nest
inside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): entry expand toggle uses the same icons as ListToggle

Swap ExpandToggle (History / Network / Task cards) from the LuArrow icons
to RiExpandVerticalLine / RiCollapseVerticalLine — the same expand/collapse
icons the list-level ListToggle uses. Same ActionIcon size, so it matches
the other small buttons on the cards; aria-labels unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): panel Clear keeps pinned entries (history-only)

Review follow-up (#1441): the panel Clear button is gated on the unpinned
count (implying history-only), but onClearHistory wiped everything
(pinned included) and left pinnedHistoryIds stale. Make Clear history-only
— clearMessages((m) => !pinnedHistoryIds.has(m.id)) — so pinning protects
an entry, matching the gating and the per-section model; the pin set stays
valid since its entries survive.

Also drop the stale "and Network" mention from the MessageDirectionFilter
docstring (Network direction filter was reverted).

Test: panel Clear's predicate keeps pinned ids and removes unpinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:21:52 -04:00
Cliff Hall
5cabc1ecd7
feat(web): URL elicitation — explicit completion step + error-path (-32042) handling (#1415) (#1436)
* feat(web): two-step URL elicitation completion (#1415)

Replace accept-on-open with an explicit completion step for URL
elicitations. Opening the URL now only transitions the panel to its
waiting state; the elicitation resolves with `accept` only when the user
clicks the revealed "I've completed it" action, or with `cancel`.

- ElicitationUrlPanel: add `onComplete`, reveal the completion button and
  relabel open→"Reopen in Browser" while `isWaiting`.
- PendingClientRequestModal: wire local `isWaiting`; open no longer sends
  a response. Removed the accept-on-open comment that pointed here.
- Tests cover open→waiting→complete and open→cancel, and assert opening
  alone does not resolve the elicitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): handle URL-elicitation-required (-32042) error path

Implements the spec's "URL mode with elicitation required error" flow
(2025-11-25). When a tools/call returns a `-32042`
UrlElicitationRequired error, the client now surfaces the required URL
elicitation(s) and retries the call once completed, instead of showing a
bare error.

Core (InspectorClient.callTool):
- Detect `-32042` (typed UrlElicitationRequiredError or generic McpError)
  via the new `getUrlElicitationsFromError` helper.
- Surface each carried elicitation in order through the existing pending-
  elicitation queue / modal (reusing the two-step "Open → I've completed
  it" UI), then re-issue the original call once all are accepted. A
  decline/cancel aborts with a clear message; a bounded retry count guards
  a server that keeps returning the error.
- `ElicitationCreateMessage.completeIfPending()` lets the optional
  `notifications/elicitation/complete` auto-advance/auto-accept an open
  URL elicitation; the completion-notification handler now resolves
  (was: only removed) the pending entry.
- Extracted `attemptToolCall` / `dispatchFailedToolCall` from the old
  callTool body to support the retry loop without duplicating bookkeeping.

Web (App):
- A non-spec `-32042` with no elicitations (nothing to open) shows a
  dedicated yellow toast linking to a new UrlElicitationErrorModal with
  the raw error body, mirroring the output-schema-validation toast.

Tests: helper unit tests, completeIfPending unit tests, callTool
error-path retry/cancel/no-list/ordinary-error tests (injected fake
client), the new modal (test + stories), and an App toast test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): abort URL-elicitation error path on a repeated URL

Guard the -32042 retry loop: if the server's retry error re-requests a
URL the user already completed earlier in the same call, completing it
again can't make progress, so callTool aborts with a new typed
UrlElicitationLoopError instead of re-prompting (which would loop until
the retry cap). The web layer detects it and shows a "URL elicitation
loop" toast naming the repeated URL rather than a generic failure.

Tests: core abort-on-repeat test (URL presented once, two attempts, then
loop error) and an App loop-toast test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): let Copy-URL reach the URL-elicitation completion step

Address review of #1436: the "I've completed it" action was gated behind
clicking "Open in Browser" (only that set isWaiting), so a user who used
Copy URL to finish the flow externally could only Cancel. Copying the URL
now also moves the panel into the waiting state, exposing the completion
step. Also trims the now-redundant second sentence of the waiting hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): address review of error-path URL elicitation

Review follow-ups on #1436 (all non-blocking):
- Remove the leftover bare block scope in attemptToolCall (extraction
  artifact); body de-indented one level.
- Log a warning when callTool gives up after the URL-elicitation retry
  cap is hit, so a server that keeps demanding new elicitations is
  diagnosable rather than looking like an ordinary failure.
- Assert the dispatch-once invariant on the loop-abort and cancel-abort
  paths (exactly one failed toolCallResultChange event).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(core): fix attemptToolCall body indentation

Run prettier over the extracted attemptToolCall method body, which was left
over-indented by 2 spaces after the bare-block removal in 0754b43d. core/ is
outside clients/web's format:check scope, so npm run validate did not catch it.

Whitespace-only; the pre-existing line 658 wrap (also outside the format scope)
is left as-is, out of scope for this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): settle pending elicitations on disconnect to avoid callTool hang

Address review of #1436: an error-path `awaitUrlElicitation` promise only
settled via respond, but `disconnect()` cleared `pendingElicitations`
wholesale — so a disconnect mid-elicitation (server crash, manual
disconnect, transport close) left `callTool` (and the UI's in-flight
toolCallState) hanging forever.

- Add `ElicitationCreateMessage.cancel()`: settles a pending elicitation
  as `{ action: "cancel" }` without calling `onRemove` (the queue is
  cleared by the caller, so it must not splice mid-iteration).
- `disconnect()` now cancels each pending elicitation before clearing the
  array, and dispatches `pendingElicitationsChange` (it already did so for
  samples) so the modal closes on a transport-close teardown too.

Tests: disconnect-mid-elicitation settles callTool (no hang); cancel()
unit tests (resolves-without-remove, no-op once responded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:25:01 -04:00
Cliff Hall
49d6be6987
feat(web): add per-server Roots config + client roots capability (#1425) (#1431)
* feat(web): add per-server Roots config + client roots capability (#1425)

Add MCP roots support to the v2 web Inspector as a per-server config
section in the server settings form (no separate Roots tab/screen).

- Persistence: add `roots: Root[]` to `InspectorServerSettings` and
  `StoredMCPServer`; thread through serverList converters (lift on read,
  drop empty-uri rows / blank names on write, omit when empty) and the
  remote server's read-side drop guard + `validateSettings`.
- Capability: `setupClientForServer` always advertises the roots
  capability (`roots: cleanRoots(savedSettings?.roots ?? [])`) so the
  server can issue `roots/list` and receive `roots/list_changed`.
- Live apply: edits to a connected server's roots are pushed via
  `inspectorClient.setRoots(...)` once, on settings-dialog close — diffed
  against the client's current roots — rather than per keystroke, to
  avoid flooding the server with `roots/list_changed` / `roots/list`.
- Form: new Roots accordion section with a required URI and an optional
  name per row (unlike v1, which was URI-only), + Add Root, and remove.

Tests cover the persistence round-trip (serverList + servers-route),
the form/modal section, and reuse the existing InspectorClient
roots/`setRoots` integration coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(roots): address review — dedup guards/cleanRoots, preserve extra fields, test live-apply

Responding to the PR #1431 code review:

- Dedup `isRootArray`: drop the copy inside `validateSettings` and reuse the
  module-scope guard the read path uses, so the two can't drift
  (core/mcp/remote/node/server.ts).
- Dedup the clean-roots normalizer: hoist a single exported `cleanRoots`
  into core/mcp/serverList.ts and reuse it from both
  `inspectorSettingsToStoredFields` and App.tsx (drops the local copy).
- Preserve non-form root fields (e.g. `_meta`): `cleanRoots` now spreads the
  carried-through fields and only normalizes uri/name, and the modal's
  `handleRootChange` spreads the existing root on edit — so a hand-edited
  root's `_meta` survives load + persist + edit instead of being silently
  dropped.
- Test the App live-apply-on-close path: new App.test.tsx cases assert
  setRoots is called once with cleaned roots when changed on the active
  server, and not called when the cleaned roots already match what the
  client advertises. Adds a cleanRoots unit test (blank drop + _meta
  preserve).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:08:18 -04:00
Cliff Hall
ed6eda7492
feat(web): wire up MCP task support — Tasks screen, Run-as-task, status toasts (#1422) (#1424)
Wires the ported Tasks screen + core task subsystem through App.tsx: sticky Clear Completed, cancel/refresh error toasts, core-side per-task progress correlation, Run-as-task on the Tools screen (gated on server support), per-server Task TTL, and live task-status toasts.
2026-06-04 08:49:47 -04:00
Cliff Hall
8f6a2e0f78
feat(web): render pending sampling/elicitation requests (#1407) (#1411)
* feat(web): render pending sampling/elicitation requests (#1407)

Server-initiated sampling and elicitation requests never reached the v2
web UI: the InspectorClient queued them and returned a Promise that only
resolved on a user response, but nothing subscribed to the pending-change
events or rendered the (already-built) request components. A tool that
triggered one spun the Execute button forever.

Wire the queues to the UI:
- Add usePendingClientRequests() (core/react) subscribing to
  pendingSamplesChange / pendingElicitationsChange and returning the live
  arrays. Expose getPendingSamples() / getPendingElicitations() on
  InspectorClientProtocol (and the test fake) so hooks can read them.
- Add PendingClientRequestModal, an App-level modal that surfaces one
  queued request at a time (sampling-first) via the existing
  SamplingRequestPanel / ElicitationFormPanel / ElicitationUrlPanel. It is
  non-dismissable so the blocked call only resolves via an explicit action.
- Wire it in App.tsx: responding/rejecting resolves the client's handler
  Promise, which unblocks the originating call and clears the spinner.

Also fold in two issues surfaced while verifying:
- SchemaForm rendered an `array` + `items.enum` (multi-select enum) as a
  JSON textarea because only `items.anyOf` had a branch; add a MultiSelect
  branch with enumNames label support.
- ToolsScreen's result lives in App and outlived the screen's local
  selection (reset on tab-switch unmount), leaving a result with no
  selected tool. Clear the result on unmount for a clean slate on return.
  Prompts/Resources gate their preview on selection, so they are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(elicitation): support explicit decline; honest queue label; review polish

Address @claude review on #1411:
- Add an explicit "Decline" action to ElicitationFormPanel so the spec's
  decline (refusal) is distinguishable from cancel (dismissal); the JSDoc
  claimed accept/decline/cancel but only accept/cancel were wired. Wire the
  modal body to emit { action: "decline" }.
- Replace the misleading "1 of N" queue label (the leading 1 never changed
  since the modal always shows the head) with an "N pending" hint, hidden when
  nothing else is queued.
- Make the dead useMemo honest: derive the head request inside the memo from
  the source arrays so it actually caches (the inline activeElicitation had a
  fresh identity every render).
- Document the URL elicitation accept-on-open semantic with a comment pointing
  at the follow-up (#1415).
- Add an App-level round-trip test driving pendingSamplesChange end-to-end
  (modal opens, respond() fires, empty event closes it).

Follow-ups filed: #1415 (URL two-step completion flow), #1416 (form required-
field submit gate + double-submit guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(elicitation): include untouched schema defaults in submitted content

The schema form displays a field's default via resolveValue(), but onChange
only writes a field into `values` once the user edits it. So an elicitation
submitted with default-only fields left untouched dropped them entirely —
v2 sent just the manually-edited fields while v1 sent defaults too (reported
with the everything server: firstLine/integer/number/enum defaults missing).

Add collectSchemaDefaults() to jsonUtils (recurses nested objects, omits
fields with no default) and seed ElicitationFormModalBody's values state with
it via a lazy initializer. The modal body is keyed by request id, so it
re-seeds per request; user edits still override seeded defaults via the
existing onChange merge.

Tests: collectSchemaDefaults unit tests (field types, nested, empty) in
jsonUtils.test; a modal test asserting an untouched-default form still submits
its defaults in content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(forms): seed schema defaults for tool-call and app-launch forms too

Extend the elicitation defaults fix (collectSchemaDefaults) to the other two
SchemaForm consumers that own form state and submit it: ToolsScreen (tool
execute args) and AppsScreen (app launch args). Both reset formValues to {} on
selection, so default-only fields the user never edited were dropped from the
submitted args — same root cause as the elicitation modal.

Seed formValues from the selected tool/app inputSchema defaults on select.
User edits still override via the existing onFormChange merge.

(InlineElicitationRequest has no consumers wired up, so it's left as-is.)

Tests: ToolsScreen sends a tool's default on Execute without editing;
AppsScreen sends an app's default on Open App without editing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(elicitation): gate form submit on required fields; guard double-submit (#1416)

Fold the #1416 hardening into this PR:

- Gate the elicitation form's Submit button on required fields. Promote the
  hasMissingRequiredFields helper out of AppDetailPanel into jsonUtils (shared)
  and use it in ElicitationFormPanel so Submit is disabled until every required
  field has a value.
- Guard against double-submit. Add an optional `busy` prop to the sampling,
  elicitation-form, and elicitation-url panels that disables their responding
  actions. PendingClientRequestModal's bodies use a new ref-guarded
  useRespondOnce() hook: the first action dispatches and locks the panel; later
  clicks no-op. This stops a fast double-click from calling respond()/reject()
  twice (which throws and produced an unhandled rejection). Copy URL stays
  enabled — it doesn't resolve the request.

Tests: hasMissingRequiredFields unit tests; ElicitationFormPanel submit-gate
and busy-lock tests; a modal double-click-resolves-once test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tools): cap detail/result panels at viewport and scroll internally

The Tools screen's detail and result columns sized to their content and bled
past the bottom of the viewport on long output (e.g. the env dump or a long
elicitation result). Cap each column at the screen's available height and let
its panel scroll internally instead.

- ToolsScreen: drop `align: flex-start` and wrap each column in a stretched
  `ContentPane` (Flex column, capped by `mah` = viewport − header − xl*2). The
  pane's definite height is what lets the card's inner ScrollArea know how far
  to shrink — a bare `mah` on the card does not (the card sizes to content, so
  the scroll region renders full-height and overflow:hidden just clips it,
  unreachable). Mirrors the proven Prompts/Resources preview panes.
- ToolResultPanel: pinned header + inner ScrollArea (flex 0 1 auto, mih 0).
- ToolDetailPanel: pinned title/annotations header, scrolling body
  (description + form + progress), pinned Execute footer.

Audited the other screens: Prompts/Resources already use this pattern,
ServerList and the four list panels (Logging/Network/History/Tasks) already cap
via ScrollArea.Autosize. Only AppsScreen's app-input form had the same gap —
fixed it the same way: AppDetailPanel now scrolls internally and the card uses
variant="preview" so the running app's iframe and a long form both stay within
the viewport.

Verified in-browser against the everything server: the get-env result now
stops at the viewport bottom with margin and scrolls within the card
(clientHeight 502 vs scrollHeight 1931), header pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(remote): use sync pino destinations in logging tests to fix teardown race

CI flaked with an uncaught `ENOENT: ... /tmp/inspector-log-unknown-level-*/app.log`
that failed an unrelated later test. The remote-logging integration tests create
`pino.destination(logFile)` loggers (async sonic-boom streams) and rmSync the temp
dir in finally/afterEach without closing the stream — a buffered background flush
then fires after the dir is gone and throws an uncaught error, which Vitest counts
as a run failure even though all tests pass.

Make those destinations `sync: true` so the write lands during the request instead
of on a later flush, removing the race. Test-only; unrelated to this PR's feature
work but it was blocking the PR's CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:52:20 -04:00
Cliff Hall
e1c8f71924
fix(storage): await store-file writes instead of polling (#1412) (#1413)
* fix(storage): await store-file writes instead of polling (#1412)

OAuth persistence is Zustand `persist` → file-storage adapter →
writeStoreFile, which the persist middleware invokes fire-and-forget:
saveTokens updates the in-memory store synchronously while the file
write lags. Tests verified persistence by polling the state file with a
2s/50ms `waitForStateFile()` budget, which flaked under full-suite load
(the file was written, the poll just didn't observe it in time).

Make the write completion awaitable instead:
- Track in-flight writeStoreFile() promises per resolved path in
  store-io and add flushStoreFileWrites(filePath?) to await them (also
  useful for graceful shutdown). Writes to the same path are chained so a
  flush awaits every queued write.
- Convert the OAuth E2E, node storage, and file-storage-adapter tests
  from poll loops (waitForStateFile / vi.waitFor on existsSync /
  setTimeout) to `await flushStoreFileWrites(path)` + a single read.
- Remove the now-unused waitForStateFile() test helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(storage): doc load-bearing sync registration; rename stale debug env

Address @claude review on #1413:
- Document that writeStoreFile()'s synchronous pendingWrites registration is
  load-bearing for flushStoreFileWrites(), in both the storage adapter's
  setItem callsite and the pendingWrites docblock, so future refactors don't
  introduce an await before registration and silently re-flake the tests.
- Rename the stale DEBUG_WAIT_FOR_STATE_FILE test env var to DEBUG_PERSIST_PATH
  (waitForStateFile was removed in this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:55:24 -04:00
Cliff Hall
5fb2b503f4
feat(apps): render running MCP Apps end-to-end (#1370) (#1403)
* feat(apps): render running MCP Apps end-to-end (#1370)

Wire the v2 Apps screen so clicking Open App actually runs an MCP App.
Previously App.tsx stubbed the three load-bearing pieces (sandbox URL,
bridge factory, action handlers), so the iframe loaded about:blank and
nothing crossed the bridge.

- Add useSandboxUrl hook (core/react) to read sandboxUrl from GET
  /api/config and thread it down as sandboxPath; render an "MCP Apps
  unavailable" state instead of a blank iframe when absent.
- Add createAppBridgeFactory: builds a host-side ext-apps AppBridge over
  the active client's SDK client, reads the tool's UI resource on the
  sandbox handshake and pushes it into the proxy, handles openLinks, and
  connects a PostMessageTransport.
- AppRenderer: pass the tool to the bridge factory; buffer tool
  input/result and release them once the view signals `initialized`
  (input before result, latest-wins).
- Move the AppRenderer handle ref to App.tsx so onOpenApp issues
  tools/call with the form values and delivers input + result to the
  running app via the bridge; onCloseApp tears the bridge/iframe down.
- Stories: themed sandbox placeholder (visible in light/dark), an
  echo-bridge running story, and an unavailable story. New unit tests
  for the factory, the buffering, useSandboxUrl, and the empty state.

The listChanged indicator badge wiring is tracked separately in #1402.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): make MCP Apps render in dev — single bridge + result passthrough

Two bugs kept apps (e.g. NightRider's MIDI Pattern Editor) stuck on an empty
shell even though the host fetched the resource and called the tool correctly.

1. StrictMode double-mounted the bridge. The dev-only effect setup→cleanup→
   setup built TWO host bridges/transports, double-loaded the sandbox, and
   raced the app's ui/initialize handshake (it timed out, so `initialized`
   never fired and buffered input/result were never flushed). AppRenderer now
   defers bridge disposal to a microtask and, when the effect re-runs
   synchronously with identical inputs, cancels the disposal and reuses the
   live bridge — so a single bridge survives the double-invoke. The pending
   input/result buffer is no longer cleared on a transient cleanup.

2. The SDK rejected the server's result. callTool() validates structuredContent
   against the tool's outputSchema and throws on a mismatch — but an MCP App is
   a passthrough: the host must forward whatever the server returned to the
   running view (which legacy hosts render fine). InspectorClient.callTool gains
   a `skipOutputValidation` option that routes through client.request() (no
   wrapper validation); onOpenApp uses it.

Verified end-to-end against a real app via Playwright: the editor now renders
its grid + pattern data identically to the v1 inspector.

Tests: AppRenderer StrictMode single-bridge/no-dispose case; a get_temp_extra
test-server fixture (returns an extra structuredContent property) with
integration tests covering both the throwing default path and the
skipOutputValidation passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): warn (toast) when an app's result violates its outputSchema

The skipOutputValidation passthrough keeps schema-mismatched apps rendering,
but that silently hid a real spec problem from server developers. Now the
host still renders, but surfaces the mismatch.

- InspectorClient.callTool runs the SDK's own Ajv output validation on the
  skipOutputValidation path NON-fatally and returns the message as
  `outputValidationError` on the ToolCallInvocation (and the
  toolCallResultChange event); the result is still delivered.
- App.tsx onOpenApp shows a yellow toast when outputValidationError is set,
  explaining strict MCP clients may not render the app.

Tests: integration cases asserting outputValidationError is set for a
schema-violating result (get_temp_extra) and unset for a valid one (get_temp).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): summarize output-schema warning in toast + details modal

The previous toast dumped the full (very long) validation error and
overflowed. Now the toast shows a one-line summary with a "View validation
details" link that opens a modal containing the full error in a read-only,
scrollable textarea.

- New OutputValidationModal group component (+ story + tests).
- App.tsx: toast message is a summary + link; clicking it opens the modal
  populated with the tool name and the full outputValidationError.

Verified via Playwright against the NightRider MIDI editor: toast is concise,
link opens the modal with the scrollable details.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(apps): address PR review — theme stability, onError, validation polish

Responds to the automated PR review on #1403.

- Theme toggle no longer rebuilds a running app's bridge. createAppBridgeFactory
  reads the resolved theme from the DOM (Mantine's data-mantine-color-scheme) at
  bridge-build time instead of capturing isDark in the factory deps, so the
  factory identity is stable across theme flips. (#1)
- Wire AppRenderer's onError through AppsScreen -> InspectorView -> App.tsx,
  surfacing bridge/runtime failures (no client, late rejection, failed
  tools/call) as a red toast instead of a silent blank iframe. (#2)
- Extract validateToolOutput into a pure, unit-tested helper covering the
  matching / extra-property / missing-structuredContent / error / bad-schema
  cases (the no-structuredContent branch can't be produced via the SDK server,
  which rejects it before sending). (#4)
- useSandboxUrl aborts its in-flight fetch on unmount (cancelled flag) to avoid
  setState after unmount. (#5)
- Schema-mismatch toast: autoClose:false (details modal is one click away). (note)
- AppRenderer: add a state-machine comment block documenting the bridge
  lifecycle refs. (#6)
- inspectorClient: note why the `as CallToolResult` casts are safe. (note)

Tests: AppsScreen onError surface; validateToolOutput unit suite; factory theme
read from DOM. validate + test:storybook green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(apps): clarify theme attribute + useSandboxUrl fetch comments

Re-review follow-ups (non-blocking):
- createAppBridgeFactory: note that Mantine's data-mantine-color-scheme is only
  ever light/dark (auto is resolved before paint), so no `auto` branch is needed
  and matchMedia only covers an absent attribute.
- useSandboxUrl: correct the doc to say it fetches on mount AND re-fetches on
  baseUrl/authToken change (idempotent), not "once on mount".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:01:44 -04:00
Cliff Hall
440b194f64
fix(state): dispatch connect event after capabilities are populated (#1405)
* fix(state): dispatch connect event after capabilities are populated

The list-state managers (tools/prompts/resources/templates) refresh on the
"connect" event and, since #1395, gate their list RPC on getCapabilities().
But connect() dispatched "connect" before fetchServerInfo() populated
this.capabilities, and refresh() runs synchronously up to its first await —
so the capability gate executed during the synchronous dispatch, read
undefined capabilities (reset to undefined on every disconnect), tripped,
and wiped every list to empty on connect. Tools, prompts, resources, and
resource templates all vanished against any server.

Move the "connect" dispatch to after the awaited fetchServerInfo() so
capabilities are in place before any manager refreshes. The unit tests
missed this because their FakeInspectorClient has capabilities populated
from construction; added an integration regression test wiring a real
ManagedToolsState on connect and asserting the tool list is non-empty
(fails without the fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(integration): harden connect-race regression test per review

Address the two non-blocking suggestions from the PR review:
- Replace the 100ms settle sleep with waitForEvent() on the managers'
  change events, so the test is deterministic against slow CI instead of
  racing a fixed timeout.
- Assert a second affected primitive (ManagedResourcesState alongside
  ManagedToolsState). The fix is a single shared line, but covering two
  distinct capabilities guards against a future change gating one
  primitive differently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:10:07 -04:00
Cliff Hall
9e494544e7
fix(state): gate list fetches on server capability (#1350) (#1395)
The four list-fetching state managers fired their list RPC against every
server on connect regardless of advertised capabilities, so a server that
doesn't implement a given primitive replied -32601 "Method not found",
surfacing in the console on every connect. Mirrors the `tasks`-capability
gate added for ManagedRequestorTasksState in #1349.

- managedToolsState        → gate on capabilities.tools
- managedPromptsState      → gate on capabilities.prompts
- managedResourcesState    → gate on capabilities.resources
- managedResourceTemplatesState → gate on capabilities.resources
  (the spec defines no separate resourceTemplates capability;
  resources/templates/list is part of the resources surface)

When the capability is absent each manager sets an empty list, dispatches
its change event, and returns — the right semantics for "this server
doesn't support X."

Tests: existing flow tests now construct their FakeInspectorClient with
the relevant capability so the live list path is still exercised; added a
"refresh skips listX when capability absent" and a "connect against an
X-less server doesn't fire listX" test per manager. Updated the
useManagedX peers and resourceSubscriptionsState (which drives a
ManagedResourcesState refresh) to advertise the capability too.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:06:55 -04:00
Cliff Hall
5c5e5ab754
feat(network): show auth response bodies with masked secrets + reveal toggle (#1386) (#1387)
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)

Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.

Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:

- New shared helper `clients/web/server/inject-auth-token.ts` embeds
  `<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
  `</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
  query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
  (`core/mcp/remote/constants.ts`).

Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): wire OAuth authorization-code flow into App.tsx (#1379)

OAuth-protected MCP servers could not be connected to from the v2 web
client: the core OAuth pipeline exists, but App.tsx never invoked it, so a
connect attempt 401'd and surfaced "Remote send failed (401): … Missing
Authorization header" as a toast.

Wire the two missing entry points (all core primitives already in place):

- Auto-trigger on 401: in onToggleConnection's catch, detect an upstream
  401 (isUnauthorizedError) and call client.authenticate(), which runs
  discovery + DCR (backend-proxied) and redirects the page to the auth
  server via BrowserNavigation. The initiating server id is persisted to
  sessionStorage first, since the OAuth `state` carries only mode+authId
  and the full-page redirect wipes React state.
- /oauth/callback handler: a mount effect that, once `servers` hydrate,
  parses the callback params, recovers the pending server, rebuilds its
  InspectorClient, runs completeOAuthFlow(code) (PKCE verifier + DCR client
  info survive in BrowserOAuthStorage), replaceState("/") so a reload can't
  replay the single-use code, then connect(). An `error=` callback toasts
  instead of retrying.

connect() already attaches the OAuth provider to the transport
(inspectorClient.ts), so once tokens land in BrowserOAuthStorage the
outbound request carries the bearer token.

Extracted the pure pieces (constants + isUnauthorizedError) to
src/utils/oauthFlow.ts with unit tests. Verified end-to-end in a real
browser against the MCP SDK demo OAuth server: Connect -> redirect ->
auto-approve -> callback -> Connected, with the access token shown in the
Connection Info modal (#1377).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384)

After #1379, the Network tab showed only the post-redirect auth HTTP
(discovery re-run + POST /token); the pre-redirect discovery and the DCR
POST /register that run during authenticate() were lost when the page
navigated to the auth server.

Root causes:

1. Ordering — BrowserNavigation set `window.location.href` before the
   client's `saveSession` event fired (OAuthManager calls onBeforeOAuthRedirect
   *after* auth() already navigated), so the save raced the unload and was
   dropped. Fix: BrowserNavigation now runs a synchronous `beforeNavigate`
   hook immediately before assigning location.href; App wires it through
   createWebEnvironment to flush the active fetch log to RemoteInspectorClient
   Storage (keyed by the authId parsed from the auth URL) via a keepalive POST
   that outlives the unload.

2. Illegal invocation — RemoteInspectorClientStorage defaulted to
   `this.fetchFn = globalThis.fetch` and called `this.fetchFn(...)`, which
   re-binds `this` and makes native fetch throw "Illegal invocation"
   (swallowed by the catch). This silently broke *all* session save/load.
   Fix: default to a wrapper that preserves the global receiver.

3. Restore race — hydrateFetchRequests replaced the list, so a load that
   resolved after the resuming connect appended live entries would clobber
   them. Fix: merge restored (older) entries ahead of live ones, dedupe by id.

saveSession also now uses keepalive: true.

Verified end-to-end against the MCP SDK demo OAuth server: the connected
page's Network tab shows the full handshake — pre-redirect discovery + DCR
/register plus post-redirect discovery + /token as `auth`, alongside
`transport`. Added unit tests for the beforeNavigate ordering and the
hydrate merge/dedupe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): show auth response bodies with masked secrets + reveal toggle (#1386)

Auth-category Network entries showed request body/headers/status but never
the response body (rendered "(empty)"), because buildEffectiveAuthFetch
deliberately skipped capturing it to avoid surfacing access_token /
refresh_token. That hid the most useful thing to inspect when debugging
OAuth — the token exchange.

Capture auth response bodies, but mask sensitive OAuth fields by default
behind a click-to-reveal toggle so they aren't exposed at a glance during
a screen-share:

- inspectorClient: wire updateResponseBody on the auth fetcher.
- src/utils/maskSecrets.ts: maskSecretsInBody() masks access_token,
  refresh_token, id_token, client_secret (case-insensitive, nested) in JSON
  bodies; reports whether anything was masked. Non-JSON / secret-free bodies
  pass through untouched.
- NetworkEntry BodyPreview: when a body has masked fields, render it masked
  with a Reveal/Hide toggle (copy honors the shown view). Masking is a UI
  concern; the raw entry is unchanged so reveal shows the real value.

access_token / refresh_token live in the post-redirect /token response,
which is never written to the session-restore files (#1384); only
pre-redirect bodies (public discovery, DCR /register) persist, so no bearer
token hits disk.

Verified end-to-end: the /token response shows masked by default
(access_token: "••••••••"), Reveal exposes the raw value, and discovery /
the public DCR /register response (no secret) render with no toggle. Added
util + component unit tests and a story play function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)

Addresses code-review feedback on the token-injection PR:

- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
  served the raw index.html off disk via serveStatic, bypassing injection — so
  a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
  Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
  real static assets (paths with a dot) still serve verbatim. Dev already
  injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
  to sessionStorage (not just the URL-param branch), priming the backstop for
  any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
  carrying a token isn't cached and served stale after a restart regenerates
  the token.

Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): address #1383 review — split OAuth/connect toasts, tighten 401 match (#1379)

Code-review feedback on the OAuth-wiring PR:

- Callback effect: split completeOAuthFlow vs connect() into separate
  try/catch blocks. A token-exchange failure now reads "OAuth token exchange
  failed … Please try connecting again." (the single-use code is spent and the
  URL was cleared, so a reload can't retry); a post-OAuth connect failure reads
  "Failed to connect" since OAuth actually succeeded and re-clicking Connect
  reuses the persisted tokens.
- isUnauthorizedError: anchor the message fallback on the transport's
  `failed …(401)` wording instead of a bare `(401)`, so an unrelated `(401)`
  spliced into an error message can't trip the OAuth flow. Added a test.
- Documented that clearing the pending id + URL before the server lookup is
  intentional (deleted/renamed server mid-flow → require a fresh Connect).

Also merges the squash-merged #1382 base from v2/main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(auth): address #1385 review — clarify double-save + keepalive cap + test fetch default (#1384)

Code-review feedback on the OAuth Network-log persistence PR:

- Documented the double-save: `FetchRequestLogState`'s `saveSession` listener
  is the backstop; `BrowserNavigation`'s `beforeNavigate` hook is the primary
  flush for the redirect case. Notes the listener may lose the navigation race
  and is harmless when it duplicates (last-writer-wins, identical payload).
- Reworded the keepalive comment in `RemoteInspectorClientStorage.saveSession`:
  the 64KB cap is general (the method is also reachable from the listener with
  the full session log), so a long session could exceed it and drop silently —
  acceptable since the persisted log is best-effort, not load-bearing.
- Added a regression test that constructs `RemoteInspectorClientStorage`
  without a `fetchFn`, stubs `globalThis.fetch`, and asserts the default
  wrapper calls it (locks in the "Illegal invocation" fix, which callers
  otherwise swallow).

Optional items (logger.warn on swallowed save errors; setupClientForServer dep
churn) acknowledged on the PR, not changed.

Also merges the squash-merged #1383 base from v2/main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): address #1387 review — form-encoded masking, cleaner flag, a11y (#1386)

Code-review feedback on the auth-response-body masking PR:

- Extended masking to form-urlencoded bodies (finding #1): the token *request*
  is `application/x-www-form-urlencoded` and carries `code` / `code_verifier` /
  `client_secret` / `refresh_token`. `maskSecretsInBody` now masks those in
  form bodies too (preserving formatting; placeholder not percent-encoded).
  `code`/`code_verifier`/`client_assertion` are form-only sensitive keys —
  deliberately NOT masked in JSON, where `code` is usually an error/status code.
- Replaced the double-stringify `hasSecrets` heuristic with an explicit
  masked-flag propagated out of `maskNode` (finding #2) — robust if the
  transform ever grows non-identity behavior, and one fewer serialization.
- Reset reveal state on body change via `key={body}` remount instead of a
  setState-in-effect (finding #3; avoids the cascading-render lint rule).
- a11y (finding #4): `aria-label` on the Reveal/Hide button and `aria-live`
  on the hidden/revealed status text.
- Security tripwire comment near the `saveSession` listener (finding #6):
  captured auth bodies are unmasked at source (masking is UI-only), so any new
  post-token-exchange persistence path must redact first.

Tests: form-encoded masking (token + refresh requests), JSON `code` NOT masked,
non-object JSON pass-through, default-fetch wrapper. Story play + NetworkEntry
tests updated for the new aria-labels and the now-masked form request body.
Finding #5 (pretty-print asymmetry) is already handled by ContentViewer's JSON
formatting; #4-aria and #6 are the doc/a11y bits.

Also merges the squash-merged #1385 base from v2/main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(network): address #1387 third-pass review — content-type masking, wholesale mask, dedup (#1386)

Third-pass code-review feedback on the auth-body masking PR:

- maskSecretsInBody now takes the body's content-type (finding #1): `*json*`
  → JSON masking, form-urlencoded → form masking, any other known type
  (HTML/plaintext/XML) → no masking; absent/unknown → sniff as before. Removes
  the implicit "non-JSON ⇒ form" guess for error pages etc. NetworkEntry passes
  the request/response `content-type` header through to BodyPreview.
- Mask any non-empty value under a sensitive key wholesale (finding #2):
  a non-standard object/array value under e.g. `access_token` is replaced
  rather than recursed into, so it can't leak. Empty-string still not flagged.
- Extracted `isSensitiveKey(set, key)` to dedupe the JSON/form key checks
  (finding #3).
- Reworded `MaskResult.masked` doc to cover the form (non-pretty-printed) case
  (finding #6).

Tests: non-string-value-under-sensitive-key wholesale mask; repeated form
param; empty form value not flagged; content-type honored (JSON type skips
form masking, text/plain skips masking, explicit form type masks).

Finding #5 (story re-hide) skipped per reviewer — unit test already covers
re-mask. Finding #4 (form edge cases) added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(network): address #1387 fourth-pass nits — content-type contract, key, form test (#1386)

Fourth-pass review (LGTM) — minor, non-blocking items:

- Documented the content-type matching contract on `maskSecretsInBody`:
  substring match (`*json*` / `*x-www-form-urlencoded*`), and we trust the
  wire's own label (a mislabeled body renders raw — acceptable for the
  screen-share threat model) (finding #1).
- Key `<BodyPreview>` by content-type + body (not body alone) so a header-only
  change would also reset the reveal state (finding #3; not reachable today).
- Storybook `AuthSuccess`: assert revealed count `>=` reveal-button count to
  mirror the `hidden >= 2` check, so adding a non-masked body later can't drift
  the assertion silently (finding #4).
- Added a `NetworkEntry` unit test for form-encoded request-body masking
  (code/code_verifier) — previously exercised only via the Storybook play.

Finding #2 (silent decodeURIComponent fallback on malformed keys) acknowledged
as a known quirk outside the threat model; no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(network): address #1387 fifth-pass — memoize masking, mask DCR mgmt token (#1386)

Fifth-pass review (LGTM):

- Memoized `maskSecretsInBody` in `BodyPreview` (recommended item #1): a
  Reveal/Hide click no longer re-parses + re-walks the body; cost is once per
  mount, and the `key` remount re-runs it on body/content-type change. Guarded
  so a too-large body is never parsed (the hook runs unconditionally, with the
  size check inside the memo).
- Added `registration_access_token` (RFC 7592 DCR management credential, same
  bearer class) to the masked key set, and a confidential-client `/register`
  response fixture test (#5) asserting client_secret + registration_access_token
  masked, client_id/metadata visible.
- Tightened the `isMaskableValue` doc to state the exact contract: non-null,
  non-empty-string values are masked wholesale (#3).

Items #2 (transport parse cost — capped by the memo) and #4 (test-only
MASK_PLACEHOLDER export) left as-is per the review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(network): clarify body-update emits list event only (#1387 sixth pass) (#1386)

Sixth-pass review (LGTM, all nits). Added a comment on
`onFetchRequestBodyUpdate` noting it re-emits only `fetchRequestsChange`, not
the per-entry `fetchRequest` event — list-reading consumers pick up the body
on the next render; a future per-entry subscriber would need its own event.

Other items left as-is per the review: full-string `BodyPreview` key (#1,
micro-perf only if profiled), NaN under a sensitive key (#3, consistent with
the mask-non-null-non-empty contract), `&`-only form separator (#4, OAuth
never emits `;`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:52:13 -04:00
Cliff Hall
98192fd86e
fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384) (#1385)
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)

Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.

Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:

- New shared helper `clients/web/server/inject-auth-token.ts` embeds
  `<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
  `</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
  query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
  (`core/mcp/remote/constants.ts`).

Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): wire OAuth authorization-code flow into App.tsx (#1379)

OAuth-protected MCP servers could not be connected to from the v2 web
client: the core OAuth pipeline exists, but App.tsx never invoked it, so a
connect attempt 401'd and surfaced "Remote send failed (401): … Missing
Authorization header" as a toast.

Wire the two missing entry points (all core primitives already in place):

- Auto-trigger on 401: in onToggleConnection's catch, detect an upstream
  401 (isUnauthorizedError) and call client.authenticate(), which runs
  discovery + DCR (backend-proxied) and redirects the page to the auth
  server via BrowserNavigation. The initiating server id is persisted to
  sessionStorage first, since the OAuth `state` carries only mode+authId
  and the full-page redirect wipes React state.
- /oauth/callback handler: a mount effect that, once `servers` hydrate,
  parses the callback params, recovers the pending server, rebuilds its
  InspectorClient, runs completeOAuthFlow(code) (PKCE verifier + DCR client
  info survive in BrowserOAuthStorage), replaceState("/") so a reload can't
  replay the single-use code, then connect(). An `error=` callback toasts
  instead of retrying.

connect() already attaches the OAuth provider to the transport
(inspectorClient.ts), so once tokens land in BrowserOAuthStorage the
outbound request carries the bearer token.

Extracted the pure pieces (constants + isUnauthorizedError) to
src/utils/oauthFlow.ts with unit tests. Verified end-to-end in a real
browser against the MCP SDK demo OAuth server: Connect -> redirect ->
auto-approve -> callback -> Connected, with the access token shown in the
Connection Info modal (#1377).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384)

After #1379, the Network tab showed only the post-redirect auth HTTP
(discovery re-run + POST /token); the pre-redirect discovery and the DCR
POST /register that run during authenticate() were lost when the page
navigated to the auth server.

Root causes:

1. Ordering — BrowserNavigation set `window.location.href` before the
   client's `saveSession` event fired (OAuthManager calls onBeforeOAuthRedirect
   *after* auth() already navigated), so the save raced the unload and was
   dropped. Fix: BrowserNavigation now runs a synchronous `beforeNavigate`
   hook immediately before assigning location.href; App wires it through
   createWebEnvironment to flush the active fetch log to RemoteInspectorClient
   Storage (keyed by the authId parsed from the auth URL) via a keepalive POST
   that outlives the unload.

2. Illegal invocation — RemoteInspectorClientStorage defaulted to
   `this.fetchFn = globalThis.fetch` and called `this.fetchFn(...)`, which
   re-binds `this` and makes native fetch throw "Illegal invocation"
   (swallowed by the catch). This silently broke *all* session save/load.
   Fix: default to a wrapper that preserves the global receiver.

3. Restore race — hydrateFetchRequests replaced the list, so a load that
   resolved after the resuming connect appended live entries would clobber
   them. Fix: merge restored (older) entries ahead of live ones, dedupe by id.

saveSession also now uses keepalive: true.

Verified end-to-end against the MCP SDK demo OAuth server: the connected
page's Network tab shows the full handshake — pre-redirect discovery + DCR
/register plus post-redirect discovery + /token as `auth`, alongside
`transport`. Added unit tests for the beforeNavigate ordering and the
hydrate merge/dedupe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)

Addresses code-review feedback on the token-injection PR:

- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
  served the raw index.html off disk via serveStatic, bypassing injection — so
  a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
  Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
  real static assets (paths with a dot) still serve verbatim. Dev already
  injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
  to sessionStorage (not just the URL-param branch), priming the backstop for
  any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
  carrying a token isn't cached and served stale after a restart regenerates
  the token.

Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): address #1383 review — split OAuth/connect toasts, tighten 401 match (#1379)

Code-review feedback on the OAuth-wiring PR:

- Callback effect: split completeOAuthFlow vs connect() into separate
  try/catch blocks. A token-exchange failure now reads "OAuth token exchange
  failed … Please try connecting again." (the single-use code is spent and the
  URL was cleared, so a reload can't retry); a post-OAuth connect failure reads
  "Failed to connect" since OAuth actually succeeded and re-clicking Connect
  reuses the persisted tokens.
- isUnauthorizedError: anchor the message fallback on the transport's
  `failed …(401)` wording instead of a bare `(401)`, so an unrelated `(401)`
  spliced into an error message can't trip the OAuth flow. Added a test.
- Documented that clearing the pending id + URL before the server lookup is
  intentional (deleted/renamed server mid-flow → require a fresh Connect).

Also merges the squash-merged #1382 base from v2/main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(auth): address #1385 review — clarify double-save + keepalive cap + test fetch default (#1384)

Code-review feedback on the OAuth Network-log persistence PR:

- Documented the double-save: `FetchRequestLogState`'s `saveSession` listener
  is the backstop; `BrowserNavigation`'s `beforeNavigate` hook is the primary
  flush for the redirect case. Notes the listener may lose the navigation race
  and is harmless when it duplicates (last-writer-wins, identical payload).
- Reworded the keepalive comment in `RemoteInspectorClientStorage.saveSession`:
  the 64KB cap is general (the method is also reachable from the listener with
  the full session log), so a long session could exceed it and drop silently —
  acceptable since the persisted log is best-effort, not load-bearing.
- Added a regression test that constructs `RemoteInspectorClientStorage`
  without a `fetchFn`, stubs `globalThis.fetch`, and asserts the default
  wrapper calls it (locks in the "Illegal invocation" fix, which callers
  otherwise swallow).

Optional items (logger.warn on swallowed save errors; setupClientForServer dep
churn) acknowledged on the PR, not changed.

Also merges the squash-merged #1383 base from v2/main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:39:25 -04:00
Cliff Hall
881ba4b5e8
feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378) (#1382)
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)

Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.

Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:

- New shared helper `clients/web/server/inject-auth-token.ts` embeds
  `<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
  `</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
  query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
  (`core/mcp/remote/constants.ts`).

Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)

Addresses code-review feedback on the token-injection PR:

- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
  served the raw index.html off disk via serveStatic, bypassing injection — so
  a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
  Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
  real static assets (paths with a dot) still serve verbatim. Dev already
  injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
  to sessionStorage (not just the URL-param branch), priming the backstop for
  any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
  carrying a token isn't cached and served stale after a restart regenerates
  the token.

Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:10:27 -04:00
Cliff Hall
e3b0b6a908
feat(connection-info): wire Connection Info modal to the connection handshake (#1377)
* feat(server-info): wire Server Info modal to the connection handshake

Closes #1364

- Add ServerInfoModal wrapping the existing ServerInfoContent, mirroring the
  ServerSettingsModal layout.
- Gate the Server Info button on ServerCard to render only when the
  connection is "connected".
- Expose `getClientCapabilities()` on InspectorClient (and protocol +
  FakeInspectorClient), and surface it through useInspectorClient so the
  modal can render the client side of the capability handshake without
  poking the SDK Client's private state.
- App.tsx: open the modal from the gated Server Info button, derive the
  active server's transport and OAuth details (synchronously, from the
  guided-OAuth state snapshot + persisted settings), and reset the open
  flag on InspectorClient `disconnect` so the modal does not auto-reopen
  on a future reconnect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-info): dedupe modal header and render instructions in ContentViewer

- Drop the in-content "Server Information" Title from ServerInfoContent and
  rename the modal title to "Server Information" so the modal renders one
  heading instead of two stacked ones.
- Swap the instructions Blockquote for ContentViewer (copyable, with the
  existing wrapping Code variant) so long instructions wrap and the user
  can copy the full payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server-info): rename ServerInfo -> ConnectionInfo

The popup covers server identity AND client capabilities AND OAuth state —
it's a snapshot of the live connection, not just the server. Rename to match
what it actually shows. Also add a "Server Implementation" heading above
the top section (name/version/protocol/transport) to balance the existing
capability headings.

- ServerInfoContent -> ConnectionInfoContent (directory, file, exports)
- ServerInfoModal -> ConnectionInfoModal (directory, file, exports)
- ServerCard button label "Server Info" -> "Connection Info";
  prop onServerInfo -> onConnectionInfo
- Modal title -> "Connection Info"
- App.tsx / ServerListScreen / InspectorView wiring updated to match

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(connection-info): cap instructions block height so it scrolls in place

A long server prompt (e.g. an Everything-style instruction document) was
pushing the OAuth section and the modal chrome off-screen — users had to
scroll the whole modal to reach the close button. Wrap the ContentViewer
in ScrollArea.Autosize with mah=280 so the instructions section scrolls
internally and the rest of the modal stays put.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(connection-info): add subtle background to section headings

Add a `section` variant for Title that paints the heading in the existing
`--inspector-surface-subtle` token (same surface token already used for
modal call-out panels). Apply it to each section heading in
ConnectionInfoContent (Server Implementation, Server Capabilities, Client
Capabilities, Server Instructions, OAuth Details) via a SectionHeading
subcomponent constant, so the sections read as distinct cards against the
modal background instead of bleeding into one stream of text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(stories): wrap long import line in ConnectionInfoModal.stories.tsx

Prettier flagged the import in CI. The line exceeded printWidth=80 and
needed to wrap onto multiple lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): require `npm run format` before committing

Add a pre-commit instruction to run the auto-fixing `npm run format` ahead
of `npm run validate`. `validate` runs `format:check` (non-fixing) which
will fail CI on any unformatted file — running the auto-fixer first
eliminates that class of failure entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(favicon): use theme/assets/MCP.svg as the favicon source of truth

Point index.html's icon link at /src/theme/assets/MCP.svg so the same MCP
logo asset already imported by ViewHeader is also the favicon. Delete the
now-unused clients/web/public/favicon.svg. Vite resolves the source path
during build and emits a hashed copy under dist/assets/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-card): surface failed-handshake errors with a 5s auto-dismiss

Two related fixes so the user actually sees what went wrong when a
connect attempt fails (e.g. OAuth-backed server, bad URL, missing dev
auth token):

- App.tsx: add `connectInFlightRef` and skip clearing `activeServerId`
  in the InspectorClient `disconnect` listener while a connect attempt
  is in flight. The transport closes during a failing handshake and
  dispatches `disconnect` before the catch block sets `errorMessage`;
  without this guard InspectorView would map the card to plain
  "disconnected" and the InlineError would never render.

- InlineError: opt-in `autoDismissMs` prop that runs a Mantine `slide-up`
  Transition exit animation after the message has been visible for the
  given duration. Visibility is derived from a "dismissed message"
  state (rather than a mounted boolean + setMounted-in-effect), so the
  timer re-arms whenever `error.message` changes without tripping the
  react-hooks/set-state-in-effect rule. ServerCard passes
  autoDismissMs={5000}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server-card): own InlineError visibility + animate parent-driven removal

Move the 5s auto-dismiss timer and visibility state out of InlineError
and into ServerCard so the slide-up exit animation runs in both cases:

- Timer-driven: after 5s the alert animates out (existing behavior).
- Parent-driven: when `connection.error` is cleared mid-flight (e.g. a
  successful reconnect), the alert ALSO slides up instead of vanishing.

To keep the message painted during the parent-driven exit, ServerCard
tracks a `lastError` state that mirrors `connection.error` only on its
truthy edges. The conditional setState during render is the supported
React pattern for "remember the last seen value of a prop"; gated on
message inequality so it can't loop. The `useRef` version of this
pattern failed `react-hooks/refs` (no ref writes/reads during render).

InlineError is now a pure presentational component: `mounted` prop
controls a Mantine `slide-up` Transition. The internal `autoDismissMs`
prop is gone — its single caller has moved to the controlled `mounted`
pattern.

Closes the "error vanishes the moment the parent removes it" half of #1364
(the timer half landed in 6d147e0f).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server-card): surface connection errors as a toast, drop InlineError

Move handshake-failure messaging out of the ServerCard body and onto a
Mantine toast (`notifications.show`). The inline alert had a 5-second
visibility contract entangled with InspectorView's status pipeline, and
the active card was flipping through error → disconnected → error during
a failing handshake; surfacing the error as a toast sidesteps that whole
timing problem and keeps the card focused on the live status indicator.

- App.tsx: toast on the connect-catch path; drop `errorMessage` state +
  `connectInFlightRef` (the latter was only there to keep the inline
  alert visible past the transport's close event).
- InspectorView: drop the `errorMessage` prop and the
  `connectionStatus === "error" && errorMessage` mapping branch.
- ServerCard: drop the InlineError rendering + the displayedError /
  errorVisible visibility state machine.
- Delete the InlineError component — no other consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(notifications): anchor toasts to the bottom right

Move the Notifications portal from top-right to bottom-right so a
connection-failure toast doesn't overlap the ViewHeader / tabs row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address PR feedback on the Connection Info modal

1. Hoist `EMPTY_CLIENT_CAPABILITIES = Object.freeze({})` to module scope
   in `useInspectorClient` so the fallback returns a stable reference
   (avoids invalidating downstream `useMemo`/`useEffect` deps when no
   client is attached).
4. Drop the `?? "stdio"` silent fallback on `connectionInfoTransport`
   in the JSX — gate the modal render on `activeServer` too. The
   "stdio" default still lives at the derivation site (config.type is
   schema-optional and `ServerType` is required by the modal prop),
   but the gate makes the invariant explicit.
6. Replace the raw `document.querySelector("button.mantine-CloseButton-root")`
   in `ConnectionInfoModal.test.tsx` with `screen.getByRole("button",
   { name: /close/i })`. Add `aria-label="Close"` to the
   CloseButton so the assertion (and screen readers) have a name to
   match on.
7. Switch the OAuth scope splitter from `/\s+/` to the literal `" "`
   per OAuth 2.1 §3.3.

Also clarify in comments:
2. Note that `connectionInfoOAuth` uses snapshot-at-open semantics on
   purpose — token refreshes mid-session won't re-render the modal.
5. Note that we prefer the AS-advertised `authorization_endpoint`
   over `authorizationUrl` because the latter is per-flight noise
   (state, code_challenge) rather than a stable identifier.

Finding 3 (modal exit animation short-circuited by the `{initializeResult
&& …}` guard) left as intentional — we want the snap-shut on disconnect
so a stale info dialog can't keep rendering with no live client behind it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(connection-info): tighten the OAuth memo comment per re-review

The reviewer noted that "snapshot-at-open" was imprecise — the memo
re-runs on any of its deps (connectionStatus, inspectorClient,
activeServer), which includes settings edits that re-reference the
active server. Reframe as "snapshot-at-last-derivation" and call out
the natural triggers (server switch, reconnect, settings edit) so a
future reader doesn't conclude the memo is open-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 16:42:37 -04:00
Cliff Hall
4641339cc3
feat(network): add Network screen surfacing backend fetch log (#1355) (#1363)
* feat(network): add Network screen surfacing backend fetch log (#1355)

Routes `FetchRequestLogState` to a new Network tab so users can inspect
HTTP-layer request/response headers, status, duration, and category
(auth vs transport) of every fetch the backend makes for an active
session. Adds `NetworkControls` + `NetworkStreamPanel` + `NetworkEntry`
groups, a `monoBreak` Text theme variant for long header values, and
wires onClearNetwork / onExportNetwork (JSON download) from `App.tsx`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): include request / response bodies in search filter

Headers were already covered but bodies were not, so searches for
strings that live inside the JSON-RPC payload (e.g. "jsonrpc",
"initialize", a tool name) silently returned no matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): always render Response Body section, note streaming

Streaming responses (text/event-stream, application/x-ndjson, POST to
/mcp) are intentionally not body-captured by the fetch tracker, so the
section was silently omitted. Now whenever a response was received we
render the section, with either the body, a "(empty)" note, or a
"Streaming response — body not captured" note keyed off content-type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): capture response bodies via async body-update events

Two related fixes:

1. The fetch tracker was marking every streamable-HTTP POST /mcp call as
   "not captured" because the response content-type is text/event-stream.
   In practice those streams are bounded — the server sends the
   JSON-RPC reply (sometimes preceded by progress events) and closes —
   so we can clone the body and read it. Only GET + SSE / ndjson is the
   unbounded long-lived channel that has to stay uncaptured.

2. Reading the body inline blocked the transport from processing
   progress notifications until the entire stream finished, breaking the
   `resetTimeoutOnProgress` integration test. The body is now read in
   the background and dispatched via a new `fetchRequestBodyUpdate`
   event that the FetchRequestLogState patches into the matching entry
   by id. Plumbing wires through node/transport, the remote SSE channel
   (RemoteSession, RemoteClientTransport), and the remote event types.

Also hides the Network tab when the active server is stdio (no HTTP
traffic to surface) and bumps the inline body preview limit to 100 KB
so typical tools/list responses render rather than tripping the "too
large" notice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(network): show orange SSE badge for long-lived stream entries

Adds a small "SSE" badge to the right side of the NetworkEntry header
(left of the HTTP status badge) when the response is a long-lived
streaming channel (GET + text/event-stream or application/x-ndjson) —
the only case where the body genuinely isn't captured. Makes the
"not captured" placeholder less surprising at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): address PR review findings

- Stop capturing OAuth response bodies (drop updateResponseBody from the
  effectiveAuthFetch tracker). Restores pre-PR behavior for auth-category
  entries so live access_token / refresh_token values can't leak into the
  Network tab body preview during a screen-share. Headers + status are
  still tracked.
- Extract `isLongLivedStreamResponse(method, contentType)` to
  core/mcp/fetchTracking so the rule lives in one place; consumed by the
  tracker and the NetworkEntry UI placeholder.
- Document the intent of NetworkEntry's `useEffect` (the list-level
  toggle is authoritative; per-entry overrides are intentionally
  discarded on each list toggle).
- Rename MAX_INLINE_BODY_BYTES → MAX_INLINE_BODY_CHARS (`.length` is
  UTF-16 code units, not bytes) and update the user-facing notice.
- Switch NetworkStreamPanel's search from joined-string `.includes` to
  per-field `.some(.includes)` so a term can't span field boundaries.
  Regression test added.
- Add aria-label to ListToggle ("Expand all" / "Collapse all") — both an
  a11y improvement and a stable selector that lets the panel test stop
  fishing the button out by text-content negation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(network): use ListToggle aria-label in panel + modal tests

Cleanup spotted in PR review follow-up:

- HistoryListPanel + ServerSettingsModal previously fished the ListToggle
  out by empty text content because the toggle had no accessible name.
  Now that ListToggle exposes "Expand all" / "Collapse all", both tests
  can use getByRole + name and the stale comments go away.
- ListToggle's own tests now assert the aria-label contract so a future
  refactor can't silently drop it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): expose category toggle state via aria-pressed

The category UnstyledButtons in NetworkControls previously communicated
their toggled state only via background color. Adds aria-pressed={active}
so assistive tech can discover the on/off state without visuals, with a
test that asserts the attribute tracks the visibleCategories prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:35:25 -04:00
Cliff Hall
f6ac075f24
fix(servers): make settings form re-render on every keystroke (#1361) (#1362)
* fix(servers): make settings form re-render on every keystroke (#1361)

The settings modal is fully controlled by App.tsx, but pending edits were
stashed in a useRef — refs don't trigger re-renders, so the input's `value`
prop stayed stale until the 300ms debounce + PUT + refetch round-tripped.
Clicking "Add Header" / "Add Metadata" showed no row, and typing in OAuth
fields had no character echo, until that round-trip completed (~500ms).

The bug landed in #1353 with the debounced-flush pattern. It surfaced after
#1356 because per-keystroke PUTs now also write the keychain, making the
round-trip latency more noticeable.

Lift the draft into `useState`. Every change re-renders the modal
immediately. Keep the 300ms debounce on the PUT side. Initialize the draft
from the server-list entry only when the modal opens to a new target (not
on every `servers` change) so background refreshes don't clobber the user's
in-progress edits. Flush on close to preserve final keystrokes.

Verified live against the dev server: header/metadata rows appear on click,
OAuth Client ID / Client Secret typing echoes without lag, and the values
persist correctly (clientId in mcp.json, clientSecret in the macOS
Keychain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(servers): extract useSettingsDraft hook + tests; review polish (#1361)

Picks up the three review observations on #1362:

1. Extract the draft state + debounce + flush logic from App.tsx into
   `core/react/useSettingsDraft`. Same behavior; thinner App.tsx;
   testable in isolation with `renderHook`. Tests pin the regression
   #1361 was about (synchronous onChange → draft update) plus the
   debounce window, burst-collapse, flush-on-close, error propagation,
   and the "background refresh must not clobber in-progress edits"
   contract via a `resolveInitial` closure swap.

2. Hoist the empty `InspectorServerSettings` fallback to a module-scope
   `EMPTY_SETTINGS` const. Both the hook's init path and the modal's
   closed-state fallback share the same object identity; the inline
   `useMemo` that previously re-derived it is gone.

3. Add a comment on the `resolveInitial` callback noting that we read
   `servers` from this render's closure under the assumption that the
   entry point is the "Settings" button on a rendered server card.
   Documents the contract for future callers (e.g. a keyboard shortcut
   that opens the modal pre-hydration).

Validate clean: 1756 tests pass. New useSettingsDraft.test.tsx adds 13
test cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(servers): stabilize flush identity + add unmount-cleanup test (#1361)

Two non-blocking observations from the third review on #1362:

1. `flush` identity churned on every keystroke because the useCallback
   closed over `draft` + `targetId`. Read both through refs instead so
   the returned callback identity is stable. The behavior is unchanged
   — but the consumer's `onSettingsModalClose` (which wraps `flush`)
   no longer re-allocates per keystroke, which means the modal's
   `onClose` prop identity also stays stable. A new test pins this.

2. Added a comment in `onChange` calling out that callers switching
   `targetId` mid-debounce must `flush()` first to preserve the
   pending PUT. The only switch path today (`onSettingsModalClose` in
   App.tsx) does that already — the comment is for future refactors.

3. New unmount-cleanup test pins the intentional "drop final
   <debounceMs window on unmount" trade-off — flushing on unmount
   could fire a PUT against an unmounting component, which is a
   worse footgun than losing a few hundred ms of typing on route
   change / HMR.

Validate clean: 144 files, 1758 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:42:14 -04:00
Cliff Hall
037225a9fb
feat(servers): move oauthClientSecret and stdio env values to OS keychain (#1356) (#1360)
* feat(servers): move oauthClientSecret and stdio env values to OS keychain (#1356)

mcp.json is designed to be tool-shareable (symlinked from Claude Desktop's
config, pasted into bug reports, synced via dotfiles), so storing OAuth
client secrets and stdio env values in plaintext there meant any of those
flows could leak them. Lift both into the OS keychain via @napi-rs/keyring
(active replacement for the archived keytar). The wire shape is unchanged:
the GET /api/servers handler rehydrates from the keychain so browser code
sees the same JSON; the on-disk file no longer contains the secret
material. Includes an idempotent migration that lifts plaintext from older
mcp.json files (or hand-edited ones) into the keychain on first read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): tolerate keychain unavailability on non-set ops (#1356)

CI on Linux failed because `@napi-rs/keyring` hard-errors without
libsecret, and the route handlers were touching the keychain even for
no-secret flows (defensive `deleteAllForServer` on POST/DELETE, sweep
on PUT). The user's intent for the Linux fallback was "hard fail when a
secret is actually involved" — not on every keychain touch.

Make `KeyringSecretStore.get / delete / deleteAllForServer` silently
degrade when the keychain is unavailable (return null / no-op); `set`
remains the one operation that hard-fails with
`KeychainUnavailableError`. The migration path catches the error and
preserves the disk plaintext rather than losing the secret.

Also inject `InMemorySecretStore` in `useServers.test.tsx` and
`servers-events.test.ts` so tests never touch a developer's real
keychain even when libsecret is present.

Adds `KeyringSecretStore` tests (vi.mock'd native bindings) and an
integration suite that simulates Linux-without-libsecret to verify the
tolerance contract end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): close GET migration race; reverse keychain/disk order (#1356)

Addresses code review:

1. **Concurrency**: GET /api/servers was reading + migrating outside the
   write lock, then writing inside it — a concurrent POST/PUT/DELETE
   between the read and the lock acquisition could be clobbered by the
   migration write. Also affected the first-launch seed write. Fix:
   take a fast unlocked peek; if the file is absent (seed branch) or
   carries plaintext to migrate (migration branch), re-read inside the
   write lock and decide there. Added `hasPlaintextSecrets()` as a
   pure predicate so the common no-migration path stays lock-free.

2. **Write ordering**: POST and PUT wrote disk first, then keychain.
   A keychain `set` failure mid-write left a half-configured entry on
   disk and trapped retries at 409. Reversed to keychain-first so a
   503 leaves both stores in their pre-write state. PUT in-place now
   sets new fields, writes disk, then deletes obsolete fields (a
   failed disk write leaves orphan keychain entries — recoverable —
   rather than premature deletions of fields the user still expects
   to see).

3. **Tolerance simplification**: dropped the no-entry regex in
   `KeyringSecretStore.delete` — both "no entry" and "keychain
   unavailable" collapse to the same desired outcome (the entry isn't
   there anymore), so a uniform swallow is clearer.

4. **Parallelism**: `readKeychainEntriesFor` now `Promise.all`s the
   per-field `get` calls. macOS Keychain round-trips are 10-50ms;
   serializing 5+ env vars per server × N servers is a meaningful
   wall-clock cost on GET.

5. **Spec doc**: reflects the tolerance contract (only `set`
   hard-fails) and the keychain-before-disk write ordering rationale.

New regression test in servers-route.test.ts covers the retry-after-503
contract directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(servers): parallel keychain writes + migration partial-state comment (#1356)

Two nice-to-have observations from the re-review:

- `writeKeychainEntriesFor` now `Promise.all`s its per-field sets,
  matching the read side. No ordering requirement among distinct
  (id, field) keys; on unavailability every set rejects and Promise.all
  surfaces the first rejection (still 503 from the route handler).
- `migratePlaintextSecrets` catch block now spells out the
  partial-migration semantics: we return the original config so the
  disk plaintext stays intact, and the next successful GET retries
  with the keychain-wins branch absorbing already-set entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(servers): parallel keychain deletes for symmetry (#1356)

Picks up the symmetry observation from the third review: `deleteKeychainFields`
now uses `Promise.all` like its read and write siblings. Distinct (id, field)
deletes have no ordering requirement, and `secretStore.delete` is already a
silent no-op on unavailability so Promise.all has no failure-mode surprise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:33:46 -04:00
Cliff Hall
a9df501856
feat(servers): flatten mcp.json settings to top-level fields (#1358) (#1359)
* feat(servers): flatten mcp.json settings to top-level fields (#1358)

Eliminate the nested `settings` block introduced in #1352 so each
server entry's Inspector-extension fields live as direct keys alongside
`type` / `url`, matching the `.mcp.json` shape Claude Code / Cursor /
Cline write:

  - `headers` becomes a `Record<string, string>` on disk (was nested
    `settings.headers` as a pair-array)
  - `oauth` becomes a nested `{ clientId?, clientSecret?, scopes? }`
    object (was flat `settings.oauthClientId` / `oauthClientSecret` /
    `oauthScopes`)
  - `metadata`, `connectionTimeout`, `requestTimeout` move to the
    entry's top level as-is (Inspector-only fields)

The in-memory + wire shape is unchanged from #1352: `InspectorServerSettings`
keeps pair-array `headers` and flat `oauth*` fields because the form
needs them in that shape to drive controlled-component editing. The
bidirectional conversion lives in `serverList.ts`
(`storedFieldsToInspectorSettings` / `inspectorSettingsToStoredFields`)
and is invoked by `mcpConfigToServerEntries` / `serverEntriesToMcpConfig`
and by the server route's `buildStoredEntry`. The `PUT /api/servers/:id`
wire envelope keeps the preserve/clear/apply semantics from #1353 — the
backend splats validated `settings.*` onto top-level disk keys when
assembling the next on-disk shape.

Hard-cutover migration per the issue's decision 4: files written by the
one pre-#1358 v2/main build have a nested `settings` block; we drop the
node on read with a one-line warn including the server id. The persisted
headers / metadata / timeouts / OAuth credentials are intentionally lost
on first read — users re-enter them via the form (or hand-edit the file
into the flat shape). v2 has not shipped a stable release with the
nested shape, so the blast radius is dogfooders only.

The smuggle-guard in `buildStoredEntry` widens from just `settings` to
all six Inspector-extension keys (`settings`, `headers`, `metadata`,
`connectionTimeout`, `requestTimeout`, `oauth`), so a caller can't
bypass `validateSettings` by nesting any of them inside `config`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR review on flatten (#1359)

- Single-source the Inspector-extension key list with `satisfies`
  (finding 1). `INSPECTOR_FIELD_KEY_MAP satisfies Record<keyof
  StoredInspectorFields, true>` in serverList.ts means a new field on
  the type forces a compile error here. The set is exported and
  re-used by the server route's smuggle guard, so adding `proxy` or
  similar in the future updates all three sites in lockstep. The PUT
  preserve path now uses a new `stripInspectorFields` helper that
  derives from the same source instead of hand-destructuring.
- Add read-path shape validation in `normalizeMcpServers` (finding 2):
  `headers: "oops"` / `metadata: 42` / `oauth: []` / non-numeric
  timeouts are dropped individually with a warn, so a hand-edited
  malformed file can't put garbage rows into the form. Mirrors the
  write-side `validateSettings` symmetry. New integration test covers
  each malformed shape with a partial fixture so a single bad key
  doesn't take out the whole entry.
- Align new warns to the `fileLogger || console.warn` fallback pattern
  (finding 3) via a small `logWarn` helper, so the legacy-drop and
  smuggle warns are visible in `npm run dev` where the logger is
  often unconfigured.
- Spec doc tweak: the CLI/TUI out-of-scope note's "writes to
  `settings.headers`" reference now reads "writes to the entry's
  top-level `headers` field on disk (post-#1358 flat shape)" (finding
  4). Avoids confusion for future readers.
- Nits: replace `current.mcpServers[originalId]!` with explicit
  narrowing; add a comment on the OAuth truthiness drop in
  `storedFieldsToInspectorSettings` mirroring the write-side comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address pass-2 review on flatten (#1359)

- Tighten `isNonNegNumber` with `Number.isFinite` so `Infinity` and
  `NaN` are rejected at the read boundary, matching the write-side
  intent (finding D). Hand-edited `connectionTimeout: Infinity` would
  have passed the prior check and propagated to the form.
- Add a one-line comment in `normalizeMcpServers` noting that the
  per-entry object is mutated in place via the `delete` calls — safe
  for today's callers (freshly parsed JSON, no other reference held)
  but worth flagging for the next reader (finding A).

Skipping findings B (move type guards to module scope — single call
site) and C (`isOauthObject` lenient on extra keys — reviewer
confirmed this is the intended forward-compat behavior, matching
`normalizeServerType`'s unknown-key passthrough).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(servers): fix mutation-comment caller branch label (#1359)

The previous comment named the GET handler's "seed branch" as a caller
of normalizeMcpServers, but that branch returns DEFAULT_SEED_CONFIG
directly without normalizing. The actual second caller is the
file-present branch (after parseStore). Correcting the label so a
future reader looking for the seed branch caller doesn't waste time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:52:37 -04:00
Cliff Hall
5f9bfdc490
feat(servers): hot-reload server list on external mcp.json edits (#1345) (#1357)
* feat(servers): hot-reload server list on external mcp.json edits (#1345)

Watch ~/.mcp-inspector/mcp.json from the backend and push a change event
on a new /api/servers/events SSE channel. The web client subscribes via
fetch + ReadableStream (so the existing bearer-auth contract is
preserved — EventSource can't send custom headers) and re-fetches on
each event. The watcher is lazy: it starts on the first SSE subscriber
and stops when the last one disconnects, so tests that never open the
channel don't spin up a real fs watcher. Self-fires from the backend's
own POST/PUT/DELETE are suppressed by capturing the post-write mtime and
comparing it on each watcher event.

Chokidar is used for cross-platform editor-save semantics (temp-file +
rename produces unlink+add sequences that bare fs.watch handles poorly
on macOS/Linux); awaitWriteFinish coalesces those into a single event.

createRemoteApp now returns a close() to release the watcher, which the
standalone server and the vite dev plugin chain into their existing
close paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR review on hot-reload (#1357)

- writeMcpAndTrackMtime now stat()s the file before writing; if the
  mtime differs from the last value we recorded, an external editor
  slipped in between our writes and chokidar would coalesce both into
  one event whose mtime matches our own write — broadcast directly so
  peer subscribers learn about the external edit (review finding 1, 5).
- Split useServers.refresh into a public refresh() (loading toggle) and
  an internal refreshInternal(background) the SSE handler calls; the
  list now stays visible during background refreshes instead of
  flashing skeleton state on every external mcp.json edit (finding 2).
- Coalesce multiple \n\n frames landing in one decode chunk to a single
  refresh call so back-to-back broadcasts don't race two concurrent
  GETs whose setState order is unspecified (finding 3).
- Clarify createRemoteApp's close() docblock about in-flight SSE stream
  callbacks (finding 7).
- Add integration coverage for PUT/DELETE self-suppression and for the
  external-edit-between-backend-writes broadcast (finding 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address pass-2 review on hot-reload (#1357)

- Clarify writeMcpAndTrackMtime comment so a future reader can't
  mistake the broadcast for a content-preservation guarantee: the
  external edit's content may already have been read-around and
  overwritten depending on the interleave with readMcpConfig.
- Drop the no-op GET in the PUT/DELETE self-suppression tests. The
  pre-stat branch is short-circuited by `lastWrittenMtimeMs === null`
  with or without it; matches the POST test's structure.
- Add explicit no-flicker regression test: tracks every `loading`
  value across renders during an SSE-driven refresh and asserts none
  flipped back to true. Catches a future regression that reaches for
  setLoading(true) in the background path even under React batching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:30:05 -04:00
Cliff Hall
85a4b4c057
feat(servers): persist per-server settings to mcp.json (#1352) (#1353)
* feat(servers): persist per-server settings to mcp.json (#1352)

- Add optional `settings` node alongside the transport config in each
  `~/.mcp-inspector/mcp.json` entry. Round-trips through
  `mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and the
  `/api/servers` POST/PUT routes.
- Drop `MCPServerConfig.headers` from `SseServerConfig` /
  `StreamableHttpServerConfig` and remove the headers textarea from
  `ServerConfigModal`. Custom headers are now entered only in
  `ServerSettingsForm` and reach the wire via `settings.headers`.
- Wire `ServerSettingsModal` into `App.tsx`; saves dispatch through a new
  `useServers.updateServerSettings` mutator. `updateServer` carries the
  existing settings across config-only saves so they aren't dropped.
- Apply settings on the *first* outbound request after a server loads —
  no need to open the settings form first:
  - `settings.headers` → SSE / streamable-http wire headers.
  - `settings.metadata` → `InspectorClientOptions.defaultMetadata`,
    merged into every outgoing `_meta` (call-time keys win).
  - `settings.requestTimeout` → `InspectorClientOptions.timeout`.
  - `settings.connectionTimeout` → Promise.race wrapper around connect().
  - `settings.oauth*` → pre-seeded OAuth client credentials.
- `RemoteConnectRequest` carries `settings` so the remote backend can
  source transport headers from the same source as the local path.
- `oauthClientSecret` is persisted in `mcp.json` alongside stdio `env`
  values, both protected by the file's `0o600` permission.
- Update `specification/v2_servers_file.md` with the new shape and the
  removal of the legacy headers field.

Tests: round-trip in `serverList.test.ts`; persistence on POST/PUT in
`servers-route.test.ts`; first-connect header injection in
`transport.test.ts`; `defaultMetadata` merging in
`inspectorClient.test.ts`; `useServers.updateServerSettings` +
`updateServer` settings-preservation in `useServers.test.tsx`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): rebuild InspectorClient on every connect so settings refresh

The previous onToggleConnection branch reused the existing InspectorClient
on a same-server reconnect, which froze settings at the moment the client
was first constructed. Editing headers, metadata, timeouts, or OAuth
credentials in the settings form was therefore invisible until the user
either switched servers or reloaded the page — surprising right after a
visible save.

Always call setupClientForServer on (re)connect so the latest
target.settings flow through to the transport's headers / requestInit, to
defaultMetadata, and to the OAuth manager.

Also adds remoteClientTransport tests that pin the wire-level contract:
settings round-trip through the /api/mcp/connect body when supplied, and
the field is omitted when no settings are configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(history): stop wiping pendingRequestEntries inside the connect event

State managers (ManagedToolsState, ManagedPromptsState, ManagedResourcesState,
ManagedResourceTemplatesState, ManagedRequestorTasksState) handle the
client's "connect" event by calling `void this.refresh()`. The synchronous
portion of refresh runs through SDK Protocol.request → transport.send →
MessageTrackingTransport.send → trackRequest, all before yielding to the
network — so each list request gets tracked into MessageLogState's
pendingRequestEntries inside the same connect event dispatch.

MessageLogState's own "connect" listener was registered after those state
managers (it's the last one constructed in App.tsx) and cleared
pendingRequestEntries on every connect. Listener order meant the clear ran
*after* the requests were tracked but before their responses arrived,
leaving the history with unmatched "response" entries marked PENDING.

The clear-on-disconnect (via statusChange → disconnected) already handles
session-boundary cleanup, so clear-on-connect was purely defensive and now
actively harmful. Drop the connect listener; keep the disconnect path.

Tests: updated the unit test to assert the new behavior (connect does not
clear); added an integration regression that wires a sibling connect
listener firing listTools and asserts the response folds into the request
entry instead of appearing as an orphan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: drop accidentally tracked debugging screenshot

pending-requests.png was a local debugging artifact attached to a
conversation, not source. Remove it from the tree (the working copy is
kept so the user still has it locally) and rely on the existing untracked
behavior going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(servers): add e2e check that settings.headers reach upstream MCP

Stitches the full v2 pipeline together in one assertion:

  browser-style RemoteClientTransport
    → POST /api/mcp/connect (settings in body)
    → backend createTransportNode applies settings.headers to requestInit
    → SDK StreamableHTTPClientTransport
    → upstream MCP test server

Captures the upstream HTTP request via FetchRequestLogState and asserts
both `X-Tenant` and `X-Trace` are present on the outbound POST. Catches
any regression that breaks the wire between the form and the upstream
server in a single integration test, instead of the previous coverage
which exercised each hop in isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ServerSettingsModal): invert ListToggle compact prop so icon matches state

ListToggle's `compact` prop represents the list's *current* compact state:
true means "currently compact" and renders the expand icon to advertise
the action (open up). The settings modal was passing
`compact={allExpanded}` — exactly inverted. When all sections were open
it showed the expand icon (suggesting "open more") and when collapsed it
showed the collapse icon, opposite of every other ListToggle caller in
the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(HistoryListPanel): match ServerListScreen Export button — rename + default variant

Renames "Export JSON" to "Export" and switches from the subtle ToolbarButton
to the same `Button variant="default"` used by ServerListControls so the
two Export affordances look identical across screens. Also moves the
button to the right of the ListToggle (mirroring the order on the server
list screen) and disables it when there are no entries to export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(HistoryListPanel): move Clear button to top toolbar, match Export style

Moves the Clear affordance out of the "History (N)" section header and
up to the panel toolbar alongside Export, where it now uses the same
`Button variant="default"` styling. Order in the toolbar is now:
ListToggle · Clear · Export. Clear is disabled when there are no
unpinned entries to remove (pinned entries are intentionally preserved).

ToolbarButton helper is unused after this change and dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: ignore local debugging screenshots at repo root

Stops `git add -A` from re-staging the `*.png` files Claude Code drops
at the repo root from attached conversation screenshots. The pattern
only matches the top-level (`/*.png`) so legitimate image assets in
subdirectories (e.g. `clients/web/public/`) still get tracked when
explicitly added.

Also untracks the two screenshots that slipped through previously
(initialize-request.png, pending-requests.png).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(LogStreamPanel): match HistoryListPanel button styling

Clear / Export / Copy All on the Logging screen were rendered with the
subtle ToolbarButton helper while History's equivalents use
`Button variant="default"`. Switch all three to the default variant so
the toolbars look identical across screens and mirror the disabling
behavior — each button is now disabled when `entries.length === 0`.

ToolbarButton helper is unused after this change and dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR review — debounce save, preserve-on-omit PUT, validate shape, lift connect timeout

Round-up of fixes from https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4526861959:

1. ServerSettingsModal saves were firing on every keystroke (one PUT +
   full mcp.json refresh per character). App.tsx now debounces by 300ms
   and flushes on modal close so a burst of edits coalesces into one PUT
   and the form value can't flicker back mid-typing.

2. `PUT /api/servers/:id` with an omitted `settings` field used to
   silently wipe the persisted settings node. The route now treats the
   three intents distinctly:
     - field omitted  → preserve existing on-disk settings
     - explicit null  → clear the settings node
     - settings object → validate + apply
   `useServers.updateServer` is simplified to drop the client-side
   carry-through hack, and the test that documented the broken behavior
   is flipped + augmented with explicit-clear and bad-shape cases.

3. `connectionTimeout` Promise.race is moved from App.tsx into
   `InspectorClient.connect()`, reading `this.serverSettings?.connectionTimeout`.
   TUI/CLI consumers now get the same behavior; on timeout the client
   internally disconnects so the next connect starts clean.

4. Custom Headers hint in `ServerSettingsForm` notes that `Authorization`
   is owned by the OAuth flow when OAuth is configured — avoids the
   sharp edge where the SDK's authProvider silently overwrites a
   user-set Authorization header.

5. New `validateSettings` on the server route rejects malformed shapes
   (`settings: []`, `settings.headers: "oops"`, non-numeric timeouts,
   etc.) with 400 instead of letting them persist and crash the UI.

7 (nit). Renamed `settings` → `savedSettings` inside `setupClientForServer`
   so the variable holding the persisted settings doesn't shadow the
   later locally-derived values.

Follow-ups opened separately:
  #1356 — Move oauthClientSecret out of mcp.json into the OS keychain (review #6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR review pass 2 — connect-timeout test, unmount cleanup, tagged intent, lenient settings read

Pass-2 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4527089705:

(a) New integration test pins the new client-layer connect-timeout
    contract: a hanging stub transport with `connectionTimeout: 50`
    rejects with the descriptive error and lands the client in "error"
    status. Documents what TUI/CLI consumers should expect from the
    lifted Promise.race in InspectorClient.connect().

(b) useEffect cleanup clears `pendingSettingsTimerRef` on unmount so a
    route change or HMR mid-debounce can't fire one stale PUT against
    an unmounted component.

(c) Refactor the PUT-route `settingsIntent` discriminator from
    `"preserve" | "clear" | { value }` to a tagged union with `kind`,
    and consume it with a `switch` for exhaustiveness — reads cleaner
    and makes adding a fourth intent painless.

(d) `normalizeMcpServers` now runs `validateSettings` on the on-disk
    settings node and silently strips it if invalid, mirroring the
    existing lenient-on-read pattern from `normalizeServerType`. Closes
    the read/write asymmetry where a hand-edited malformed `settings`
    could survive on disk and get carried through preserve-on-PUT.
    New route test pins the strip-on-GET + no-propagate-on-preserve-PUT
    behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): pass-3 review — patch-style PUT, toast on close-flush fail

Pass-3 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529276897.

(b) `PUT /api/servers/:id` now treats both `config` and `settings` as
    optional patches:
      - field omitted → preserve the on-disk value
      - explicit body value → apply
      - settings: null → clear
    `updateServerSettings` stops reading `existing.config` from the
    in-memory `servers` snapshot, which closes the closure-capture
    hazard where a refresh between debounce-schedule and flush could
    silently revert a separate edit. The route is the single source of
    truth for transport config now. New tests cover settings-only PUT,
    rejected non-object config, and the empty-body no-op patch case.
    The previous "rejects a missing config" test is rewritten to
    document the new no-op-preserve semantics.

(c) Inline comment near the PUT rebuild loop documents that writing
    the full mcpServers map back is what gives the route its desirable
    self-healing property for malformed settings on *other* entries —
    deliberate side-effect, not an accident of normalization.

(d) `flushPendingSettings` surfaces failures via @mantine/notifications
    instead of swallowing them. The modal already closed by the time
    the flush PUT resolves, so a silent fail would leave the user
    thinking their last edits saved (especially painful for the OAuth
    client secret). The notifications wiring was already mounted in
    main.tsx.

Pass-3 (a) — settings form being effectively uncontrolled mid-edit — is
acknowledged in the reply: working as intended today; the proper fix is
internal modal state when file-watching (#1345) forces the issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): pass-4 review — strip smuggled settings out of config

Pass-4 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529861349.

Main finding: `normalizeServerType` spreads unknown keys, so a body
that nested `settings` inside `config` would smuggle a settings field
onto the stored entry without ever passing through `validateSettings`.
Strip the settings key off the incoming config in `buildStoredEntry`
so `validateSettings` remains the single write path for the settings
node — the "one source of truth" invariant pass 2 set up holds again.

Two new tests pin the strip on both routes:
  - POST with `config.settings = { bogus }` lands an entry with no
    settings node on disk.
  - PUT with `config.settings = { bogus }` + `settings: null` clears
    the real settings *and* doesn't re-attach the bogus payload via
    the spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): pass-5 review — absorb late connect rejection, tighten validateSettings, log smuggle warning

Pass-5 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529954490.

Main finding: when the connect-time `Promise.race` lost to the timeout,
the original `this.client.connect(this.transport)` promise was left
pending. After `disconnect()` tore the transport down, real transports
(SSE / streamable-http) would reject that promise later — with no
handler attached, producing a Node `unhandledRejection` (and a vitest
suite warning). Attach a no-op `.catch` right after creating the
connect promise so Node sees the late rejection as handled.

Nit (a): `validateSettings` was casting the raw object through to
`InspectorServerSettings`, which let unknown keys ride along onto disk.
Replaced the cast with an explicit pick-and-build over the known
fields. Unknown stowaways now silently drop on the way through. New
route test pins the behavior.

Nit (b): `buildStoredEntry` now logs a `warn` via the route's pino
logger when an incoming `config` carries a `settings` key. The strip
is still defensive (it isn't a documented contract) but the warning
lets a misconfigured client find out about the wasted field instead
of debugging a silent drop.

Nit (c) — the defensive `?? {}` fallback in `buildStoredEntry` —
acknowledged in the review reply; left in place as belt-and-braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): pass-6 polish — scope absorber, thread id, drop empty OAuth, guard `in`

Pass-6 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529979135. Reviewer signed off as ready; landing the four marginal cleanups since they're all small.

(a) `connectPromise.catch(() => {})` moves inside the
    `if (connectTimeoutMs > 0)` branch. The await-only path propagates
    rejection cleanly through the outer try/catch — the absorber is
    only needed when the race leaves the original promise unattended.
    Code now reads as "absorber is tied to the race," which is the
    real invariant.

(b) Thread `id` (POST id, PUT newId) through `buildStoredEntry` and
    include it in the smuggle warning's pino bindings. Two clients
    smuggling simultaneously are distinguishable in the log instead
    of indistinguishable.

(c) `validateSettings` coerces empty-string OAuth fields to absent.
    The form emits `""` when the user clears an input, and an empty
    `oauthClientId` on disk would later be misread as "OAuth configured."
    New route test pins it.

(d) `buildStoredEntry` adds a null/object guard before `"settings" in
    configObj`. The route layer pre-checks this on POST and PUT, but
    the helper's `unknown` parameter is a weak contract; the guard
    makes it safe to call from anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(spec): reflect settings persistence + patch-PUT contract in v2_servers_file

The doc was authored when `mcp.json` only stored the server list. Bring it
forward to match what shipped in #1352 / PR #1353:

- Summary calls out that the file also stores per-server settings and
  links into the Per-server settings section.
- On-disk format note explains that `settings` is an optional Inspector-
  specific extension that other MCP tools ignore.
- New top paragraph on the Per-server settings section explains the
  basic-config / settings UI split rationale (simpler form for the
  common case, advanced settings tucked behind a second dialog).
- UI bullet is rewritten to match the patch-style PUT semantics that
  emerged across the review cycle: settings-only and config-only
  PUTs preserve their counterpart inside the write lock, so neither
  modal can silently clobber the other half. The earlier "must
  re-send settings on PUT" hack is gone.
- New bullets capture the save cadence (300ms debounce + flush-on-close
  + failure toast), the patch-PUT contract (omit-preserve / null-clear /
  rename), and the write-path / read-path validator gates.
- Secret storage bullet links to #1356 (OS-keychain follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(spec): show a third entry with the optional settings node

The On-disk format example demonstrated only basic stdio entries. Add a
streamable-http entry with a populated `settings` node so readers can
see the shape inline rather than having to scroll to the Per-server
settings section. Inline comment in the block cross-links there for the
full contract and notes that other MCP tools ignore the extension key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:23:26 -04:00
Cliff Hall
420da2e415
feat(servers): export server list as mcp.json download (#1346) (#1351)
* feat(servers): export server list as mcp.json download (#1346)

Adds an Export button to the Servers screen, next to "Add Servers".
Clicking it downloads the current server list as a canonical mcp.json
file (2-space indent, same shape serializeStore writes), so the user
can hand it off, sync it to another machine, or symlink into Claude
Desktop / Cursor / Cline.

Wiring:
- ServerListControls: new Export Button (variant=default), disabled
  when serverCount === 0 (nothing to download).
- ServerListScreen / InspectorView: thread `onExport` / `onServerExport`
  through.
- App.tsx: onServerExport runs serverEntriesToMcpConfig on the in-memory
  servers list, JSON.stringify with 2-space indent, and triggers a Blob
  download via a temporary anchor (appended to body for Firefox
  compatibility, then removed; object URL revoked).

Tests:
- ServerListControls unit tests cover button-count (toggle hidden vs
  shown, Export always present), disabled-on-empty, click fires onExport.
- ServerListControls.stories grows play functions for both stories —
  WithServers asserts Export is enabled + click fires the spy in real
  Chromium, WithoutServers asserts Export is disabled.
- Existing screen + view tests / stories updated for the new prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR #1351 review (extract helpers + exception safety + guard)

Substantive items from the review:

1. Serialization format parity (review #1): added
   `serializeMcpConfig(entries)` to core/mcp/serverList.ts — a
   browser-safe helper that runs `serverEntriesToMcpConfig` then
   stringifies with 2-space indent. Single source of truth for export
   formatting; can't drift from serializeStore's on-disk shape.

2. Exception safety on the anchor cleanup (review #2) + a direct test
   for the App.tsx download wiring (review #6): extracted
   `downloadJsonFile(filename, json)` to clients/web/src/lib/
   downloadFile.ts. removeChild + revokeObjectURL run in a `finally`
   so a thrown click() doesn't leak the DOM node or the object URL.
   Unit-tested under happy-dom with URL.createObjectURL mocked
   (happy-dom doesn't ship it).

3. Empty-list guard (review #5): App.tsx onServerExport bails when
   `servers.length === 0`. The button's disabled prop already prevents
   the UI path, but the handler is now locally correct against any
   future programmatic caller (storybook, keyboard shortcut, etc.).

Acknowledged but not changed:
- Tooltip on the disabled Export button (review #3) — Mantine's
  disabled-button + Tooltip wart wants a span wrapper + custom events
  config. Optional per the reviewer; deferred.
- Repeated exports → `mcp (1).json` browser suffix (review #4) — a
  deliberate trade-off since `mcp.json` is the conventional name a user
  would expect to symlink into Claude Desktop / Cursor / etc.

Tests: 1647 unit (+6) + 410 integration + 306 storybook = 2363 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:15:56 -04:00
Cliff Hall
db1916cd34
feat(servers): persist server list to ~/.mcp-inspector/mcp.json (#1343) (#1349)
* feat(servers): persist server list to ~/.mcp-inspector/mcp.json (#1343)

Replaces the hardcoded SEED_SERVERS in App.tsx with a file-backed list at
~/.mcp-inspector/mcp.json. Full CRUD round-trips through new /api/servers
routes; first launch writes the two existing seeds if the file is absent.

Backend (core):
- store-io: getDefaultMcpConfigPath() — sibling of getDefaultStorageDir()
- core/mcp/serverList.ts: pure converters MCPConfig <-> ServerEntry[],
  DEFAULT_SEED_CONFIG, and normalizeServerType (moved from node/config.ts
  to keep serverList.ts Node-free)
- core/mcp/remote/node/server.ts: GET/POST/PUT/:id/DELETE/:id /api/servers
  routes, plus RemoteServerOptions.mcpConfigPath defaulting to
  getDefaultMcpConfigPath(). Ids validated with validateStoreId.

Frontend:
- core/react/useServers.ts: { servers, loading, error, refresh, addServer,
  updateServer, removeServer } over the new endpoints
- App.tsx: drops SEED_SERVERS, wires useServers, disconnects on remove of
  the active server, follows id on rename of the active server
- ServerConfigModal: Add/Edit/Clone form (stdio: command/args/env/cwd;
  sse/streamable-http: url/headers as one-per-line text)
- ServerRemoveConfirmModal: confirm-on-remove with id + transport summary

Tests (52 new):
- unit: getDefaultMcpConfigPath env-var permutations, serverList round-trip,
  hook flows against real Hono app.fetch (no TCP), modal forms + validation
- integration: /api/servers routes against a real createRemoteApp + tmp file
- stories with play functions for both new modals

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR #1349 review (correctness + nits)

Bugs:
- normalizeServerType: validate against the allowed set and default
  unknown / non-string types to "stdio" (matches the missing-type case).
  Prevents a hand-edited {"type":"websocket"} from leaking past `as
  ServerType` and surprising downstream narrowing.
- onConfirmRemove: when removing the active server, also destroy() the 9
  state managers and null them + the client out so they GC immediately
  instead of waiting for the next server switch.
- /api/servers mutating routes: serialize POST/PUT/DELETE through an
  in-process write-lock so concurrent read-modify-write cycles can't lose
  updates. New integration test fires 25 parallel POSTs and asserts every
  entry lands.
- readMcpConfig comment: update to match actual behavior (valid-JSON file
  with no `mcpServers` → empty; invalid JSON → 500 from the outer try, so
  corruption surfaces rather than silently emptying the list). New
  integration tests pin the 500 path on both GET and POST.

Nits:
- Add --inspector-surface-subtle token, use it in ServerRemoveConfirmModal
  instead of the raw Mantine var.
- Rename useServers buildHeaders boolean param to includeJsonBody.
- Add a comment on the env-value vs header-value trim asymmetry (env
  values preserve trailing whitespace; header values follow RFC 7230).

Tests: 1630 unit + 404 integration + 306 storybook all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ServerConfigModal): null currentTarget in functional setState updater

React nulls SyntheticEvent.currentTarget after the synchronous handler
returns. Reading e.currentTarget.value inside `setForm((f) => ...)`
worked under happy-dom (which keeps currentTarget around) but threw
"Cannot read properties of null (reading 'value')" on the first
keystroke in a real browser. Capture the value before the functional
updater closes over it.

All seven onChange handlers in ServerConfigModal had the bug. Extended
the AddEmpty storybook play to type into Server ID and Command — the
real-Chromium runner now exercises this path so a future regression
fails the test suite instead of the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(remote-server): surface stderr + transport_error when subprocess crashes during startup

A stdio subprocess that spawns successfully but exits before the browser
opens the SSE events stream used to vanish into a bare "Session not
found" 404. The fix has three parts:

1. RemoteSession.markTransportDead always pushes the transport_error
   event (via pushEvent's queue) rather than dropping it when no
   consumer is attached. The queued event drains when the consumer
   eventually arrives.

2. /api/mcp/connect's transport.onclose handler no longer deletes the
   session immediately when no consumer is attached. Instead it holds
   the session (with queued stderr + transport_error) for a 30s grace
   window so the events endpoint can drain it. A timeout sweeps if no
   consumer ever connects.

3. /api/mcp/events detects isTransportDead after attaching the consumer
   (which just drained the queue), yields once to flush, then closes
   the stream and deletes the session.

End result: the user sees the actual error (e.g. "Cannot find module"
from stderr + the transport-closed reason) instead of a confusing 404.

Tests:
- New unit tests (clients/web/src/test/integration/mcp/remote/remote-session.test.ts)
  cover the queueing behavior, live delivery, and clearEventConsumer
  signaling under all transport-state permutations.
- New integration test (connect-crash.test.ts) spawns a node subprocess
  that writes to stderr and exits, then verifies the events stream
  delivers both the stderr line and the transport_error event before
  closing — the exact scenario from PR #1349.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(state): gate listRequestorTasks on the server's `tasks` capability

ManagedRequestorTasksState was calling tasks/list on every connect.
Servers that don't advertise the `tasks` capability (the common case)
reply with -32601 "Method not found", which then surfaces in the
inspector's console for every connect.

Skip the call when the server doesn't declare `tasks` — empty tasks
list is the right semantics. Existing tests opt into capabilities so
the live-path coverage is preserved; two new tests pin the gated
behavior (direct refresh + connect-event-triggered refresh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(App): clear activeServerId on disconnect so other server cards re-enable

ServerCard dims any card whose id differs from `activeServer` (and marks
it inert), so cards stay non-interactive after a disconnect until the
user reconnects elsewhere. activeServerId was set on connect but never
reset on disconnect, leaving the whole screen locked to the just-
disconnected card.

Subscribe to InspectorClient's `disconnect` event and clear
activeServerId there. Covers all three disconnect paths uniformly:

- user toggles the connected card off
- user clicks the header Disconnect button
- mid-session transport failure (subprocess exit / network drop)

Event-based instead of effect-based on connectionStatus to avoid the
first-render-clobbers-new-id trap (status starts as "disconnected" for
a freshly-created client before connect() runs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:26:23 -04:00
Cliff Hall
6678313373
Expose live resource subscriptions via a hook (#1325) (#1326)
* Expose live resource subscriptions via a hook (#1325)

Add ResourceSubscriptionsState that mirrors InspectorClient's subscribed
URIs as InspectorResourceSubscription[], resolving each URI against the
managed resources list (so the subscription tile shows server-supplied
name/title) and stamping lastUpdated on notifications/resources/updated.
Pair it with useResourceSubscriptions and wire App.tsx so the Resources
screen reflects subscribe/unsubscribe actions in real time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review notes on resourceSubscriptionsState

- Soften the onResourceUpdated comment: the client's dispatch is already
  guarded by subscribedResources.has(uri), so the re-check is true
  defense-in-depth rather than guarding a known hazard.
- Use this.getSubscriptions() in the statusChange handler so every emit
  goes through the defensive-copy path.
- Document the deliberate fallback to a synthetic Resource when a
  previously-listed resource is removed from the managed list while
  the user is still subscribed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Reflect subscribed state in preview + compact subscribed tile labels

- Wire `isSubscribed` on the ReadResourceState passed to ResourcesScreen
  by deriving it from the live subscriptions list in App.tsx. The
  ResourcePreviewPanel's SubscribeButton already flips its label to
  "Unsubscribe" when subscribed; without this derivation isSubscribed
  was always false and the button looked stuck on "Subscribe".
- In ResourceSubscribedItem, display only the last URI path segment
  (truncated with ellipsis if it still overflows) and surface the full
  URI via a hover tooltip. Keeps the Subscriptions pleat readable when
  resources have long path-style URIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auto-read resource on sidebar click

Clicking a resource in the URIs accordion now triggers onReadResource
in addition to setting the selection, so the preview panel jumps
straight to the pending → ok render path. Removes the unreachable
"Click to read this resource" placeholder, since the loader / preview /
error states always cover the rendered output for a selected resource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Render markdown resources via react-markdown + fix preview overflow

- Add react-markdown / remark-gfm dependencies.
- ContentViewer accepts an optional mimeType prop; when it matches
  text/markdown (or text/x-markdown), text blocks render via
  react-markdown wrapped in a `.markdown-content` container. Non-markdown
  text now uses the existing Code "wrapping" variant so long lines stay
  inside the panel.
- ResourcePreviewPanel infers `text/markdown` from a `.md` / `.markdown`
  URI suffix when the server didn't supply a mimeType, and threads the
  effective mime through to ContentViewer per content item.
- ResourcesScreen flex children get miw=0 so a long unwrappable line in
  the resource body can no longer push the preview past the viewport's
  right edge.
- App.css gains a `.markdown-content` ruleset constraining nested pre,
  table, code, and img elements to the container's width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Contain resource preview: sticky header/footer, scrollable body

Restructure ResourcePreviewPanel into a fixed-height column with the
resource title + URI pinned to the top and the timestamp / annotations /
subscribe-refresh actions pinned to the bottom. The content viewer area
in the middle now owns its own ScrollArea, so a long markdown body
scrolls within the panel instead of pushing the subscribe button below
the viewport.

In ResourcesScreen, the selectedResource branch (and the template
branch's right pane) now hosts the panel inside a PreviewPane Flex
column with the screen's max-height, and renderReadState wraps in a
FillDetailCard sized to fill that column. The legacy outer
ScrollArea.Autosize is removed for these panes since scrolling is
internal now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auto-hide template after read; size preview to content with cap

- After the user clicks Read Resource on the template form,
  handleReadResource now clears selectedTemplateUri so the screen
  swaps from the template form to the resource preview. Previously
  both panels stayed mounted side-by-side.
- The preview no longer hard-fills the viewport. The Card uses a new
  "preview" theme variant (overflow: hidden) and is content-sized,
  capped at SCROLL_MAX_HEIGHT. ResourcePreviewPanel's flex column
  marks the header / meta / footer rows as `flex: 0 0 auto` and the
  ContentScroll as `flex: 0 1 auto` with `mih: 0`, so:
    - short content → card hugs it, footer sits right under the body
    - long content → card caps at viewport, ContentScroll shrinks
      and scrolls internally, footer stays pinned at the cap
- ScrollArea / Group imports in ResourcesScreen pruned to match the
  simpler single-pane layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Cap template form panel at 40% width

A bare text input + Read Resource button stretched across the whole
main content area looks awkward on wide displays. Apply maw=40% to
the PreviewPane in the template branch so the form keeps a reasonable
form-field width regardless of viewport.

The preview branch is unaffected — resource bodies still get the full
main area for content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Live completion/complete suggestions on resource template inputs

Wires the server's `completions` capability through to the resource
template form so each variable's input becomes an Autocomplete that
fires `completion/complete` (debounced, 300ms) on every keystroke and
surfaces the returned values as a dropdown — mirroring v1's behavior.

- core/mcp/inspectorClientProtocol.ts: surface getCompletions on the
  protocol so non-runtime callers (state managers, hooks, tests) can
  depend on it. FakeInspectorClient gains a vi.fn-backed stub.
- ResourceTemplatePanel: accepts onCompleteArgument + completionsSupported.
  When both are present it renders Mantine Autocomplete instead of
  TextInput, debounces keystrokes via per-arg timers, aborts in-flight
  requests on the next keystroke, and disables client-side filtering
  (the server already filtered for the typed prefix).
- ResourcesScreen / InspectorView: thread the props through; the
  screen-level callback re-injects the active template's URI as the
  `ref: "ref/resource"` so the panel-level callback stays ref-free.
- App.tsx: wires onCompleteArgument to inspectorClient.getCompletions
  and derives completionsSupported from `capabilities?.completions`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:15:51 -04:00
Cliff Hall
02182e3b6c
test(core): backfill coverage on inspectorClient + remote server (#1317)
* test(core): backfill coverage on inspectorClient + remote server

Resolves #1310. Drops the two TODO(#1310) exclusions from
clients/web/vite.config.ts (coverage.exclude); both files now pass the
per-file gate (lines >=90, statements >=85, functions >=80, branches >=50).

core/mcp/remote/node/server.ts (88.15 / 78.37 -> 97.29 / 100):
- Extract createTokenAuthProvider to its own file
  (core/mcp/remote/node/tokenAuthProvider.ts) so the 7 no-op
  OAuthClientProvider stubs can be unit-tested directly.
- Add focused integration tests covering the remaining gaps:
  * createTransportNode failure path on POST /api/mcp/connect
  * /api/mcp/send when transport is dead
  * /api/log non-JSON body (catch fallback)
  * forwardLogEvent unknown-level / no-messages / string-first-message
    fallbacks (lines 232, 240-242, 257-265 in the original)
  * /api/fetch missing-url + upstream-throw paths
  * /api/storage POST invalid storeId + invalid JSON body
  * /api/storage GET when the on-disk store is unparseable

core/mcp/inspectorClient.ts (73.48 / 72.09 -> 91.16 / 89.92):
Added a single "Coverage backfill (#1310)" describe block to the existing
integration test, plus a few unit-style cases:
- OAuth getters / setOAuthConfig / authenticate when oauthManager is unset.
- Simple session, roots, subscription, and instructions accessors.
- subscribe / unsubscribe error paths against a server without the
  subscribe capability; happy path against one with it.
- getPrompt + readResourceFromTemplate happy path + expansion-failure.
- setLoggingLevel "Server does not support logging" guard.
- getAppRendererClient (null before connect, cached proxy after).
- OAuth + stdio: authenticate() throws via getServerUrl()'s stdio check.
- listChanged notifications: tools/resources/prompts handlers via the
  fixture add_tool / add_resource / add_prompt tools.
- Misc tiny branches: re-connect no-op, elicit form mode, getTaskCapabilities
  on a tasks-less server, receiver-task TTL cleanup + cancel via the
  private surface (the focused-unit pass the issue describes).
- defensive `if (!this.client) throw` guards: one nullify-and-call test
  exercises 16 of them via a single instance, plus the connect() entry
  guard.

The receiver-task internals test uses (instance as unknown as
{ private fields... }) to drive createReceiverTask / cancelReceiverTask
/ TTL cleanup directly; per the issue, refactoring private helpers to
be testable from a unit test was authorized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(core): address #1317 PR review feedback

- Rename the misnamed "listChanged notifications" describe in
  inspectorClient.test.ts to "capability detection after connect", since
  the test asserts only on getCapabilities() round-tripping and never
  triggers or observes a notification dispatch. Real dispatch coverage
  lives in the adjacent "list_changed + resourceUpdated notifications"
  block. Kept the resources/prompts fixtures with a note explaining why
  they are required for the server to advertise the matching capabilities.
- Expand the comment on the 250ms wait in remote-transport.test.ts'
  /api/mcp/send dead-transport test so it is clear the >= 500 assertion
  is intentionally permissive across both the dead-transport short-
  circuit and the downstream send-failure branches.
- Add a "keep this in sync" pointer on the nullify() helper so a future
  cleanup that removes any of the defensive `if (!this.client) throw`
  guards in inspectorClient.ts is reminded to drop the matching call here
  rather than silently under-covering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:43:55 -04:00
Cliff Hall
82269e78cb
refactor(core): apply v1.5-port cleanups deferred from #1306 (#1316)
* refactor(core): apply v1.5-port cleanups deferred from #1306

Addresses issue #1309 — five small, mechanical cleanups deferred from the
v1.5 runtime port:

1. Collapse BrowserOAuthStorage / NodeOAuthStorage / RemoteOAuthStorage
   onto a shared OAuthStorageBase. Three ~130-line duplicates become
   thin adapter wrappers; behavior + public API unchanged.
2. Re-enable erasableSyntaxOnly in tsconfig.app.json by rewriting the
   seven (plus two the issue missed in sampling/elicitation create-
   message) parameter-property constructors as explicit field + assign.
3. Route the lone console.error in inspectorClient.ts through this.logger.
4. Replace `${Date.now()}-${Math.random()}` with crypto.randomUUID() at
   the five local-only ID sites (message tracking + sampling/elicit).
5. Pin the remote-server hono app generic at new Hono<Env>() and drop
   the streamSSE cast. Root cause of the cast was an unresolved hono
   subpath: the tsconfig "hono/*" wildcard didn't honor the exports
   map, so hono/streaming leaked to the root-hoisted hono@4.12.12 while
   hono itself resolved to clients/web/hono@4.12.18. Replaced the
   wildcard with an explicit hono/streaming entry in both tsconfig.app
   and tsconfig.test, removing the package-identity mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(core): address #1316 PR review feedback

- Drop the stranded no-op getServerState() read at the top of
  clearClientInformation in OAuthStorageBase (carried over verbatim
  from the three originals).
- Drop the crypto.randomUUID() guard at the task-ID site so all
  six in-scope sites are consistent; the fallback branch was dead
  code on every supported runtime.
- Remove the residual `c as Context` cast at the streamSSE call site
  in core/mcp/remote/node/server.ts — once both hono imports
  resolve to the same package the route-typed Context flows through
  to streamSSE with no cast at all.
- Move the new `options` / `config` field declarations in
  RemoteClientTransport up with the rest of the field block, matching
  the layout used by the other five rewritten classes in this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:15:48 -04:00