Skip to content

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

@cliffhall

Description

@cliffhall

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.redirectToAuthorizationBrowserNavigation.navigateToAuthorizationwindow.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

  1. Add an OAuth-protected MCP server to mcp.json (e.g. the test ts-test-oauth-server fixture).
  2. Click Connect on its ServerCard.
  3. Browser → POST /api/mcp/connect with oauthTokens: undefined (none persisted for this server URL).
  4. Dev backend (core/mcp/remote/node/server.ts:474) creates an auth provider from those (undefined) tokens, spawns a server-side transport, POSTs initialize to the upstream MCP server with no Authorization header.
  5. Upstream MCP server returns 401 { error: "invalid_token", error_description: "Missing Authorization header" }.
  6. Dev backend forwards the 401 (remoteClientTransport.ts:319-323Remote send failed (401): …).
  7. 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-68createWebEnvironment constructs environment.fetch = createRemoteFetch(…). Same module also wires BrowserOAuthStorage + BrowserNavigation.
  • core/mcp/remote/createRemoteFetch.ts — serializes the outgoing request as { url, init: { method, headers, body } } JSON, POSTs to ${baseUrl}/api/fetch with x-mcp-remote-auth: Bearer <token>, and reconstructs a Response from the mirrored body. Structurally identical to v1's createProxyFetch from 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/fetch route 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:195this.fetchFn = options.environment.fetch plumbs the proxy fetcher into the client.
  • core/mcp/inspectorClient.ts:316-325buildEffectiveAuthFetch wraps that with tracking (without capturing response bodies, so access_token / refresh_token don't end up in the Network tab body preview) and passes it into OAuthManager.
  • core/mcp/oauthManager.ts:120-124, 187, 293 — every call site that invokes the SDK's auth() / discovery / token exchange threads fetchFn: this.params.effectiveAuthFetch through.

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:

  1. Two entry points into client.authenticate() in clients/web/src/App.tsx:
    • Auto on 401: connectMcpServer()'s catch detects status === 401 and calls await 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: AuthDebugger component exposes "Quick OAuth Flow" → client.authenticate() and "Guided OAuth Flow" → client.beginGuidedAuth() + step-through UI.
  2. /oauth/callback handler: App.tsx checks window.location.pathname === "/oauth/callback" on mount (no React Router) and renders <OAuthCallback />. That component parses code / error / state via parseOAuthCallbackParams() (in clients/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".
  3. Guided UI is driven by oauthStepChange events, not oauthAuthorizationRequired. AuthDebugger and OAuthFlowProgress subscribe via client.addEventListener("oauthStepChange", …) and update their step indicators. The actual redirect happens because BrowserNavigation.navigateToAuthorization does window.location.href = url from inside provider.redirectToAuthorization — the event is informational, not load-bearing.
  4. Token storage: BrowserOAuthStorage (browser sessionStorage, keyed per server URL) — same module v2/main already uses in createWebEnvironment.

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/callback route 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.tsxoauthStepChange listener + per-step UI.
  • clients/web/src/utils/oauthUtils.tsparseOAuthCallbackParams, generateOAuthState, generateOAuthErrorDescription.
  • clients/web/src/lib/adapters/environmentFactory.ts — confirm the BrowserOAuthStorage + BrowserNavigation injection matches v2/main's existing createWebEnvironment.

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 catch block in onToggleConnection, detect status === 401 (the RemoteClientTransport already preserves the upstream status on the thrown error), then await inspectorClient.authenticate(). That returns the authorization URL and the BrowserNavigation adapter 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_description from window.location.search — port parseOAuthCallbackParams from v1.5's oauthUtils.ts.
  • Look up which server's OAuth flow this state belongs to (the state machine persists this; parseOAuthState(state) in core/auth/utils.ts is the right helper — mode === "guided" branches to the step UI, anything else is the quick path).
  • Reconstruct the InspectorClient for that server, call client.completeOAuthFlow(code) (v1.5's quick-path entry into the token exchange).
  • Replace the URL with the bare origin via window.history.replaceState so 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, and accessToken after 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

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 BrowserOAuthStorage already namespaces tokens by server URL in sessionStorage, so multi-server OAuth coexistence comes for free.

Metadata

Metadata

Assignees

Labels

authIssues and PRs related to authorizationv2Issues and PRs for v2

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions