Skip to content

Commit ba80c97

Browse files
committed
fix(mcp): close pinned h2 Agent on disconnect and revoke OAuth tokens on auth-type change
1 parent f085ef3 commit ba80c97

5 files changed

Lines changed: 52 additions & 9 deletions

File tree

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,19 @@ export function createPinnedFetch(
444444
resolvedIP: string,
445445
options?: { allowH2?: boolean }
446446
): typeof fetch {
447+
return createPinnedFetchWithDispatcher(resolvedIP, options).fetch
448+
}
449+
450+
/**
451+
* Same as {@link createPinnedFetch} but also returns the underlying `Agent` so a
452+
* caller with a defined connection lifetime (e.g. a long-lived MCP transport) can
453+
* tear the Agent down on close instead of waiting for its idle timeout. Closing
454+
* the Agent is what releases any pooled keep-alive / HTTP/2 sockets it holds.
455+
*/
456+
export function createPinnedFetchWithDispatcher(
457+
resolvedIP: string,
458+
options?: { allowH2?: boolean }
459+
): { fetch: typeof fetch; dispatcher: Agent } {
447460
const dispatcher = new Agent({
448461
allowH2: options?.allowH2 ?? false,
449462
connect: { lookup: createPinnedLookup(resolvedIP) },
@@ -459,7 +472,7 @@ export function createPinnedFetch(
459472
return response as unknown as Response
460473
}
461474

462-
return pinned
475+
return { fetch: pinned, dispatcher }
463476
}
464477

465478
/**

apps/sim/lib/mcp/client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export class McpClient {
7373
private onToolsChanged?: McpToolsChangedCallback
7474
private authProvider?: McpClientOptions['authProvider']
7575
private isConnected = false
76+
private closePinnedTransport?: () => Promise<void>
7677

7778
constructor(options: McpClientOptions) {
7879
this.config = options.config
@@ -95,10 +96,12 @@ export class McpClient {
9596
throw new McpError('OAuth MCP server requires an authProvider')
9697
}
9798
const useOauth = this.config.authType === 'oauth'
99+
const pinned = resolvedIP ? createPinnedMcpFetch(resolvedIP) : undefined
100+
this.closePinnedTransport = pinned?.close
98101
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
99102
authProvider: useOauth ? this.authProvider : undefined,
100103
requestInit: { headers: this.config.headers },
101-
...(resolvedIP ? { fetch: createPinnedMcpFetch(resolvedIP) } : {}),
104+
...(pinned ? { fetch: pinned.fetch } : {}),
102105
})
103106

104107
this.client = new Client(
@@ -214,6 +217,12 @@ export class McpClient {
214217
logger.warn(`Error during disconnect from ${this.config.name}:`, error)
215218
}
216219

220+
try {
221+
await this.closePinnedTransport?.()
222+
} catch (error) {
223+
logger.warn(`Error closing pinned transport for ${this.config.name}:`, error)
224+
}
225+
217226
this.isConnected = false
218227
this.connectionStatus.connected = false
219228
logger.info(`Disconnected from MCP server: ${this.config.name}`)

apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import {
1414
} from '@sim/testing'
1515
import { beforeEach, describe, expect, it, vi } from 'vitest'
1616

17-
const { mockClearCache, mockOauthCredsChanged } = vi.hoisted(() => ({
17+
const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens } = vi.hoisted(() => ({
1818
mockClearCache: vi.fn(),
1919
mockOauthCredsChanged: vi.fn(),
20+
mockRevokeOauthTokens: vi.fn(),
2021
}))
2122

2223
vi.mock('@sim/audit', () => auditMock)
@@ -41,7 +42,7 @@ vi.mock('@/lib/mcp/domain-check', () => ({
4142
vi.mock('@/lib/mcp/oauth', () => ({
4243
detectMcpAuthType: vi.fn(),
4344
oauthCredsChanged: mockOauthCredsChanged,
44-
revokeMcpOauthTokens: vi.fn(),
45+
revokeMcpOauthTokens: mockRevokeOauthTokens,
4546
}))
4647
vi.mock('@/lib/mcp/service', () => ({
4748
mcpService: { clearCache: mockClearCache },
@@ -136,5 +137,7 @@ describe('MCP server lifecycle orchestration', () => {
136137
lastError: null,
137138
})
138139
)
140+
// ...and revoke the now-orphaned OAuth tokens rather than leaving them stored and valid.
141+
expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1')
139142
})
140143
})

apps/sim/lib/mcp/orchestration/server-lifecycle.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,11 @@ export async function performUpdateMcpServer(
349349
currentClientId: currentServer.oauthClientId,
350350
currentEncryptedClientSecret: currentServer.oauthClientSecret,
351351
})
352-
const shouldClearOauth = urlChanged || credsChanged
353352
const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType
354353
const authTypeChanged = resolvedAuthType !== currentServer.authType
354+
// Turning OAuth off must revoke and delete its now-orphaned tokens, not just reset the connection.
355+
const oauthDisabled = currentServer.authType === 'oauth' && resolvedAuthType !== 'oauth'
356+
const shouldClearOauth = urlChanged || credsChanged || oauthDisabled
355357
// An auth-type flip (either direction) or OAuth creds/URL change invalidates the connection: reset and clear stale state.
356358
if (authTypeChanged || (shouldClearOauth && resolvedAuthType === 'oauth')) {
357359
updateData.connectionStatus = 'disconnected'

apps/sim/lib/mcp/pinned-fetch.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
2-
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
2+
import {
3+
createPinnedFetch,
4+
createPinnedFetchWithDispatcher,
5+
} from '@/lib/core/security/input-validation.server'
36
import { validateMcpServerSsrf } from '@/lib/mcp/domain-check'
47

8+
/** Pinned fetch for the live MCP transport, plus a handle to release its sockets. */
9+
export interface PinnedMcpFetch {
10+
/** Pinned fetch to hand to the MCP transport's `fetch` option. */
11+
fetch: typeof fetch
12+
/** Tears down the underlying HTTP/2 Agent; call when the MCP client disconnects. */
13+
close: () => Promise<void>
14+
}
15+
516
/**
617
* Pinned fetch for the long-lived MCP transport, which reuses one Agent across
718
* a connection's requests. MCP servers are commonly behind HTTP/2 fronts (CDNs,
819
* cloud LBs), and undici's Agent is h1.1-only unless opted into h2 via ALPN, so
920
* the transport enables it. h2 is *not* used for one-shot flows (OAuth discovery,
1021
* auth-type probe), where a per-request Agent would leave idle h2 sessions with
1122
* no reuse benefit. Pinning is unaffected: the pinned lookup forces the socket to
12-
* `resolvedIP` regardless of negotiated protocol.
23+
* `resolvedIP` regardless of negotiated protocol. The returned `close` binds the
24+
* Agent's teardown to the transport lifecycle so h2 sessions don't linger past
25+
* disconnect.
1326
*/
14-
export function createPinnedMcpFetch(resolvedIP: string): typeof fetch {
15-
return createPinnedFetch(resolvedIP, { allowH2: true })
27+
export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch {
28+
const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP, {
29+
allowH2: true,
30+
})
31+
return { fetch: pinnedFetch, close: () => dispatcher.destroy() }
1632
}
1733

1834
/**

0 commit comments

Comments
 (0)