Commit graph inspector/clients/web/src/test/integration
Author SHA1 Message Date
Cliff Hall
7fecebed5e
OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1694)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1625)

Re-key the persisted OAuth store to (server, issuer) and add the
SEP-837/2350/2352 authorization-hardening UX. The SDK v2 auth() drives the
SEP-2352 flow off the provider's ctx.issuer; the Inspector's job is to honor it
and round-trip the issuer stamp.

SEP-2352 — issuer-bound credentials
- ServerOAuthState nests issuer-bound credentials under
  byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with
  an activeIssuer pointer answering the transport's ctx-less per-request bearer
  read.
- Fix a latent bug: OAuthStorageBase re-parsed tokens/client-info through
  OAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFC
  `issuer` field and silently defeat the SDK's discardIfIssuerMismatch. The
  store re-attaches the stamp from the byIssuer key.
- BaseOAuthClientProvider threads ctx.issuer and implements
  discoveryState()/saveDiscoveryState() (activates the SDK's callback-leg
  AuthorizationServerMismatchError binding check) and invalidateCredentials().
- Lazy, migration-free upgrade: a legacy pre-1625 blob's top-level
  clientInformation/tokens are returned as an unkeyed fallback until the first
  issuer-stamped save promotes them into byIssuer.

SEP-837 — application_type
- DCR declares application_type: "native" (localhost Inspector).
- ClientSettingsForm renders RegistrationRejectedError detail and reflects DCR's
  deprecated-in-favor-of-CIMD (SEP-991) status.

SEP-2350 — step-up scope union
- Per-server onInsufficientScope (reauthorize | throw) setting wired
  ServerSettingsForm -> StoredMCPServer.oauth -> StreamableHTTPClientTransport.
- StepUpAuthModal visualizes the prior-union-challenged scope set, tagging each
  scope `new` vs `already granted`.

mcpAuth simplification
- Delete the hand-rolled authorizeWithoutRefresh (mishandled issuer stamping);
  forward forceReauthorization/skipIssuerMetadataValidation to native SDK auth()
  so both the initial and step-up paths get SEP-2352 stamping.

Vocabulary + docs
- OAuthStep gains cimd_fetch / issuer_comparison / scope_step_up (recorded on a
  step-up redirect); the Network tab labels auth requests by OAuth phase.
- v2_auth_hardening.md gains an as-built status section.

Deferred to the era-aware card: the SdkError(EraNegotiationFailed) ->
UnauthorizedError unwrap and finishAuth(URLSearchParams) form (only fire under
non-legacy negotiation).

Closes #1625

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

* Address @claude review: issuer-stamp source gating + clear/activeIssuer + phase over-match

