You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 test ts-test-oauth-server fixture).
Click Connect on its ServerCard.
Browser → POST /api/mcp/connect with oauthTokens: 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, POSTs initialize to the upstream MCP server with no 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:
clients/web/src/lib/environmentFactory.ts:51-68 — createWebEnvironment 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:195 — this.fetchFn = options.environment.fetch plumbs the proxy fetcher into the client.
core/mcp/inspectorClient.ts:316-325 — buildEffectiveAuthFetch 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:
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:
/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".
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.
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>):
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.
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:
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.
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()→ SDKauth()→BaseOAuthClientProvider.redirectToAuthorization→BrowserNavigation.navigateToAuthorization→window.location.href = …), butApp.tsxnever invokes it. There's no entry point to start the flow, no listener for theoauthAuthorizationRequiredevent the InspectorClient already dispatches, and no/oauth/callbackroute handler to exchange the returning auth code for tokens.Concrete repro
mcp.json(e.g. the testts-test-oauth-serverfixture).ServerCard.POST /api/mcp/connectwithoauthTokens: undefined(none persisted for this server URL).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.401 { error: "invalid_token", error_description: "Missing Authorization header" }.remoteClientTransport.ts:319-323→Remote send failed (401): …).App.tsx's connect-catch fires the failure toast.The
Connection Infomodal 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
mainbranch) 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:
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 noOAuthStateMachinere-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.navigateToAuthorizationincore/auth/browser/providers.ts:15-25doeswindow.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
/oauthroutes; it stays a pure transport/fetch proxy for/api/*. The browser'sInspectorClient(viaOAuthManager+BaseOAuthClientProvider+BrowserNavigation+BrowserOAuthStorage— all already ported to v2/maincore/) 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:
client.authenticate()inclients/web/src/App.tsx:connectMcpServer()'s catch detectsstatus === 401and callsawait client.authenticate(). If that throws, the AuthDebugger UI is popped:AuthDebuggercomponent exposes "Quick OAuth Flow" →client.authenticate()and "Guided OAuth Flow" →client.beginGuidedAuth()+ step-through UI./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".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.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:
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.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/callbackwhen the auth server redirects backThe 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 inApp.tsxthat runs once on mount:window.location.pathname === "/oauth/callback".code,state,error,error_descriptionfromwindow.location.search— portparseOAuthCallbackParamsfrom v1.5'soauthUtils.ts.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).InspectorClientfor that server, callclient.completeOAuthFlow(code)(v1.5's quick-path entry into the token exchange).window.history.replaceStateso a reload doesn't re-trigger the exchange.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
oauthStepChangefor a step UIIf we want feature parity with v1.5's AuthDebugger / OAuthFlowProgress, add an
oauthStepChangelistener. 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:
oauthAuthorizationRequiredis dispatched by the InspectorClient but is informational in v1.5's flow (the navigation already happens insideredirectToAuthorization). Listen to it only if we want a "Redirecting to {hostname} to authorize…" toast — not required for correctness.Acceptance criteria
/oauth/callback→ tokens stored → InspectorClient connects.authUrl,scopes, andaccessTokenafter the successful flow.error=in the query string surfaces the error as a toast instead of silently retrying.Out of scope
Notes
BrowserOAuthStoragealready namespaces tokens by server URL insessionStorage, so multi-server OAuth coexistence comes for free.