Commit graph inspector/clients/web/src/test
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
1e6718e2fd test(web): disable Mantine transition timers in the unit env
CI failed with an unhandled `ReferenceError: window is not defined` thrown from
a Mantine `Transition` `setTimeout` (`use-transition.mjs`) firing after the test
environment was torn down — every test passed, but the leaked timer failed the
run. happy-dom has no `matchMedia`, so `useReducedMotion` resolves to
"motion allowed" and every Transition (ScreenStage, Modal, …) schedules real
enter/exit timers; a timer that outlives its test throws post-teardown. This
surfaced now because MonitoringScreen started wrapping its screens in
ScreenStage (a Transition) where it previously rendered them directly.

Mock `window.matchMedia` in the test setup to report
`prefers-reduced-motion: reduce`, so Mantine transitions render instantly (no
timers). All other queries return `false`, matching the prior absent-matchMedia
behavior, so `useMediaQuery`-driven layout is unchanged. Full unit suite passes
with no unhandled errors across repeated runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
2026-07-10 22:52:48 -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
ace4250d31 refactor(web): rename "History" tab/feature to "Protocol" (#1623)
Rename the web client's History tab and its supporting code to Protocol
so the feature reads as the MCP "Protocol" view.

- Tab renders as "Protocol"; INSPECTOR_TAB_IDS, InspectorView tab
  constants/MonitorTab, TAB_UI_REGISTRY key, and navigation.ts tab id all
  updated to Protocol.
- Rename components + folders: HistoryScreen -> ProtocolScreen,
  HistoryListPanel -> ProtocolListPanel, HistoryEntry -> ProtocolEntry,
  HistoryControls -> ProtocolControls, historyUtils -> protocolUtils
  (plus their tests and stories, and exported types).
- Rename App/InspectorView plumbing: historyUi, pinnedHistoryIds,
  on*History* handlers/props, replayHistoryRequest,
  isReplayableHistoryMethod, sort/compact scopes, etc. -> Protocol names.
- Export filenames use the "protocol" slug (buildExportFilename +
  ExportKind).

Preserved (per issue #1623 decisions):
- The in-panel section discriminator keeps its "pinned" | "history"
  values, and the sub-sections stay labeled Pinned / History.
- window.history and core message-log/replay concepts are untouched.

npm run ci passes (format, lint, build, unit tests, >=90 per-file
coverage gate, smoke, Storybook).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
2026-07-09 19:04:54 -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
98517c2758 test(auth): fix type error in remote backend write() test
`tsc -b` (run in CI's validate step, but not by vitest locally) rejected
`ok.mock.calls[0][1].body` — under the project's strict indexing the
untyped mock's call tuple is `[]`, so `[0]` is possibly-undefined and
`[1]` is out of range. Capture the request body inside a `vi.fn<typeof
fetch>` implementation instead of indexing into `mock.calls`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:43:41 -04:00
cliffhall
4c30dc6e2e test(auth): make the browser-safe guard transitive
Extend the Node-free guard beyond oauth-persist.ts to every module
reachable from the browser's RemoteOAuthStorage (storage-remote,
oauth-storage, store, oauth-persist, store-serialize), so a forbidden
store-io/node:/atomically import added one hop away is caught too — not
just a direct import in oauth-persist.ts. Addresses the non-blocking
hardening note on Claude review of #1612.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:25:14 -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
cliffhall
cdeb2316d0 test(auth): assert parseOAuthPersistBlob({}) returns null
Document the contract the remote read() path leans on — the server's
missing-file response is `c.json({}, 200)`, and the backend now passes
that empty object straight to parseOAuthPersistBlob. Addresses the
optional note on Claude review of #1612.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:57:16 -04:00
cliffhall
edc7dbb1c1 Merge remote-tracking branch 'origin/v2/main' into v2/1549-oauth-storage-no-zustand
# Conflicts:
#	core/auth/store.ts
2026-07-08 13:44:12 -04:00
cliffhall
ba48e06ffd fix(auth): guard OAuth-storage async paths against unhandled rejections
Review follow-ups on the RemoteOAuthStorage web migration:

- useEmaIdpLoginState.logout(): add a .catch to the now-async
  clearEmaIdpSession call so a storage failure no longer surfaces as an
  unhandled rejection; leave loginState unchanged on failure (the session
  was not actually cleared). Add a test asserting no unhandled rejection.
- createOAuthStore: attach a no-op catch to the eager persistLoadPromise
  so a hydration read that fails before any load() awaits it does not
  become an unhandled rejection; real awaiters still observe the rejection
  via the shared reference.
- storage-remote.test: replace `as unknown as typeof fetch` double-casts
  with the typed vi.fn<typeof fetch>() pattern the same PR established.
- remoteOAuthStorage: correct the doc comment to describe the cache-of-one
  accurately instead of implying a keyed map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:59:40 -04:00
Bob Dickinson
af174666d6 fix(auth): address Claude review on OAuth persist serialization
Serialize persist() in OAuthStorageBase so overlapping remote POSTs cannot
reorder and clobber state. Regenerate launcher package-lock.json to drop
stale zustand entries. Let parseOAuthPersistBlob accept parsed objects
from res.json() without a redundant JSON round-trip.
2026-07-05 18:38:13 -07:00
Bob Dickinson
3c65bdb4c6 Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage. 2026-07-04 21:56:45 -07:00
Bob Dickinson
512f45d357 fix(auth): reject load() when persist read fails 2026-07-03 14:50:04 -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
Bob Dickinson
7bd26c938e refactor: migrate to RemoteOAuthStorage for web OAuth handling 2026-07-03 13:11:13 -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
8f1ed7588d Prettier fix on last commit 2026-07-02 18:03:18 -07:00
Bob Dickinson
52023ff1c3 fix(auth): clear OAuth callback timer and reorder EMA scope persist
Clear the loopback callback timeout in runner-interactive-oauth finally
so already_authorized and error paths do not hang the CLI/TUI. Check EMA
step-up satisfaction against minted token scope before persisting the
requested union, matching the standard OAuth path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:55:34 -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
775b98a019 docs(core): clarify redaction scope + add nested request-body test
Address round-1 review feedback on fetchTracking redaction:
- Cross-reference REDACTED_VALUE / REDACTED_HEADER_VALUE so a reader
  understands why two identical "[REDACTED]" constants coexist.
- Document that redactBody deliberately scopes to form-encoded + JSON
  (multipart/binary bodies pass through; OAuth never uses multipart).
- Add a tracker-path test asserting a nested sensitive key in a JSON
  request body is redacted in the recorded copy while the live outbound
  body stays byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 22:34:40 -04:00
cliffhall
524bf2d543 feat(core): redact sensitive body + URL-query fields in fetch log (closes #1593)
Extends the header redaction from #1585. Before a fetch entry reaches any
sink, mask known-sensitive credentials in the request/response bodies and
the URL query string, in addition to headers:

- redactBody(): form-urlencoded + JSON bodies, recursive over nested
  objects/arrays, best-effort (never throws), values-only.
- redactUrlQuery(): masks sensitive query params, preserves path,
  non-sensitive params, and any fragment.
- Sensitive fields: client_secret, code, refresh_token, access_token,
  id_token, code_verifier, client_assertion, assertion, password, token
  (case-insensitive), reusing the [REDACTED] sentinel.

Applied to the request success + error entries and the async response-body
callback. Live outbound requests are byte-identical — only the logged copy
is redacted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 22:20:42 -04: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
cc37a755ae merge: v2/1562-oauth-state-hardening (#1589) 2026-07-01 22:04:22 -04:00
cliffhall
52cd90bd50 merge: v2/1560-downloadfile (#1586) 2026-07-01 22:04:18 -04:00
cliffhall
9257c849b9 merge: v2/1557-mcp-app-demo (#1587) 2026-07-01 22:04:07 -04:00
cliffhall
4e3cd591a4 merge: v2/1563-node-proxy (#1590) 2026-07-01 22:03:55 -04:00
cliffhall
2d1a99ab37 merge: v2/1561-redact-headers (#1585) 2026-07-01 22:03:51 -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
4e149719bd fix(auth): route clearClientInformation through clearAfterHydration too
Round-2 @claude review of #1592 caught a seventh member of the clear
family that the first pass missed: clearClientInformation still wrote to
the store synchronously, carrying the same pre-hydration clobber risk the
clearAfterHydration helper was written for (a clear before hydration lands
persists a near-empty blob, overwriting the whole on-disk oauth.json).
Wrap its mutation the same way and add a clear-before-hydration test
proving a sibling token (and thus every other server's blob) survives.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 19:20:49 -04:00
cliffhall
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
2b84281346 feat(auth): harden OAuth state generation and callback validation (closes #1562)
generateOAuthState() now throws when crypto.getRandomValues is unavailable
instead of silently degrading to a non-cryptographic Math.random() fallback —
OAuth state is a CSRF token and must be unpredictable.

The web /oauth/callback handler now rejects a returned `state` param that does
not parse to the expected 64-char-hex authId shape (a forgery indicator),
surfacing a clear error toast instead of proceeding with an undefined session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:23:38 -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
cliffhall
26478159be feat(web): generalize downloadFile.ts with downloadBlob, fileNameFromUri, isHttpUrl (closes #1560)
Extend the browser download library beyond JSON so the Apps host's
ui/download-file support can download arbitrary embedded resources and
open http(s) links safely, with no UI wiring in this change:

- downloadBlob(): temp-anchor download core with a setTimeout(...,0)-
  deferred revokeObjectURL so a synchronous revoke can't abort the
  scheduled download (Firefox/Safari, intermittently Chrome).
- downloadJsonFile(): refactored on top of downloadBlob; callers unchanged.
- fileNameFromUri(): last path segment sanitized (control/format chars
  stripped, disallowed filename chars -> _, capped at 255, "download"
  fallback).
- isHttpUrl(): parse-and-allowlist http(s) only, else null.

Re-implementation of the downloadFile.ts slice of PR #1510 as Wave 1 of
the #1579 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:21:41 -04:00
cliffhall
1a6e32be50 feat(core): add AppInfo + extractAppInfo() to core/mcp/apps.ts (closes #1556)
Add a machine-readable summary of an MCP App's UI metadata: the AppInfo
interface plus extractAppInfo(), which merges a tool's _meta.ui
(resourceUri, visibility) with the linked UI resource's _meta.ui (csp,
permissions, domain, prefersBorder, mimeType). Content-block matching is
exact-URI first, then a lowercase + trailing-slash normalized match, then
a single-block fallback since resources/read returns the requested
resource by definition. Shared plumbing for the programmatic-review path
(CLI --app-info, integration tests) without rendering the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:20:13 -04:00
cliffhall
381027063d feat(core): redact sensitive headers in the fetch log (closes #1561)
Redact Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-Api-Key,
and x-mcp-remote-auth to [REDACTED] in createFetchTracker before any
request/response entry reaches the in-memory log, pino logger, or
persisted session storage. Comparison is case-insensitive; original key
casing is preserved and the live outbound request is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:19:59 -04:00