- SEP-2352: stamp the issuer only when the credential actually came from the
  byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated
  slot alongside a surviving legacy field would otherwise stamp a legacy (possibly
  cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch.
- Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer`
  (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion).
- oauthNetworkPhase matches register/token/authorize as the final path segment
  (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer
  over-match.
- Tests for all three.

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-07-16 16:52:53 -04:00
cliffhall
f4fb4b50c9 Forward RFC 9207 iss to the v2 SDK (fixes OAuth + EMA leg 1)
SDK v2 added RFC 9207 §2.4 authorization-response issuer validation
(`validateAuthorizationResponseIssuer` / `IssuerMismatchError`) as a mix-up
attack defense. It is strict: when AS metadata advertises
`authorization_response_iss_parameter_supported: true` and no `iss` reaches
`exchangeAuthorization`/`auth()`, the exchange throws.

v1.29.0 had no `iss` parameter and no RFC 9207 validation at all, so dropping
the callback `iss` was harmless. The migration swapped in the stricter SDK
without feeding it the new input, breaking the authorization-code exchange
against any RFC 9207 authorization server (Okta/xaa.dev advertise it):

    Issuer mismatch in authorization response (RFC 9207):
    expected "https://idp.xaa.dev", received undefined

Found by smoke-testing EMA against live xaa.dev staging; not caught by CI
because the mock IdP never advertised the parameter, so the SDK took its
lenient path (a missing `iss` is only fatal when advertised).

`iss` was dropped in three places, with the plumbing half-built —
`oauthManager`/`inspectorClient` already accepted `iss`, but nothing supplied
or forwarded it:

- `parseOAuthCallbackParams` never extracted `iss` from the callback query
- `mcpAuth` never forwarded `options.iss` to SDK `auth()` — its own comment
  ("forwarded on v2 upgrade; ignored by v1 SDK auth()") predicted this exact
  gap, so standard OAuth was broken identically, not just EMA
- EMA leg 1 (`completeIdpOidcAuthorization`) never passed `iss` to
  `exchangeAuthorization`

Thread `iss` end to end: callback query -> CallbackParams -> resumeAfterOAuth
-> completeOAuthFlow -> {mcpAuth | EMA leg 1} -> SDK. The Node callback server
and runner carry it for CLI/TUI.

Tests: `ema-mock-servers.ts` now advertises
`authorization_response_iss_parameter_supported: true` so the mock is at least
as strict as real IdPs — with it absent the SDK silently takes the lenient path
and a dropped `iss` goes unnoticed. Flipping it made three existing tests
reproduce the failure. Adds regression coverage for forwarding and for
rejecting a mismatched `iss` before any network call.

Verified end to end against live xaa.dev staging: EMA legs 1-3 (IdP SSO ->
ID-JAG mint -> resource token) now complete and the MCP session initializes.

web 3099, cli 212, tui 274 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjMemXZohSR2EndBQYif2b
2026-07-15 20:16:43 -04:00
cliffhall
8a7a38f800 Fix MCP App load regression: translate ext-apps v1 setNotificationHandler API to v2
An MCP App (e.g. NightRider's drum-pad) failed to load with
"'[object Object]' is not a spec notification method; pass schemas as the
second argument to setNotificationHandler()". `@modelcontextprotocol/ext-apps`
still peers on SDK v1 and, during the AppBridge connect handshake, subscribes
to tools/resources/prompts list_changed with the v1 schema-first API
`setNotificationHandler(NotificationSchema, handler)`. SDK v2 requires a method
STRING and throws on a schema object — the migration's compat shim covered the
types but not this runtime API difference.

Fix in the app-renderer client Proxy (`getAppRendererClient`): translate a
schema-first `setNotificationHandler` call to v2's method-string form by reading
the method literal off the schema's `shape.method.value`; native string-first
calls pass through, and an unrecognized first arg falls through to the SDK's
clear error rather than being swallowed. Only `setNotificationHandler` needed
this — AppBridge's `request(req, schema, …)` calls work as-is (the v1 SDK
schemas expose `~standard`, so v2 accepts them). Remove when ext-apps#702 ships
a v2 peer.

Added an integration test covering the schema-first translation end-to-end
(handler fires on a real tools/list_changed), plus the string-passthrough and
unrecognized-schema branches. Coverage ≥90 holds; typecheck + App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:44:20 -04:00
cliffhall
19a6df7ad3 SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624)
Migrate all three clients + core + test-servers from
@modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior
identical against legacy (2025-11-25) servers. Foundation for the SDK V2 +
New Spec stack.

Packages
- Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client,
  transports, OAuth, error taxonomy, protocol types) + core (Zod schemas);
  stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers ->
  @modelcontextprotocol/server (+ server-legacy/sse). Deterministic import
  rewrite (schemas -> core, everything else -> client); the codemod misroutes
  client code to /server so it was not used for path routing.
- new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing
  2026-era goes on the wire.

Runtime (core/mcp/inspectorClient.ts)
- Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode;
  RequestTimeout -> SdkError/SdkErrorCode.
- Handlers take method strings; task methods (tasks/*, notifications/tasks/*)
  use the 3-arg custom form (excluded from v2's spec-method set).
- Legacy tasks kept working (per project requirement): requestor tasks re-
  implemented over raw client.request + explicit deprecated schemas;
  experimental.tasks.callToolStream replaced by a local pollTaskToolCall
  async generator (with _progressHandlers revival so task-execution progress
  still reaches the caller). Receiver (server-initiated) tasks: the v2 Client
  validates a sampling/elicit handler result and rejects the legacy { task }
  response with -32602 — installReceiverTaskResponseBypass swaps the wrapped
  entry in the private _requestHandlers map for the task-augmented branch so
  { task } reaches the wire (fixes server-initiated sampling-via-task).
- Pagination: list verbs use raw client.request({method}, List*ResultSchema)
  for single-page behavior (v2's client.listX() auto-aggregate all pages).

Web / auth / apps
- Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema()
  structural narrowing at the SDK-schema -> form boundary.
- ext-apps still peers on SDK v1: one compat boundary with documented casts
  (remove when ext-apps#702 lands a v2 peer).
- OAuth provider saveClientInformation reconciled with v2's
  OAuthClientInformationContext; InvalidClientMetadataError re-homed as a
  local class (server-legacy/auth is not browser-safe).
- vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so
  vi.mock intercepts the SUT's import.

Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call
rejects with -32602; cancellation is stream-abort (no guaranteed
notifications/cancelled frame); output-schema strictness restored via the
Inspector's own Ajv check on the default path.

npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5),
coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook
(410).

Closes #1624

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:44:24 -04:00
Cliff Hall
a029e42a19
cli: Wave 3 — programmatic review path (rollup of #1573–#1575) (#1655)
* feat(cli): programmatic ergonomics — initialize, tool-args-json, connect-timeout, env-var fixes

Adds the #1573 programmatic-ergonomics surface to the CLI, re-implemented
informed by PR #1510 and adapted to the v2/main OAuth-wired cli.ts:

- `--method initialize` — connect-only probe emitting
  `{serverInfo, protocolVersion, capabilities, instructions}`.
- `--tool-args-json '{"k":"v"}'` — tool args passed verbatim as JSON (no
  key=value coercion); mutually exclusive with `--tool-arg`, with clear
  errors for combined use, malformed JSON, and non-object values.
- `--connect-timeout <ms>` — default 15000 for ad-hoc `--server-url`/target
  runs, file-level timeout otherwise; `0` disables. Backed by the exported
  `withConnectTimeout()` helper.
- MCP_CATALOG_PATH is ignored when an ad-hoc target is given, so a shell that
  exports it can still run one-off ad-hoc invocations.
- MCP_STORAGE_DIR is honored by core `getStateFilePath` (`<dir>/oauth.json`),
  below the per-file MCP_INSPECTOR_OAUTH_STATE_PATH override.

Tests: new programmatic-ergonomics.test.ts (CLI, in-process runCli + direct
withConnectTimeout unit tests) and getStateFilePath resolution cases in the
web storage integration suite. Per-file coverage gate stays ≥90.
README: documents the new flags and env-var semantics.

Closes #1573

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* feat(cli): --app-info probe + --format json envelope

Adds the #1574 app-probing and machine-readable output surface, re-implemented
informed by PR #1510 and adapted to the v2/main OAuth-wired cli.ts.

- `--method tools/call --tool-name <t> --app-info` — prints one JSON line
  describing the tool's app posture (hasApp, resourceUri, csp, permissions,
  domain, …) WITHOUT invoking the tool; exit 0 when it has an app, 2 (no_app)
  when it doesn't. A missing tool exits 5 (tool_not_found) — distinct from the
  no-app exit so a typo isn't mistaken for "no app".
- `--method tools/list --app-info` — one NDJSON line per tool over a single
  connection (`| jq -c 'select(.hasApp)'`).
- `--format json` — a single stdout envelope (`{result}` plus `{appInfo}` as a
  sibling key for App tools), no banner, so any method pipes into jq.
- A tools/call returning isError:true now exits 5 (tool_is_error) after
  printing its payload, so && chains stop on a failed call.

Implementation: `collectAppInfo()` (core `extractAppInfo` + a tolerated
resource read that reports `resourceError`), an NDJSON loop in tools/list, and
a single `emitResult()` that owns format + exit-code handling. `runMethod` now
returns a `MethodOutcome` discriminated union so the NDJSON path can signal
"already emitted".

Tests: app-info.test.ts (exit-0/2/5 + NDJSON + no-invoke), format-json.test.ts
(envelope shapes + bad --format), emit-result.test.ts (direct unit coverage of
the isError→exit-5 and resourceError branches via exported emitResult/
collectAppInfo). Updated the tools.test "nonexistent tool" case to the new
exit-5 tool_not_found contract. Per-file coverage ≥90 on all four dimensions
(cli.ts 100% stmts/funcs/lines). README documents the flags, NDJSON shape, and
exit semantics.

Closes #1574

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* feat(cli): stored-auth suite — --use-stored-auth, --wait-for-auth, --list-stored-auth, --print-handoff

Adds the #1575 web→CLI OAuth handoff surface, re-implemented informed by PR
#1510 and adapted to v2/main.

- `--use-stored-auth` — read the shared oauth.json and inject
  `Authorization: Bearer <token>` for --server-url; exit 3 (no_stored_token),
  listing stored keys, when none matches.
- `--wait-for-auth <sec>` — poll oauth.json (500ms) until a token for
  --server-url appears, then proceed; timeout → exit 3 (auth_wait_timeout).
- `--list-stored-auth` — print `{oauthStatePath, storedServerUrls}` and exit
  (short-circuit, no connect).
- `--print-handoff` — emit `{serverUrl, deepLink, portForwardCmd,
  oauthStatePath, apiToken, note}` for --server-url and exit.

Adaptations vs. the reference:
- State-path resolution reuses core `getStateFilePath()` (the
  MCP_INSPECTOR_OAUTH_STATE_PATH → <MCP_STORAGE_DIR>/oauth.json → default
  precedence added in #1573) instead of a CLI-local resolver.
- The state file is read through the shared isomorphic
  `parseOAuthPersistBlob`, so both the plain `{servers,idpSessions}` layout the
  web backend writes and the legacy `{state,version}` envelope are accepted.
- `parseArgs` now returns a ParseResult union so the short-circuit modes return
  without a server connection.

Blind injection: the token is passed as-is; a stale one surfaces as HTTP 401 →
exit 3. Documented in the README along with all four flags, the state-path
precedence, and a follow-up note that --print-handoff's deepLink shape is
interim pending #1576's canonical handoff format.

Tests: stored-auth.test.ts drives injection, --header coexistence, missing
--server-url, no-token exit-3, URL-normalisation matching, MCP_STORAGE_DIR
resolution, listing, handoff (+note), wait-for-auth polling/timeout, and
normalizeServerUrl units — all against a real in-process HTTP test server.
Per-file coverage ≥90 on all four dimensions (cli.ts 100% stmts/funcs/lines).

Closes #1575

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* docs(core): scope MCP_STORAGE_DIR comment in getStateFilePath to the OAuth backend

Address review of PR #1651: the doc comment claimed MCP_STORAGE_DIR was
honoured "across the app, so setting it once points every persist backend at
the same directory", but the branch only relocates oauth.json. Narrow the
comment to state it is OAuth-backend-only (client.json/mcp.json still resolve
from getDefaultStorageDir → HOME) and record why the resolution lives here
rather than in getDefaultStorageDir (the default-path constant is evaluated at
module load, so a runtime-set MCP_STORAGE_DIR — as the CLI stored-auth /
--wait-for-auth flows set it — must be read at call time). Comment-only; no
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review(cli): per-tool-tolerant app-info listing + drop dead helper (PR #1653)

Address review of PR #1653:

- collectAppInfo now never throws: a malformed `_meta.ui.resourceUri`
  (extractAppInfo throws) folds into `{hasApp:false, resourceError}` just like a
  resources/read failure. This makes `tools/list --app-info` per-tool tolerant —
  one bad tool no longer aborts the whole NDJSON listing with a generic exit-1.
  Guarding inside collectAppInfo (vs. a loop-local try/catch) keeps the branch
  unit-testable; added an emit-result.test.ts case for the malformed-URI path.
- Documented that `tools/list --app-info` always emits NDJSON regardless of
  `--format` (the per-tool list shape is fixed; --format json only reshapes the
  single-result paths) — in both the code comment and the README.
- Removed the now-unused `expectJsonError` helper (its only caller moved to the
  exit-5 tool_not_found contract).

Coverage stays ≥90 on all four dimensions (cli.ts 100% stmts/funcs/lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review(cli): stored-auth follow-ups — TODO(#1576), wait-for-auth guard test, docs (PR #1655)

Address review of PR #1655:

- Added an explicit TODO(#1576) on buildHandoff's deepLink: `transport:"http"`
  is hardcoded even for SSE, and `autoConnect=<token>` is not one of the three
  token sources the web app reads (per CLAUDE.md) — both to be reconciled with
  #1576's canonical handoff format.
- Added a test for `--wait-for-auth` without `--server-url`, covering the
  previously-untested `--wait-for-auth` arm of the shared missing-server-url
  guard's message ternary.
- Documented that `--list-stored-auth` / `--print-handoff` are mutually
  exclusive short-circuit modes (list takes precedence) and that a stored
  refresh_token is not yet used (noted as a follow-up in code + README).

No behavior change; coverage stays ≥90 on all four dimensions (cli.ts 100%
stmts/funcs/lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:14:23 -04:00
Cliff Hall
0ddb79203a
web: Wave 2 — spec-conformant MCP Apps host (rollup of #1565–#1572) (#1660)
* web: sandbox hardening — opaque-origin inner frame, srcdoc-only, frame-ancestors

Harden the MCP Apps sandbox isolation model:

- The inner (untrusted-widget) iframe is sandboxed WITHOUT `allow-same-origin`
  (default "allow-scripts allow-forms") and any server-supplied sandbox value
  has `allow-same-origin` stripped case-insensitively, so the widget runs under
  an opaque origin and cannot reach the proxy's DOM to bypass its own CSP via
  the parent realm.
- Widget delivery is `srcdoc`-only (no document.write), which respects the
  opaque origin.
- The relay accepts the inner frame's "null" origin with
  `event.source === inner.contentWindow` as the unforgeable identity check.
- sandbox-controller.ts serves the proxy with
  `Content-Security-Policy: frame-ancestors http://127.0.0.1:* http://localhost:*`;
  fetch directives are deliberately omitted so a srcdoc iframe's inherited
  policy container does not override the per-app CSP.

Updated the isolation-model comments and the sandbox-controller integration
test asserts the frame-ancestors header (and the absence of fetch directives).

Closes #1565

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: enforce per-app _meta.ui.csp in the app bridge + surface resource-read failures

- createAppBridgeFactory now builds each app's `_meta.ui.csp` host-side via the
  sandbox-csp library (approveCspSources → buildSandboxCspPolicy) and wraps the
  untrusted widget HTML with wrapSandboxedHtml so the CSP <meta> is always the
  first <head> child before the HTML reaches the sandboxed iframe. The approved
  (post-filter) csp + permissions are echoed back via hostCapabilities.sandbox
  so the view sees what was granted. The csp is no longer sent inline through
  sendSandboxResourceReady — it is enforced via the wrapped meta.
- A per-app copy of HOST_CAPABILITIES prevents the sandbox echo from mutating
  the shared constant.
- New optional `onResourceError` dep on AppBridgeFactoryDeps routes resource
  read/post failures to the caller (also always console.error'd) instead of
  leaving a silent blank frame.

Closes #1566

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: full hostContext delivery + live host-context-changed updates

- createAppBridgeFactory now seeds the full hostContext via
  snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES): theme, styles
  (Mantine design tokens), displayMode ("inline"), availableDisplayModes, and
  containerDimensions — replacing the theme-only seed. The local currentTheme
  helper is dropped in favor of hostContext.ts.
- AppRenderer gains `displayMode` and `containerRef` props and pushes live
  host-context changes via the SDK's sendHostContextChange (partial
  host-context-changed notifications), never a hand-maintained snapshot:
  - theme + styles via a MutationObserver on <html data-mantine-color-scheme>,
  - containerDimensions via a ResizeObserver on the host container (or the
    iframe fallback), gated on `initialized`, skipping 0x0 and value-equal
    repeats, plus a one-shot push on initialize once layout settles,
  - displayMode whenever the prop changes (Maximize/Restore), gated on init.

