Skip to content

fix(mcp): deliver OAuth callback result over BroadcastChannel so COOP can't strand the connect#5767

Merged
waleedlatif1 merged 5 commits into
stagingfrom
fix/mcp-oauth-connect-robustness
Jul 19, 2026
Merged

fix(mcp): deliver OAuth callback result over BroadcastChannel so COOP can't strand the connect#5767
waleedlatif1 merged 5 commits into
stagingfrom
fix/mcp-oauth-connect-robustness

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Debugged a live staging report: connecting an OAuth MCP server (Gauge, https://app.withgauge.com/mcp) hangs on "Connecting…" forever. AWS logs showed the OAuth round-trip completing (/oauth/start 200, user authorized, /oauth/callback hit) but the UI never updating.
  • Root cause: Gauge's authorization page sends Cross-Origin-Opener-Policy: same-origin. Per MDN, that sets window.opener = null for the cross-origin popup, so the callback's window.opener.postMessage was silently dropped — the parent never learns the flow finished. Our page already uses same-origin-allow-popups, but that doesn't help when the provider's page forces same-origin.
  • Fix: deliver the completion over a same-origin BroadcastChannel instead of window.opener.postMessage. BroadcastChannel is origin-scoped and COOP-immune by design — the documented workaround for exactly this. The message is scoped by workspaceId so a concurrent flow in another open workspace is ignored.
  • Also: log every OAuth callback failure (reason + serverId). The early-return gates previously returned a silent ok:false popup close, so a failed authorization couldn't be diagnosed from server logs. (Separately, the reported attempt's callback returned in 11ms — an early-gate failure before token exchange; this logging makes that reason visible on the next attempt.)

Type of Change

  • Bug fix

Testing

  • Added callback-route tests: success signals over the BroadcastChannel scoped to server + workspace; an early failure reports over the channel without attempting token exchange.
  • tsc, Biome, and 40 MCP OAuth tests pass.
  • Note: the end-to-end popup behavior needs a live OAuth smoke test on staging (COOP severance only reproduces in a real browser against a same-origin provider).

Out of scope (deliberately not changed)

  • Kept the callback's awaited tool discovery (guarantees status+tools are ready when the signal fires — no UI flicker); making it non-blocking is a separate change with frontend-coordination implications.
  • Did not touch the popup-open timing; the popup opens fine today (the failure was completion signalling, not opening).

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

… can't strand the connect

An MCP provider whose authorization page sets `Cross-Origin-Opener-Policy:
same-origin` (e.g. Gauge) severs `window.opener` when the popup navigates
through it, so the callback's `window.opener.postMessage` was silently lost and
the row hung on "Connecting…" forever — even when the callback succeeded.

- Signal completion over a same-origin `BroadcastChannel` instead, which is
  origin-scoped and immune to opener severance (the MDN/Chrome-recommended COOP
  workaround). Scope the message by workspaceId so other open workspaces ignore it.
- Log every OAuth callback failure with its reason + serverId. The early-return
  gates previously returned a silent `ok:false` popup close, so a failed
  authorization was undiagnosable from the server logs.
@cursor

cursor Bot commented Jul 19, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches OAuth completion signaling and client flow lifecycle; behavior change is intentional but needs live browser smoke tests against COOP providers.

Overview
Fixes MCP OAuth popups that never leave "Connecting…" when the provider’s authorize page sets COOP same-origin, which nulls window.opener and drops the old postMessage completion.

The callback HTML now posts results on a same-origin BroadcastChannel('mcp-oauth'), and every outcome (including early failures and invalid_state without a serverId) echoes the OAuth state so only the tab that started that flow reacts. Failed callbacks also get a server log warning with reason (and server id when known).

The popup hook listens on that channel, tracks in-flight flows by state with a 10-minute safety timeout, and keeps popup.closed polling as a best-effort spinner clear that does not settle the flow (COOP can lie about closed). useStartMcpOauth parses and returns state from the authorization URL. Tests assert channel payloads for success, missing code, and invalid state.

Reviewed by Cursor Bugbot for commit eb69205. Configure here.

