diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index a70353a80f8..80931413320 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -350,14 +350,18 @@ export class McpClient { } } - async ping(): Promise<{ _meta?: Record }> { + async ping(timeoutMs?: number): Promise<{ _meta?: Record }> { if (!this.isConnected) { throw new McpConnectionError('Not connected to server', this.config.name) } try { logger.info(`[${this.config.name}] Sending ping to server`) - const response = await this.client.ping() + // Bound the ping so a half-open connection (no FIN/RST, so `onclose` never + // fires) is detected quickly instead of stalling on the SDK's 60s default. + const response = await this.client.ping( + timeoutMs !== undefined ? { timeout: timeoutMs } : undefined + ) logger.info(`[${this.config.name}] Ping successful`) return response } catch (error) { diff --git a/apps/sim/lib/mcp/connection-pool.test.ts b/apps/sim/lib/mcp/connection-pool.test.ts new file mode 100644 index 00000000000..4df677f13e0 --- /dev/null +++ b/apps/sim/lib/mcp/connection-pool.test.ts @@ -0,0 +1,342 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpClient } from '@/lib/mcp/client' +import { type AcquireParams, McpConnectionPool } from '@/lib/mcp/connection-pool' + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +interface FakeClient extends McpClient { + __fireClose(): void + __setConnected(connected: boolean): void +} + +function makeFakeClient(init: { connected?: boolean; pingRejects?: boolean } = {}): FakeClient { + let connected = init.connected ?? true + const closeCallbacks: Array<() => void> = [] + const client = { + getStatus: vi.fn(() => ({ connected })), + ping: vi.fn(async () => { + if (init.pingRejects) throw new Error('ping failed') + return {} + }), + disconnect: vi.fn(async () => { + connected = false + }), + onClose: vi.fn((cb: () => void) => { + closeCallbacks.push(cb) + }), + __fireClose: () => { + for (const cb of closeCallbacks) cb() + }, + __setConnected: (value: boolean) => { + connected = value + }, + } + // double-cast-allowed: test double only implements the McpClient surface the pool touches + return client as unknown as FakeClient +} + +/** Acquire + immediately release (models a completed borrow). */ +async function borrow( + pool: McpConnectionPool, + params: AcquireParams, + poison = false +): Promise { + const lease = await pool.acquire(params) + await lease.release(poison) +} + +function params(key: string, create: () => Promise): AcquireParams { + return { key, serverId: key.split(':')[0], create } +} + +/** Drain the microtask queue so an in-flight acquire fully settles before the next step. */ +async function flushMicrotasks(turns = 50): Promise { + for (let i = 0; i < turns; i++) await Promise.resolve() +} + +describe('McpConnectionPool', () => { + let pool: McpConnectionPool + + beforeEach(() => { + vi.useFakeTimers() + pool = new McpConnectionPool() + }) + + afterEach(() => { + pool.dispose() + vi.useRealTimers() + }) + + it('reuses a warm connection across acquires (connects once)', async () => { + const client = makeFakeClient() + const create = vi.fn(async () => client) + + await borrow(pool, params('s1:w1:u1', create)) + await borrow(pool, params('s1:w1:u1', create)) + + expect(create).toHaveBeenCalledTimes(1) + expect(client.disconnect).not.toHaveBeenCalled() + }) + + it('dedups concurrent creates into a single connect (single-flight)', async () => { + let resolveCreate: ((client: McpClient) => void) | undefined + const created = new Promise((resolve) => { + resolveCreate = resolve + }) + const create = vi.fn(() => created) + + const p1 = pool.acquire(params('s1:w1:u1', create)) + const p2 = pool.acquire(params('s1:w1:u1', create)) + + resolveCreate?.(makeFakeClient()) + const [l1, l2] = await Promise.all([p1, p2]) + + expect(create).toHaveBeenCalledTimes(1) + expect(l1.client).toBe(l2.client) + await l1.release() + await l2.release() + }) + + it('keys by (server, workspace, user) so different users do not share a connection', async () => { + const a = makeFakeClient() + const b = makeFakeClient() + const createA = vi.fn(async () => a) + const createB = vi.fn(async () => b) + + const la = await pool.acquire(params('s1:w1:userA', createA)) + const lb = await pool.acquire(params('s1:w1:userB', createB)) + + expect(la.client).toBe(a) + expect(lb.client).toBe(b) + expect(createA).toHaveBeenCalledTimes(1) + expect(createB).toHaveBeenCalledTimes(1) + }) + + it('rebuilds after the max connection age', async () => { + const oldClient = makeFakeClient() + const newClient = makeFakeClient() + const create = vi + .fn<() => Promise>() + .mockResolvedValueOnce(oldClient) + .mockResolvedValueOnce(newClient) + + await borrow(pool, params('s1:w1:u1', create)) + vi.setSystemTime(Date.now() + 11 * 60 * 1000) + const lease = await pool.acquire(params('s1:w1:u1', create)) + + expect(create).toHaveBeenCalledTimes(2) + expect(oldClient.disconnect).toHaveBeenCalledTimes(1) + expect(lease.client).toBe(newClient) + await lease.release() + }) + + it('caches liveness for 60s, then pings before reuse', async () => { + const client = makeFakeClient() + const create = vi.fn(async () => client) + + await borrow(pool, params('s1:w1:u1', create)) + vi.setSystemTime(Date.now() + 30 * 1000) + await borrow(pool, params('s1:w1:u1', create)) + expect(client.ping).not.toHaveBeenCalled() + + vi.setSystemTime(Date.now() + 61 * 1000) + await borrow(pool, params('s1:w1:u1', create)) + expect(client.ping).toHaveBeenCalledTimes(1) + expect(create).toHaveBeenCalledTimes(1) + }) + + it('evicts and rebuilds when the liveness ping fails', async () => { + const dead = makeFakeClient({ pingRejects: true }) + const fresh = makeFakeClient() + const create = vi + .fn<() => Promise>() + .mockResolvedValueOnce(dead) + .mockResolvedValueOnce(fresh) + + await borrow(pool, params('s1:w1:u1', create)) + vi.setSystemTime(Date.now() + 61 * 1000) + const lease = await pool.acquire(params('s1:w1:u1', create)) + + expect(dead.disconnect).toHaveBeenCalledTimes(1) + expect(lease.client).toBe(fresh) + await lease.release() + }) + + it('drops a connection from the pool when its transport closes', async () => { + const client = makeFakeClient() + const fresh = makeFakeClient() + const create = vi + .fn<() => Promise>() + .mockResolvedValueOnce(client) + .mockResolvedValueOnce(fresh) + + await borrow(pool, params('s1:w1:u1', create)) + client.__fireClose() + const lease = await pool.acquire(params('s1:w1:u1', create)) + + expect(create).toHaveBeenCalledTimes(2) + expect(lease.client).toBe(fresh) + await lease.release() + }) + + it('does not disconnect a connection while a borrower still holds it', async () => { + const client = makeFakeClient() + const create = vi.fn(async () => client) + + const lease = await pool.acquire(params('s1:w1:u1', create)) + // Retire while borrowed (e.g. server config cleared): must defer the close. + await pool.evictServer('s1', 'test') + expect(client.disconnect).not.toHaveBeenCalled() + + // Closes only once the last borrower releases. + await lease.release() + expect(client.disconnect).toHaveBeenCalledTimes(1) + }) + + it('does not let one borrower failure disconnect a connection another borrower is using', async () => { + const client = makeFakeClient() + const create = vi.fn(async () => client) + + const a = await pool.acquire(params('s1:w1:u1', create)) + const b = await pool.acquire(params('s1:w1:u1', create)) + expect(create).toHaveBeenCalledTimes(1) + + // A fails and poisons the connection while B is still in flight. + await a.release(true) + expect(client.disconnect).not.toHaveBeenCalled() + + // B finishes → now the retired connection closes. + await b.release() + expect(client.disconnect).toHaveBeenCalledTimes(1) + }) + + it('does not idle-evict a connection with an active borrower', async () => { + const client = makeFakeClient() + const lease = await pool.acquire(params('s1:w1:u1', async () => client)) + + await vi.advanceTimersByTimeAsync(6 * 60 * 1000) + expect(client.disconnect).not.toHaveBeenCalled() + + await lease.release() + }) + + it('idle-evicts a connection once no borrower holds it', async () => { + const client = makeFakeClient() + await borrow( + pool, + params('s1:w1:u1', async () => client) + ) + + await vi.advanceTimersByTimeAsync(6 * 60 * 1000) + expect(client.disconnect).toHaveBeenCalledTimes(1) + }) + + it('does not evict a replacement when a stale connection closes under the same key', async () => { + const oldClient = makeFakeClient() + const newClient = makeFakeClient() + const create = vi + .fn<() => Promise>() + .mockResolvedValueOnce(oldClient) + .mockResolvedValueOnce(newClient) + + // oldClient's transport closes → retired; the next acquire pools newClient. + await borrow(pool, params('s1:w1:u1', create)) + oldClient.__fireClose() + await borrow(pool, params('s1:w1:u1', create)) + + // A late duplicate close from the old client must NOT drop the replacement. + oldClient.__fireClose() + await borrow(pool, params('s1:w1:u1', create)) + + // Still 2 creates — the replacement survived the stale close. + expect(create).toHaveBeenCalledTimes(2) + }) + + it('uses an evicted-mid-create connection one-shot and does not pool it', async () => { + let resolveCreate: ((client: McpClient) => void) | undefined + const created = new Promise((resolve) => { + resolveCreate = resolve + }) + const staleClient = makeFakeClient() + const freshClient = makeFakeClient() + const create = vi + .fn<() => Promise>() + .mockImplementationOnce(() => created) + .mockImplementation(async () => freshClient) + + const acquireP = pool.acquire(params('s1:w1:u1', create)) + // Server config changes while the connect is still in flight. + await pool.evictServer('s1', 'config changed') + resolveCreate?.(staleClient) + const lease = await acquireP + + // The in-flight request still uses the connection it built (as connect-per-op + // would), but it isn't pooled and is disconnected on release. + expect(lease.client).toBe(staleClient) + expect(staleClient.disconnect).not.toHaveBeenCalled() + await lease.release() + expect(staleClient.disconnect).toHaveBeenCalledTimes(1) + + // The next acquire builds fresh — the stale connection was never pooled. + const next = await pool.acquire(params('s1:w1:u1', create)) + expect(next.client).toBe(freshClient) + expect(create).toHaveBeenCalledTimes(2) + await next.release() + }) + + it('reuses a concurrently-pooled replacement rather than orphaning it (recreate race)', async () => { + // stale's ping is deferred per-call so we can fully resolve one acquire before + // the other's ping settles — the exact interleaving that used to leak. + const releasePing: Array<(alive: boolean) => void> = [] + const stale = makeFakeClient() + ;(stale.ping as ReturnType).mockImplementation( + () => + new Promise((resolve, reject) => { + releasePing.push((alive) => (alive ? resolve({}) : reject(new Error('dead')))) + }) + ) + const fresh = makeFakeClient() + const create = vi + .fn<() => Promise>() + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce(fresh) + + await borrow(pool, params('s1:w1:u1', create)) + vi.setSystemTime(Date.now() + 61 * 1000) + + const pA = pool.acquire(params('s1:w1:u1', create)) + const pB = pool.acquire(params('s1:w1:u1', create)) + while (releasePing.length < 2) await Promise.resolve() + + // A's ping fails → A retires stale, rebuilds `fresh`, pools it, clears pending. + releasePing[0](false) + await flushMicrotasks() + // B's ping fails → B must reuse the pooled `fresh`, not create a third client. + releasePing[1](false) + + const [la, lb] = await Promise.all([pA, pB]) + expect(create).toHaveBeenCalledTimes(2) + expect(la.client).toBe(fresh) + expect(lb.client).toBe(fresh) + await la.release() + await lb.release() + }) + + it('bypasses the pool once disposed (connects without caching)', async () => { + const client = makeFakeClient() + const create = vi.fn(async () => client) + + pool.dispose() + const lease = await pool.acquire(params('s1:w1:u1', create)) + expect(lease.client).toBe(client) + await lease.release() + + await pool.acquire(params('s1:w1:u1', create)).then((l) => l.release()) + expect(create).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/mcp/connection-pool.ts b/apps/sim/lib/mcp/connection-pool.ts new file mode 100644 index 00000000000..e367fb7fd68 --- /dev/null +++ b/apps/sim/lib/mcp/connection-pool.ts @@ -0,0 +1,279 @@ +/** + * Warm MCP connection pool: one reused connection per (server, workspace, user) + * for the tool-exec and discovery hot paths, replacing connect-per-operation. + * + * The key includes the workspace + user because a server's headers/URL resolve + * from the caller's env (`resolveMcpConfigEnvVars`), so two users must never share + * a credential-bearing connection. Concurrent borrowers multiplex over one client + * (the SDK tracks requests by id); creation is single-flight; liveness is + * ping-cached; reconnect is demand-driven (callers' retries re-acquire). + * + * Connections are ref-counted. Eviction *retires* an entry — removes it from the + * pool so later acquires stop reusing it — but the socket is closed only once the + * last in-flight borrower releases, so a sibling failure, idle sweep, or config + * change never tears down a connection mid-request. Retirement is identity-checked, + * so a stale `onClose` can't disconnect a replacement stored under the same key. + * Pools are per-process. Callers must release every acquired lease and must not + * disconnect the client themselves. + * + * Config-change invalidation is the caller's job via `evictServer` (wired to the + * service's `evictServerConnections`), not a per-acquire timestamp check — `updatedAt` + * is also bumped by status telemetry, so it can't distinguish a config edit. + * Cross-process, a config edit is bounded by max age (self-heals when a stale + * connection errors). + */ + +import { createLogger } from '@sim/logger' +import { isTest } from '@/lib/core/config/env-flags' +import type { McpClient } from '@/lib/mcp/client' + +const logger = createLogger('McpConnectionPool') + +const MAX_POOL_SIZE = 100 +/** Max lifetime; on expiry the pinned SSRF `resolvedIP` re-resolves. */ +const MAX_CONNECTION_AGE_MS = 10 * 60 * 1000 +const LIVENESS_TTL_MS = 60 * 1000 +const LIVENESS_PING_TIMEOUT_MS = 5 * 1000 +const IDLE_TIMEOUT_MS = 5 * 60 * 1000 +const IDLE_CHECK_INTERVAL_MS = 60 * 1000 + +export interface AcquireParams { + /** Auth-scoped pool key — `${serverId}:${workspaceId}:${userId}`. */ + key: string + /** Server id, for bulk `evictServer` on config change/delete. */ + serverId: string + /** Builds and connects a fresh client; invoked once per miss (single-flight). */ + create: () => Promise +} + +/** A borrowed connection. `release(poison)` must be called exactly once; pass `poison: true` to retire on failure. */ +export interface ConnectionLease { + client: McpClient + release: (poison?: boolean) => Promise +} + +interface PoolEntry { + key: string + serverId: string + client: McpClient + createdAt: number + lastActivityAt: number + lastLivenessCheckAt: number + borrowers: number + retired: boolean + closing: boolean +} + +export class McpConnectionPool { + private entries = new Map() + private pending = new Map>() + /** Per-server counter bumped by `evictServer`; an in-flight create built against an older value is retired on completion. */ + private serverGenerations = new Map() + private idleCheckTimer: ReturnType | null = null + private disposed = false + + /** Borrow a warm connection for `key`, creating one on a miss. Caller must `release`. */ + async acquire(params: AcquireParams): Promise { + if (this.disposed) { + const client = await params.create() + return { client, release: () => client.disconnect().catch(() => {}) } + } + // A concurrent evict/release/idle could have started disconnecting the resolved + // entry during an `await` gap; `closing` (not `retired`, which a usable one-shot + // also sets) means the socket is going away, so resolve again. + let entry = await this.resolveEntry(params) + while (entry.closing) entry = await this.resolveEntry(params) + entry.borrowers++ + entry.lastActivityAt = Date.now() + return { client: entry.client, release: (poison) => this.release(entry, poison ?? false) } + } + + private async resolveEntry(params: AcquireParams): Promise { + while (true) { + const pending = this.pending.get(params.key) + if (pending) return pending + + const current = this.entries.get(params.key) + if (!current) return this.createEntry(params) + + const reusable = await this.tryReuse(current) + if (reusable) return reusable + // tryReuse awaited a ping and retired `current`; loop to re-check pending/entries — + // a concurrent acquire may have pooled a replacement we must reuse, not overwrite. + } + } + + /** Return `entry` if in-age, connected, and live; else retire it and return null. */ + private async tryReuse(entry: PoolEntry): Promise { + const now = Date.now() + if (now - entry.createdAt > MAX_CONNECTION_AGE_MS) { + this.retire(entry, 'max age reached') + return null + } + if (!entry.client.getStatus().connected) { + this.retire(entry, 'not connected') + return null + } + if (now - entry.lastLivenessCheckAt > LIVENESS_TTL_MS) { + const alive = await entry.client + .ping(LIVENESS_PING_TIMEOUT_MS) + .then(() => true) + .catch(() => false) + // A concurrent poison/evict/age eviction may have retired this during the + // ping await; don't hand out a connection that's already closing. + if (entry.retired) return null + if (!alive) { + this.retire(entry, 'liveness ping failed') + return null + } + entry.lastLivenessCheckAt = now + } + logger.debug(`Reusing pooled MCP connection ${entry.key}`) + return entry + } + + private createEntry(params: AcquireParams): Promise { + // Re-check: the `await` in `resolveEntry` yields, so two acquires can both + // reach here; the first registers the pending create, the rest join. + const inFlight = this.pending.get(params.key) + if (inFlight) return inFlight + + const creation = (async () => { + const generation = this.serverGenerations.get(params.serverId) ?? 0 + const client = await params.create() + const now = Date.now() + const entry: PoolEntry = { + key: params.key, + serverId: params.serverId, + client, + createdAt: now, + lastActivityAt: now, + lastLivenessCheckAt: now, + borrowers: 0, + retired: false, + closing: false, + } + // Disposed, or the server was evicted (config edit/delete) while connecting: + // don't pool a connection built against the now-stale config. The current + // borrower still uses it once (as connect-per-op would for an in-flight + // request); it's disconnected on release, and the next acquire builds fresh. + if (this.disposed || (this.serverGenerations.get(params.serverId) ?? 0) !== generation) { + entry.retired = true + return entry + } + this.evictLruIfFull() + this.entries.set(params.key, entry) + client.onClose(this.makeCloseHandler(entry)) + this.ensureIdleCheck() + logger.debug( + `Established and pooled MCP connection ${params.key} (${this.entries.size}/${MAX_POOL_SIZE})` + ) + return entry + })() + + this.pending.set(params.key, creation) + return creation.finally(() => { + if (this.pending.get(params.key) === creation) this.pending.delete(params.key) + }) + } + + private async release(entry: PoolEntry, poison: boolean): Promise { + entry.borrowers = Math.max(0, entry.borrowers - 1) + entry.lastActivityAt = Date.now() + if (poison) this.retire(entry, 'poisoned by failed operation') + await this.closeIfIdle(entry) + } + + /** Own scope so the handler captures only `entry` (never the create params / secrets). */ + private makeCloseHandler(entry: PoolEntry): () => void { + return () => { + if (this.entries.get(entry.key) === entry) this.retire(entry, 'transport closed') + } + } + + /** Remove `entry` from the pool so no new borrower takes it; close it once idle. */ + private retire(entry: PoolEntry, reason: string): void { + if (!entry.retired) { + entry.retired = true + if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key) + logger.debug(`Retiring pooled MCP connection ${entry.key}: ${reason}`, { + borrowers: entry.borrowers, + poolSize: this.entries.size, + }) + } + void this.closeIfIdle(entry) + } + + private async closeIfIdle(entry: PoolEntry): Promise { + if (!entry.retired || entry.borrowers > 0 || entry.closing) return + entry.closing = true + logger.debug(`Closing pooled MCP connection ${entry.key}`) + await entry.client.disconnect().catch((error) => { + logger.warn(`Error disconnecting pooled MCP connection ${entry.key}:`, error) + }) + } + + /** Retire every connection for a server (all users) — config changed or deleted. */ + async evictServer(serverId: string, reason: string): Promise { + // Bump first so an in-flight create for this server retires on completion + // instead of pooling a connection built against the now-stale config. + this.serverGenerations.set(serverId, (this.serverGenerations.get(serverId) ?? 0) + 1) + for (const entry of this.entries.values()) { + if (entry.serverId === serverId) this.retire(entry, reason) + } + } + + private evictLruIfFull(): void { + if (this.entries.size < MAX_POOL_SIZE) return + let lru: PoolEntry | undefined + for (const entry of this.entries.values()) { + if (!lru || entry.lastActivityAt < lru.lastActivityAt) lru = entry + } + // Retiring a still-borrowed LRU frees the map slot now; its socket closes on release. + if (lru) this.retire(lru, 'pool at capacity (LRU)') + } + + private ensureIdleCheck(): void { + if (this.idleCheckTimer) return + this.idleCheckTimer = setInterval(() => { + const now = Date.now() + for (const entry of this.entries.values()) { + if (entry.borrowers === 0 && now - entry.lastActivityAt > IDLE_TIMEOUT_MS) + this.retire(entry, 'idle timeout') + } + if (this.entries.size === 0 && this.idleCheckTimer) { + clearInterval(this.idleCheckTimer) + this.idleCheckTimer = null + } + }, IDLE_CHECK_INTERVAL_MS) + this.idleCheckTimer.unref?.() + } + + /** Tear down every connection and the idle sweep. */ + dispose(): void { + this.disposed = true + if (this.idleCheckTimer) { + clearInterval(this.idleCheckTimer) + this.idleCheckTimer = null + } + const entries = [...this.entries.values()] + // Mark retired before disconnecting so an in-flight liveness ping observes it + // and can't hand out a torn-down client (the retired-before-disconnect invariant). + for (const entry of entries) entry.retired = true + this.entries.clear() + this.pending.clear() + this.serverGenerations.clear() + void Promise.allSettled(entries.map((entry) => entry.client.disconnect())) + } +} + +type McpPoolGlobal = typeof globalThis & { + _mcpConnectionPool?: McpConnectionPool | null +} + +const _g = globalThis as McpPoolGlobal +if (!('_mcpConnectionPool' in _g)) { + _g._mcpConnectionPool = isTest ? null : new McpConnectionPool() +} + +export const mcpConnectionPool: McpConnectionPool | null = _g._mcpConnectionPool ?? null diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index b95f6103291..a1861c1ad63 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -14,11 +14,13 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens } = vi.hoisted(() => ({ - mockClearCache: vi.fn(), - mockOauthCredsChanged: vi.fn(), - mockRevokeOauthTokens: vi.fn(), -})) +const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens, mockEvictServerConnections } = + vi.hoisted(() => ({ + mockClearCache: vi.fn(), + mockOauthCredsChanged: vi.fn(), + mockRevokeOauthTokens: vi.fn(), + mockEvictServerConnections: vi.fn(), + })) vi.mock('@sim/audit', () => auditMock) vi.mock('@sim/db', () => ({ @@ -45,12 +47,18 @@ vi.mock('@/lib/mcp/oauth', () => ({ revokeMcpOauthTokens: mockRevokeOauthTokens, })) vi.mock('@/lib/mcp/service', () => ({ - mcpService: { clearCache: mockClearCache }, + mcpService: { + clearCache: mockClearCache, + evictServerConnections: mockEvictServerConnections, + }, })) vi.mock('@/lib/mcp/utils', () => ({ generateMcpServerId: vi.fn() })) vi.mock('@/lib/posthog/server', () => posthogServerMock) -import { performUpdateMcpServer } from '@/lib/mcp/orchestration/server-lifecycle' +import { + performDeleteMcpServer, + performUpdateMcpServer, +} from '@/lib/mcp/orchestration/server-lifecycle' describe('MCP server lifecycle orchestration', () => { beforeEach(() => { @@ -140,4 +148,19 @@ describe('MCP server lifecycle orchestration', () => { // ...and revoke the now-orphaned OAuth tokens rather than leaving them stored and valid. expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1') }) + + it('evicts the deleted server from the connection pool (row is already gone from clearCache)', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'server-1', workspaceId: 'workspace-1', name: 'Example', transport: 'streamable-http' }, + ]) + + const result = await performDeleteMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + }) + + expect(result.success).toBe(true) + expect(mockEvictServerConnections).toHaveBeenCalledWith('server-1', expect.any(String)) + }) }) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index e0a29488fd3..75c3071ee9e 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -207,6 +207,7 @@ export async function performCreateMcpServer( }) await mcpService.clearCache(params.workspaceId) + await mcpService.evictServerConnections(serverId, 'config changed') return { success: true, serverId, updated: true, authType: resolvedAuthType } } @@ -396,7 +397,10 @@ export async function performUpdateMcpServer( params.timeout !== undefined || params.retries !== undefined - if (shouldClearCache) await mcpService.clearCache(params.workspaceId) + if (shouldClearCache) { + await mcpService.clearCache(params.workspaceId) + await mcpService.evictServerConnections(params.serverId, 'config changed') + } recordAudit({ workspaceId: params.workspaceId, @@ -439,6 +443,7 @@ export async function performDeleteMcpServer( if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } await mcpService.clearCache(params.workspaceId) + await mcpService.evictServerConnections(params.serverId, 'server deleted') const source = params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts new file mode 100644 index 00000000000..bb614710d05 --- /dev/null +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -0,0 +1,202 @@ +/** + * @vitest-environment node + * + * Integration coverage for the connection-reuse wiring in `McpService` + * (`withServerClient`): the pooled path leases without disconnecting and skips + * env resolution on a hit, per-request headers bypass the pool, and a + * dead-connection error poisons the lease. Pool internals are unit-tested in + * `connection-pool.test.ts`. + */ +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + MockMcpClient, + mockCallTool, + mockConnect, + mockDisconnect, + mockAcquire, + mockRelease, + mockResolveEnvVars, + mockCacheAdapter, + poolClient, +} = vi.hoisted(() => { + const mockCallTool = vi.fn() + const mockConnect = vi.fn() + const mockDisconnect = vi.fn() + const mockRelease = vi.fn(async () => {}) + const poolClient = { callTool: mockCallTool, disconnect: vi.fn() } + return { + mockCallTool, + mockConnect, + mockDisconnect, + mockRelease, + poolClient, + mockAcquire: vi.fn(async () => ({ client: poolClient, release: mockRelease })), + mockResolveEnvVars: vi.fn(), + mockCacheAdapter: { + get: vi.fn(async () => null), + set: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + dispose: () => {}, + }, + MockMcpClient: vi.fn().mockImplementation( + class { + constructor() { + Object.assign(this, { + connect: mockConnect, + disconnect: mockDisconnect, + callTool: mockCallTool, + listTools: vi.fn(async () => []), + hasListChangedCapability: vi.fn(() => false), + onClose: vi.fn(), + }) + } + } + ), + } +}) + +vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/core/config/env-flags', () => ({ isTest: true })) +vi.mock('@/lib/mcp/connection-pool', () => ({ + mcpConnectionPool: { acquire: mockAcquire, evictServer: vi.fn() }, +})) +vi.mock('@/lib/mcp/connection-manager', () => ({ mcpConnectionManager: null })) +vi.mock('@/lib/mcp/client', () => ({ McpClient: MockMcpClient })) + +const WORKSPACE_ID = 'ws-1' +const USER_ID = 'user-1' + +vi.mock('@sim/db', () => { + const where = () => { + const rows = Promise.resolve([ + { + id: 'server-1', + name: 'Server 1', + description: null, + transport: 'streamable-http', + url: 'https://server-1.example.com/mcp', + authType: 'headers', + workspaceId: WORKSPACE_ID, + headers: {}, + timeout: 30000, + retries: 3, + enabled: true, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + return Object.assign(rows, { limit: (n: number) => rows.then((r) => r.slice(0, n)) }) + } + return { + db: { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where }) }) }, + } +}) +vi.mock('@/lib/mcp/domain-check', () => ({ + isMcpDomainAllowed: () => true, + validateMcpDomain: () => {}, + validateMcpServerSsrf: async () => '203.0.113.10', +})) +vi.mock('@/lib/mcp/oauth', () => ({ + getOrCreateOauthRow: vi.fn(), + loadPreregisteredClient: vi.fn(), + SimMcpOauthProvider: vi.fn(), + withMcpOauthRefreshLock: vi.fn(), +})) +vi.mock('@/lib/mcp/resolve-config', () => ({ + resolveMcpConfigEnvVars: (...args: unknown[]) => mockResolveEnvVars(...args), +})) +vi.mock('@/lib/mcp/storage', () => ({ + createMcpCacheAdapter: () => mockCacheAdapter, + getMcpCacheType: () => 'memory', +})) + +import { mcpService } from '@/lib/mcp/service' + +describe('McpService connection reuse wiring', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveEnvVars.mockImplementation(async (config: unknown) => ({ config })) + mockAcquire.mockResolvedValue({ client: poolClient, release: mockRelease }) + mockCallTool.mockResolvedValue({ content: [] }) + }) + + it('leases from the pool (keyed by server+workspace+user) and never disconnects on a hit', async () => { + await mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID) + + expect(mockAcquire).toHaveBeenCalledTimes(1) + expect(mockAcquire).toHaveBeenCalledWith( + expect.objectContaining({ key: `server-1:${WORKSPACE_ID}:${USER_ID}`, serverId: 'server-1' }) + ) + expect(mockCallTool).toHaveBeenCalledTimes(1) + expect(mockRelease).toHaveBeenCalledWith(false) + expect(poolClient.disconnect).not.toHaveBeenCalled() + // A pool hit must not re-resolve env vars (acquire never invoked `create`). + expect(mockResolveEnvVars).not.toHaveBeenCalled() + }) + + it('bypasses the pool for calls carrying per-request headers', async () => { + await mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID, { + Authorization: 'Bearer per-call', + }) + + expect(mockAcquire).not.toHaveBeenCalled() + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(mockDisconnect).toHaveBeenCalledTimes(1) + expect(mockCallTool).toHaveBeenCalledTimes(1) + }) + + it('poisons the lease when the tool call hits a dead-connection error', async () => { + mockCallTool.mockRejectedValue(new StreamableHTTPError(404, 'session gone')) + + await expect( + mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID) + ).rejects.toThrow() + + expect(mockRelease).toHaveBeenCalledWith(true) + }) + + it('keeps the pooled connection warm on a benign (non-connection) tool error', async () => { + mockCallTool.mockRejectedValue(new Error('tool blew up')) + + await expect( + mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID) + ).rejects.toThrow() + + expect(mockRelease).toHaveBeenCalledWith(false) + }) + + it('poisons the lease on an auth failure so a rotated credential is re-resolved', async () => { + mockCallTool.mockRejectedValue(new UnauthorizedError('token rejected')) + + await expect( + mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID) + ).rejects.toThrow() + + expect(mockRelease).toHaveBeenCalledWith(true) + }) + + it('retries and recovers when a rotated credential causes a one-off auth failure', async () => { + mockCallTool + .mockRejectedValueOnce(new UnauthorizedError('stale key')) + .mockResolvedValueOnce({ content: [] }) + + const result = await mcpService.executeTool( + USER_ID, + 'server-1', + { name: 'do', arguments: {} }, + WORKSPACE_ID + ) + + expect(result).toEqual({ content: [] }) + expect(mockCallTool).toHaveBeenCalledTimes(2) + // First attempt poisoned the stale lease; the retry re-acquired a fresh one. + expect(mockRelease).toHaveBeenCalledWith(true) + expect(mockAcquire).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index 7349b39e71f..1fc6e1e4b8f 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -341,7 +341,7 @@ describe('McpService.discoverTools per-server caching', () => { statusConfig: { consecutiveFailures: 0, lastSuccessfulDiscovery: null }, }), ]) - mockListTools.mockRejectedValueOnce( + mockListTools.mockRejectedValue( new UnauthorizedError(`Rejected Authorization: ${reflectedCredential}`) ) @@ -491,7 +491,7 @@ describe('McpService.discoverTools per-server caching', () => { statusConfig: { consecutiveFailures: 0, lastSuccessfulDiscovery: null }, }), ]) - mockListTools.mockRejectedValueOnce( + mockListTools.mockRejectedValue( new UnauthorizedError(`Rejected Authorization: ${reflectedCredential}`) ) @@ -517,6 +517,19 @@ describe('McpService.discoverTools per-server caching', () => { expect(mockListTools).not.toHaveBeenCalled() }) + it('recovers a rotated headers-auth credential via a single discovery retry', async () => { + mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')]) + // Stale key 401s once, then the retry re-resolves and succeeds. + mockListTools + .mockRejectedValueOnce(new UnauthorizedError('stale key')) + .mockResolvedValueOnce([tool('a1', 'mcp-a')]) + + const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID) + + expect(tools).toHaveLength(1) + expect(mockListTools).toHaveBeenCalledTimes(2) + }) + it('keeps per-server UnauthorizedError soft-pending for OAuth auth', async () => { mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A', { authType: 'oauth' })]) mockResolveEnvVars.mockRejectedValue(new UnauthorizedError('OAuth token rejected')) diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 960007bf868..dfa4d1b7e5a 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -12,6 +12,7 @@ import { isTest } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' +import { mcpConnectionPool } from '@/lib/mcp/connection-pool' import { isMcpDomainAllowed, validateMcpDomain, @@ -56,12 +57,7 @@ const FAILURE_CACHE_SENTINEL: McpTool[] = [] type DiscoveryOutcome = | { kind: 'cached'; tools: McpTool[] } - | { - kind: 'fetched' - tools: McpTool[] - resolvedConfig: McpServerConfig - resolvedIP: string | null - } + | { kind: 'fetched'; tools: McpTool[] } | { kind: 'oauth-pending' } | { kind: 'unhealthy' } // originalError preserves the type so markServerUnhealthy's instanceof @@ -100,6 +96,47 @@ function isTimeoutError(error: unknown): boolean { return getErrorMessage(error, '').toLowerCase().includes('timed out') } +/** + * A pooled connection is dead and must be retired so the caller's retry rebuilds + * fresh: a stale session (400/404), an auth failure (401 — a rotated/revoked + * credential; the rebuild re-resolves it), a closed transport, a timeout (no + * response — possibly wedged), or a reset socket. Benign tool/consent errors and + * healthy upstream responses (429/5xx) keep the connection warm. + */ +function isDeadConnectionError(error: unknown): boolean { + if (error instanceof UnauthorizedError) { + return true + } + if (error instanceof StreamableHTTPError) { + return error.code === 404 || error.code === 400 || error.code === 401 + } + if (error instanceof McpError && error.code === ErrorCode.ConnectionClosed) { + return true + } + if (isTimeoutError(error)) { + return true + } + const message = getErrorMessage(error, '').toLowerCase() + return ( + message.includes('econnreset') || + message.includes('econnrefused') || + message.includes('epipe') || + message.includes('socket hang up') + ) +} + +/** + * An auth failure (401) from a rotated/revoked credential. Safe for `executeTool` + * to retry — auth is rejected *before* the tool runs, so re-acquiring on a fresh + * connection (which re-resolves the credential) can't double-execute a tool. + */ +function isAuthError(error: unknown): boolean { + return ( + error instanceof UnauthorizedError || + (error instanceof StreamableHTTPError && error.code === 401) + ) +} + /** Transient failures a read-only `tools/list` may safely retry (idempotent, unlike `tools/call`); excludes OAuth and terminal 4xx. */ function isRetryableDiscoveryError(error: unknown): boolean { if (isTimeoutError(error)) return true @@ -302,6 +339,106 @@ class McpService { }) } + /** Auth-scoped pool key: a server's resolved credentials depend on the (user, workspace) env. */ + private poolKey( + serverId: string, + workspaceId: string | undefined, + userId: string | undefined + ): string { + return `${serverId}:${workspaceId ?? ''}:${userId ?? ''}` + } + + /** + * A `create` thunk for {@link withServerClient} that resolves env vars + SSRF-pins + * and connects. Deferred so a pool hit skips this work; `extraHeaders` (per-request) + * are merged in and force the caller to bypass the pool. + */ + private buildClient( + config: McpServerConfig, + userId: string, + workspaceId: string, + extraHeaders?: Record + ): () => Promise { + return async () => { + const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars( + config, + userId, + workspaceId + ) + if (extraHeaders) { + resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders } + } + return this.createClient(resolvedConfig, resolvedIP, userId) + } + } + + /** + * Pooled `tools/list` for one server, with a single retry on a non-OAuth auth + * failure: a rotated header key throws 401, which retires the pooled connection, + * so the retry re-acquires a fresh one that re-resolves the credential. (OAuth + * 401s are left to the caller's oauth-pending handling.) `listTools` is + * idempotent, so the retry is always safe. + */ + private async fetchServerTools( + config: McpServerConfig, + userId: string, + workspaceId: string + ): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await this.withServerClient( + { + key: this.poolKey(config.id, workspaceId, userId), + serverId: config.id, + allowPool: true, + }, + this.buildClient(config, userId, workspaceId), + (client) => client.listTools() + ) + } catch (error) { + if (attempt === 0 && isAuthError(error) && config.authType !== 'oauth') continue + throw error + } + } + } + + /** + * Run `fn` against a connected client. When `allowPool`, borrow from the warm + * pool (`create` runs only on a miss, so a hit skips env resolution + DNS); a + * dead-connection error retires it, benign tool/consent errors keep it warm. + * Otherwise connect one-shot and always disconnect. + */ + private async withServerClient( + opts: { key: string; serverId: string; allowPool: boolean }, + create: () => Promise, + fn: (client: McpClient) => Promise + ): Promise { + const pool = mcpConnectionPool + if (opts.allowPool && pool) { + const lease = await pool.acquire({ + key: opts.key, + serverId: opts.serverId, + create, + }) + let poison = false + try { + return await fn(lease.client) + } catch (error) { + poison = isDeadConnectionError(error) + throw error + } finally { + await lease.release(poison) + } + } + + const client = await create() + try { + return await fn(client) + } finally { + await client.disconnect() + } + } + /** * Execute a tool on a specific server with retry logic for session errors. * Retries once on session-related errors (400, 404, session ID issues). @@ -327,27 +464,25 @@ class McpService { throw new Error(`Server ${serverId} not found or not accessible`) } - const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars( - config, - userId, - workspaceId + const hasExtraHeaders = Boolean(extraHeaders && Object.keys(extraHeaders).length > 0) + const result = await this.withServerClient( + { + key: this.poolKey(serverId, workspaceId, userId), + serverId, + allowPool: !hasExtraHeaders, + }, + this.buildClient(config, userId, workspaceId, hasExtraHeaders ? extraHeaders : undefined), + (client) => client.callTool(toolCall) ) - if (extraHeaders && Object.keys(extraHeaders).length > 0) { - resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders } - } - const client = await this.createClient(resolvedConfig, resolvedIP, userId) - - try { - const result = await client.callTool(toolCall) - logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`) - return result - } finally { - await client.disconnect() - } + logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`) + return result } catch (error) { - if (this.isSessionError(error) && attempt < maxRetries - 1) { + // A stale session (400/404) or a rotated/revoked credential (401) is rejected + // before the tool runs, so retrying on a fresh connection is safe and recovers + // the request. Timeouts/resets are NOT retried — the tool may have executed. + if ((this.isSessionError(error) || isAuthError(error)) && attempt < maxRetries - 1) { logger.warn( - `[${requestId}] Session error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`, + `[${requestId}] Retryable connection error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`, error ) await sleep(100) @@ -571,21 +706,11 @@ class McpService { } try { - const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars( - config, - userId, - workspaceId + const tools = await this.fetchServerTools(config, userId, workspaceId) + logger.debug( + `[${requestId}] Discovered ${tools.length} tools from server ${config.name}` ) - const client = await this.createClient(resolvedConfig, resolvedIP, userId) - try { - const tools = await client.listTools() - logger.debug( - `[${requestId}] Discovered ${tools.length} tools from server ${config.name}` - ) - return { kind: 'fetched', tools, resolvedConfig, resolvedIP } - } finally { - await client.disconnect() - } + return { kind: 'fetched', tools } } catch (error) { if (isOauthAuthorizationError(error, config.authType)) { return { kind: 'oauth-pending' } @@ -602,10 +727,7 @@ class McpService { const allTools: McpTool[] = [] const cacheWrites: Promise[] = [] const deferredSideEffects: Promise[] = [] - const liveConnections: Array<{ - resolvedConfig: McpServerConfig - resolvedIP: string | null - }> = [] + const liveConnections: McpServerConfig[] = [] let cachedCount = 0 let fetchedCount = 0 let failedCount = 0 @@ -634,10 +756,7 @@ class McpService { ) ) deferredSideEffects.push(this.clearServerFailure(workspaceId, server.id)) - liveConnections.push({ - resolvedConfig: outcome.resolvedConfig, - resolvedIP: outcome.resolvedIP, - }) + liveConnections.push(server) return } if (outcome.kind === 'oauth-pending') { @@ -690,15 +809,24 @@ class McpService { for (const p of deferredSideEffects) p.catch(() => {}) if (mcpConnectionManager) { - for (const conn of liveConnections) { - mcpConnectionManager - .connect(conn.resolvedConfig, userId, workspaceId, conn.resolvedIP) - .catch((err) => { - logger.warn( - `[${requestId}] Persistent connection failed for ${conn.resolvedConfig.name}:`, - err + const manager = mcpConnectionManager + for (const config of liveConnections) { + // Kick the notification manager for every fetched server; `connect` is + // idempotent (skips a live/connecting one) and reconnects a lost one with + // this current config — do not pre-gate on `hasConnection`, whose state + // survives a transport loss and would block that fresh reconnect. + void (async () => { + try { + const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars( + config, + userId, + workspaceId ) - }) + await manager.connect(resolvedConfig, userId, workspaceId, resolvedIP) + } catch (err) { + logger.warn(`[${requestId}] Persistent connection failed for ${config.name}:`, err) + } + })() } } @@ -783,32 +911,21 @@ class McpService { } authType = config.authType - const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars( - config, - userId, - workspaceId - ) - const client = await this.createClient(resolvedConfig, resolvedIP, userId) - - try { - const tools = await client.listTools() - logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`) - await Promise.allSettled([ - this.cacheAdapter - .set(serverCacheKey(workspaceId, serverId), tools, this.cacheTimeout) - .catch((err) => - logger.warn(`[${requestId}] Cache write failed for ${config.name}:`, err) - ), - this.clearServerFailure(workspaceId, serverId), - this.updateServerStatus(serverId, workspaceId, { - outcome: 'connected', - toolCount: tools.length, - }), - ]) - return tools - } finally { - await client.disconnect() - } + const tools = await this.fetchServerTools(config, userId, workspaceId) + logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`) + await Promise.allSettled([ + this.cacheAdapter + .set(serverCacheKey(workspaceId, serverId), tools, this.cacheTimeout) + .catch((err) => + logger.warn(`[${requestId}] Cache write failed for ${config.name}:`, err) + ), + this.clearServerFailure(workspaceId, serverId), + this.updateServerStatus(serverId, workspaceId, { + outcome: 'connected', + toolCount: tools.length, + }), + ]) + return tools } catch (error) { if (isRetryableDiscoveryError(error) && attempt < maxRetries - 1) { logger.warn( @@ -854,14 +971,7 @@ class McpService { for (const config of servers) { try { - const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars( - config, - userId, - workspaceId - ) - const client = await this.createClient(resolvedConfig, resolvedIP, userId) - const tools = await client.listTools() - await client.disconnect() + const tools = await this.fetchServerTools(config, userId, workspaceId) summaries.push({ id: config.id, @@ -907,6 +1017,11 @@ class McpService { } } + /** + * Invalidate the MCP tool cache. This does NOT evict pooled connections — + * pool eviction is tied to config changes (see `evictServerConnections`), so a + * refresh or a single-server edit doesn't tear down unrelated warm connections. + */ async clearCache(workspaceId?: string): Promise { try { if (workspaceId) { @@ -932,6 +1047,11 @@ class McpService { logger.warn('Failed to clear cache:', error) } } + + /** Evict a single server's warm pooled connections (all users) — call on config change/delete. */ + async evictServerConnections(serverId: string, reason: string): Promise { + await mcpConnectionPool?.evictServer(serverId, reason) + } } export const mcpService = new McpService()