@@ -3,7 +3,6 @@ import { db } from '@sim/db'
33import { mcpServers } from '@sim/db/schema'
44import { createLogger } from '@sim/logger'
55import { getErrorMessage , toError } from '@sim/utils/errors'
6- import { sleep } from '@sim/utils/helpers'
76import { and , eq , isNull } from 'drizzle-orm'
87import type { NextRequest } from 'next/server'
98import { NextResponse } from 'next/server'
@@ -29,14 +28,17 @@ const logger = createLogger('McpOauthStartAPI')
2928const timedStep = makeTimedStep ( logger )
3029const 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
4042const MAX_SURFACED_ERROR_LENGTH = 250
4143const 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