Skip to content

Commit d9e793c

Browse files
authored
improvement(mcp): bound pool create-invalidation to in-flight creates; document DCR match (#5777)
1 parent c6e8aac commit d9e793c

2 files changed

Lines changed: 40 additions & 15 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ const MAX_SURFACED_ERROR_LENGTH = 250
2828
const DCR_UNSUPPORTED_MESSAGE =
2929
"This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead."
3030

31+
/**
32+
* The MCP SDK throws a plain `Error` (no typed class or code) when an auth server lacks a
33+
* `registration_endpoint`, so this string-match is the only available signal. Pinned to
34+
* `@modelcontextprotocol/sdk` v1.29.0 `registerClient` (client/auth.js): "Incompatible auth
35+
* server: does not support dynamic client registration". If a version bump rephrases this, the
36+
* 422 branch silently stops firing (users get a generic 500) — update the substring here.
37+
*/
3138
function isDynamicClientRegistrationUnsupported(error: unknown): boolean {
3239
return getErrorMessage(error, '')
3340
.toLowerCase()

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

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,21 @@ interface PoolEntry {
6464
closing: boolean
6565
}
6666

67+
/**
68+
* An in-flight `createEntry`. `evictServer` sets `invalidated` on the records for its
69+
* server so a connect racing a config edit/delete is one-shot instead of pooled stale.
70+
* Lives only until the create settles (cleared in `finally`), so it's self-bounded.
71+
*/
72+
interface PendingCreate {
73+
serverId: string
74+
/** `flag.invalidated` is set by `evictServer` racing this create → it one-shots instead of pooling. */
75+
flag: { invalidated: boolean }
76+
promise: Promise<PoolEntry>
77+
}
78+
6779
export class McpConnectionPool {
6880
private entries = new Map<string, PoolEntry>()
69-
private pending = new Map<string, Promise<PoolEntry>>()
70-
/** Per-server counter bumped by `evictServer`; an in-flight create built against an older value is retired on completion. */
71-
private serverGenerations = new Map<string, number>()
81+
private pending = new Map<string, PendingCreate>()
7282
private idleCheckTimer: ReturnType<typeof setInterval> | null = null
7383
private disposed = false
7484

@@ -91,7 +101,7 @@ export class McpConnectionPool {
91101
private async resolveEntry(params: AcquireParams): Promise<PoolEntry> {
92102
while (true) {
93103
const pending = this.pending.get(params.key)
94-
if (pending) return pending
104+
if (pending) return pending.promise
95105

96106
const current = this.entries.get(params.key)
97107
if (!current) return this.createEntry(params)
@@ -136,10 +146,12 @@ export class McpConnectionPool {
136146
// Re-check: the `await` in `resolveEntry` yields, so two acquires can both
137147
// reach here; the first registers the pending create, the rest join.
138148
const inFlight = this.pending.get(params.key)
139-
if (inFlight) return inFlight
149+
if (inFlight) return inFlight.promise
140150

151+
// Declared before the create closure so `evictServer` can invalidate a create
152+
// that's racing a config edit/delete (the closure reads it after its `await`).
153+
const flag = { invalidated: false }
141154
const creation = (async () => {
142-
const generation = this.serverGenerations.get(params.serverId) ?? 0
143155
const client = await params.create()
144156
const now = Date.now()
145157
const entry: PoolEntry = {
@@ -157,7 +169,7 @@ export class McpConnectionPool {
157169
// don't pool a connection built against the now-stale config. The current
158170
// borrower still uses it once (as connect-per-op would for an in-flight
159171
// request); it's disconnected on release, and the next acquire builds fresh.
160-
if (this.disposed || (this.serverGenerations.get(params.serverId) ?? 0) !== generation) {
172+
if (this.disposed || flag.invalidated) {
161173
entry.retired = true
162174
return entry
163175
}
@@ -171,10 +183,15 @@ export class McpConnectionPool {
171183
return entry
172184
})()
173185

174-
this.pending.set(params.key, creation)
175-
return creation.finally(() => {
176-
if (this.pending.get(params.key) === creation) this.pending.delete(params.key)
177-
})
186+
const record: PendingCreate = {
187+
serverId: params.serverId,
188+
flag,
189+
promise: creation.finally(() => {
190+
if (this.pending.get(params.key) === record) this.pending.delete(params.key)
191+
}),
192+
}
193+
this.pending.set(params.key, record)
194+
return record.promise
178195
}
179196

180197
private async release(entry: PoolEntry, poison: boolean): Promise<void> {
@@ -215,9 +232,11 @@ export class McpConnectionPool {
215232

216233
/** Retire every connection for a server (all users) — config changed or deleted. */
217234
async evictServer(serverId: string, reason: string): Promise<void> {
218-
// Bump first so an in-flight create for this server retires on completion
219-
// instead of pooling a connection built against the now-stale config.
220-
this.serverGenerations.set(serverId, (this.serverGenerations.get(serverId) ?? 0) + 1)
235+
// Flag in-flight creates first so one racing this eviction is one-shot on
236+
// completion instead of pooling a connection built against the now-stale config.
237+
for (const record of this.pending.values()) {
238+
if (record.serverId === serverId) record.flag.invalidated = true
239+
}
221240
for (const entry of this.entries.values()) {
222241
if (entry.serverId === serverId) this.retire(entry, reason)
223242
}
@@ -262,7 +281,6 @@ export class McpConnectionPool {
262281
for (const entry of entries) entry.retired = true
263282
this.entries.clear()
264283
this.pending.clear()
265-
this.serverGenerations.clear()
266284
void Promise.allSettled(entries.map((entry) => entry.client.disconnect()))
267285
}
268286
}

0 commit comments

Comments
 (0)