Adds AppRenderer host-context tests (theme flip incl. resolved styles,
displayMode push + init gate, container-dimensions on init and resize, observer
teardown) and updates the factory + story mocks for the new hostContext shape
and sendHostContextChange method.

Closes #1567

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: honor ui/notifications/size-changed and ui/request-display-mode

AppRenderer:
- New `onSizeChange` prop forwards the view's ui/notifications/size-changed
  reports (via the bridge `sizechange` event).
- New `onRequestDisplayMode` prop backs an `onrequestdisplaymode` bridge handler
  that returns the mode the host actually applied; with no handler the request
  is declined by returning the current host-side mode (falling back to inline).

AppsScreen:
- Adds a host-controlled RendererContainer (measured for containerDimensions)
  with an inner box sized by the view-reported height (`appHeight`), so
  host→view container size and view→host size-changed don't couple.
- `handleSizeChange` records the reported height (ignored while maximized);
  `displayMode` is derived from the maximize toggle and pushed to the view;
  `handleRequestDisplayMode` filters against HOST_AVAILABLE_DISPLAY_MODES
  (declining e.g. "pip") and maps fullscreen↔inline onto the maximize state.

Adds AppRenderer tests (size forwarding, request-display-mode apply/decline/
fallback) and AppsScreen tests (frame sizing on report, no-height ignore,
fullscreen/restore/decline via request-display-mode). Story mocks gain
sendHostContextChange/addEventListener.

