Skip to content

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

Merged
cliffhall merged 6 commits into
v2/mainfrom
v2/wire-oauth-flow
May 31, 2026
Merged

feat(auth): wire OAuth authorization-code flow into App.tsx (#1379)#1383
cliffhall merged 6 commits into
v2/mainfrom
v2/wire-oauth-flow

Conversation

@cliffhall
Copy link
Copy Markdown
Member

Closes #1379.

Stacked on #1382 (#1378). Base is v2/auto-inject-api-token so the diff shows only the OAuth wiring. Rebase onto v2/main once #1382 merges. (Local OAuth testing needs #1378's token injection, which is why this stacks.)

Problem

OAuth-protected MCP servers couldn't be connected to from the v2 web client. The full OAuth pipeline already exists in core/, but App.tsx never invoked it — so a connect attempt 401'd and surfaced the toast the reporter saw:

Failed to connect to "ts-test-oauth-server"
Remote send failed (401): {"error":"invalid_token","error_description":"Missing Authorization header"}

Change

All the core primitives (authenticate(), completeOAuthFlow(), BrowserOAuthStorage, BrowserNavigation, backend-proxied OAuth fetches) were already in place; this wires the two missing entry points in App.tsx:

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

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

The pure pieces (constants + isUnauthorizedError) are extracted to src/utils/oauthFlow.ts with unit tests; the React orchestration stays in App.tsx.

Acceptance criteria

  • Connect on an OAuth-protected server (no saved tokens) completes the flow end-to-end → Connected.
  • Connection Info modal (feat(connection-info): wire Connection Info modal to the connection handshake #1377) shows the OAuth section populated (access token).
  • error= callback surfaces a toast instead of silently retrying.
  • Reload at the bare origin after auth doesn't re-trigger the exchange (URL replaced).
  • Existing non-OAuth servers (stdio, plain HTTP) connect with no behavior change (401-only branch).

Testing

Verified end-to-end in a real browser against the MCP TypeScript SDK demo OAuth server (resource :3000, auth server :3001, DCR): Connect → redirect → auto-approve → /oauth/callbackConnected (82ms), with the access token shown in the Connection Info modal and the URL cleaned back to /.

npm run validate (1851 unit tests + coverage gate), npm run test:integration (491), and npm run test:storybook (322) all green.

Out of scope (per issue)

Guided OAuth step-through UI, explicit "Sign in"/"Sign out" affordances, DCR corner cases (#571).

🤖 Generated with Claude Code

cliffhall and others added 2 commits May 30, 2026 17:15
)

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OAuth-protected MCP servers could not be connected to from the v2 web
client: the core OAuth pipeline exists, but App.tsx never invoked it, so a
connect attempt 401'd and surfaced "Remote send failed (401): … Missing
Authorization header" as a toast.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cliffhall cliffhall added auth Issues and PRs related to authorization v2 Issues and PRs for v2 labels May 30, 2026
@cliffhall cliffhall requested a review from Copilot May 30, 2026 21:51
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Wires the existing OAuth authorization-code primitives into the web client's App.tsx: a 401 on connect now auto-triggers client.authenticate() and a new mount effect handles the /oauth/callback page load to exchange the code and reconnect. Pure helpers (OAUTH_CALLBACK_PATH, OAUTH_PENDING_SERVER_KEY, isUnauthorizedError) are extracted into utils/oauthFlow.ts with unit tests, while the React orchestration stays in App.tsx.

Changes:

  • Add utils/oauthFlow.ts with the callback path constant, the sessionStorage key for the pending server id, and a robust isUnauthorizedError predicate (covers err.status, err.code, and (401) in the message).
  • In App.tsx's onToggleConnection connect-catch: detect 401, persist the initiating server id to sessionStorage, call client.authenticate() (full-page redirect); toast on auth failure.
  • Add a mount useEffect that, once servers hydrate, parses /oauth/callback params, recovers the pending server, runs completeOAuthFlow(code) then connect(), and cleans the URL with replaceState("/"); toasts on error= or unmatched server, guarded by a one-shot ref.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
clients/web/src/utils/oauthFlow.ts New shared constants + isUnauthorizedError helper for the App-level OAuth wiring.
clients/web/src/utils/oauthFlow.test.ts Unit tests covering the constants and the various 401-detection branches.
clients/web/src/App.tsx Imports the new helpers, replaces the inline /oauth/callback literal, adds the callback useEffect, and extends the connect-catch with the 401 → authenticate() path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@cliffhall
Copy link
Copy Markdown
Member Author

Server Connected

server-connected

Connection Info

connection-info

Tools Working

tools-work

@cliffhall
Copy link
Copy Markdown
Member Author

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 30, 2026

Claude finished @cliffhall's task in 3m 9s —— View job


Code Review

Solid, well-documented wiring. The extraction of pure logic into oauthFlow.ts with focused tests is the right shape, and the comments explain why (PKCE survives in BrowserOAuthStorage, full-page redirect wipes React state, single-use code → replaceState("/") before async work). The 401-only catch branch correctly leaves stdio/plain-HTTP behavior untouched. End-to-end screenshots confirm the happy path.

A few suggestions, mostly nits — none are blockers.


1. Toast message conflates two failure modes — App.tsx:601-616

try {
  await client.completeOAuthFlow(params.code);
  connectStartRef.current = Date.now();
  await client.connect();
} catch (err) {
  // …
  title: `Failed to complete OAuth for "${server.name}"`,

If completeOAuthFlow succeeds but connect() then fails (network blip, server restarted, etc.), the user sees "Failed to complete OAuth" — but OAuth actually completed and the tokens are persisted. A retry by clicking Connect again will skip OAuth entirely and just connect. Worth splitting so the toast title matches what actually failed:

try {
  await client.completeOAuthFlow(params.code);
} catch (err) {
  // toast: "OAuth token exchange failed"
  return;
}
try {
  connectStartRef.current = Date.now();
  await client.connect();
} catch (err) {
  connectStartRef.current = undefined;
  // toast: `Failed to connect to "${server.name}"`
}

Fix this →

2. isUnauthorizedError regex can theoretically false-positive — oauthFlow.ts:40

/\(401\)/.test(message) is a fine fallback for the transport's formatted "... (401): ..." pattern, and the structured err.status === 401 check fires first so this rarely matters. But an error whose message happens to embed (401) from a different context (e.g., a tool result spliced into a connect error message) would trip the OAuth flow. Realistic risk is low; if you want a tighter match, anchor to the transport's wording, e.g. /\bfailed[^\n]*\(401\)/i. Tests look good either way.

3. pendingId is removed before the server lookup — App.tsx:571-599

const pendingId = window.sessionStorage.getItem(OAUTH_PENDING_SERVER_KEY) ?? undefined;
window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY);
// …
window.history.replaceState({}, "", "/");
// …
const server = pendingId ? servers.find((s) => s.id === pendingId) : undefined;
if (!server) { /* toast + return */ }

If pendingId is present but no longer matches a server (e.g., the user deleted/renamed it in another tab), the key is gone and the URL is clean — recovery requires a fresh Connect click. That's probably fine, just worth being explicit that this is the design.

4. URL cleanup happens before the exchange — App.tsx:577

The comment explains the rationale (replay protection of a single-use code), and that's the right call. The tradeoff is that a transient completeOAuthFlow failure can't be recovered by reloading /oauth/callback; the user has to restart the whole flow. Worth a one-line mention in the toast ("Please try connecting again.") so the user knows reload won't help.

5. No tests cover the React orchestration

The PR description correctly calls out that pure logic lives in oauthFlow.ts with unit tests and the orchestration is in App.tsx. The branches that aren't exercised by the unit suite — the mount effect's path check / pending-key/ servers-empty/ error= branches, and the 401 catch in onToggleConnection — are validated by the manual end-to-end run. That's acceptable for now given the per-file 90% coverage gate may still pass, but a small App.test.tsx exercising the four callback effect branches (pathname !== callback, params.successful === false, missing pendingId, happy path with mocked InspectorClient) would harden against future regressions.

6. Minor — setupClientForServer dependency churn in the callback effect

useEffect(..., [servers, setupClientForServer]) re-runs whenever any of the 9 state-manager deps in setupClientForServer changes. The ref + path guards short-circuit cleanly, so this is harmless waste, but if it ever starts mattering you can move the guard ahead of the early returns (set the ref to true once pathname === OAUTH_CALLBACK_PATH even before servers hydrates) and accept the cost of dropping the callback if servers never load.


Things that look right

  • oauthCallbackHandledRef after the early returns is intentional — lets the effect wait for servers to hydrate, then fires exactly once.
  • connectStartRef = Date.now() placed right before connect() (not before completeOAuthFlow) so handshake latency excludes the token exchange.
  • setupClientForServer(server) returns the client directly to the closure rather than relying on the state setter to flush — avoids the re-render lag.
  • sessionStorage use for OAUTH_PENDING_SERVER_KEY is correct (origin-isolated, cleared on tab close, survives the redirect).
  • redirectUrlProvider hoisted to module scope — good (no per-render churn for BrowserOAuthClientProvider).

Summary

Looks good to merge. Items 1 and 3/4's toast copy are the most user-visible — happy to ship as-is or tweak if you want. The rest are observations.

  • Read changed files
  • Read related OAuth core (authenticate, completeOAuthFlow, BrowserOAuthStorage, BrowserNavigation)
  • Review for correctness, security, edge cases
  • Review test coverage
  • Post review
    · Branch: v2/wire-oauth-flow

… + no-store (#1378)

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cliffhall cliffhall deleted the branch v2/main May 31, 2026 00:10
@cliffhall cliffhall closed this May 31, 2026
@cliffhall cliffhall reopened this May 31, 2026
@cliffhall cliffhall changed the base branch from v2/auto-inject-api-token to v2/main May 31, 2026 00:11
cliffhall and others added 2 commits May 30, 2026 20:12
… 401 match (#1379)

Code-review feedback on the OAuth-wiring PR:

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cliffhall
Copy link
Copy Markdown
Member Author

Thanks for the thorough review — addressed the actionable items in b5de744.

1 + 4. Toast conflated OAuth-exchange vs connect failures — Fixed. Split the callback effect into two try/catch blocks: a completeOAuthFlow failure now reads "OAuth token exchange failed … Please try connecting again." (the single-use code is spent and the URL was already cleared, so a reload can't retry), while a post-OAuth connect() failure reads "Failed to connect" — since OAuth actually succeeded and re-clicking Connect reuses the persisted tokens with no second authorization. Added a comment explaining why conflating them would mislead the user into re-authorizing.

2. isUnauthorizedError regex could false-positive — Tightened. The message fallback now anchors on the transport's own wording, /\bfailed\b[^\n]*\(401\)/i (matches Remote (connect|send|events stream) failed (401): …) instead of a bare (401), so an unrelated (401) spliced into an error message can't trip the OAuth flow. Added a test for that case. (The structured err.status === 401 check still fires first as the primary path.)

3. pendingId removed before the server lookup — Made the design explicit with a comment: clearing the pending id + URL before the lookup is intentional, so a server deleted/renamed mid-flow surfaces the error and requires a fresh Connect rather than leaving stale callback state around.

5. No tests for the React orchestration — Acknowledged; deferring as a follow-up. Standing up App.test.tsx means mocking InspectorClient/useServers/the 9 state managers to render the full InspectorView, which is a sizable harness for the wiring component. The pure branches stay covered in oauthFlow.test.ts, and the effect's paths are exercised by the e2e run. Happy to file an issue for the focused callback-effect test if you'd like it tracked.

6. setupClientForServer dep churn in the effect — Acknowledged as harmless (the ref + path guards short-circuit). Left as-is.

Thanks for the positive callouts on the ref-after-early-returns pattern, the connectStartRef placement, and the module-scoped redirectUrlProvider — kept those.

Note: this PR's base is now v2/main (#1382 merged), and these changes are propagated down the rest of the stack (#1385, #1387). validate (1856 on this branch), test:integration, and test:storybook all green.

@cliffhall cliffhall merged commit 9622e81 into v2/main May 31, 2026
1 check passed
@cliffhall cliffhall deleted the v2/wire-oauth-flow branch May 31, 2026 00:26
cliffhall added a commit that referenced this pull request May 31, 2026
…p + test fetch default (#1384)

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cliffhall added a commit that referenced this pull request May 31, 2026
…#1384) (#1385)

* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Root causes:

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

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

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

saveSession also now uses keepalive: true.

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

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

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

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

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

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

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

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

Code-review feedback on the OAuth-wiring PR:

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cliffhall added a commit that referenced this pull request May 31, 2026
… toggle (#1386) (#1387)

* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Root causes:

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

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

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

saveSession also now uses keepalive: true.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Code-review feedback on the OAuth-wiring PR:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fifth-pass review (LGTM):

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Issues and PRs related to authorization v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire up OAuth flow in App.tsx: authenticate, oauthAuthorizationRequired listener, /oauth/callback handler

2 participants