Skip to content

Commit 3ac3d2d

Browse files
committed
fix(mcp): retry auth failures in executeTool; simplify acquire re-check and clear generations on dispose
1 parent 0edd043 commit 3ac3d2d

4 files changed

Lines changed: 57 additions & 18 deletions

File tree

apps/sim/lib/mcp/connection-pool.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ function params(key: string, create: () => Promise<McpClient>): AcquireParams {
5454
return { key, serverId: key.split(':')[0], create }
5555
}
5656

57+
/** Drain the microtask queue so an in-flight acquire fully settles before the next step. */
58+
async function flushMicrotasks(turns = 50): Promise<void> {
59+
for (let i = 0; i < turns; i++) await Promise.resolve()
60+
}
61+
5762
describe('McpConnectionPool', () => {
5863
let pool: McpConnectionPool
5964

@@ -310,7 +315,7 @@ describe('McpConnectionPool', () => {
310315

311316
// A's ping fails → A retires stale, rebuilds `fresh`, pools it, clears pending.
312317
releasePing[0](false)
313-
for (let i = 0; i < 20; i++) await Promise.resolve()
318+
await flushMicrotasks()
314319
// B's ping fails → B must reuse the pooled `fresh`, not create a third client.
315320
releasePing[1](false)
316321

apps/sim/lib/mcp/connection-pool.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* ping-cached; reconnect is demand-driven (callers' retries re-acquire).
1010
*
1111
* Connections are ref-counted. Eviction *retires* an entry — removes it from the
12-
* pool so no new borrower can take it — but the socket is closed only once the
12+
* pool so later acquires stop reusing it — but the socket is closed only once the
1313
* last in-flight borrower releases, so a sibling failure, idle sweep, or config
1414
* change never tears down a connection mid-request. Retirement is identity-checked,
1515
* so a stale `onClose` can't disconnect a replacement stored under the same key.
@@ -78,30 +78,29 @@ export class McpConnectionPool {
7878
const client = await params.create()
7979
return { client, release: () => client.disconnect().catch(() => {}) }
8080
}
81-
const entry = await this.resolveEntry(params)
82-
// A concurrent evict/release/idle could have retired + started disconnecting
83-
// this entry during the resolve's `await` gap; `closing` (not `retired`, which
84-
// a usable one-shot also sets) means the socket is going away — get a fresh one.
85-
if (entry.closing) return this.acquire(params)
81+
// A concurrent evict/release/idle could have started disconnecting the resolved
82+
// entry during an `await` gap; `closing` (not `retired`, which a usable one-shot
83+
// also sets) means the socket is going away, so resolve again.
84+
let entry = await this.resolveEntry(params)
85+
while (entry.closing) entry = await this.resolveEntry(params)
8686
entry.borrowers++
8787
entry.lastActivityAt = Date.now()
8888
return { client: entry.client, release: (poison) => this.release(entry, poison ?? false) }
8989
}
9090

9191
private async resolveEntry(params: AcquireParams): Promise<PoolEntry> {
92-
const pending = this.pending.get(params.key)
93-
if (pending) return pending
92+
while (true) {
93+
const pending = this.pending.get(params.key)
94+
if (pending) return pending
95+
96+
const current = this.entries.get(params.key)
97+
if (!current) return this.createEntry(params)
9498

95-
const current = this.entries.get(params.key)
96-
if (current) {
9799
const reusable = await this.tryReuse(current)
98100
if (reusable) return reusable
99-
// tryReuse awaited a ping and retired `current`; a concurrent acquire may
100-
// have pooled a replacement meanwhile, so re-resolve rather than blindly
101-
// creating (which would overwrite and leak that replacement).
102-
return this.resolveEntry(params)
101+
// tryReuse awaited a ping and retired `current`; loop to re-check pending/entries —
102+
// a concurrent acquire may have pooled a replacement we must reuse, not overwrite.
103103
}
104-
return this.createEntry(params)
105104
}
106105

107106
/** Return `entry` if in-age, connected, and live; else retire it and return null. */
@@ -258,6 +257,7 @@ export class McpConnectionPool {
258257
for (const entry of entries) entry.retired = true
259258
this.entries.clear()
260259
this.pending.clear()
260+
this.serverGenerations.clear()
261261
void Promise.allSettled(entries.map((entry) => entry.client.disconnect()))
262262
}
263263
}

apps/sim/lib/mcp/service-pool.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,23 @@ describe('McpService connection reuse wiring', () => {
180180

181181
expect(mockRelease).toHaveBeenCalledWith(true)
182182
})
183+
184+
it('retries and recovers when a rotated credential causes a one-off auth failure', async () => {
185+
mockCallTool
186+
.mockRejectedValueOnce(new UnauthorizedError('stale key'))
187+
.mockResolvedValueOnce({ content: [] })
188+
189+
const result = await mcpService.executeTool(
190+
USER_ID,
191+
'server-1',
192+
{ name: 'do', arguments: {} },
193+
WORKSPACE_ID
194+
)
195+
196+
expect(result).toEqual({ content: [] })
197+
expect(mockCallTool).toHaveBeenCalledTimes(2)
198+
// First attempt poisoned the stale lease; the retry re-acquired a fresh one.
199+
expect(mockRelease).toHaveBeenCalledWith(true)
200+
expect(mockAcquire).toHaveBeenCalledTimes(2)
201+
})
183202
})

apps/sim/lib/mcp/service.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ function isDeadConnectionError(error: unknown): boolean {
125125
)
126126
}
127127

128+
/**
129+
* An auth failure (401) from a rotated/revoked credential. Safe for `executeTool`
130+
* to retry — auth is rejected *before* the tool runs, so re-acquiring on a fresh
131+
* connection (which re-resolves the credential) can't double-execute a tool.
132+
*/
133+
function isAuthError(error: unknown): boolean {
134+
return (
135+
error instanceof UnauthorizedError ||
136+
(error instanceof StreamableHTTPError && error.code === 401)
137+
)
138+
}
139+
128140
/** Transient failures a read-only `tools/list` may safely retry (idempotent, unlike `tools/call`); excludes OAuth and terminal 4xx. */
129141
function isRetryableDiscoveryError(error: unknown): boolean {
130142
if (isTimeoutError(error)) return true
@@ -435,9 +447,12 @@ class McpService {
435447
logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`)
436448
return result
437449
} catch (error) {
438-
if (this.isSessionError(error) && attempt < maxRetries - 1) {
450+
// A stale session (400/404) or a rotated/revoked credential (401) is rejected
451+
// before the tool runs, so retrying on a fresh connection is safe and recovers
452+
// the request. Timeouts/resets are NOT retried — the tool may have executed.
453+
if ((this.isSessionError(error) || isAuthError(error)) && attempt < maxRetries - 1) {
439454
logger.warn(
440-
`[${requestId}] Session error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`,
455+
`[${requestId}] Retryable connection error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`,
441456
error
442457
)
443458
await sleep(100)

0 commit comments

Comments
 (0)