Skip to content

Commit 3553676

Browse files
committed
fix(mcp): correlate the OAuth result on the state nonce, not serverId
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.
1 parent 1d84f3c commit 3553676

5 files changed

Lines changed: 99 additions & 55 deletions

File tree

apps/sim/app/api/mcp/oauth/callback/route.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('MCP OAuth callback route', () => {
7373
)
7474
})
7575

76-
it('signals success over a same-origin BroadcastChannel carrying the server id', async () => {
76+
it('signals success over a same-origin BroadcastChannel carrying the state nonce', async () => {
7777
const request = new NextRequest(
7878
'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
7979
)
@@ -82,10 +82,11 @@ describe('MCP OAuth callback route', () => {
8282

8383
// The completion is delivered over a BroadcastChannel (not window.opener.postMessage)
8484
// so a COOP `same-origin` provider that severs the opener can't strand the parent. The
85-
// server id lets the hook react only in the tab that opened this flow's popup.
85+
// `state` nonce lets the hook react only in the tab that started this exact flow.
8686
expect(body).toContain("new BroadcastChannel('mcp-oauth')")
8787
expect(body).toContain('ok: true')
8888
expect(body).toContain('"server-1"')
89+
expect(body).toContain('"state-1"')
8990
})
9091

9192
it('reports an early failure over the channel without attempting token exchange', async () => {
@@ -97,4 +98,19 @@ describe('MCP OAuth callback route', () => {
9798
expect(body).toContain('ok: false')
9899
expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled()
99100
})
101+
102+
it('echoes the state on a serverless invalid_state failure so the initiating tab can react', async () => {
103+
// No row loads for the state -> failure with no serverId. The state must still be echoed,
104+
// or the initiating tab would sit on "Connecting…" until its safety timeout.
105+
mcpOauthMockFns.mockLoadOauthRowByState.mockResolvedValueOnce(null)
106+
const request = new NextRequest(
107+
'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
108+
)
109+
110+
const body = await (await GET(request)).text()
111+
112+
expect(body).toContain('ok: false')
113+
expect(body).toContain('"state-1"')
114+
expect(body).toContain('serverId: undefined')
115+
})
100116
})

apps/sim/app/api/mcp/oauth/callback/route.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ function htmlClose(
4343
message: string,
4444
ok: boolean,
4545
reason: McpOauthCallbackReason,
46-
serverId?: string
46+
serverId?: string,
47+
state?: string
4748
): NextResponse {
4849
if (!ok) {
4950
logger.warn(
@@ -56,9 +57,10 @@ function htmlClose(
5657
// `window.opener.postMessage`: a provider whose authorize page sets COOP
5758
// `same-origin` severs `window.opener`, which would silently drop the result and
5859
// leave the parent stuck on "Connecting…". A BroadcastChannel is origin-scoped and
59-
// unaffected by opener severance; the hook ignores results for popups it did not open.
60+
// unaffected by opener severance; the hook correlates on `state` and ignores flows it
61+
// did not start.
6062
const body = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui; padding: 24px"><p>${safeMessage}</p><script>
61-
try { var ch = new BroadcastChannel('mcp-oauth'); ch.postMessage({ type: 'mcp-oauth', ok: ${ok ? 'true' : 'false'}, serverId: ${jsonLiteral(serverId)}, reason: ${jsonLiteral(reason)} }); ch.close() } catch (e) {}
63+
try { var ch = new BroadcastChannel('mcp-oauth'); ch.postMessage({ type: 'mcp-oauth', ok: ${ok ? 'true' : 'false'}, serverId: ${jsonLiteral(serverId)}, state: ${jsonLiteral(state)}, reason: ${jsonLiteral(reason)} }); ch.close() } catch (e) {}
6264
setTimeout(function () { window.close() }, 800)
6365
</script></body></html>`
6466
return new NextResponse(body, {
@@ -73,21 +75,26 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7375
}
7476
const { state, code, error: errorParam } = parsed.data.query
7577

78+
// Echo the flow's `state` on every result so the opener can correlate a broadcast back to
79+
// the exact flow it started — including failures (e.g. `invalid_state`) that never resolve
80+
// a serverId. Without it those results would strand the initiating tab on "Connecting…".
81+
const respond = (
82+
message: string,
83+
ok: boolean,
84+
reason: McpOauthCallbackReason,
85+
serverId?: string
86+
) => htmlClose(message, ok, reason, serverId, state)
87+
7688
const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null
7789
const stateRowServerId = initialRow?.mcpServerId
7890

7991
if (errorParam) {
8092
logger.warn(`MCP OAuth callback received error: ${errorParam}`)
8193
if (initialRow) await clearState(initialRow.id).catch(() => {})
82-
return htmlClose(
83-
`Authorization failed: ${errorParam}`,
84-
false,
85-
'provider_error',
86-
stateRowServerId
87-
)
94+
return respond(`Authorization failed: ${errorParam}`, false, 'provider_error', stateRowServerId)
8895
}
8996
if (!state || !code) {
90-
return htmlClose(
97+
return respond(
9198
'Missing state or code in callback URL.',
9299
false,
93100
'missing_params',
@@ -99,7 +106,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
99106
try {
100107
const session = await getSession()
101108
if (!session?.user?.id) {
102-
return htmlClose(
109+
return respond(
103110
'You must be signed in to complete authorization.',
104111
false,
105112
'unauthenticated',
@@ -109,12 +116,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
109116

110117
const row = initialRow
111118
if (!row) {
112-
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
119+
return respond('Invalid or expired authorization state.', false, 'invalid_state')
113120
}
114121
serverId = row.mcpServerId
115122

116123
if (session.user.id !== row.userId) {
117-
return htmlClose(
124+
return respond(
118125
'You must be signed in as the same user that initiated the flow.',
119126
false,
120127
'user_mismatch',
@@ -128,10 +135,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
128135
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
129136
.limit(1)
130137
if (!server || !server.url) {
131-
return htmlClose('Server no longer exists.', false, 'server_gone', serverId)
138+
return respond('Server no longer exists.', false, 'server_gone', serverId)
132139
}
133140
if (server.workspaceId !== row.workspaceId) {
134-
return htmlClose(
141+
return respond(
135142
'Workspace mismatch on authorization callback.',
136143
false,
137144
'invalid_state',
@@ -141,7 +148,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
141148
try {
142149
assertSafeOauthServerUrl(server.url)
143150
} catch {
144-
return htmlClose(
151+
return respond(
145152
'MCP OAuth requires https (or http://localhost for development).',
146153
false,
147154
'insecure_url',
@@ -162,7 +169,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
162169
})
163170
} catch (e) {
164171
logger.error('Token exchange failed during MCP OAuth callback', e)
165-
return htmlClose(
172+
return respond(
166173
'Token exchange failed. Please try again.',
167174
false,
168175
'token_exchange_failed',
@@ -173,7 +180,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
173180
}
174181

175182
if (result !== 'AUTHORIZED') {
176-
return htmlClose('Authorization did not complete.', false, 'token_exchange_failed', server.id)
183+
return respond('Authorization did not complete.', false, 'token_exchange_failed', server.id)
177184
}
178185

179186
try {
@@ -183,9 +190,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
183190
logger.warn('Post-auth tools refresh failed', toError(e).message)
184191
}
185192

186-
return htmlClose('Connected. You can close this window.', true, 'authorized', server.id)
193+
return respond('Connected. You can close this window.', true, 'authorized', server.id)
187194
} catch (error) {
188195
logger.error('MCP OAuth callback failed', error)
189-
return htmlClose('Authorization failed. Please try again.', false, 'unknown', serverId)
196+
return respond('Authorization failed. Please try again.', false, 'unknown', serverId)
190197
}
191198
})

apps/sim/hooks/mcp/use-mcp-oauth-popup.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
4949
const { mutateAsync: startOauth } = useStartMcpOauth()
5050

5151
const [connectingServers, setConnectingServers] = useState<Set<string>>(() => new Set())
52-
// serverId -> safety timeout. This is the correlation source of truth for the
53-
// BroadcastChannel gate: it is cleared only when the flow completes or times out, never by
54-
// popup.closed polling — COOP can make popup.closed misreport, and clearing it early would
55-
// drop the genuine completion this fix exists to deliver.
56-
const pendingFlowsRef = useRef<Map<string, number>>(new Map())
52+
// OAuth `state` nonce -> { serverId, safety timeout }. The state keys the BroadcastChannel
53+
// correlation: the callback echoes it on every result (even failures that can't resolve a
54+
// serverId), so the tab that started this exact flow matches it while other same-origin tabs
55+
// ignore it. Cleared only when the flow completes or times out, never by popup.closed polling
56+
// — COOP can make popup.closed misreport, and clearing early would drop a genuine completion.
57+
const pendingFlowsRef = useRef<Map<string, { serverId: string; timeout: number }>>(new Map())
5758
// serverId -> popup.closed poll. Best-effort fast "Connecting…" clear when the user
5859
// abandons the popup; never used to correlate a result.
5960
const popupPollsRef = useRef<Map<string, number>>(new Map())
@@ -75,14 +76,15 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
7576
}
7677
}, [])
7778

78-
/** End a flow entirely: stop the spinner, its safety timeout, and its popup poll. */
79+
/** End a flow entirely (by its state nonce): stop the spinner, safety timeout, and popup poll. */
7980
const settleFlow = useCallback(
80-
(serverId: string) => {
81-
const timeout = pendingFlowsRef.current.get(serverId)
82-
if (timeout !== undefined) window.clearTimeout(timeout)
83-
pendingFlowsRef.current.delete(serverId)
84-
stopPopupPoll(serverId)
85-
stopConnecting(serverId)
81+
(state: string) => {
82+
const flow = pendingFlowsRef.current.get(state)
83+
if (!flow) return
84+
window.clearTimeout(flow.timeout)
85+
pendingFlowsRef.current.delete(state)
86+
stopPopupPoll(flow.serverId)
87+
stopConnecting(flow.serverId)
8688
},
8789
[stopConnecting, stopPopupPoll]
8890
)
@@ -91,7 +93,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
9193
const pending = pendingFlowsRef.current
9294
const polls = popupPollsRef.current
9395
return () => {
94-
for (const t of pending.values()) window.clearTimeout(t)
96+
for (const { timeout } of pending.values()) window.clearTimeout(timeout)
9597
for (const p of polls.values()) window.clearInterval(p)
9698
pending.clear()
9799
polls.clear()
@@ -107,15 +109,18 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
107109
channel.onmessage = (event) => {
108110
const data = event.data as Partial<McpOauthCallbackMessage> | null
109111
if (data?.type !== 'mcp-oauth') return
110-
// A BroadcastChannel reaches every same-origin tab, so react only to a result for a
111-
// flow THIS tab started. A result without a serverId can't be correlated to a flow, so
112-
// it's ignored here — the initiating tab's safety timeout clears its own "Connecting…".
113-
if (!data.serverId || !pendingFlowsRef.current.has(data.serverId)) return
114-
settleFlow(data.serverId)
112+
// A BroadcastChannel reaches every same-origin tab, so react only to a result for a flow
113+
// THIS tab started, matched on the OAuth `state` nonce. Every result (success or failure)
114+
// carries it, so unrelated tabs — and unrelated flows in this tab — ignore the broadcast.
115+
if (!data.state) return
116+
const flow = pendingFlowsRef.current.get(data.state)
117+
if (!flow) return
118+
const { serverId } = flow
119+
settleFlow(data.state)
115120
if (data.ok) {
116121
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) })
117122
queryClient.invalidateQueries({
118-
queryKey: mcpKeys.serverToolsList(workspaceId, data.serverId),
123+
queryKey: mcpKeys.serverToolsList(workspaceId, serverId),
119124
})
120125
queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) })
121126
toast.success('Server authorized')
@@ -132,19 +137,19 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
132137
try {
133138
const result = await startOauth({ serverId, workspaceId })
134139
if (result.status === 'already_authorized') {
135-
settleFlow(serverId)
140+
stopConnecting(serverId)
136141
return
137142
}
138-
const { popup } = result
139-
// Track this in-flight flow for the BroadcastChannel gate, bounded by a safety
140-
// timeout in case no result ever arrives (popup abandoned, or a callback failure
141-
// that couldn't carry a serverId).
142-
const existingTimeout = pendingFlowsRef.current.get(serverId)
143-
if (existingTimeout !== undefined) window.clearTimeout(existingTimeout)
144-
pendingFlowsRef.current.set(
143+
const { popup, state } = result
144+
// Track this in-flight flow keyed by its `state` nonce for the BroadcastChannel gate,
145+
// bounded by a safety timeout in case no result ever arrives (popup abandoned, or a
146+
// callback failure the client can't otherwise clear).
147+
const existing = pendingFlowsRef.current.get(state)
148+
if (existing !== undefined) window.clearTimeout(existing.timeout)
149+
pendingFlowsRef.current.set(state, {
145150
serverId,
146-
window.setTimeout(() => settleFlow(serverId), OAUTH_FLOW_TIMEOUT_MS)
147-
)
151+
timeout: window.setTimeout(() => settleFlow(state), OAUTH_FLOW_TIMEOUT_MS),
152+
})
148153
// Best-effort: clear "Connecting…" quickly when the user closes the popup without
149154
// finishing. popup.closed can misreport under COOP, so this only stops the spinner —
150155
// it never touches `pendingFlowsRef`, so it can't drop a real result.
@@ -159,7 +164,8 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
159164
}, 500)
160165
)
161166
} catch (e) {
162-
settleFlow(serverId)
167+
stopPopupPoll(serverId)
168+
stopConnecting(serverId)
163169
logger.error('Failed to start MCP OAuth', e)
164170
toast.error(toError(e).message || 'Failed to start authorization')
165171
}

apps/sim/hooks/queries/mcp.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,13 @@ export function useCreateMcpServer() {
304304
})
305305
}
306306

307-
/** On `redirect`, the caller must wait for `popup.closed` or the `mcp-oauth` BroadcastChannel signal. */
307+
/**
308+
* On `redirect`, the caller waits for the `mcp-oauth` BroadcastChannel signal (matched on
309+
* `state`) or `popup.closed`. `state` is the per-flow OAuth nonce the callback echoes, used to
310+
* correlate the eventual result back to this exact flow.
311+
*/
308312
export type StartMcpOauthMutationResult =
309-
| { status: 'redirect'; popup: Window }
313+
| { status: 'redirect'; popup: Window; state: string }
310314
| { status: 'already_authorized' }
311315

312316
export function useStartMcpOauth() {
@@ -324,6 +328,10 @@ export function useStartMcpOauth() {
324328
if (parsedUrl.protocol !== 'https:' && !isLoopbackHttp) {
325329
throw new Error('Authorization URL must use HTTPS')
326330
}
331+
const state = parsedUrl.searchParams.get('state')
332+
if (!state) {
333+
throw new Error('Authorization URL is missing the OAuth state parameter')
334+
}
327335
const popup = window.open(
328336
result.authorizationUrl,
329337
`mcp-oauth-${serverId}`,
@@ -332,7 +340,7 @@ export function useStartMcpOauth() {
332340
if (!popup) {
333341
throw new Error('Popup blocked. Please allow popups for this site and retry.')
334342
}
335-
return { status: 'redirect', popup }
343+
return { status: 'redirect', popup, state }
336344
},
337345
}
338346
)

apps/sim/lib/mcp/oauth/callback-reasons.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,12 @@ export interface McpOauthCallbackMessage {
2222
type: 'mcp-oauth'
2323
ok: boolean
2424
serverId?: string
25+
/**
26+
* The OAuth `state` nonce, echoed on every result — including failures that can't resolve
27+
* a serverId. The opener correlates a broadcast to the exact flow it started by matching
28+
* this, so other same-origin tabs ignore it. Absent only on a malformed callback with no
29+
* parseable state.
30+
*/
31+
state?: string
2532
reason?: McpOauthCallbackReason
2633
}

0 commit comments

Comments
 (0)