Comment thread apps/sim/app/api/mcp/oauth/callback/route.ts Outdated
Comment thread apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a live staging regression where connecting an OAuth MCP server whose authorization page sends Cross-Origin-Opener-Policy: same-origin would hang on "Connecting…" forever. The root cause is that COOP severs window.opener, silently dropping the postMessage the popup sent back. The fix replaces popup postMessage with a same-origin BroadcastChannel, scoped per-flow via the OAuth state nonce so other open tabs (even in the same origin) ignore broadcasts meant for a different flow.

  • route.ts: htmlClose now emits a BroadcastChannel script instead of window.opener.postMessage; a respond closure captures the parsed state nonce and forwards it on every response path, including early failures; all failure paths now log a warning.
  • use-mcp-oauth-popup.ts: Completely rewrites the message listener — replaces window.addEventListener('message', …) with a BroadcastChannel, tracks in-flight flows by state nonce in a pendingFlowsRef map, and adds a 10-minute safety timeout per flow; the popup-closed poll is retained as best-effort UX but no longer drives correlation logic.
  • mcp.ts / callback-reasons.ts: The mutation result type is extended with state (extracted from the authorization URL before the popup opens); the McpOauthCallbackMessage interface gains the optional state field.

Confidence Score: 5/5

Safe to merge. The fix is well-scoped and directly targets the confirmed live regression without touching unrelated paths.

The BroadcastChannel approach is the correct solution for COOP-severed openers, and the state-nonce correlation scheme correctly isolates each flow to the tab that started it — other same-origin tabs exit early before reaching any toast or cache invalidation. The previous review concern about spurious error toasts in other-workspace tabs is fully addressed: those tabs have no matching state in their pendingFlowsRef and bail at the guard. The popup-closed poll is correctly kept as a best-effort spinner reset, never touching pendingFlowsRef, so it cannot race with a genuine completion. Safety timeout, cleanup on unmount, and idempotent state transitions all look correct.

No files require special attention. All five changed files are consistent with each other and the approach is sound end-to-end.

Important Files Changed

Filename Overview
apps/sim/app/api/mcp/oauth/callback/route.ts Switched from window.opener.postMessage to BroadcastChannel; added state forwarding via a respond closure; all failure paths now log a warning. HTML escaping and JSON serialization of injected values look safe.
apps/sim/hooks/mcp/use-mcp-oauth-popup.ts Replaced window.addEventListener('message') with BroadcastChannel; tracking moved from serverId intervals to state-nonce-keyed pendingFlowsRef with a safety timeout. Cleanup logic is correct and idempotent.
apps/sim/hooks/queries/mcp.ts State extracted from the authorization URL before popup opens; mutation result type extended; throws clearly if state is absent.
apps/sim/lib/mcp/oauth/callback-reasons.ts Added optional state field to McpOauthCallbackMessage interface; comment updated to reflect BroadcastChannel delivery.
apps/sim/app/api/mcp/oauth/callback/route.test.ts Three new tests cover: success signal via BroadcastChannel with state nonce, early failure without token exchange, and state echoing on invalid_state. Assertions are appropriate.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Parent Tab
    participant MQ as useStartMcpOauth
    participant API as /api/mcp/oauth/start
    participant Popup as OAuth Popup
    participant Provider as Provider (COOP same-origin)
    participant CB as /api/mcp/oauth/callback
    participant BC as BroadcastChannel

    UI->>MQ: startOauthForServer(serverId)
    MQ->>API: GET /api/mcp/oauth/start
    API-->>MQ: authorizationUrl with state nonce
    MQ->>MQ: extract state from URL
    MQ->>Popup: window.open(authorizationUrl)
    MQ-->>UI: redirect with popup and state
    UI->>UI: pendingFlowsRef.set(state, serverId + timeout)
    UI->>BC: subscribe on mcp-oauth channel

    Popup->>Provider: navigate to authorizationUrl
    Note over Provider: COOP same-origin severs window.opener
    Provider-->>Popup: user authorizes then redirected
    Popup->>CB: "GET /callback?state=...&code=..."
    CB->>CB: token exchange and tool discovery
    CB-->>Popup: HTML with inline BroadcastChannel script

    Popup->>BC: ch.postMessage with ok, state, serverId, reason
    BC-->>UI: onmessage fires
    UI->>UI: pendingFlowsRef.get(state) matches flow
    UI->>UI: settleFlow clears timeout and spinner
    alt authorized
        UI->>UI: invalidateQueries and toast success
    else failed
        UI->>UI: toast error with reason message
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Parent Tab
    participant MQ as useStartMcpOauth
    participant API as /api/mcp/oauth/start
    participant Popup as OAuth Popup
    participant Provider as Provider (COOP same-origin)
    participant CB as /api/mcp/oauth/callback
    participant BC as BroadcastChannel

    UI->>MQ: startOauthForServer(serverId)
    MQ->>API: GET /api/mcp/oauth/start
    API-->>MQ: authorizationUrl with state nonce
    MQ->>MQ: extract state from URL
    MQ->>Popup: window.open(authorizationUrl)
    MQ-->>UI: redirect with popup and state
    UI->>UI: pendingFlowsRef.set(state, serverId + timeout)
    UI->>BC: subscribe on mcp-oauth channel

    Popup->>Provider: navigate to authorizationUrl
    Note over Provider: COOP same-origin severs window.opener
    Provider-->>Popup: user authorizes then redirected
    Popup->>CB: "GET /callback?state=...&code=..."
    CB->>CB: token exchange and tool discovery
    CB-->>Popup: HTML with inline BroadcastChannel script

    Popup->>BC: ch.postMessage with ok, state, serverId, reason
    BC-->>UI: onmessage fires
    UI->>UI: pendingFlowsRef.get(state) matches flow
    UI->>UI: settleFlow clears timeout and spinner
    alt authorized
        UI->>UI: invalidateQueries and toast success
    else failed
        UI->>UI: toast error with reason message
    end
