Skip to content

Commit 6d4ce34

Browse files
committed
fix(mcp): harden OAuth request isolation
1 parent b341a9d commit 6d4ce34

6 files changed

Lines changed: 142 additions & 48 deletions

File tree

apps/sim/lib/mcp/client.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ describe('McpClient notification handler', () => {
465465
expect(logged).not.toContain('test-session')
466466
})
467467

468-
it('passes configured headers for OAuth transports as well as header auth transports', () => {
468+
it('scopes configured headers to the MCP endpoint for OAuth transports', () => {
469469
const authProvider = {} as unknown as NonNullable<McpClientOptions['authProvider']>
470470
new McpClient({
471471
config: {
@@ -479,10 +479,13 @@ describe('McpClient notification handler', () => {
479479

480480
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
481481
new URL('https://test.example.com/mcp'),
482-
{
482+
expect.objectContaining({
483483
authProvider,
484-
requestInit: { headers: { 'X-Sim-Via': 'workflow' } },
485-
}
484+
fetch: expect.any(Function),
485+
})
486+
)
487+
expect(vi.mocked(StreamableHTTPClientTransport).mock.calls.at(-1)?.[1]).not.toHaveProperty(
488+
'requestInit'
486489
)
487490
})
488491
})

apps/sim/lib/mcp/client.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import { getErrorMessage } from '@sim/utils/errors'
1313
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1414
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
1515
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
16-
import { createCoordinatedMcpOauthFetch } from '@/lib/mcp/oauth/coordinated-fetch'
16+
import {
17+
createCoordinatedMcpOauthFetch,
18+
createMcpEndpointFetch,
19+
} from '@/lib/mcp/oauth/coordinated-fetch'
1720
import { createGuardedMcpFetch, createPinnedPrivateMcpFetch } from '@/lib/mcp/pinned-fetch'
1821
import {
1922
type McpClientOptions,
@@ -127,17 +130,22 @@ export class McpClient {
127130
: createGuardedMcpFetch(this.config.url)
128131
: undefined
129132
this.closeGuardedTransport = guarded?.close
133+
const oauthFetch = useOauth
134+
? createMcpEndpointFetch(guarded?.fetch ?? fetch, {
135+
serverUrl: this.config.url,
136+
headers: this.config.headers,
137+
})
138+
: undefined
130139
const transportFetch =
131-
useOauth && options.oauthCredentials
140+
options.oauthCredentials && oauthFetch
132141
? createCoordinatedMcpOauthFetch(options.oauthCredentials, {
133142
serverUrl: this.config.url,
134-
fetch: guarded?.fetch ?? fetch,
135-
requestInit: { headers: this.config.headers },
143+
fetch: oauthFetch,
136144
})
137-
: guarded?.fetch
145+
: (oauthFetch ?? guarded?.fetch)
138146
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
139147
authProvider: useOauth ? this.authProvider : undefined,
140-
requestInit: { headers: this.config.headers },
148+
...(useOauth ? {} : { requestInit: { headers: this.config.headers } }),
141149
...(transportFetch ? { fetch: transportFetch } : {}),
142150
})
143151

apps/sim/lib/mcp/oauth/coordinated-fetch.test.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
88
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
99
import { encryptionMock, redisConfigMockFns, resetRedisConfigMock } from '@sim/testing'
1010
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11-
import { createCoordinatedMcpOauthFetch } from '@/lib/mcp/oauth/coordinated-fetch'
11+
import {
12+
createCoordinatedMcpOauthFetch,
13+
createMcpEndpointFetch,
14+
} from '@/lib/mcp/oauth/coordinated-fetch'
1215
import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth/storage'
1316

1417
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
@@ -447,7 +450,25 @@ describe('coordinated MCP OAuth with the real SDK and refresh mutex', () => {
447450
expect(new Headers(request.mock.calls[0][1]?.headers).has('authorization')).toBe(false)
448451
})
449452

450-
it('does not refresh or retry a request cancelled while waiting for the lock', async () => {
453+
it('applies configured headers only to the MCP endpoint', async () => {
454+
const request = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }))
455+
const scoped = createMcpEndpointFetch(request, {
456+
serverUrl: SERVER,
457+
headers: { 'x-mcp-credential': 'configured-value' },
458+
})
459+
460+
await scoped(SERVER, { headers: { accept: 'application/json' } })
461+
await scoped(TOKEN_URL, { headers: { 'content-type': 'application/x-www-form-urlencoded' } })
462+
463+
const mcpHeaders = new Headers(request.mock.calls[0][1]?.headers)
464+
expect(mcpHeaders.get('x-mcp-credential')).toBe('configured-value')
465+
expect(mcpHeaders.get('accept')).toBe('application/json')
466+
const oauthHeaders = new Headers(request.mock.calls[1][1]?.headers)
467+
expect(oauthHeaders.has('x-mcp-credential')).toBe(false)
468+
expect(oauthHeaders.get('content-type')).toBe('application/x-www-form-urlencoded')
469+
})
470+
471+
it('rejects promptly without refreshing when cancelled while waiting for the lock', async () => {
451472
const entered = deferred()
452473
const finish = deferred()
453474
const holder = withMcpOauthRefreshLock('shared-grant', async () => {
@@ -464,12 +485,15 @@ describe('coordinated MCP OAuth with the real SDK and refresh mutex', () => {
464485
const abort = new AbortController()
465486
const call = fetchFor(grant, request)(SERVER, { signal: abort.signal })
466487
const outcome = expect(call).rejects.toThrow('cancelled')
467-
await rejected.promise
468-
abort.abort(new Error('cancelled'))
469-
finish.resolve()
470-
await holder
471-
await outcome
472-
expect(grant.tokenRequest).not.toHaveBeenCalled()
473-
expect(request).toHaveBeenCalledTimes(1)
488+
try {
489+
await rejected.promise
490+
abort.abort(new Error('cancelled'))
491+
await outcome
492+
expect(grant.tokenRequest).not.toHaveBeenCalled()
493+
expect(request).toHaveBeenCalledTimes(1)
494+
} finally {
495+
finish.resolve()
496+
await holder
497+
}
474498
})
475499
})

apps/sim/lib/mcp/oauth/coordinated-fetch.ts

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
type OAuthClientProvider,
44
UnauthorizedError,
55
} from '@modelcontextprotocol/sdk/client/auth.js'
6-
import { createFetchWithInit, type FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
6+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
77
import { mcpAuthGuarded } from '@/lib/mcp/oauth/auth'
88
import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth/storage'
99

@@ -19,6 +19,23 @@ export interface McpOauthSession extends McpOauthCredentials {
1919
initialProvider: OAuthClientProvider
2020
}
2121

22+
/** Applies configured headers only to the MCP endpoint, never to OAuth discovery or token URLs. */
23+
export function createMcpEndpointFetch(
24+
fetchFn: FetchLike,
25+
options: { serverUrl: string; headers?: HeadersInit }
26+
): FetchLike {
27+
if (!options.headers) return fetchFn
28+
const serverUrl = new URL(options.serverUrl)
29+
const configuredHeaders = new Headers(options.headers)
30+
31+
return (input, init) => {
32+
if (new URL(input).href !== serverUrl.href) return fetchFn(input, init)
33+
const headers = new Headers(init?.headers)
34+
configuredHeaders.forEach((value, key) => headers.set(key, value))
35+
return fetchFn(input, { ...init, headers })
36+
}
37+
}
38+
2239
/**
2340
* Coordinates the SDK's public OAuth flow across clients without locking MCP requests.
2441
* The transport must omit authProvider so it cannot refresh outside this boundary.
@@ -27,10 +44,9 @@ export interface McpOauthSession extends McpOauthCredentials {
2744
*/
2845
export function createCoordinatedMcpOauthFetch(
2946
{ credentialId, loadProvider, initialProvider }: McpOauthSession,
30-
options: { serverUrl: string; fetch: FetchLike; requestInit?: RequestInit }
47+
options: { serverUrl: string; fetch: FetchLike }
3148
): FetchLike {
3249
const serverUrl = new URL(options.serverUrl)
33-
const authFetch = createFetchWithInit(options.fetch, options.requestInit)
3450
let currentProvider = initialProvider
3551

3652
return async (input, init) => {
@@ -63,27 +79,31 @@ export function createCoordinatedMcpOauthFetch(
6379
if (authenticatedChallenges.has(challengeKey)) return response
6480

6581
await response.body?.cancel()
66-
await withMcpOauthRefreshLock(credentialId, async () => {
67-
init?.signal?.throwIfAborted()
68-
const current = await loadProvider()
69-
const latestTokens = await current.tokens()
70-
const refreshedElsewhere =
71-
latestTokens && latestTokens.access_token !== tokens?.access_token
82+
await withMcpOauthRefreshLock(
83+
credentialId,
84+
async () => {
85+
init?.signal?.throwIfAborted()
86+
const current = await loadProvider()
87+
const latestTokens = await current.tokens()
88+
const refreshedElsewhere =
89+
latestTokens && latestTokens.access_token !== tokens?.access_token
7290

73-
init?.signal?.throwIfAborted()
74-
if (!refreshedElsewhere) {
75-
const result = await mcpAuthGuarded(current, {
76-
serverUrl,
77-
resourceMetadataUrl: challenge.resourceMetadataUrl,
78-
scope: challenge.scope,
79-
fetchFn: authFetch,
80-
})
81-
if (result !== 'AUTHORIZED') throw new UnauthorizedError()
82-
authenticatedChallenges.add(challengeKey)
83-
}
84-
provider = current
85-
currentProvider = current
86-
})
91+
init?.signal?.throwIfAborted()
92+
if (!refreshedElsewhere) {
93+
const result = await mcpAuthGuarded(current, {
94+
serverUrl,
95+
resourceMetadataUrl: challenge.resourceMetadataUrl,
96+
scope: challenge.scope,
97+
fetchFn: options.fetch,
98+
})
99+
if (result !== 'AUTHORIZED') throw new UnauthorizedError()
100+
authenticatedChallenges.add(challengeKey)
101+
}
102+
provider = current
103+
currentProvider = current
104+
},
105+
init?.signal ?? undefined
106+
)
87107
}
88108
}
89109
}

apps/sim/lib/mcp/oauth/storage.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,20 @@ describe('withMcpOauthRefreshLock', () => {
150150
expect(fn).toHaveBeenCalledTimes(1)
151151
})
152152

153+
it('stops waiting for a cross-process lock when the caller aborts', async () => {
154+
mockAcquireLock.mockResolvedValue(false)
155+
const fn = vi.fn(async () => 'should-not-run')
156+
const controller = new AbortController()
157+
const pending = withMcpOauthRefreshLock('row-abort', fn, controller.signal)
158+
const assertion = expect(pending).rejects.toThrow('cancelled')
159+
160+
await vi.waitFor(() => expect(mockAcquireLock).toHaveBeenCalled())
161+
controller.abort(new Error('cancelled'))
162+
163+
await assertion
164+
expect(fn).not.toHaveBeenCalled()
165+
})
166+
153167
it('falls open when Redis is unavailable on acquire', async () => {
154168
mockAcquireLock.mockRejectedValueOnce(new Error('Redis connection refused'))
155169
const fn = vi.fn(async () => 'uncoordinated')

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { db } from '@sim/db'
77
import { mcpServerOauth } from '@sim/db/schema'
88
import { createLogger } from '@sim/logger'
99
import { toError } from '@sim/utils/errors'
10-
import { sleep } from '@sim/utils/helpers'
10+
import { interruptibleSleep } from '@sim/utils/helpers'
1111
import { generateId, generateShortId } from '@sim/utils/id'
1212
import { and, eq, gt } from 'drizzle-orm'
1313
import { acquireLock, extendLock, releaseLock } from '@/lib/core/config/redis'
@@ -275,7 +275,12 @@ const REFRESH_QUEUE_WAIT_TIMEOUT_MS = 90_000
275275

276276
const inflightChains = new Map<string, Promise<unknown>>()
277277

278-
export async function withMcpOauthRefreshLock<T>(rowId: string, fn: () => Promise<T>): Promise<T> {
278+
export async function withMcpOauthRefreshLock<T>(
279+
rowId: string,
280+
fn: () => Promise<T>,
281+
signal?: AbortSignal
282+
): Promise<T> {
283+
signal?.throwIfAborted()
279284
const lockKey = `mcp:oauth:refresh:${rowId}`
280285
const prev = inflightChains.get(lockKey) ?? Promise.resolve()
281286
const prevSettled = prev.catch(() => undefined)
@@ -285,7 +290,8 @@ export async function withMcpOauthRefreshLock<T>(rowId: string, fn: () => Promis
285290
if (queueTimedOut) {
286291
throw new Error(`MCP OAuth refresh queue for ${rowId} abandoned after timeout`)
287292
}
288-
return runWithRedisMutex(lockKey, rowId, fn)
293+
signal?.throwIfAborted()
294+
return runWithRedisMutex(lockKey, rowId, fn, signal)
289295
})
290296
inflightChains.set(lockKey, next)
291297
const cleanup = () => {
@@ -305,11 +311,25 @@ export async function withMcpOauthRefreshLock<T>(rowId: string, fn: () => Promis
305311
}, REFRESH_QUEUE_WAIT_TIMEOUT_MS)
306312
queueTimer.unref?.()
307313
})
314+
let abortListener: (() => void) | undefined
315+
const queueAbort = new Promise<never>((_resolve, reject) => {
316+
if (!signal) return
317+
abortListener = () => {
318+
try {
319+
signal.throwIfAborted()
320+
} catch (error) {
321+
reject(error)
322+
}
323+
}
324+
signal.addEventListener('abort', abortListener, { once: true })
325+
if (signal.aborted) abortListener()
326+
})
308327

309328
try {
310-
await Promise.race([prevSettled, queueDeadline])
329+
await Promise.race([prevSettled, queueDeadline, queueAbort])
311330
} finally {
312331
clearTimeout(queueTimer)
332+
if (signal && abortListener) signal.removeEventListener('abort', abortListener)
313333
}
314334

315335
return next
@@ -318,16 +338,19 @@ export async function withMcpOauthRefreshLock<T>(rowId: string, fn: () => Promis
318338
async function runWithRedisMutex<T>(
319339
lockKey: string,
320340
rowId: string,
321-
fn: () => Promise<T>
341+
fn: () => Promise<T>,
342+
signal?: AbortSignal
322343
): Promise<T> {
323344
const ownerToken = generateShortId()
324345
const deadline = Date.now() + REFRESH_MAX_WAIT_MS
325346

326347
while (true) {
348+
signal?.throwIfAborted()
327349
let acquired = false
328350
try {
329351
acquired = await acquireLock(lockKey, ownerToken, REFRESH_LOCK_TTL_SEC)
330352
} catch (error) {
353+
signal?.throwIfAborted()
331354
logger.warn('Redis unavailable, running OAuth flow uncoordinated', {
332355
rowId,
333356
error: toError(error).message,
@@ -345,6 +368,7 @@ async function runWithRedisMutex<T>(
345368
})
346369
}, REFRESH_LOCK_EXTEND_INTERVAL_MS)
347370
try {
371+
signal?.throwIfAborted()
348372
return await fn()
349373
} finally {
350374
clearInterval(watchdog)
@@ -357,6 +381,7 @@ async function runWithRedisMutex<T>(
357381
}
358382
}
359383

384+
signal?.throwIfAborted()
360385
if (Date.now() >= deadline) {
361386
// Lock still held by another process AND its watchdog is keeping it
362387
// alive — falling open would let us refresh concurrently and race the
@@ -367,6 +392,6 @@ async function runWithRedisMutex<T>(
367392
`MCP OAuth refresh lock for ${rowId} held longer than ${REFRESH_MAX_WAIT_MS}ms`
368393
)
369394
}
370-
await sleep(REFRESH_POLL_INTERVAL_MS)
395+
await interruptibleSleep(REFRESH_POLL_INTERVAL_MS, signal)
371396
}
372397
}

0 commit comments

Comments
 (0)