Closes #1568

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: honor ui/download-file with confirmation and http(s) allowlist

createAppBridgeFactory now advertises the `downloadFile` host capability and
attaches an `ondownloadfile` handler:

- Requires a host-mediated confirmation (window.confirm) listing the requested
  file(s); a declined prompt or an empty payload returns isError without acting.
- Inline EmbeddedResources (text or base64 blob) are written to disk via the
  shared downloadBlob object-URL anchor; base64 is decoded via base64ToBytes.
- ResourceLinks are opened in a new tab, restricted to http(s) by the shared
  isHttpUrl allowlist (javascript:/data:/file:/malformed are rejected).
- Labels shown in the confirmation are sanitized (control + format chars
  stripped, length clamped) so a server-supplied filename/URI can't inject
  newlines or reflow the prompt.
- Partial-batch success is surfaced (isError only when nothing downloaded), with
  skipped items warned.

Adds factory tests for the full download surface (text/blob/link, decline,
empty, throw, non-http rejection, label sanitization/clamping, partial batch).

Closes #1569

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: surface ui/message and app logging in panels below the running app

AppRenderer:
- New `onMessage` prop backs an `onmessage` bridge handler that surfaces the
  view's ui/message content and returns the spec-required empty result;
  with no handler the submission is declined (isError).
