Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(mcp): bound and retry OAuth start so a transient stall recovers instead of a blank popup#5874
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a89b243
fix(mcp): bound and retry OAuth start so a transient stall recovers i…
waleedlatif1 cf5563e
fix(mcp): drop the unsafe OAuth-start retry; fail fast without error-…
waleedlatif1 d013b03
fix(mcp): route all bounded-step timeouts to the 504 handler
waleedlatif1 43dcd07
fix(mcp): bound the setOauthRowUser write too so no step escapes the …
waleedlatif1 d793545
fix(mcp): shrink OAuth-start step budgets to fit the 30s client deadl…
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -16,14 +16,31 @@ import { | ||
| loadPreregisteredClient, | ||
| McpOauthInsecureUrlError, | ||
| McpOauthRedirectRequired, | ||
| makeTimedStep, | ||
| mcpAuthGuarded, | ||
| OauthStepTimeoutError, | ||
| SimMcpOauthProvider, | ||
| setOauthRowUser, | ||
| } from '@/lib/mcp/oauth' | ||
| import { createMcpErrorResponse } from '@/lib/mcp/utils' | ||
| const logger = createLogger('McpOauthStartAPI') | ||
| const timedStep = makeTimedStep(logger) | ||
| const OAUTH_START_TTL_MS = 10 * 60 * 1000 | ||
| /** | ||
| * Per-step budgets, kept small so the whole request stays under the client's 30s `/oauth/start` | ||
| * deadline even in the worst case: up to four bounded DB steps (loadServer, getOrCreateOauthRow, | ||
| * setOauthRowUser, loadPreregisteredClient) + the auth step = 4×4 + 10 = 26s, leaving margin for | ||
| * middleware and network. OAuth discovery + DCR occasionally hits the transient | ||
| * headers-then-stalled-body class documented for CDN-fronted MCP hosts; the bound turns that into | ||
| * a fast, labeled failure so the popup closes with a clear error and the user can retry (a fresh | ||
| * click = a fresh connection that dodges the per-connection stall) rather than the popup hanging | ||
| * blank. We deliberately do NOT auto-retry here: `timedStep` can't cancel a wedged attempt, and a | ||
| * lingering first attempt sharing this server's OAuth row could overwrite the retry's PKCE | ||
| * verifier / state and break the callback. | ||
| */ | ||
| const DB_STEP_MS = 4_000 | ||
| const MCP_AUTH_STEP_MS = 10_000 | ||
| const MAX_SURFACED_ERROR_LENGTH = 250 | ||
| const DCR_UNSUPPORTED_MESSAGE = | ||
| "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( | ||
| const parsed = await parseRequest(startMcpOauthContract, request, {}) | ||
| if (!parsed.success) return parsed.response | ||
| const { serverId } = parsed.data.query | ||
| logger.info(`Starting MCP OAuth flow for server ${serverId}`) | ||
| const [server] = await db | ||
| .select() | ||
| .from(mcpServers) | ||
| .where( | ||
| and( | ||
| eq(mcpServers.id, serverId), | ||
| eq(mcpServers.workspaceId, workspaceId), | ||
| isNull(mcpServers.deletedAt) | ||
| const [server] = await timedStep('loadServer', DB_STEP_MS, () => | ||
| db | ||
| .select() | ||
| .from(mcpServers) | ||
| .where( | ||
| and( | ||
| eq(mcpServers.id, serverId), | ||
| eq(mcpServers.workspaceId, workspaceId), | ||
| isNull(mcpServers.deletedAt) | ||
| ) | ||
| ) | ||
| ) | ||
| .limit(1) | ||
| .limit(1) | ||
| ) | ||
| if (!server) { | ||
| return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) | ||
| @@ -107,8 +127,9 @@ export const GET = withRouteHandler( | ||
| if (!server.url) { | ||
| return createMcpErrorResponse(new Error('Server has no URL'), 'Missing server URL', 400) | ||
| } | ||
| const serverUrl = server.url | ||
| try { | ||
| assertSafeOauthServerUrl(server.url) | ||
| assertSafeOauthServerUrl(serverUrl) | ||
| } catch (e) { | ||
| if (e instanceof McpOauthInsecureUrlError) { | ||
| return createMcpErrorResponse( | ||
| @@ -120,11 +141,13 @@ export const GET = withRouteHandler( | ||
| throw e | ||
| } | ||
| const row = await getOrCreateOauthRow({ | ||
| mcpServerId: server.id, | ||
| userId, | ||
| workspaceId, | ||
| }) | ||
| const row = await timedStep('getOrCreateOauthRow', DB_STEP_MS, () => | ||
| getOrCreateOauthRow({ | ||
| mcpServerId: server.id, | ||
| userId, | ||
| workspaceId, | ||
| }) | ||
| ) | ||
| const hasActiveFlow = | ||
| !!row.state && | ||
| !!row.stateCreatedAt && | ||
| @@ -137,17 +160,38 @@ export const GET = withRouteHandler( | ||
| ) | ||
| } | ||
| if (row.userId !== userId) { | ||
| await setOauthRowUser(row.id, userId) | ||
| await timedStep('setOauthRowUser', DB_STEP_MS, () => setOauthRowUser(row.id, userId)) | ||
| row.userId = userId | ||
| } | ||
| const preregistered = await loadPreregisteredClient(server.id) | ||
| const preregistered = await timedStep('loadPreregisteredClient', DB_STEP_MS, () => | ||
| loadPreregisteredClient(server.id) | ||
| ) | ||
| const provider = new SimMcpOauthProvider({ row, preregistered }) | ||
| try { | ||
| const result = await mcpAuthGuarded(provider, { | ||
| serverUrl: server.url, | ||
| // OAuth discovery + DCR through the guarded fetch, bounded so a transient stall fails | ||
| // fast with a labeled log instead of hanging the popup. `McpOauthRedirectRequired` is | ||
| // the SUCCESS signal (a throw carrying the authorize URL), so we catch it INSIDE the | ||
| // bounded step and return it as a normal value — otherwise timedStep would error-log | ||
| // every successful authorize. Only a real error or a timeout escapes as a throw. | ||
| const authOutcome = await timedStep('mcpAuthGuarded', MCP_AUTH_STEP_MS, async () => { | ||
| try { | ||
| return { kind: 'result' as const, value: await mcpAuthGuarded(provider, { serverUrl }) } | ||
| } catch (e) { | ||
| if (e instanceof McpOauthRedirectRequired) { | ||
| return { kind: 'redirect' as const, authorizationUrl: e.authorizationUrl } | ||
| } | ||
| throw e | ||
| } | ||
| }) | ||
| if (result === 'AUTHORIZED') { | ||
| if (authOutcome.kind === 'redirect') { | ||
| logger.info(`OAuth redirect for server ${serverId}`) | ||
| return NextResponse.json({ | ||
| status: 'redirect', | ||
| authorizationUrl: authOutcome.authorizationUrl, | ||
| }) | ||
| } | ||
| if (authOutcome.value === 'AUTHORIZED') { | ||
| return NextResponse.json({ status: 'already_authorized' }) | ||
| } | ||
| return createMcpErrorResponse( | ||
| @@ -156,19 +200,26 @@ export const GET = withRouteHandler( | ||
| 500 | ||
| ) | ||
| } catch (e) { | ||
| if (e instanceof McpOauthRedirectRequired) { | ||
| logger.info(`OAuth redirect for server ${serverId}`) | ||
| return NextResponse.json({ | ||
| status: 'redirect', | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| authorizationUrl: e.authorizationUrl, | ||
| }) | ||
| } | ||
| if (isDynamicClientRegistrationUnsupported(e)) { | ||
| return createMcpErrorResponse(toError(e), DCR_UNSUPPORTED_MESSAGE, 422) | ||
| } | ||
| throw e | ||
| } | ||
| } catch (error) { | ||
| // Any bounded step timing out (DB reads or the auth step) is a stall, not a bug — | ||
| // surface it as a fast 504 so the popup closes with a clear "try again" rather than a | ||
| // generic 500. A fresh retry is a clean flow: the callback correlates on the `state` | ||
| // nonce, so even if a lingering timed-out attempt later overwrites the row's state, the | ||
| // user's authorize URL (carrying the fresh nonce) simply fails `invalid_state` — a clean | ||
| // retry, never silent corruption. | ||
| if (error instanceof OauthStepTimeoutError) { | ||
| logger.warn('MCP OAuth start stalled') | ||
| return createMcpErrorResponse( | ||
| error, | ||
| 'Authorization is taking too long — please try again.', | ||
| 504 | ||
| ) | ||
| } | ||
| logger.error('Error starting MCP OAuth flow:', error) | ||
| // Only surface OAuth-flow errors verbatim; everything else (DB, decryption, | ||
| // network) gets a generic message to avoid leaking internal details. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import type { Logger } from '@sim/logger' | ||
| import { toError } from '@sim/utils/errors' | ||
| /** Thrown when a `timedStep`-bounded operation doesn't settle within its budget. */ | ||
| export class OauthStepTimeoutError extends Error { | ||
| constructor(step: string, ms: number) { | ||
| super(`MCP OAuth step "${step}" did not settle within ${ms}ms`) | ||
| this.name = 'OauthStepTimeoutError' | ||
| } | ||
| } | ||
| /** | ||
| * Times and bounds one awaited step of an OAuth route so a stalled operation surfaces | ||
| * as a labeled, logged error instead of hanging the request (and the browser popup | ||
| * waiting on it) forever. The losing promise is not cancelled — a wedged DB/socket op | ||
| * can't be — so it settles in the background with its rejection swallowed; the point is | ||
| * that the request stops waiting on it and the logs name the exact step that stalled. | ||
| */ | ||
| export function makeTimedStep(logger: Logger) { | ||
| return async function timedStep<T>(step: string, ms: number, fn: () => Promise<T>): Promise<T> { | ||
| const start = Date.now() | ||
| logger.info(`OAuth step start: ${step}`) | ||
| const work = Promise.resolve(fn()) | ||
| work.catch(() => {}) | ||
| let timer: ReturnType<typeof setTimeout> | undefined | ||
| try { | ||
| const value = await Promise.race([ | ||
| work, | ||
| new Promise<never>((_, reject) => { | ||
| timer = setTimeout(() => reject(new OauthStepTimeoutError(step, ms)), ms) | ||
| timer.unref?.() | ||
| }), | ||
| ]) | ||
| logger.info(`OAuth step done: ${step} (${Date.now() - start}ms)`) | ||
| return value | ||
| } catch (error) { | ||
| logger.error(`OAuth step failed: ${step} (${Date.now() - start}ms)`, { | ||
| error: toError(error).message, | ||
| }) | ||
| throw error | ||
| } finally { | ||
| clearTimeout(timer) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.