Loading

Reviews (6): Last reviewed commit: "fix(mcp): drop a server's prior in-fligh..." | Re-trigger Greptile

…e popup

A BroadcastChannel reaches every same-origin tab, so the previous workspace-id
scoping (which the success path carried but failure paths omitted) still let
unrelated tabs clear state, refetch, and show spurious toasts. Gate every
reaction on whether this tab actually has an in-flight popup for the result's
server (`popupIntervalsRef`) — strictly more precise than workspace scoping and
correct for both cross-workspace and same-workspace-second-tab cases. Removes
the now-redundant workspaceId from the callback message.
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 19, 2026 5:59pm

Request Review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/hooks/mcp/use-mcp-oauth-popup.ts Outdated
Comment thread apps/sim/hooks/mcp/use-mcp-oauth-popup.ts Outdated
Cursor flagged two defects in the previous gate, both rooted in keying the
BroadcastChannel filter on `popupIntervalsRef` (the popup.closed poll map):

- A genuine completion was dropped whenever the poll had already removed the
  server's entry — and COOP can make `popup.closed` misreport, which is exactly
  the case this PR targets, so the fix could silently fail to apply.
- A result without a serverId fell back to "any in-flight popup", waking
  unrelated same-origin tabs with spurious toasts/refetches.

Introduce `pendingFlowsRef` (serverId -> safety timeout) as the correlation
source of truth: cleared only on completion or a 10-min timeout (matching the
server OAuth start TTL), never by popup.closed. The popup.closed poll now only
clears the spinner (best-effort abandon UX) and never touches correlation, so a
real completion is always processed. Results without a serverId are ignored;
the initiating tab's safety timeout clears its own "Connecting…".
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
Cursor flagged that a failure which can't resolve a serverId (notably
invalid_state) broadcasts ok:false with no serverId, so the serverId-keyed gate
ignored it and the initiating tab sat on "Connecting…" until the safety timeout
with no error feedback.

Correlate on the OAuth `state` instead — the per-flow nonce the callback echoes
on every result, success or failure. The client parses it from the authorization
URL and keys the in-flight map by it; the callback includes it on every response
via a `respond` helper. This is the canonical popup-OAuth correlation: it reaches
the initiating tab even when no serverId exists, and — because each flow has a
unique state — it also fixes the same-user-same-server-in-two-tabs edge a serverId
key left open. Results with no parseable state (a malformed callback) are still
ignored; that flow clears via its timeout.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3553676. Configure here.

Each start mints a new `state`, so an abandoned attempt's safety timeout was
keyed under a different state than its retry and never cleared. In the contrived
case where both stayed pending ~10 min, the stale timer would clear the newer
flow's spinner. Sweep any prior in-flight flow for the same serverId on a new
start (replacing the can't-happen same-state check). Result delivery was already
correct; this makes the state machine airtight.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit eb69205. Configure here.

@waleedlatif1
waleedlatif1 merged commit fb439c9 into staging Jul 19, 2026
20 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/mcp-oauth-connect-robustness branch July 19, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant