Skip to content

Commit 8d8cfcb

Browse files
committed
fix(mcp): open the OAuth popup synchronously and bound the start request
Two bugs behind 'pressed Connect/Reopen and nothing happened': - window.open ran AFTER awaiting /oauth/start, outside the browser's user activation, so the popup was silently blocked (worst on the add-server flow). Popup-first now: open about:blank synchronously in the click (named window, so a re-click focuses/reuses an existing authorization window), navigate it once the start returns; close it on failure/already_authorized; clear toast when genuinely blocked (and skip the request entirely). - /oauth/start had no client timeout, so a stalled start held the re-entrancy guard and the connecting label indefinitely. Bounded at 30s; on timeout the popup closes, the label resets, and retry is immediately available.
1 parent daa2416 commit 8d8cfcb

3 files changed

Lines changed: 97 additions & 17 deletions

File tree

apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ async function flush() {
7979
describe('useMcpOauthPopup', () => {
8080
beforeEach(() => {
8181
vi.clearAllMocks()
82+
// Popup-first: startOauthForServer opens a blank window synchronously.
83+
window.open = vi.fn(
84+
() =>
85+
({
86+
close: vi.fn(),
87+
focus: vi.fn(),
88+
location: { replace: vi.fn() },
89+
closed: false,
90+
}) as unknown as Window
91+
)
8292
// jsdom has no BroadcastChannel; the hook opens one on mount.
8393
class FakeBroadcastChannel {
8494
onmessage: ((event: MessageEvent) => void) | null = null
@@ -115,15 +125,23 @@ describe('useMcpOauthPopup', () => {
115125

116126
// Settle the first flow so the guard clears.
117127
await act(async () => {
118-
resolveStart({ status: 'redirect', popup: { closed: false }, state: 'state-1' })
128+
resolveStart({
129+
status: 'redirect',
130+
authorizationUrl: 'https://as.example/a?state=state-1',
131+
state: 'state-1',
132+
})
119133
})
120134
await flush()
121135

122136
hook.unmount()
123137
})
124138

125139
it('allows a fresh start after the previous one settles (reopen after abandon)', async () => {
126-
mockStartOauth.mockResolvedValue({ status: 'redirect', popup: { closed: false }, state: 'st' })
140+
mockStartOauth.mockResolvedValue({
141+
status: 'redirect',
142+
authorizationUrl: 'https://as.example/a?state=st',
143+
state: 'st',
144+
})
127145

128146
const hook = renderHookWithClient(() => useMcpOauthPopup({ workspaceId: 'w1' }))
129147
await flush()
@@ -165,7 +183,7 @@ describe('useMcpOauthPopup', () => {
165183
it('keeps the row connecting when a reopen fails but a prior flow is still live', async () => {
166184
mockStartOauth.mockResolvedValueOnce({
167185
status: 'redirect',
168-
popup: { closed: false },
186+
authorizationUrl: 'https://as.example/a?state=st-a',
169187
state: 'st-a',
170188
})
171189
const hook = renderHookWithClient(() => useMcpOauthPopup({ workspaceId: 'w1' }))
@@ -188,4 +206,40 @@ describe('useMcpOauthPopup', () => {
188206

189207
hook.unmount()
190208
})
209+
210+
it('does not issue the start request when the popup is blocked', async () => {
211+
;(window.open as ReturnType<typeof vi.fn>).mockReturnValue(null)
212+
const hook = renderHookWithClient(() => useMcpOauthPopup({ workspaceId: 'w1' }))
213+
await flush()
214+
215+
await act(async () => {
216+
await hook.result().startOauthForServer('s1')
217+
})
218+
await flush()
219+
220+
expect(mockStartOauth).not.toHaveBeenCalled()
221+
expect(hook.result().connectingServers.has('s1')).toBe(false)
222+
hook.unmount()
223+
})
224+
225+
it('opens the popup before the start request resolves (popup-first)', async () => {
226+
const order: string[] = []
227+
;(window.open as ReturnType<typeof vi.fn>).mockImplementation(() => {
228+
order.push('open')
229+
return { close: vi.fn(), focus: vi.fn(), location: { replace: vi.fn() } } as unknown as Window
230+
})
231+
mockStartOauth.mockImplementation(async () => {
232+
order.push('start')
233+
return { status: 'redirect', authorizationUrl: 'https://as.example/a?state=st', state: 'st' }
234+
})
235+
const hook = renderHookWithClient(() => useMcpOauthPopup({ workspaceId: 'w1' }))
236+
await flush()
237+
238+
await act(async () => {
239+
await hook.result().startOauthForServer('s1')
240+
})
241+
242+
expect(order).toEqual(['open', 'start'])
243+
hook.unmount()
244+
})
191245
})

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

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,27 +153,55 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
153153
const starting = (startingRef.current ??= new Set())
154154
if (starting.has(serverId)) return
155155
starting.add(serverId)
156+
// Popup-first: open (or, via the shared window name, reuse+focus) the popup
157+
// SYNCHRONOUSLY inside the user's click so the browser's activation gate sees
158+
// it. Opening after the /oauth/start await loses the activation and gets
159+
// silently popup-blocked — the "pressed Connect and nothing happened" bug.
160+
const popup = window.open(
161+
'about:blank',
162+
`mcp-oauth-${serverId}`,
163+
'width=560,height=720,resizable=yes,scrollbars=yes'
164+
)
165+
if (!popup) {
166+
starting.delete(serverId)
167+
toast.error('Popup blocked. Please allow popups for this site and retry.')
168+
return
169+
}
170+
popup.focus?.()
156171
incConnecting(serverId) // this attempt begins
157172
try {
158173
const result = await startOauth({ serverId, workspaceId })
159-
// The replacement start succeeded (already-authorized, or a fresh popup opened), so retire
160-
// any prior attempt for this server now — its result is moot and the server-side `state`
161-
// it depended on has been overwritten. A *failed* start (below) leaves prior flows intact.
174+
// The replacement start succeeded (already-authorized, or a fresh authorization is
175+
// underway), so retire any prior attempt for this server now — its result is moot and
176+
// the server-side `state` it depended on has been overwritten. A *failed* start
177+
// (below) leaves prior flows intact.
162178
retireFlows(serverId)
163179
if (result.status === 'already_authorized') {
180+
try {
181+
popup.close()
182+
} catch {}
164183
invalidateServer(serverId)
165184
decConnecting(serverId) // this attempt ends
166185
return
167186
}
187+
const { authorizationUrl, state } = result
188+
try {
189+
popup.location.replace(authorizationUrl)
190+
} catch {
191+
// A COOP-severed reused window can refuse scripted navigation; reopen by name.
192+
window.open(authorizationUrl, `mcp-oauth-${serverId}`)
193+
}
168194
// Track the in-flight flow by its `state` nonce for the BroadcastChannel gate, bounded by
169195
// a safety timeout in case no result ever arrives (popup abandoned, or a callback the
170196
// client can't otherwise observe under COOP).
171-
const { state } = result
172197
pendingFlowsRef.current.set(state, {
173198
serverId,
174199
timeout: window.setTimeout(() => settleFlow(state), OAUTH_FLOW_TIMEOUT_MS),
175200
})
176201
} catch (e) {
202+
try {
203+
popup.close()
204+
} catch {}
177205
decConnecting(serverId) // this attempt ends; any prior flow keeps its own count
178206
logger.error('Failed to start MCP OAuth', e)
179207
toast.error(toError(e).message || 'Failed to start authorization')

apps/sim/hooks/queries/mcp.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export function useCreateMcpServer() {
310310
* correlate the eventual result back to this exact flow.
311311
*/
312312
export type StartMcpOauthMutationResult =
313-
| { status: 'redirect'; popup: Window; state: string }
313+
| { status: 'redirect'; authorizationUrl: string; state: string }
314314
| { status: 'already_authorized' }
315315

316316
export function useStartMcpOauth() {
@@ -319,6 +319,9 @@ export function useStartMcpOauth() {
319319
mutationFn: async ({ serverId, workspaceId }) => {
320320
const result = await requestJson(startMcpOauthContract, {
321321
query: { serverId, workspaceId },
322+
// A stalled /oauth/start must settle so the caller can reset the connecting
323+
// state and close its pre-opened popup instead of appearing bricked.
324+
signal: AbortSignal.timeout(30_000),
322325
})
323326
if (result.status === 'already_authorized') return { status: 'already_authorized' }
324327

@@ -332,15 +335,10 @@ export function useStartMcpOauth() {
332335
if (!state) {
333336
throw new Error('Authorization URL is missing the OAuth state parameter')
334337
}
335-
const popup = window.open(
336-
result.authorizationUrl,
337-
`mcp-oauth-${serverId}`,
338-
'width=560,height=720,resizable=yes,scrollbars=yes'
339-
)
340-
if (!popup) {
341-
throw new Error('Popup blocked. Please allow popups for this site and retry.')
342-
}
343-
return { status: 'redirect', popup, state }
338+
// The popup itself is opened SYNCHRONOUSLY by the caller inside the user's
339+
// click (popup-first) — opening it here, after the network await, loses the
340+
// user activation and gets silently popup-blocked.
341+
return { status: 'redirect', authorizationUrl: result.authorizationUrl, state }
344342
},
345343
}
346344
)

0 commit comments

Comments
 (0)