diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
index 1a1f5bcb8e8..96a7de727fe 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
@@ -72,4 +72,45 @@ describe('MCP OAuth callback route', () => {
})
)
})
+
+ it('signals success over a same-origin BroadcastChannel carrying the state nonce', async () => {
+ const request = new NextRequest(
+ 'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
+ )
+
+ const body = await (await GET(request)).text()
+
+ // The completion is delivered over a BroadcastChannel (not window.opener.postMessage)
+ // so a COOP `same-origin` provider that severs the opener can't strand the parent. The
+ // `state` nonce lets the hook react only in the tab that started this exact flow.
+ expect(body).toContain("new BroadcastChannel('mcp-oauth')")
+ expect(body).toContain('ok: true')
+ expect(body).toContain('"server-1"')
+ expect(body).toContain('"state-1"')
+ })
+
+ it('reports an early failure over the channel without attempting token exchange', async () => {
+ // Missing `code` fails at the param gate, before any network work.
+ const request = new NextRequest('http://localhost:3000/api/mcp/oauth/callback?state=state-1')
+
+ const body = await (await GET(request)).text()
+
+ expect(body).toContain('ok: false')
+ expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled()
+ })
+
+ it('echoes the state on a serverless invalid_state failure so the initiating tab can react', async () => {
+ // No row loads for the state -> failure with no serverId. The state must still be echoed,
+ // or the initiating tab would sit on "Connecting…" until its safety timeout.
+ mcpOauthMockFns.mockLoadOauthRowByState.mockResolvedValueOnce(null)
+ const request = new NextRequest(
+ 'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
+ )
+
+ const body = await (await GET(request)).text()
+
+ expect(body).toContain('ok: false')
+ expect(body).toContain('"state-1"')
+ expect(body).toContain('serverId: undefined')
+ })
})
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts
index 5c75416a3bc..f0b99eb522b 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.ts
@@ -43,12 +43,24 @@ function htmlClose(
message: string,
ok: boolean,
reason: McpOauthCallbackReason,
- serverId?: string
+ serverId?: string,
+ state?: string
): NextResponse {
+ if (!ok) {
+ logger.warn(
+ `MCP OAuth callback did not complete: ${reason}${serverId ? ` (server ${serverId})` : ''}`
+ )
+ }
const safeMessage = escapeHtml(message)
const title = ok ? 'Connected' : 'Connection failed'
+ // Signal the opener over a same-origin BroadcastChannel rather than
+ // `window.opener.postMessage`: a provider whose authorize page sets COOP
+ // `same-origin` severs `window.opener`, which would silently drop the result and
+ // leave the parent stuck on "Connecting…". A BroadcastChannel is origin-scoped and
+ // unaffected by opener severance; the hook correlates on `state` and ignores flows it
+ // did not start.
const body = `
${title}${safeMessage}
`
return new NextResponse(body, {
@@ -63,21 +75,26 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
const { state, code, error: errorParam } = parsed.data.query
+ // Echo the flow's `state` on every result so the opener can correlate a broadcast back to
+ // the exact flow it started — including failures (e.g. `invalid_state`) that never resolve
+ // a serverId. Without it those results would strand the initiating tab on "Connecting…".
+ const respond = (
+ message: string,
+ ok: boolean,
+ reason: McpOauthCallbackReason,
+ serverId?: string
+ ) => htmlClose(message, ok, reason, serverId, state)
+
const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null
const stateRowServerId = initialRow?.mcpServerId
if (errorParam) {
logger.warn(`MCP OAuth callback received error: ${errorParam}`)
if (initialRow) await clearState(initialRow.id).catch(() => {})
- return htmlClose(
- `Authorization failed: ${errorParam}`,
- false,
- 'provider_error',
- stateRowServerId
- )
+ return respond(`Authorization failed: ${errorParam}`, false, 'provider_error', stateRowServerId)
}
if (!state || !code) {
- return htmlClose(
+ return respond(
'Missing state or code in callback URL.',
false,
'missing_params',
@@ -89,7 +106,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
- return htmlClose(
+ return respond(
'You must be signed in to complete authorization.',
false,
'unauthenticated',
@@ -99,12 +116,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const row = initialRow
if (!row) {
- return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
+ return respond('Invalid or expired authorization state.', false, 'invalid_state')
}
serverId = row.mcpServerId
if (session.user.id !== row.userId) {
- return htmlClose(
+ return respond(
'You must be signed in as the same user that initiated the flow.',
false,
'user_mismatch',
@@ -118,10 +135,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
.limit(1)
if (!server || !server.url) {
- return htmlClose('Server no longer exists.', false, 'server_gone', serverId)
+ return respond('Server no longer exists.', false, 'server_gone', serverId)
}
if (server.workspaceId !== row.workspaceId) {
- return htmlClose(
+ return respond(
'Workspace mismatch on authorization callback.',
false,
'invalid_state',
@@ -131,7 +148,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
assertSafeOauthServerUrl(server.url)
} catch {
- return htmlClose(
+ return respond(
'MCP OAuth requires https (or http://localhost for development).',
false,
'insecure_url',
@@ -152,7 +169,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
} catch (e) {
logger.error('Token exchange failed during MCP OAuth callback', e)
- return htmlClose(
+ return respond(
'Token exchange failed. Please try again.',
false,
'token_exchange_failed',
@@ -163,7 +180,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
if (result !== 'AUTHORIZED') {
- return htmlClose('Authorization did not complete.', false, 'token_exchange_failed', server.id)
+ return respond('Authorization did not complete.', false, 'token_exchange_failed', server.id)
}
try {
@@ -173,9 +190,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
logger.warn('Post-auth tools refresh failed', toError(e).message)
}
- return htmlClose('Connected. You can close this window.', true, 'authorized', server.id)
+ return respond('Connected. You can close this window.', true, 'authorized', server.id)
} catch (error) {
logger.error('MCP OAuth callback failed', error)
- return htmlClose('Authorization failed. Please try again.', false, 'unknown', serverId)
+ return respond('Authorization failed. Please try again.', false, 'unknown', serverId)
}
})
diff --git a/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts b/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
index b3fd9a6c58a..a7588ba7bac 100644
--- a/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
+++ b/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts
@@ -37,103 +37,146 @@ interface UseMcpOauthPopupProps {
workspaceId: string
}
+/**
+ * Bounds how long a row shows "Connecting…" without a result. Matches the server-side OAuth
+ * start TTL: once it lapses the authorization state has expired and the flow can no longer
+ * complete, so a still-pending flow is safe to drop.
+ */
+const OAUTH_FLOW_TIMEOUT_MS = 10 * 60 * 1000
+
export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
const queryClient = useQueryClient()
const { mutateAsync: startOauth } = useStartMcpOauth()
const [connectingServers, setConnectingServers] = useState>(() => new Set())
- const popupIntervalsRef = useRef