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>
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>
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>
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.
- Add a dedicated `inspectorTabs.test.ts` (was relying only on incidental
coverage from App.test.tsx) — covers the tab-id constants and both branches
of the `isInspectorTabId` type guard; 100% per-file.
- AGENTS.md: extend the `core/auth/` structure-tree entry to describe the new
mid-session recovery modules (challenge parsing, scope union, shared OAuth UX
copy, force-reauthorization) and the node loopback callback flow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc
- App.tsx: replace the inline `style={{}}` on the re-auth banner wrapper with
Mantine style props (pos/top/bg) plus a `.reauth-banner-bar` class for the
z-index/box-shadow that have no Mantine shorthand.
- App.tsx: extract the ~100-line inline `onAuthorize` and the inline `onCancel`
step-up handlers into named `handleStepUpAuthorize`/`handleStepUpCancel`.
- ReAuthBanner: move the flat `styles={{ root }}` (background/border) into a
`reauth` Alert theme variant; replace the raw `<span>` with `<Text>`.
- StepUpAuthModal: extract the inline `onClick` wrapper into `handleAuthorize`.
No behavior change; validate + integration green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc
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
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>
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
Broaden the web vitest `coverage.include` from the single-file
`src/lib/sandbox-csp.{ts,tsx}` entry to `src/lib/**/*.{ts,tsx}` so the
per-file >=90 four-dimension gate applies to every library module under
`src/lib`, not just sandbox-csp.
- Add unit tests for the previously-ungated `environmentFactory.ts`
(createWebEnvironment) and `types/customHeaders.ts` value helpers,
bringing both to 100% on all four dimensions.
- Annotate downloadFile.ts's provably-dead `?? ""` fallback (String.split
always returns a non-empty array so .pop() is never undefined) with a
justified `v8 ignore`, closing its last uncovered branch (92.3 -> 100).
All five src/lib files now report 100/100/100/100.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
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
The "cancels a pending debounce timer when the input is re-focused" test
passed trivially after the delay:null change: synchronous typing meant the
assertion ran at t≈0, before the 300ms debounce could ever fire, so it
passed whether or not handleFocus's clearTimeout worked.
Rewrote it with fake timers so the debounce window elapses deterministically
(advance the clock by 400ms directly). The interaction is driven with
fireEvent rather than userEvent because userEvent's async internals deadlock
under vitest fake timers with the Mantine/happy-dom stack. Verified
load-bearing: removing the clearTimeout in handleFocus makes it fail
(expected 1 to be 0), and it passes once restored.
Also tightened the sibling-context comment (with delay:null the sibling is
reliably "es"); the tolerant /^es?$/ regex is left as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
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
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
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
Address round-1 @claude review of #1588 (no behavior change to the
enforced policy):
- approveCspSources / buildSandboxCspPolicy: document that
resourceDomains intentionally feeds script-src/style-src per the
McpUiResourceCsp contract, and that approve screens injection-safety
only (not breadth — a bare `*` is accepted; safe under the
opaque-origin sandbox).
- CSP_KEYS: replace the `satisfies` (proves listed keys valid) with an
exhaustiveCspKeys() helper that ALSO fails to compile if the upstream
ext-apps type gains a domain key CSP_KEYS omits, so a requested
restriction can never be silently dropped.
- SAFE_CSP_SOURCE: accept case-insensitive schemes (URL schemes are
case-insensitive); previously over-rejected `HTTPS://…`. Adds tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
undici 8.5.0 requires Node >= 22.19.0 (its declared engines), so CI's
Node 20.x could not load undici's CacheStorage (missing
webidl.util.markAsUncloneable) and the proxy integration tests failed.
Bump the CI setup-node to 22.x to match the repo's Node floor.
Reconcile the declared floor with undici's real requirement: bump root
engines.node to >=22.19.0 and correct the CLI README note (undici 8.5.0
needs >=22.19.0, not >=22.7.5). Also document proxy support in the web
README and clarify that a caller-supplied eventSourceInit.fetch bypasses
the proxy wrapper by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
- Soften the OAuth-callback-rejected toast: the shape guard is
defense-in-depth, not full CSRF prevention (PKCE remains primary), so the
message no longer overstates ("rejecting to prevent a cross-site request" ->
"did not originate from this session").
- Document the intentional asymmetry between a present-but-malformed `state`
(rejected) and a wholly absent `state` (accepted, matched via
OAUTH_PENDING_SERVER_KEY) — rejecting the null case would mask real provider
error redirects that omit `state`.
- Make the "valid state is not rejected" test assert the specific downstream
"could not be matched" toast (seeding OAUTH_PENDING_SERVER_KEY) instead of an
indirect "some toast fired" check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
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
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
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
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
Add a pure, DOM-free CSP-builder library at
clients/web/src/lib/sandbox-csp.ts that validates app-supplied
`_meta.ui.csp` requests, filters them to safe sources, builds the
locked-down Content-Security-Policy string, and wraps untrusted widget
HTML in a fixed host shell whose first <head> child is the CSP meta.
- SAFE_CSP_SOURCE: strict regex that rejects directive/attribute
breakouts (`;`, `"`, `<`, `>`, whitespace).
- approveCspSources(): drops unsafe entries (with a warning) and omits
empty keys; echoes back only what the host will enforce.
- buildSandboxCspPolicy(): `default-src 'none'` catch-all, `form-action
'none'`, source-allowlist filtering, `'unsafe-inline'` for the app's
own inline script/style.
- escapeHtmlAttr() / wrapSandboxedHtml(): defense-in-depth so untrusted
bytes never precede the applied policy.
Re-implements the sandbox-csp portion of PR #1510 (Wave 1 of #1579) as a
standalone library; wiring into the app bridge is a follow-up issue.
Adds full unit tests (100% lines/statements/functions/branches) and
lists the file in the web coverage `include` so it is gated (the
untested legacy src/lib siblings stay out until they get tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
Add clients/web/src/components/elements/AppRenderer/hostContext.ts — a pure
utilities module for the MCP Apps host:
- currentTheme(): read the resolved Mantine color scheme from the DOM
- currentStyles(): map the inspector's Mantine/-inspector design tokens to a
McpUiHostStyles snapshot via STYLE_VARIABLE_SOURCES
- measureContainerDimensions(): whole-pixel container measurement
- snapshotHostContext(): assemble the initial McpUiHostContext seed
Wave 1 of the PR #1510 decomposition (#1579). Pure extraction only — the
AppRenderer/AppsScreen/createAppBridgeFactory rewrites belong to Wave 2.
Full unit coverage (100% lines/statements/functions/branches).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
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
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
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
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