Skip to content

Commit cf5563e

Browse files
committed
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.
1 parent a89b243 commit cf5563e

2 files changed

Lines changed: 53 additions & 47 deletions

File tree

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,33 +87,36 @@ describe('MCP OAuth start route', () => {
8787
)
8888
})
8989

90-
it('retries mcpAuthGuarded once on a transient stall and succeeds on the fresh attempt', async () => {
91-
mcpOauthMockFns.mockMcpAuthGuarded
92-
.mockRejectedValueOnce(new OauthStepTimeoutErrorMock('mcpAuthGuarded (attempt 1)', 12_000))
93-
.mockRejectedValueOnce(new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize'))
90+
it('returns 504 (not a retry) when the auth step times out', async () => {
91+
// The stall is intentionally NOT auto-retried — a lingering attempt shares the OAuth row and
92+
// could corrupt the retry's PKCE/state. The bounded step fails fast; the user re-clicks.
93+
mcpOauthMockFns.mockMcpAuthGuarded.mockImplementationOnce(() => {
94+
throw new OauthStepTimeoutErrorMock('mcpAuthGuarded', 12_000)
95+
})
9496
const request = new NextRequest(
9597
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
9698
)
9799

98100
const response = await GET(request)
99-
const body = await response.json()
100101

101-
expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(2)
102-
expect(response.status).toBe(200)
103-
expect(body).toEqual({ status: 'redirect', authorizationUrl: 'https://mcp.exa.ai/authorize' })
102+
expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1)
103+
expect(response.status).toBe(504)
104104
})
105105

106-
it('does not retry a non-timeout error (e.g. redirect success on the first attempt)', async () => {
106+
it('returns the authorize URL without error-logging the success redirect throw', async () => {
107107
mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValueOnce(
108108
new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize')
109109
)
110110
const request = new NextRequest(
111111
'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
112112
)
113113

114-
await GET(request)
114+
const response = await GET(request)
115+
const body = await response.json()
115116

116117
expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1)
118+
expect(response.status).toBe(200)
119+
expect(body).toEqual({ status: 'redirect', authorizationUrl: 'https://mcp.exa.ai/authorize' })
117120
})
118121

119122
it('requires workspace write permission via MCP auth middleware', async () => {

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

Lines changed: 40 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { db } from '@sim/db'
33
import { mcpServers } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { getErrorMessage, toError } from '@sim/utils/errors'
6-
import { sleep } from '@sim/utils/helpers'
76
import { and, eq, isNull } from 'drizzle-orm'
87
import type { NextRequest } from 'next/server'
98
import { NextResponse } from 'next/server'
@@ -29,14 +28,17 @@ const logger = createLogger('McpOauthStartAPI')
2928
const timedStep = makeTimedStep(logger)
3029
const OAUTH_START_TTL_MS = 10 * 60 * 1000
3130
/**
32-
* OAuth discovery + DCR occasionally hits the transient headers-then-stalled-body class we've
33-
* documented for CDN-fronted MCP hosts — a per-connection stall a fresh attempt dodges. Bound
34-
* each attempt tightly and retry once so an intermittent stall recovers automatically instead
35-
* of hanging the browser popup to the client's timeout. Two 12s attempts stay under the
36-
* client's 30s `/oauth/start` deadline.
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 (5+5+5 DB + 12 auth = 27s). OAuth discovery + DCR occasionally
33+
* hits the transient headers-then-stalled-body class documented for CDN-fronted MCP hosts; the
34+
* bound turns that into a fast, labeled failure so the popup closes with a clear error and the
35+
* user can retry (a fresh click = a fresh connection that dodges the per-connection stall) rather
36+
* than the popup hanging blank. We deliberately do NOT auto-retry here: `timedStep` can't cancel
37+
* a wedged attempt, and a lingering first attempt sharing this server's OAuth row could overwrite
38+
* the retry's PKCE verifier / state and break the callback.
3739
*/
38-
const MCP_AUTH_ATTEMPT_MS = 12_000
39-
const MCP_AUTH_MAX_ATTEMPTS = 2
40+
const DB_STEP_MS = 5_000
41+
const MCP_AUTH_STEP_MS = 12_000
4042
const MAX_SURFACED_ERROR_LENGTH = 250
4143
const DCR_UNSUPPORTED_MESSAGE =
4244
"This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead."
@@ -96,7 +98,7 @@ export const GET = withRouteHandler(
9698
const { serverId } = parsed.data.query
9799
logger.info(`Starting MCP OAuth flow for server ${serverId}`)
98100

99-
const [server] = await timedStep('loadServer', 15_000, () =>
101+
const [server] = await timedStep('loadServer', DB_STEP_MS, () =>
100102
db
101103
.select()
102104
.from(mcpServers)
@@ -137,7 +139,7 @@ export const GET = withRouteHandler(
137139
throw e
138140
}
139141

140-
const row = await timedStep('getOrCreateOauthRow', 15_000, () =>
142+
const row = await timedStep('getOrCreateOauthRow', DB_STEP_MS, () =>
141143
getOrCreateOauthRow({
142144
mcpServerId: server.id,
143145
userId,
@@ -159,35 +161,35 @@ export const GET = withRouteHandler(
159161
await setOauthRowUser(row.id, userId)
160162
row.userId = userId
161163
}
162-
const preregistered = await timedStep('loadPreregisteredClient', 15_000, () =>
164+
const preregistered = await timedStep('loadPreregisteredClient', DB_STEP_MS, () =>
163165
loadPreregisteredClient(server.id)
164166
)
165167
const provider = new SimMcpOauthProvider({ row, preregistered })
166168

167169
try {
168-
// OAuth discovery + DCR through the guarded fetch. Each attempt is bounded (labeled in
169-
// logs); a per-connection transient stall on the first attempt is retried on a fresh
170-
// one. `McpOauthRedirectRequired` is the SUCCESS signal — rethrow it immediately so the
171-
// outer catch returns the authorize URL; only a bounded timeout is retried.
172-
let result: Awaited<ReturnType<typeof mcpAuthGuarded>> | undefined
173-
for (let attempt = 1; attempt <= MCP_AUTH_MAX_ATTEMPTS; attempt++) {
170+
// OAuth discovery + DCR through the guarded fetch, bounded so a transient stall fails
171+
// fast with a labeled log instead of hanging the popup. `McpOauthRedirectRequired` is
172+
// the SUCCESS signal (a throw carrying the authorize URL), so we catch it INSIDE the
173+
// bounded step and return it as a normal value — otherwise timedStep would error-log
174+
// every successful authorize. Only a real error or a timeout escapes as a throw.
175+
const authOutcome = await timedStep('mcpAuthGuarded', MCP_AUTH_STEP_MS, async () => {
174176
try {
175-
result = await timedStep(
176-
`mcpAuthGuarded (attempt ${attempt})`,
177-
MCP_AUTH_ATTEMPT_MS,
178-
() => mcpAuthGuarded(provider, { serverUrl })
179-
)
180-
break
181-
} catch (attemptError) {
182-
if (attemptError instanceof OauthStepTimeoutError && attempt < MCP_AUTH_MAX_ATTEMPTS) {
183-
logger.warn(`MCP OAuth start stalled for server ${serverId}, retrying`)
184-
await sleep(250)
185-
continue
177+
return { kind: 'result' as const, value: await mcpAuthGuarded(provider, { serverUrl }) }
178+
} catch (e) {
179+
if (e instanceof McpOauthRedirectRequired) {
180+
return { kind: 'redirect' as const, authorizationUrl: e.authorizationUrl }
186181
}
187-
throw attemptError
182+
throw e
188183
}
184+
})
185+
if (authOutcome.kind === 'redirect') {
186+
logger.info(`OAuth redirect for server ${serverId}`)
187+
return NextResponse.json({
188+
status: 'redirect',
189+
authorizationUrl: authOutcome.authorizationUrl,
190+
})
189191
}
190-
if (result === 'AUTHORIZED') {
192+
if (authOutcome.value === 'AUTHORIZED') {
191193
return NextResponse.json({ status: 'already_authorized' })
192194
}
193195
return createMcpErrorResponse(
@@ -196,16 +198,17 @@ export const GET = withRouteHandler(
196198
500
197199
)
198200
} catch (e) {
199-
if (e instanceof McpOauthRedirectRequired) {
200-
logger.info(`OAuth redirect for server ${serverId}`)
201-
return NextResponse.json({
202-
status: 'redirect',
203-
authorizationUrl: e.authorizationUrl,
204-
})
205-
}
206201
if (isDynamicClientRegistrationUnsupported(e)) {
207202
return createMcpErrorResponse(toError(e), DCR_UNSUPPORTED_MESSAGE, 422)
208203
}
204+
if (e instanceof OauthStepTimeoutError) {
205+
logger.warn(`MCP OAuth start stalled for server ${serverId}`)
206+
return createMcpErrorResponse(
207+
e,
208+
'Authorization is taking too long — please try again.',
209+
504
210+
)
211+
}
209212
throw e
210213
}
211214
} catch (error) {

0 commit comments

Comments
 (0)