Repository navigation
Wire up OAuth flow in App.tsx: authenticate, oauthAuthorizationRequired listener, /oauth/callback handler #1379
Description
Problem
OAuth-protected MCP servers cannot be connected to from the v2 web client. The core libraries already implement the full OAuth pipeline (OAuthManager.authenticate() → SDK auth() → BaseOAuthClientProvider.redirectToAuthorization → BrowserNavigation.navigateToAuthorization → window.location.href = …), but App.tsx never invokes it. There's no entry point to start the flow, no listener for the oauthAuthorizationRequired event the InspectorClient already dispatches, and no /oauth/callback route handler to exchange the returning auth code for tokens.
Concrete repro
- Add an OAuth-protected MCP server to
mcp.json(e.g. the testts-test-oauth-serverfixture). - Click Connect on its
ServerCard. - Browser →
POST /api/mcp/connectwithoauthTokens: undefined(none persisted for this server URL). - Dev backend (
core/mcp/remote/node/server.ts:474) creates an auth provider from those (undefined) tokens, spawns a server-side transport, POSTsinitializeto the upstream MCP server with no Authorization header. - Upstream MCP server returns
401 { error: "invalid_token", error_description: "Missing Authorization header" }. - Dev backend forwards the 401 (
remoteClientTransport.ts:319-323→Remote send failed (401): …). App.tsx's connect-catch fires the failure toast.
The Connection Info modal added in PR #1377 can't surface OAuth details for such a server either, because the OAuth state machine on the InspectorClient never runs.
Backend-mediated OAuth fetches: already done
v1 (legacy main branch) merged PR #1047 to push OAuth network requests (metadata discovery, DCR, token exchange) to the backend proxy server so the browser never makes direct cross-origin requests to auth servers — bypassing CORS for auth servers that don't return CORS-friendly headers. We want the same behavior on v2.
Good news: v2 already does this, just at a different layer. The pipeline:
createWebEnvironment() →
environment.fetch = createRemoteFetch({ baseUrl, authToken }) // POSTs to /api/fetch
→ InspectorClient: this.fetchFn = environment.fetch
this.effectiveAuthFetch = buildEffectiveAuthFetch() // wraps fetchFn with tracking
→ OAuthManager({ effectiveAuthFetch }) // inspectorClient.ts:237
→ auth(provider, { fetchFn: effectiveAuthFetch }) // oauthManager.ts:120-124
→ SDK uses fetchFn for ALL OAuth HTTP calls
(discovery, DCR, token exchange)
Concretely, the moving parts are:
clients/web/src/lib/environmentFactory.ts:51-68—createWebEnvironmentconstructsenvironment.fetch = createRemoteFetch(…). Same module also wiresBrowserOAuthStorage+BrowserNavigation.core/mcp/remote/createRemoteFetch.ts— serializes the outgoing request as{ url, init: { method, headers, body } }JSON, POSTs to${baseUrl}/api/fetchwithx-mcp-remote-auth: Bearer <token>, and reconstructs aResponsefrom the mirrored body. Structurally identical to v1'screateProxyFetchfrom PR Inspector client: Added proxy fetch for use by auth (to avoid CORS issues) #1047.core/mcp/remote/node/server.ts:1107-1157— the/api/fetchroute on the dev/prod backend. Validates http/https scheme, reuses the existing inspector auth middleware, executes the fetch in Node, mirrors status + headers + body back to the browser.core/mcp/inspectorClient.ts:195—this.fetchFn = options.environment.fetchplumbs the proxy fetcher into the client.core/mcp/inspectorClient.ts:316-325—buildEffectiveAuthFetchwraps that with tracking (without capturing response bodies, soaccess_token/refresh_tokendon't end up in the Network tab body preview) and passes it intoOAuthManager.core/mcp/oauthManager.ts:120-124, 187, 293— every call site that invokes the SDK'sauth()/ discovery / token exchange threadsfetchFn: this.params.effectiveAuthFetchthrough.
So PR #1047's principle ("the backend does as much of the OAuth network as possible") is already a property of v2/main's core. No new proxy endpoint is needed and no OAuthStateMachine re-wiring is needed.
What cannot be backend-only, by design: the user's browser navigation to the authorization URL — that has to be a top-level redirect (the user actually has to be on the auth server's page to authenticate), which is why BrowserNavigation.navigateToAuthorization in core/auth/browser/providers.ts:15-25 does window.location.href = url. That's a property of the authorization-code grant, not a CORS leak.
Reference: how v1.5/main wires this (entirely browser-side)
The OAuth handshake in v1.5 runs ~99% in the browser. The dev backend hosts no /oauth routes; it stays a pure transport/fetch proxy for /api/*. The browser's InspectorClient (via OAuthManager + BaseOAuthClientProvider + BrowserNavigation + BrowserOAuthStorage — all already ported to v2/main core/) drives metadata discovery, DCR, the redirect, and the code→token exchange. The dev backend is uninvolved in the OAuth flow itself.
The v1.5 pattern, in order:
- Two entry points into
client.authenticate()inclients/web/src/App.tsx:- Auto on 401:
connectMcpServer()'s catch detectsstatus === 401and callsawait client.authenticate(). If that throws, the AuthDebugger UI is popped:if (status === 401) { try { await client.authenticate(); } catch (authError) { setIsAuthDebuggerVisible(true); toast({ ... }); } }
- Explicit "Auth" tab:
AuthDebuggercomponent exposes "Quick OAuth Flow" →client.authenticate()and "Guided OAuth Flow" →client.beginGuidedAuth()+ step-through UI.
- Auto on 401:
/oauth/callbackhandler:App.tsxcheckswindow.location.pathname === "/oauth/callback"on mount (no React Router) and renders<OAuthCallback />. That component parsescode/error/stateviaparseOAuthCallbackParams()(inclients/web/src/utils/oauthUtils.ts) and calls either:client.completeOAuthFlow(code)— quick path, auto-redirects home after the exchange.client.setGuidedAuthorizationCode(code)— guided path, waits for the user to click "Continue".
- Guided UI is driven by
oauthStepChangeevents, notoauthAuthorizationRequired.AuthDebuggerandOAuthFlowProgresssubscribe viaclient.addEventListener("oauthStepChange", …)and update their step indicators. The actual redirect happens becauseBrowserNavigation.navigateToAuthorizationdoeswindow.location.href = urlfrom insideprovider.redirectToAuthorization— the event is informational, not load-bearing. - Token storage:
BrowserOAuthStorage(browser sessionStorage, keyed per server URL) — same module v2/main already uses increateWebEnvironment.
Key files on v1.5/main to skim before starting v2 wiring (read via git show v1.5/main:<path>):
clients/web/src/App.tsx— 401-catch (~835-845),/oauth/callbackroute check (~1100-1130), AuthDebugger tab (~1150-1200).clients/web/src/components/OAuthCallback.tsx— quick vs. guided code exchange (lines ~80-150).clients/web/src/components/AuthDebugger.tsx— entry-point UI for both flows.clients/web/src/components/OAuthFlowProgress.tsx—oauthStepChangelistener + per-step UI.clients/web/src/utils/oauthUtils.ts—parseOAuthCallbackParams,generateOAuthState,generateOAuthErrorDescription.clients/web/src/lib/adapters/environmentFactory.ts— confirm the BrowserOAuthStorage + BrowserNavigation injection matches v2/main's existingcreateWebEnvironment.
Proposed change
The architecture lift is small because all the core primitives are already in place on v2/main. Three pieces of work, all in clients/web/src/App.tsx:
1. Trigger the OAuth flow at the right moment
Two reasonable entry points; pick one:
- Auto-trigger on connect failure with a 401-shaped error. Inside the
catchblock inonToggleConnection, detectstatus === 401(theRemoteClientTransportalready preserves the upstream status on the thrown error), thenawait inspectorClient.authenticate(). That returns the authorization URL and theBrowserNavigationadapter already redirects the page. This mirrors v1.5's pattern at App.tsx:835-845. - Explicit "Sign in" affordance. A button on the
ServerCard(or a banner inside the Connection Info modal feat(connection-info): wire Connection Info modal to the connection handshake #1377 added) that the user clicks to start the flow. More UI work, fewer surprises.
Recommendation: ship auto-trigger first (matches v1.5 and gets a working connection in one click). The explicit affordance is a follow-up for re-auth / scope changes / the guided-flow UI (v1.5's AuthDebugger has no v2 equivalent yet — likely a separate issue).
2. Handle /oauth/callback when the auth server redirects back
The redirect URL provider (App.tsx:60-66) already points at ${window.location.origin}/oauth/callback. We need a route — easiest path is a top-level effect in App.tsx that runs once on mount:
- Check
window.location.pathname === "/oauth/callback". - Parse
code,state,error,error_descriptionfromwindow.location.search— portparseOAuthCallbackParamsfrom v1.5'soauthUtils.ts. - Look up which server's OAuth flow this
statebelongs to (the state machine persists this;parseOAuthState(state)incore/auth/utils.tsis the right helper —mode === "guided"branches to the step UI, anything else is the quick path). - Reconstruct the
InspectorClientfor that server, callclient.completeOAuthFlow(code)(v1.5's quick-path entry into the token exchange). - Replace the URL with the bare origin via
window.history.replaceStateso a reload doesn't re-trigger the exchange. - Re-attempt
client.connect(). If it succeeds, you're done. If it fails again, fall through to the same failure toast.
3. (Optional, v1.5 parity) Subscribe to oauthStepChange for a step UI
If we want feature parity with v1.5's AuthDebugger / OAuthFlowProgress, add an oauthStepChange listener. Not load-bearing for the happy path — quick-flow auto-redirect doesn't need it. Defer to a follow-up issue if the quick flow alone is the v1 scope here.
Note: oauthAuthorizationRequired is dispatched by the InspectorClient but is informational in v1.5's flow (the navigation already happens inside redirectToAuthorization). Listen to it only if we want a "Redirecting to {hostname} to authorize…" toast — not required for correctness.
Acceptance criteria
- Clicking Connect on an OAuth-protected MCP server (with no saved tokens) successfully completes the OAuth flow end-to-end: browser → auth server →
/oauth/callback→ tokens stored → InspectorClient connects. - The Connection Info modal (feat(connection-info): wire Connection Info modal to the connection handshake #1377) shows the OAuth section populated with the resulting
authUrl,scopes, andaccessTokenafter the successful flow. - An OAuth callback with
error=in the query string surfaces the error as a toast instead of silently retrying. - A reload at the bare origin after auth doesn't re-trigger the token exchange (URL was replaced).
- Existing non-OAuth servers (stdio, plain HTTP) connect with no behavior change.
Out of scope
- Pre-registered client credentials UI (already lives in the server Settings modal).
- Guided OAuth step-through UI (v1.5's AuthDebugger / OAuthFlowProgress) — separate issue if/when needed.
- "Sign out" / token revocation UI — file separately if needed.
- Dynamic Client Registration corner cases (covered by Dynamic Client Registration with 3rd party Auth providers (e.g. Okta) #571).
- The dev-backend auth-token auto-injection (Auto-inject MCP_INSPECTOR_API_TOKEN so reloads don't 401 without the query string #1378) — independent.
Notes
- v1.5/main has the equivalent wiring; cribbing from there is fair game — see the "Reference" section above for specific files and line ranges.
- The
BrowserOAuthStoragealready namespaces tokens by server URL insessionStorage, so multi-server OAuth coexistence comes for free.
Activity
fix(auth): address #1383 review — split OAuth/connect toasts, tighten…
feat(auth): wire OAuth authorization-code flow into App.tsx (#1379) (#…
fix(auth): persist OAuth pre-redirect Network log across the redirect (…
feat(network): show auth response bodies with masked secrets + reveal…