- New `onLog` prop forwards MCP `notifications/message` log entries via the
  bridge `loggingmessage` event, honoring the advertised `logging` capability.

AppsScreen:
- Adds `messages`/`appLogs` state (cleared with the reported height on
  select/open/close/back via resetAppChannels) and `handleMessage`/`handleLog`.
- Renders a "Messages from app" panel (data-testid="apps-messages", reusing
  ContentViewer) and a default-expanded collapsible "App logs" panel
  (data-testid="apps-logs", reusing LogLevelBadge) with logger names and a
  Clear button, both as pinned panels below the running widget.

Adds AppRenderer tests (log forward + no-handler no-throw, ui/message
route/empty-result/decline) and AppsScreen tests (message log render + empty
result + clear-on-close, log panel expand/collapse/clear).

Closes #1570

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: stage partial input — replay tool-input-partial before final tool input

AppRenderer:
- New `partialInputs` prop (ordered fragments), snapshotted into pendingPartials
  at bridge-build time (read via a ref so prop churn never rebuilds the iframe)
  and cleared on dispose. flushPending replays them via
  bridge.sendToolInputPartial BEFORE the complete tool-input, per spec.

AppsScreen:
- New `partialStages` state + `handleStagePartialInput` (snapshots current form
  values). resetAppChannels gains a `keepPartials` option so Open App preserves
  the staged fragments (they're consumed by the renderer) while select/close/
  back clear them.
- Adds a "Stage partial input" control (fielded apps only) with a staged count
  and "Clear staged", wired to AppRenderer's `partialInputs`.

Adds AppRenderer tests (ordered replay before tool-input; none when omitted) and
AppsScreen tests (stage/count/clear gated on fields; staged partials survive
Open and are replayed).

Closes #1571

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: app lifecycle status + machine-readable data attributes on the Apps screen

AppRenderer:
- New AppRendererStatus ("loading" | "ready" | "error") + onAppStatusChange
  callback. Fires "loading" at the start of every (re)build, "ready" on the
  view's initialized signal, and "error" on a factory throw/rejection.

AppsScreen:
- appStatus/appError state; handleAppError captures the reason locally and
  forwards to the parent onError. resetAppChannels clears both.
- Surfaces the contract as data-testid="apps-form" carrying data-app-status and
  data-app-error, renders an error panel (data-testid="apps-error") in place of
  the blank frame, and tags the stage button data-testid="apps-stage".
- AppDetailPanel's Open App button gains data-testid="open-app".

App.tsx:
- Wires the bridge factory's onResourceError to a red toast so a malformed/404
  UI resource is no longer console-only (the renderer separately drives
  data-app-status, so a driver times out on never-reaching-ready and reads it).

Documents the data-testid/data-* automation contract in clients/web/README.md.
Adds AppRenderer status-transition tests and AppsScreen status/error-panel
tests.

Closes #1572

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: warn when server sandbox drops allow-scripts + revert incidental lockfile churn

Addresses the claude[bot] review on PR #1650:
- sandbox_proxy.html: log a console.warn when a server-supplied `sandbox`
  value lacks `allow-scripts` after the `allow-same-origin` strip, so a
  fail-closed (app-never-runs) sandbox is diagnosable instead of silent.
- Restore clients/{cli,launcher,tui,web}/package-lock.json to their
  v2/main state — the earlier churn (@emnapi/@napi-rs optional deps, a
  dropped version field) was incidental `npm install` regeneration
  unrelated to the sandbox change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: document CSP-vs-permissions filtering asymmetry (PR #1652)

Address the claude[bot] review's finding #2: add a comment explaining why
`csp` is injection-filtered (approveCspSources) while `permissions` is passed
through verbatim — permissions is a structured boolean object consumed only by
the sandbox proxy's buildAllowAttribute() (fixed key→token mapping), so it
never reaches the iframe sandbox/allow attribute as raw text. No logic change.

Also merges v2/1565-sandbox-hardening (review fixes) up the stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* test: lock in per-app HOST_CAPABILITIES isolation (PR #1652 round 2)

Add a test asserting the shared HOST_CAPABILITIES.sandbox stays undefined after
a sandboxready run sets hostCapabilities.sandbox — guards against a silent
regression if the per-app spread copy were ever dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: gate theme observer on initialized + document seed/dep assumptions (PR #1654)

Address the claude[bot] review:
- Finding #1 (applied): gate the theme+styles MutationObserver on the view's
  `initialized` signal, matching the container/displayMode pushes — a theme
  flip in the construction→handshake window no longer races ui/initialize. The
  factory seeds the construction-time theme, and the first post-init flip
  carries the current value. Added tests: pre-init flip is dropped; the live
  flip test now completes the handshake first.
- Finding #2 (applied, comment): note in snapshotHostContext that the
  `displayMode: "inline"` seed assumes inline-at-open (AppsScreen always opens
  inline; #1568's displayMode push carries transitions).
- Finding #4 (applied, comment): explain why `containerRef` is a build-effect
  dep yet doesn't force a rebuild (sameInputs ignores it; read lazily).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: guard 0-height size reports, distinct RendererFrame, shared payload type (PR #1656)

Address the claude[bot] review:
- #1 (applied): handleSizeChange ignores a non-positive height — a view's
  ResizeObserver can transiently fire 0 pre-layout/teardown, which would
  collapse the frame; mirrors AppRenderer's 0×0 skip. Added a test.
- #2 (applied): reinstate a distinct `RendererFrame` constant for the inner
  content-sized box so its role reads clearly vs. the outer RendererContainer.
- #3 (declined + comment): keep carrying `appHeight` across maximize→restore —
  clearing it would flash the frame to full-card height until the next
  size-changed; the carry-over restores at the prior size. Documented.
- #4 (applied): type handleSizeChange's param as AppBridgeEventMap["sizechange"]
  so the payload shape has a single source of truth with AppRenderer's prop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: distinguish links, cap batch size, comment truncation (PR #1657)

Address the claude[bot] review:
- #1 (applied): the confirmation now reads "download or open N item(s)" and
  resource_links are prefixed "↗" in the summary, so a link that opens in a tab
  is distinguishable from an embedded file that saves to disk.
- #2 (applied): cap a single ui/download-file batch at MAX_DOWNLOAD_ITEMS (20);
  an oversized batch is rejected (isError) before the prompt, with a warn.
- #3 (declined): can't check window.open's return value to detect a blocked
  popup — we pass `noopener`, and per spec window.open returns null even on a
  successful noopener open, so null can't distinguish blocked from succeeded.
- #4b (applied, comment): note the start-of-label truncation is intentional so a
  link's scheme+host stays visible for the trust decision.

Adds tests: oversized batch rejected without confirming/acting; resource-link ↗
prefix in the summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: skip embedded resource with neither text nor blob (PR #1657 round 2)

Address the round-2 observation: an untrusted EmbeddedResource carrying neither
`blob` nor a string `text` previously produced `new Blob([undefined])` — a file
containing the literal text "undefined", counted as success. Now such an item
is skipped (returns false, like a rejected link), so isError reflects it. Added
a test asserting no object URL is created and the batch reports isError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: type-honest formatLogData, cap channels, log-panel a11y (PR #1658)

Address the claude[bot] review:
- #1 (applied): formatLogData coalesces JSON.stringify(undefined) → "" so the
  `: string` return type is honest for a data-less log.
- #2 (applied): cap retained messages/logs at MAX_APP_CHANNEL_ENTRIES (500) via
  appendCapped — a chatty widget no longer grows the panels/DOM without bound
  between Clear/close; oldest entries drop.
- #3 (declined): keep the Messages panel without a Clear/collapse — messages are
  sparser and already cleared on close/switch; keeping it simple.
- #4 (applied): the App-logs toggle now has aria-controls pointing at the
  Collapse region (id="apps-logs-region") for assistive tech.

Adds tests: no-data log renders (coalesce branch), 501 logs cap at 500 (oldest
dropped), aria-controls assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: stable id key for Messages panel (PR #1658 round 2)

Address the round-2 nit: give AppMessage a stable `id` (assigned via
nextMessageIdRef in handleMessage) and key the Messages panel on it instead of
the array index — so the appendCapped front-drop can't renumber keys once a run
exceeds the cap, matching AppLogEntry's treatment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: note partialStages intentionally survives handleOpen (PR #1659)

Address the claude[bot] review's observation #2: add a comment at the
handleOpen call site clarifying that partialStages is intentionally NOT cleared
there — AppRenderer snapshots the fragments into its own pendingPartialsRef at
build time, and the staging UI only renders while not running, so the surviving
state is invisible until the next reset drains it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* review: align error-panel docs with behavior + gate data-app-error on running (PR #1660)

Address the claude[bot] review:
- #1 (applied, docs): the error panel renders BELOW the (blank) frame, not in
  place of it — the renderer stays mounted so a factory error still shows the
  reason alongside the iframe (gating the frame off on error would drop the
  auto-launched iframe an integration test relies on). Reworded the README row
  and the test comment to say "below the frame"; added a code comment.
- #2 (applied): gate data-app-error on `running` (like data-app-status) so the
  two attributes can't desync if a future path sets an error without running.
- #3 (no change): resource-read failures staying at data-app-status="loading"
  (toast only) is intentional and documented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:14:20 -04:00
cliffhall
a26cbac609 review: harden version read + fix MonitoringScreen docstring
Address PR #1641 review feedback:

- Add readInspectorVersionSafe() (non-throwing) in core/node/version.ts and use
  it in the web backend, so a failure to resolve the root manifest hides the
  (cosmetic) version badge instead of crashing the backend at startup. Covered
  by tests for both the resolved and unresolvable paths.
- Update MonitoringScreen's docstrings: it now wraps every supplied screen in a
  ScreenStage (only the active tab mounts, cross-fading) rather than rendering
  just the active tab's node.

The third fetch-consolidation suggestion (one useInitialConfig instead of three
GET /api/config hooks) is tracked as a follow-up in #1643.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
2026-07-10 22:40:34 -04:00
cliffhall
650e5bd020 feat(web): show the Inspector version in the lower-right corner (#1639)
Display the single-source version (root package.json) in the web UI's
lower-right corner, in grey — matching the CLI and TUI. The browser can't read
the filesystem, so the version flows backend → browser via GET /api/config:

- Add `version?: string` to InitialConfigPayload; the web backend
  (webServerConfigToInitialPayload) reads it once via the shared
  readInspectorVersion() and tags it onto the payload.
- Add a `useInspectorVersion` hook mirroring useSandboxUrl/useServerListWritable.
- Add a presentational `VersionBadge` element rendered fixed lower-right, grey,
  non-interactive; styling lives in a `versionBadge` Text theme variant (no
  inline styles, --inspector-* tokens).
- Wire it into App.tsx.

Verified live: /api/config serves `version: "2.0.0"` and the badge renders
`v2.0.0` fixed at the lower-right in grey. Tests: useInspectorVersion (full
branch coverage), VersionBadge (unit + Storybook), web-server-config payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
2026-07-10 19:07:31 -04:00
cliffhall
7815548300 refactor(version): single-source the version from the root package.json
The Inspector ships as one package with one version, but four client
package.jsons each carried their own `version` and were read at runtime
(cli.ts, tui App.tsx), and core hardcoded a third, stale value (0.20.0) that
the TUI and web actually reported to servers. Consolidate onto the root
package.json as the single source of truth.

- Add core/node/version.ts: shared, Node-only readInspectorVersion() that walks
  up to the root package.json (always shipped in the tarball). 100% covered by a
  new integration test.
- CLI and TUI read the version via the shared reader instead of their own
  manifest; TUI now also passes a real clientIdentity (was falling through to
  the stale core default).
- Drop the `version` field from all four client package.jsons; the root keeps
  the only version. TUI header name/description become local display constants.
- core default client identity: replace the stale 0.20.0 hardcode with a neutral
  0.0.0 placeholder (real clients pass clientIdentity; web will source its
  version from GET /api/config, see #1639).
- Stop shipping clients/cli/package.json and clients/tui/package.json (no longer
  read at runtime) — supersedes the earlier "ship them" fix; update pack:verify
  and the root README packaging invariants accordingly.
- Add core/node/** to the web coverage include so the new module is gated.
- Add the shared-code architecture diagram to the root README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
2026-07-10 17:31:21 -04:00
cliffhall
32a3610737 Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts:
#	clients/cli/package-lock.json
#	clients/cli/package.json
#	clients/web/src/App.tsx
#	clients/web/src/lib/environmentFactory.test.ts
#	clients/web/src/lib/environmentFactory.ts
#	clients/web/src/lib/remoteOAuthStorage.test.ts
#	clients/web/src/lib/remoteOAuthStorage.ts
#	clients/web/src/test/core/auth/connection-state.test.ts
#	clients/web/src/test/core/auth/providers.test.ts
#	clients/web/src/test/core/auth/storage-browser.test.ts
#	clients/web/src/test/core/auth/storage-remote.test.ts
#	clients/web/src/test/core/mcp/oauthManager.test.ts
#	clients/web/src/test/integration/auth/node/storage.test.ts
#	clients/web/src/test/integration/storage/adapters.test.ts
#	clients/web/src/utils/clearServerOAuthState.test.ts
#	clients/web/src/utils/clearServerOAuthState.ts
#	core/auth/connection-state.ts
#	core/auth/oauth-storage.ts
#	core/auth/providers.ts
#	core/auth/storage.ts
#	core/auth/store.ts
#	core/storage/adapters/remote-storage.ts
2026-07-09 09:41:08 -04:00
cliffhall
1e6a2825d4 fix(auth): keep oauth-persist browser-safe (move Node file backend out)
The refactor routed the browser's RemoteOAuthStorage through
`oauth-persist.ts`, which value-imported the Node-only `store-io.js`
(`atomically` -> `stubborn-fs` -> `node:process.getuid`, plus
`node:fs/promises`). That dragged Node built-ins into the browser bundle
and the web app crashed to a blank page at runtime — a regression from
v2/main, where browser OAuth storage used a Zustand JSON adapter with no
Node imports. Unit/integration tests run in node/happy-dom and
`smoke:web` only checks the served HTML, so none of them execute the app
in a real browser and the break slipped through.

Fix, following the existing `store-id.ts` precedent:
- Split the pure JSON (de)serializers into Node-free
  `core/storage/store-serialize.ts`; `store-io.ts` re-exports them so
  existing importers are unaffected.
- Move the Node-only `createFileOAuthPersistBackend` into
  `core/auth/node/oauth-persist-file.ts`; update `storage-node.ts` and
  the storage adapters test to import from there.
- `oauth-persist.ts` now imports only the Node-free serializers, so the
  browser bundle no longer pulls in `node:fs`/`atomically`.

Tests:
- oauth-persist-browser-safe.test.ts: source-level guard that
  `oauth-persist.ts` never imports store-io/node:/atomically.
- Direct unit tests for the isomorphic remote + session backends
  (restoring oauth-persist.ts coverage after the file backend moved out).
- store.test.ts: OAuthMemoryStore unit coverage incl. replace() fallbacks.
- storage.test.ts: clearAllOAuthClientState null-snapshot (no file) path.

Verified end-to-end in a real browser (Playwright): DCR OAuth flow
completes, tokens persist via the remote store, and after a full page
reload the server reconnects with no re-authentication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:20:31 -04:00
Bob Dickinson
3c65bdb4c6 Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage. 2026-07-04 21:56:45 -07:00
cliffhall
cfbdfdf2bd Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts:
#	AGENTS.md
#	clients/cli/README.md
#	clients/web/src/App.test.tsx
#	clients/web/src/App.tsx
#	clients/web/src/test/core/auth/utils.test.ts
#	core/mcp/node/transport.ts
#	test-servers/src/composable-test-server.ts
2026-07-03 17:39:07 -04:00
Bob Dickinson
a24091484c fix(auth): await OAuth storage load() to fix remote OAuth races 2026-07-03 14:18:00 -07:00
cliffhall
f4b6095719 test: close ≥90% per-file coverage gaps for mid-session auth
The mid-session auth work passed `validate`/CI but failed the local-only
`npm run test:coverage` per-file gate (≥90% lines/statements/functions/
branches) on 12 files. Add targeted unit/integration tests so every changed
file clears the gate; annotate provably-dead defensive branches with justified
`/* v8 ignore */` comments rather than relaxing the gate.

Files brought to ≥90 on all four dimensions:
- core/auth: mcpAuth, oauthUx, utils, challenge, node/runner-interactive-oauth
- core/mcp: oauthManager, inspectorClient, node/authChallengeFetch,
  remote/remoteClientTransport, remote/node/{remote-session,server}
- clients/web/src/utils: oauthResume, browserTabVisibility

`npm run test:coverage` now passes (3575 tests, 0 threshold failures);
`npm run validate` green across all four clients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc
2026-07-03 12:54:37 -04:00
Bob Dickinson
a066873e37 Fixes for remaining issues 2026-07-02 18:36:44 -07:00
Bob Dickinson
0635d09e83 Fixed CI failure (test fixture) and added local CI script 2026-07-02 17:07:25 -07:00
Bob Dickinson
562bdd00f1 All of the mid-session and step-up auth work 2026-07-02 16:35:31 -07:00
cliffhall
c6b5af3b81 merge: v2/1596-test-stability (#1597) 2026-07-01 22:05:57 -04:00
cliffhall
e425602295 merge: v2/1548-web-remote-oauth-storage (#1592)
# Conflicts:
#	clients/web/src/test/core/auth/utils.test.ts
2026-07-01 22:05:52 -04:00
cliffhall
9257c849b9 merge: v2/1557-mcp-app-demo (#1587) 2026-07-01 22:04:07 -04:00
cliffhall
4bad110942 test(web): eliminate timeout flakiness in the web test suite (closes #1596)
Root-cause and deterministically fix the recurring 5s-timeout flakes that
surfaced under v8-instrumented, concurrent load. All fixes are test-only; no
product source changed. No test was skipped or disabled, and the per-file
coverage gate (>=90 on all four dims) still holds.

Common root cause across the modal/form/screen suites: userEvent.setup()
schedules a real setTimeout between keystrokes. Under CPU contention those
yields balloon, so multi-field interactions blew past the 5s per-test
timeout. Fix: userEvent.setup({ delay: null }) removes the per-keystroke
real-timer dependence (typing dispatches synchronously), making the suites
load-independent.

Per file:
- ServerConfigModal / ServerImportConfigModal / ServerImportJsonModal /
  ResourcesScreen / InspectorView: userEvent.setup({ delay: null }).
- PromptArgumentsForm (completions): replaced wall-clock sleeps sized just
  past the 300ms completion debounce (await setTimeout(400)) — which race
  the debounce timer under load — with awaited conditions (findBy/waitFor)
  on the real rendered outcome, plus delay:null. Negative assertions now
  lean on the component's synchronous state/timer teardown instead of a
  timed window, so they are deterministic regardless of machine speed.
- inspectorClient integration "tracks stderr logs": the child's stderr is
  piped out-of-band from the tool's JSON-RPC response, so reading the log
  synchronously after callTool raced the stderr chunk. Wrapped the assertion
  in vi.waitFor so it polls until the line lands.

Verified: web validate + 6x test:coverage (4 sequential + 2 concurrent
under load) + standalone test:integration, all green (229 files / 3237
tests per coverage run; 799 integration tests), zero intermittent failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 21:11:11 -04:00
cliffhall
e5dbdca65c test(test-servers): export MCP_APP_DEMO_URI, dedupe in integration test
Address code-review DRY note on PR #1587: MCP_APP_DEMO_URI was defined
in both the fixture and the integration test. Export it from the fixture
and import it in the test so the two stay in lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 19:12:44 -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
cliffhall
d5be4929e9 feat(core): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY in the Node transport (closes #1563)
Wrap the Node transport's fetch with an undici EnvHttpProxyAgent dispatcher
when a standard proxy env var is set, so the CLI and web backend can reach
remote MCP servers through corporate proxies with zero new flags. undici is
imported lazily (only when a proxy var is present) and added as a dependency
at the repo root and in the CLI client; a missing undici raises an actionable
error. Documents the behavior and dependency in the CLI README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:25:16 -04:00
cliffhall
0d50b908a3 feat(test-servers): mcp_app_demo preset + _meta on tool/resource defs (closes #1557)
Add an `mcp_app_demo` tool/resource preset — a self-contained MCP App widget
that exercises size-changed, ui/message, log notifications, and host-context
rendering with no external server. Requires plumbing an optional `_meta` field
through the composable test server's `ToolDefinition` and `ResourceDefinition`
so clients can read tool-level `_meta.ui.resourceUri` and resource-level
`_meta.ui.csp`.

- composable-test-server: optional `_meta` on ToolDefinition/ResourceDefinition,
  passed through to registerTool config and the resource content item.
- test-server-fixtures: `createMcpAppDemoTool()`, `createMcpAppDemoResource()`,
  inline `MCP_APP_DEMO_HTML` widget; wired into getDefaultServerConfig.
- preset-registry: `mcp_app_demo` tool + `mcp_app_demo_widget` resource presets.
- integration test asserting the `_meta` plumbing end-to-end.

Wave 1 of PR #1510 decomposition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:22:25 -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
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
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
36b48e61a2
feat(web): build dist on demand for prod --web + end-to-end CI smoke (#1486) (#1497)
* feat(web): build dist on demand for prod --web + end-to-end CI smoke (#1486)

Prod `mcp-inspector --web` serves static assets from clients/web/dist,
which only exists after a build. In a fresh checkout it was absent and the
prod path served a broken page (ENOENT on index.html); the launcher smoke
test only covered --help, so this path was unverified in CI.

- ensure-web-build.ts: build dist on demand (vite build via build:client)
  when missing, or exit with an actionable error if the build can't run.
- run-web.ts: invoke ensureWebBuild before startHonoServer in the prod path
  (--dev runs Vite directly and skips it).
- scripts/smoke-web.mjs + smoke:web: start --web against built dist and
  assert GET / returns 200 with the injected __INSPECTOR_API_TOKEN__.
  Wired into validate:launcher and a new CI step.
- Docs: launcher README + AGENTS.md.

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

* fix(web): surface spawn error in build-on-demand; clarify smoke comment

Address PR #1497 review:
- runViteBuild now logs result.error.message (e.g. ENOENT when npm isn't on
  PATH) before returning a null status, so the spawn-failure cause surfaces
  instead of only the generic actionable error. The build seam gains a `log`
  param; added a test covering the ENOENT path.
- Reword scripts/smoke-web.mjs header so it no longer implies the readiness
  wait covers a cold build-on-demand vite build (it's sized for a pre-built
  dist, which validate/CI guarantee).

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 18:34:52 -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
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