Skip to content

Commit 227cd65

Browse files
authored
fix(mcp): bound and retry OAuth start so a transient stall recovers instead of a blank popup (#5874)
* fix(mcp): bound and retry OAuth start so a transient stall recovers instead of a blank popup Empirically root-caused a blank/stuck authorize popup: the provider (planetscale) and our guarded OAuth fetch are both fast (120/120 legs clean from staging), and /oauth/start uses local AES encryption with no Redis lock in its path — so the intermittent hang is the same transient headers-then-stalled-body class we've documented for CDN-fronted MCP hosts (a per-connection stall a fresh attempt dodges), which /oauth/start had no server-side bound against. - Bound every /oauth/start step with the shared timedStep helper (extracted from the callback route, now used by both) + an entry log, so a stalled step surfaces as a labeled error instead of hanging the request (and the browser popup) to the client's 30s timeout. - Retry mcpAuthGuarded once on a bounded 12s timeout: a fresh attempt gets a fresh connection and recovers from the transient stall automatically (two 12s attempts stay under the client's 30s deadline). McpOauthRedirectRequired (the success signal) and DCR-unsupported errors are never retried. Adds OauthStepTimeoutError + makeTimedStep to the shared oauth barrel and test mocks. * fix(mcp): drop the unsafe OAuth-start retry; fail fast without error-logging success Review fixes on the bound+retry change: - Removed the mcpAuthGuarded auto-retry. timedStep can't cancel the loser, so a lingering first attempt shares this server's OAuth row and could overwrite the retry's PKCE verifier / state after the client already got the second authorize URL, breaking the callback. Recovery is now fail-fast (504) → the user re-clicks, which is a clean fresh flow (fresh connection dodges the transient stall) with no shared-state race. - Catch McpOauthRedirectRequired (the success signal) INSIDE the bounded step and return it as a value, so a successful authorize is no longer error-logged as 'OAuth step failed'. - Tighten step budgets (5s DB x3 + 12s auth = 27s) to stay under the client's 30s /oauth/start deadline. * fix(mcp): route all bounded-step timeouts to the 504 handler Move OauthStepTimeoutError handling to the outer catch so a DB-step timeout (loadServer/getOrCreateOauthRow/loadPreregisteredClient) returns the same fast 504 'try again' as the auth step, not a generic 500. Documents that the fresh retry is race-safe: the callback correlates on the state nonce, so a lingering timed-out attempt overwriting the row's state only yields a clean invalid_state on the user's fresh authorize URL — never silent corruption. * fix(mcp): bound the setOauthRowUser write too so no step escapes the budget The user-stamp write was the one DB op left unbounded on the start path; wrap it in timedStep(DB_STEP_MS) so every step stays inside the sub-30s budget and its timeout routes to the same 504. * fix(mcp): shrink OAuth-start step budgets to fit the 30s client deadline with 4 DB steps Bounding setOauthRowUser added a fourth possible DB step, so 4x5+12=32s exceeded the client's 30s /oauth/start abort. Lower DB steps to 4s and auth to 10s: 4x4+10=26s worst case, leaving margin for middleware/network. Comment corrected.
1 parent dda602a commit 227cd65

7 files changed

Lines changed: 191 additions & 68 deletions

File tree

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

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,55 +16,17 @@ import {
1616
loadOauthRowByState,
1717
loadPreregisteredClient,
1818
type McpOauthCallbackReason,
19+
makeTimedStep,
1920
mcpAuthGuarded,
2021
SimMcpOauthProvider,
2122
} from '@/lib/mcp/oauth'
2223
import { mcpService } from '@/lib/mcp/service'
2324

2425
const logger = createLogger('McpOauthCallbackAPI')
26+
const timedStep = makeTimedStep(logger)
2527

2628
export const dynamic = 'force-dynamic'
2729

28-
class OauthCallbackStepTimeout extends Error {
29-
constructor(step: string, ms: number) {
30-
super(`MCP OAuth callback step "${step}" did not settle within ${ms}ms`)
31-
this.name = 'OauthCallbackStepTimeout'
32-
}
33-
}
34-
35-
/**
36-
* Times and bounds one awaited step of the callback so a stalled operation
37-
* surfaces as a labeled, logged error instead of hanging the request forever.
38-
* The losing promise is not cancelled (a wedged DB/socket op can't be), so it
39-
* settles in the background with its rejection swallowed; the point is that the
40-
* request stops waiting on it and the logs name the exact step that stalled.
41-
*/
42-
async function timedStep<T>(step: string, ms: number, fn: () => Promise<T>): Promise<T> {
43-
const start = Date.now()
44-
logger.info(`OAuth callback step start: ${step}`)
45-
const work = Promise.resolve(fn())
46-
work.catch(() => {})
47-
let timer: ReturnType<typeof setTimeout> | undefined
48-
try {
49-
const value = await Promise.race([
50-
work,
51-
new Promise<never>((_, reject) => {
52-
timer = setTimeout(() => reject(new OauthCallbackStepTimeout(step, ms)), ms)
53-
timer.unref?.()
54-
}),
55-
])
56-
logger.info(`OAuth callback step done: ${step} (${Date.now() - start}ms)`)
57-
return value
58-
} catch (error) {
59-
logger.error(`OAuth callback step failed: ${step} (${Date.now() - start}ms)`, {
60-
error: toError(error).message,
61-
})
62-
throw error
63-
} finally {
64-
clearTimeout(timer)
65-
}
66-
}
67-
6830
function escapeHtml(value: string): string {
6931
return value
7032
.replace(/&/g, '&amp;')

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
McpOauthRedirectRequiredMock,
88
mcpOauthMock,
99
mcpOauthMockFns,
10+
OauthStepTimeoutErrorMock,
1011
permissionsMock,
1112
permissionsMockFns,
1213
resetDbChainMock,
@@ -75,6 +76,54 @@ describe('MCP OAuth start route', () => {
7576
)
7677
})
7778

79+
it('returns 504 (not a retry) when the auth step times out', async () => {
80+
// The stall is intentionally NOT auto-retried — a lingering attempt shares the OAuth row and
81+
// could corrupt the retry's PKCE/state. The bounded step fails fast; the user re-clicks.
82+
mcpOauthMockFns.mockMcpAuthGuarded.mockImplementationOnce(() => {
83+
throw new OauthStepTimeoutErrorMock('mcpAuthGuarded', 12_000)
84+
})
85+
const request = new NextRequest(
86+
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
87+
)
88+
89+
const response = await GET(request)
90+
91+
expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1)
92+
expect(response.status).toBe(504)
93+
})
94+
95+
it('returns 504 (not a generic 500) when a DB step times out', async () => {
96+
// DB-step timeouts are bounded too; their OauthStepTimeoutError must reach the same
97+
// 504 handler, not fall through to the generic 500.
98+
mcpOauthMockFns.mockGetOrCreateOauthRow.mockImplementationOnce(() => {
99+
throw new OauthStepTimeoutErrorMock('getOrCreateOauthRow', 5_000)
100+
})
101+
const request = new NextRequest(
102+
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
103+
)
104+
105+
const response = await GET(request)
106+
107+
expect(response.status).toBe(504)
108+
expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled()
109+
})
110+
111+
it('returns the authorize URL without error-logging the success redirect throw', async () => {
112+
mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValueOnce(
113+
new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize')
114+
)
115+
const request = new NextRequest(
116+
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
117+
)
118+
119+
const response = await GET(request)
120+
const body = await response.json()
121+
122+
expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1)
123+
expect(response.status).toBe(200)
124+
expect(body).toEqual({ status: 'redirect', authorizationUrl: 'https://mcp.exa.ai/authorize' })
125+
})
126+
78127
it('requires workspace write permission via MCP auth middleware', async () => {
79128
const request = new NextRequest(
80129
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'

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

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,31 @@ import {
1616
loadPreregisteredClient,
1717
McpOauthInsecureUrlError,
1818
McpOauthRedirectRequired,
19+
makeTimedStep,
1920
mcpAuthGuarded,
21+
OauthStepTimeoutError,
2022
SimMcpOauthProvider,
2123
setOauthRowUser,
2224
} from '@/lib/mcp/oauth'
2325
import { createMcpErrorResponse } from '@/lib/mcp/utils'
2426

2527
const logger = createLogger('McpOauthStartAPI')
28+
const timedStep = makeTimedStep(logger)
2629
const OAUTH_START_TTL_MS = 10 * 60 * 1000
30+
/**
31+
* Per-step budgets, kept small so the whole request stays under the client's 30s `/oauth/start`
32+
* deadline even in the worst case: up to four bounded DB steps (loadServer, getOrCreateOauthRow,
33+
* setOauthRowUser, loadPreregisteredClient) + the auth step = 4×4 + 10 = 26s, leaving margin for
34+
* middleware and network. OAuth discovery + DCR occasionally hits the transient
35+
* headers-then-stalled-body class documented for CDN-fronted MCP hosts; the bound turns that into
36+
* a fast, labeled failure so the popup closes with a clear error and the user can retry (a fresh
37+
* click = a fresh connection that dodges the per-connection stall) rather than the popup hanging
38+
* blank. We deliberately do NOT auto-retry here: `timedStep` can't cancel a wedged attempt, and a
39+
* lingering first attempt sharing this server's OAuth row could overwrite the retry's PKCE
40+
* verifier / state and break the callback.
41+
*/
42+
const DB_STEP_MS = 4_000
43+
const MCP_AUTH_STEP_MS = 10_000
2744
const MAX_SURFACED_ERROR_LENGTH = 250
2845
const DCR_UNSUPPORTED_MESSAGE =
2946
"This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead."
@@ -81,18 +98,21 @@ export const GET = withRouteHandler(
8198
const parsed = await parseRequest(startMcpOauthContract, request, {})
8299
if (!parsed.success) return parsed.response
83100
const { serverId } = parsed.data.query
101+
logger.info(`Starting MCP OAuth flow for server ${serverId}`)
84102

85-
const [server] = await db
86-
.select()
87-
.from(mcpServers)
88-
.where(
89-
and(
90-
eq(mcpServers.id, serverId),
91-
eq(mcpServers.workspaceId, workspaceId),
92-
isNull(mcpServers.deletedAt)
103+
const [server] = await timedStep('loadServer', DB_STEP_MS, () =>
104+
db
105+
.select()
106+
.from(mcpServers)
107+
.where(
108+
and(
109+
eq(mcpServers.id, serverId),
110+
eq(mcpServers.workspaceId, workspaceId),
111+
isNull(mcpServers.deletedAt)
112+
)
93113
)
94-
)
95-
.limit(1)
114+
.limit(1)
115+
)
96116

97117
if (!server) {
98118
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
@@ -107,8 +127,9 @@ export const GET = withRouteHandler(
107127
if (!server.url) {
108128
return createMcpErrorResponse(new Error('Server has no URL'), 'Missing server URL', 400)
109129
}
130+
const serverUrl = server.url
110131
try {
111-
assertSafeOauthServerUrl(server.url)
132+
assertSafeOauthServerUrl(serverUrl)
112133
} catch (e) {
113134
if (e instanceof McpOauthInsecureUrlError) {
114135
return createMcpErrorResponse(
@@ -120,11 +141,13 @@ export const GET = withRouteHandler(
120141
throw e
121142
}
122143

123-
const row = await getOrCreateOauthRow({
124-
mcpServerId: server.id,
125-
userId,
126-
workspaceId,
127-
})
144+
const row = await timedStep('getOrCreateOauthRow', DB_STEP_MS, () =>
145+
getOrCreateOauthRow({
146+
mcpServerId: server.id,
147+
userId,
148+
workspaceId,
149+
})
150+
)
128151
const hasActiveFlow =
129152
!!row.state &&
130153
!!row.stateCreatedAt &&
@@ -137,17 +160,38 @@ export const GET = withRouteHandler(
137160
)
138161
}
139162
if (row.userId !== userId) {
140-
await setOauthRowUser(row.id, userId)
163+
await timedStep('setOauthRowUser', DB_STEP_MS, () => setOauthRowUser(row.id, userId))
141164
row.userId = userId
142165
}
143-
const preregistered = await loadPreregisteredClient(server.id)
166+
const preregistered = await timedStep('loadPreregisteredClient', DB_STEP_MS, () =>
167+
loadPreregisteredClient(server.id)
168+
)
144169
const provider = new SimMcpOauthProvider({ row, preregistered })
145170

146171
try {
147-
const result = await mcpAuthGuarded(provider, {
148-
serverUrl: server.url,
172+
// OAuth discovery + DCR through the guarded fetch, bounded so a transient stall fails
173+
// fast with a labeled log instead of hanging the popup. `McpOauthRedirectRequired` is
174+
// the SUCCESS signal (a throw carrying the authorize URL), so we catch it INSIDE the
175+
// bounded step and return it as a normal value — otherwise timedStep would error-log
176+
// every successful authorize. Only a real error or a timeout escapes as a throw.
177+
const authOutcome = await timedStep('mcpAuthGuarded', MCP_AUTH_STEP_MS, async () => {
178+
try {
179+
return { kind: 'result' as const, value: await mcpAuthGuarded(provider, { serverUrl }) }
180+
} catch (e) {
181+
if (e instanceof McpOauthRedirectRequired) {
182+
return { kind: 'redirect' as const, authorizationUrl: e.authorizationUrl }
183+
}
184+
throw e
185+
}
149186
})
150-
if (result === 'AUTHORIZED') {
187+
if (authOutcome.kind === 'redirect') {
188+
logger.info(`OAuth redirect for server ${serverId}`)
189+
return NextResponse.json({
190+
status: 'redirect',
191+
authorizationUrl: authOutcome.authorizationUrl,
192+
})
193+
}
194+
if (authOutcome.value === 'AUTHORIZED') {
151195
return NextResponse.json({ status: 'already_authorized' })
152196
}
153197
return createMcpErrorResponse(
@@ -156,19 +200,26 @@ export const GET = withRouteHandler(
156200
500
157201
)
158202
} catch (e) {
159-
if (e instanceof McpOauthRedirectRequired) {
160-
logger.info(`OAuth redirect for server ${serverId}`)
161-
return NextResponse.json({
162-
status: 'redirect',
163-
authorizationUrl: e.authorizationUrl,
164-
})
165-
}
166203
if (isDynamicClientRegistrationUnsupported(e)) {
167204
return createMcpErrorResponse(toError(e), DCR_UNSUPPORTED_MESSAGE, 422)
168205
}
169206
throw e
170207
}
171208
} catch (error) {
209+
// Any bounded step timing out (DB reads or the auth step) is a stall, not a bug —
210+
// surface it as a fast 504 so the popup closes with a clear "try again" rather than a
211+
// generic 500. A fresh retry is a clean flow: the callback correlates on the `state`
212+
// nonce, so even if a lingering timed-out attempt later overwrites the row's state, the
213+
// user's authorize URL (carrying the fresh nonce) simply fails `invalid_state` — a clean
214+
// retry, never silent corruption.
215+
if (error instanceof OauthStepTimeoutError) {
216+
logger.warn('MCP OAuth start stalled')
217+
return createMcpErrorResponse(
218+
error,
219+
'Authorization is taking too long — please try again.',
220+
504
221+
)
222+
}
172223
logger.error('Error starting MCP OAuth flow:', error)
173224
// Only surface OAuth-flow errors verbatim; everything else (DB, decryption,
174225
// network) gets a generic message to avoid leaking internal details.

apps/sim/lib/mcp/oauth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ export {
2828
setOauthRowUser,
2929
withMcpOauthRefreshLock,
3030
} from './storage'
31+
export { makeTimedStep, OauthStepTimeoutError } from './timed-step'
3132
export { assertSafeOauthServerUrl, McpOauthInsecureUrlError } from './url-validation'
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Logger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
4+
/** Thrown when a `timedStep`-bounded operation doesn't settle within its budget. */
5+
export class OauthStepTimeoutError extends Error {
6+
constructor(step: string, ms: number) {
7+
super(`MCP OAuth step "${step}" did not settle within ${ms}ms`)
8+
this.name = 'OauthStepTimeoutError'
9+
}
10+
}
11+
12+
/**
13+
* Times and bounds one awaited step of an OAuth route so a stalled operation surfaces
14+
* as a labeled, logged error instead of hanging the request (and the browser popup
15+
* waiting on it) forever. The losing promise is not cancelled — a wedged DB/socket op
16+
* can't be — so it settles in the background with its rejection swallowed; the point is
17+
* that the request stops waiting on it and the logs name the exact step that stalled.
18+
*/
19+
export function makeTimedStep(logger: Logger) {
20+
return async function timedStep<T>(step: string, ms: number, fn: () => Promise<T>): Promise<T> {
21+
const start = Date.now()
22+
logger.info(`OAuth step start: ${step}`)
23+
const work = Promise.resolve(fn())
24+
work.catch(() => {})
25+
let timer: ReturnType<typeof setTimeout> | undefined
26+
try {
27+
const value = await Promise.race([
28+
work,
29+
new Promise<never>((_, reject) => {
30+
timer = setTimeout(() => reject(new OauthStepTimeoutError(step, ms)), ms)
31+
timer.unref?.()
32+
}),
33+
])
34+
logger.info(`OAuth step done: ${step} (${Date.now() - start}ms)`)
35+
return value
36+
} catch (error) {
37+
logger.error(`OAuth step failed: ${step} (${Date.now() - start}ms)`, {
38+
error: toError(error).message,
39+
})
40+
throw error
41+
} finally {
42+
clearTimeout(timer)
43+
}
44+
}
45+
}

packages/testing/src/mocks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export {
112112
McpOauthRedirectRequiredMock,
113113
mcpOauthMock,
114114
mcpOauthMockFns,
115+
OauthStepTimeoutErrorMock,
115116
} from './mcp-oauth.mock'
116117
// Permission mocks
117118
export { permissionsMock, permissionsMockFns } from './permissions.mock'

packages/testing/src/mocks/mcp-oauth.mock.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export class McpOauthInsecureUrlErrorMock extends Error {
4444
}
4545
}
4646

47+
export class OauthStepTimeoutErrorMock extends Error {
48+
constructor(step: string, ms: number) {
49+
super(`MCP OAuth step "${step}" did not settle within ${ms}ms`)
50+
this.name = 'OauthStepTimeoutErrorMock'
51+
}
52+
}
53+
4754
/**
4855
* Returns the provider config back as the constructed instance, matching the
4956
* original identity passthrough. Declared as a named function (not an arrow) so
@@ -82,5 +89,12 @@ export const mcpOauthMock = {
8289
withMcpOauthRefreshLock: mcpOauthMockFns.mockWithMcpOauthRefreshLock,
8390
McpOauthRedirectRequired: McpOauthRedirectRequiredMock,
8491
McpOauthInsecureUrlError: McpOauthInsecureUrlErrorMock,
92+
OauthStepTimeoutError: OauthStepTimeoutErrorMock,
8593
SimMcpOauthProvider: vi.fn().mockImplementation(buildSimMcpOauthProvider),
94+
// Pass-through: run the step immediately, no bounding, so route tests exercise real behavior.
95+
// Wrap in Promise.resolve like the real helper so a mock returning a non-promise still chains.
96+
makeTimedStep:
97+
() =>
98+
<T>(_step: string, _ms: number, fn: () => Promise<T>): Promise<T> =>
99+
Promise.resolve(fn()),
86100
}

0 commit comments

Comments
 (0)