From 7ce696d6fda84e79a4aa85aef7c5a338653a71d7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 11:58:09 -0700 Subject: [PATCH 1/6] improvement(mcp): negotiate HTTP/2 for MCP transport MCP servers are commonly behind HTTP/2 fronts (CDNs, cloud LBs), but the SSRF-pinned undici Agent is HTTP/1.1-only unless it opts into h2 via ALPN. Add an allowH2 option to createPinnedFetch (default false, so LLM-provider callers are unchanged) and centralize the MCP h2 decision in one createPinnedMcpFetch routed through the transport, OAuth probe, and SSRF-guarded fetch. Pinning is unaffected: the pinned lookup forces every connection to the resolved IP. --- .../lib/core/security/input-validation.server.ts | 16 ++++++++++++++-- .../core/security/pinned-fetch.server.test.ts | 12 ++++++++++++ apps/sim/lib/mcp/oauth/probe.test.ts | 4 +--- apps/sim/lib/mcp/oauth/probe.ts | 5 ++--- apps/sim/lib/mcp/pinned-fetch.test.ts | 4 ++-- apps/sim/lib/mcp/pinned-fetch.ts | 14 +++++++++++++- 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 7f81039622c..e3074a2accd 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -433,9 +433,21 @@ export function createPinnedLookup(resolvedIP: string): LookupFunction { * * The `Agent` is captured for the lifetime of the returned function, so repeated * calls (e.g. a provider tool loop) reuse its keep-alive connections. + * + * `allowH2` opts the pinned Agent into HTTP/2 (ALPN-negotiated, h1.1 fallback). + * It defaults to `false` to leave existing consumers unchanged. Enabling it does + * not weaken pinning: the pinned `connect.lookup` forces every connection on the + * Agent to `resolvedIP` regardless of authority, so h2 connection coalescing can + * never reach an address other than the validated one. */ -export function createPinnedFetch(resolvedIP: string): typeof fetch { - const dispatcher = new Agent({ connect: { lookup: createPinnedLookup(resolvedIP) } }) +export function createPinnedFetch( + resolvedIP: string, + options?: { allowH2?: boolean } +): typeof fetch { + const dispatcher = new Agent({ + allowH2: options?.allowH2 ?? false, + connect: { lookup: createPinnedLookup(resolvedIP) }, + }) const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise => { // double-cast-allowed: DOM RequestInfo/URL and undici fetch input types differ but are structurally compatible at runtime (Node's global fetch IS undici) diff --git a/apps/sim/lib/core/security/pinned-fetch.server.test.ts b/apps/sim/lib/core/security/pinned-fetch.server.test.ts index e07d0d412dd..0ecfa22cb9e 100644 --- a/apps/sim/lib/core/security/pinned-fetch.server.test.ts +++ b/apps/sim/lib/core/security/pinned-fetch.server.test.ts @@ -55,6 +55,18 @@ describe('createPinnedFetch', () => { expect(resolved).toEqual({ address: '203.0.113.10', family: 4 }) }) + it('defaults allowH2 to false so existing consumers are unchanged', () => { + createPinnedFetch('203.0.113.10') + const opts = capturedAgentOptions[0] as { allowH2?: boolean } + expect(opts.allowH2).toBe(false) + }) + + it('opts the Agent into HTTP/2 when allowH2 is requested', () => { + createPinnedFetch('203.0.113.10', { allowH2: true }) + const opts = capturedAgentOptions[0] as { allowH2?: boolean } + expect(opts.allowH2).toBe(true) + }) + it('uses IPv6 family when the validated IP is IPv6', async () => { createPinnedFetch('2606:4700:4700::1111') const { connect } = capturedAgentOptions[0] as { connect: { lookup: PinnedLookup } } diff --git a/apps/sim/lib/mcp/oauth/probe.test.ts b/apps/sim/lib/mcp/oauth/probe.test.ts index d691f1178c7..ff5d96525a7 100644 --- a/apps/sim/lib/mcp/oauth/probe.test.ts +++ b/apps/sim/lib/mcp/oauth/probe.test.ts @@ -15,10 +15,8 @@ const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, m } }) -vi.mock('@/lib/core/security/input-validation.server', () => ({ - createPinnedFetch: mockCreatePinnedFetch, -})) vi.mock('@/lib/mcp/pinned-fetch', () => ({ + createPinnedMcpFetch: mockCreatePinnedFetch, createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch, })) diff --git a/apps/sim/lib/mcp/oauth/probe.ts b/apps/sim/lib/mcp/oauth/probe.ts index 4343f27870a..431480ae0f6 100644 --- a/apps/sim/lib/mcp/oauth/probe.ts +++ b/apps/sim/lib/mcp/oauth/probe.ts @@ -1,9 +1,8 @@ import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js' import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' -import { createPinnedFetch } from '@/lib/core/security/input-validation.server' import { isLoopbackHostname } from '@/lib/core/utils/urls' -import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' +import { createPinnedMcpFetch, createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' import type { McpAuthType } from '@/lib/mcp/types' const logger = createLogger('McpOauthProbe') @@ -34,7 +33,7 @@ export async function detectMcpAuthType( } const probeFetch: FetchLike = resolvedIP - ? createPinnedFetch(resolvedIP) + ? createPinnedMcpFetch(resolvedIP) : createSsrfGuardedMcpFetch() const controller = new AbortController() diff --git a/apps/sim/lib/mcp/pinned-fetch.test.ts b/apps/sim/lib/mcp/pinned-fetch.test.ts index 64354ee708b..3dc7701d6a7 100644 --- a/apps/sim/lib/mcp/pinned-fetch.test.ts +++ b/apps/sim/lib/mcp/pinned-fetch.test.ts @@ -31,7 +31,7 @@ describe('createSsrfGuardedMcpFetch', () => { await fetchLike('https://attacker.example/revoke', { method: 'POST' }) expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke') - expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true }) expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', { method: 'POST', }) @@ -54,7 +54,7 @@ describe('createSsrfGuardedMcpFetch', () => { await fetchLike(new URL('https://attacker.example/discover')) expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover') - expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true }) }) it('falls back to global fetch when validation returns no IP', async () => { diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index 3184a0da7a9..4188d348cac 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -2,6 +2,18 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createPinnedFetch } from '@/lib/core/security/input-validation.server' import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' +/** + * Pinned fetch for all MCP traffic. MCP servers are commonly deployed behind + * HTTP/2 fronts (CDNs, cloud LBs), and undici's Agent is h1.1-only unless opted + * into h2 via ALPN — so every MCP connection enables it. This is the single + * source of the "MCP implies h2" decision; the base `createPinnedFetch` keeps + * its h1.1 default for non-MCP consumers. Pinning is unaffected: the pinned + * lookup forces the socket to `resolvedIP` regardless of negotiated protocol. + */ +export function createPinnedMcpFetch(resolvedIP: string): typeof fetch { + return createPinnedFetch(resolvedIP, { allowH2: true }) +} + /** * Builds a `FetchLike` that validates every outbound request URL against the * MCP SSRF policy before issuing it, then pins the connection to the resolved @@ -25,7 +37,7 @@ export function createSsrfGuardedMcpFetch(): FetchLike { return (async (url, init) => { const target = typeof url === 'string' ? url : url.href const resolvedIP = await validateMcpServerSsrf(target) - const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch + const pinnedFetch: FetchLike = resolvedIP ? createPinnedMcpFetch(resolvedIP) : globalThis.fetch return pinnedFetch(url, init) }) satisfies FetchLike } From 98b27d27a01b10ed92af80fad9704d7ddae55888 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 11:58:10 -0700 Subject: [PATCH 2/6] fix(mcp): correct auth-type handling, OAuth-pending UX, and safe error logging - Classify a non-OAuth UnauthorizedError as an auth failure instead of an OAuth redirect, so static-bearer servers that merely advertise OAuth stop being diverted into the OAuth flow (fixes the class of server that couldn't connect). - Reset a server to disconnected when it is switched to OAuth, so it can't falsely read as connected before completing its auth flow. - Surface OAuth-pending state as 'OAuth authorization required' in the server list and refresh action instead of a generic 'Not Connected'. - Return an actionable 422 for OAuth servers that don't support dynamic client registration. - Redact error messages/cause/session-ids from MCP transport/connect logs via a shared getMcpSafeErrorDiagnostics helper. --- .../sim/app/api/mcp/oauth/start/route.test.ts | 17 ++++ apps/sim/app/api/mcp/oauth/start/route.ts | 13 ++- .../settings/components/mcp/mcp.tsx | 16 ++- .../mcp/refresh-action-state.test.ts | 31 ++++++ .../components/mcp/refresh-action-state.ts | 15 ++- .../components/mcp/server-tools-label.test.ts | 14 ++- .../components/mcp/server-tools-label.ts | 7 +- apps/sim/lib/mcp/client.test.ts | 50 ++++++++++ apps/sim/lib/mcp/client.ts | 49 ++++++---- apps/sim/lib/mcp/error-diagnostics.test.ts | 39 ++++++++ apps/sim/lib/mcp/error-diagnostics.ts | 25 +++++ .../orchestration/server-lifecycle.test.ts | 98 +++++++++++++++++++ .../lib/mcp/orchestration/server-lifecycle.ts | 8 +- 13 files changed, 354 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/mcp/error-diagnostics.test.ts create mode 100644 apps/sim/lib/mcp/error-diagnostics.ts create mode 100644 apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts index 68dd96b2bf3..555e2d2fa4d 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts @@ -162,6 +162,23 @@ describe('MCP OAuth start route', () => { expect(body.error).not.toContain('ECONNREFUSED') expect(body.error).not.toContain('internal-db-host') }) + + it('returns an actionable 4xx when the server does not support dynamic client registration', async () => { + mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValueOnce( + new Error('Incompatible auth server: does not support dynamic client registration') + ) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(422) + expect(body.error).toBe( + "This server doesn't support OAuth client registration. Configure a token instead." + ) + }) }) describe('surfaceOauthError', () => { diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts index edb17a0f1c9..4354257a1e0 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -2,7 +2,7 @@ import { OAuthError, ServerError } from '@modelcontextprotocol/sdk/server/auth/e import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' @@ -25,6 +25,14 @@ import { createMcpErrorResponse } from '@/lib/mcp/utils' const logger = createLogger('McpOauthStartAPI') const OAUTH_START_TTL_MS = 10 * 60 * 1000 const MAX_SURFACED_ERROR_LENGTH = 250 +const DCR_UNSUPPORTED_MESSAGE = + "This server doesn't support OAuth client registration. Configure a token instead." + +function isDynamicClientRegistrationUnsupported(error: unknown): boolean { + return getErrorMessage(error, '') + .toLowerCase() + .includes('does not support dynamic client registration') +} export function surfaceOauthError(error: unknown): string { // Spec-compliant OAuth servers throw typed subclasses with clean RFC 6749 fields. @@ -148,6 +156,9 @@ export const GET = withRouteHandler( authorizationUrl: e.authorizationUrl, }) } + if (isDynamicClientRegistrationUnsupported(e)) { + return createMcpErrorResponse(toError(e), DCR_UNSUPPORTED_MESSAGE, 422) + } throw e } } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index c2769a36e54..92f3fc7f60a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -85,7 +85,12 @@ function ServerListItem({ onViewDetails, }: ServerListItemProps) { const transportLabel = formatTransportLabel(server.transport || 'http') - const toolsLabel = getServerToolsLabel(tools, server.connectionStatus, server.lastError) + const toolsLabel = getServerToolsLabel( + tools, + server.connectionStatus, + server.lastError, + server.authType + ) const hasConnectionIssue = server.connectionStatus === 'error' || server.connectionStatus === 'disconnected' @@ -380,6 +385,8 @@ export function MCP() { const refreshAction = getRefreshActionState({ mutationStatus: isCurrentRefresh ? refreshServerMutation.status : 'idle', connectionStatus: isCurrentRefresh ? refreshServerMutation.data?.status : undefined, + authType: server.authType, + error: isCurrentRefresh ? refreshServerMutation.data?.error : undefined, workflowsUpdated: isCurrentRefresh ? refreshServerMutation.data?.workflowsUpdated : undefined, }) @@ -427,7 +434,12 @@ export function MCP() {
Status

- {getServerToolsLabel([], server.connectionStatus, server.lastError)} + {getServerToolsLabel( + [], + server.connectionStatus, + server.lastError, + server.authType + )}

)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts index a9eeb89513e..4a036e8827b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts @@ -31,6 +31,37 @@ describe('getRefreshActionState', () => { }) }) + it('shows OAuth authorization required when an OAuth refresh finishes disconnected', () => { + expect( + getRefreshActionState({ + mutationStatus: 'success', + connectionStatus: 'disconnected', + authType: 'oauth', + workflowsUpdated: 0, + }) + ).toEqual({ + text: 'OAuth authorization required', + textTone: 'error', + disabled: false, + }) + }) + + it('keeps Failed when a disconnected OAuth refresh has a concrete error', () => { + expect( + getRefreshActionState({ + mutationStatus: 'success', + connectionStatus: 'disconnected', + authType: 'oauth', + error: 'The MCP server took too long to respond and timed out', + workflowsUpdated: 0, + }) + ).toEqual({ + text: 'Failed', + textTone: 'error', + disabled: false, + }) + }) + it('preserves successful refresh feedback', () => { expect( getRefreshActionState({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts index 28a6869921e..8b4ae5da87d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts @@ -1,10 +1,12 @@ import type { MutationStatus } from '@tanstack/react-query' -import type { RefreshMcpServerResult } from '@/lib/api/contracts/mcp' +import type { McpServer, RefreshMcpServerResult } from '@/lib/api/contracts/mcp' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' interface RefreshActionStateInput { mutationStatus: MutationStatus connectionStatus?: RefreshMcpServerResult['status'] + authType?: McpServer['authType'] + error?: RefreshMcpServerResult['error'] workflowsUpdated?: number } @@ -13,12 +15,23 @@ type RefreshActionState = Pick export function getRefreshActionState({ mutationStatus, connectionStatus, + authType, + error, workflowsUpdated, }: RefreshActionStateInput): RefreshActionState { if (mutationStatus === 'pending') { return { text: 'Refreshing...', textTone: undefined, disabled: true } } + if ( + mutationStatus === 'success' && + connectionStatus === 'disconnected' && + authType === 'oauth' && + !error?.trim() + ) { + return { text: 'OAuth authorization required', textTone: 'error', disabled: false } + } + if ( mutationStatus === 'error' || (mutationStatus === 'success' && connectionStatus !== 'connected') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts index e65dadf68e4..373ae480af4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts @@ -12,12 +12,20 @@ describe('getServerToolsLabel', () => { expect(getServerToolsLabel([], 'error', null)).toBe('Unable to connect') }) - it('shows a disconnected state when OAuth was not completed', () => { - expect(getServerToolsLabel([], 'disconnected', null)).toBe('Not Connected') + it('shows that OAuth authorization is required when OAuth was not completed', () => { + expect(getServerToolsLabel([], 'disconnected', null, 'oauth')).toBe( + 'OAuth authorization required' + ) + }) + + it('keeps the generic disconnected state for non-OAuth servers', () => { + expect(getServerToolsLabel([], 'disconnected', null, 'headers')).toBe('Not Connected') }) it('shows the persisted error for disconnected connections', () => { - expect(getServerToolsLabel([], 'disconnected', 'Request timed out')).toBe('Request timed out') + expect(getServerToolsLabel([], 'disconnected', 'Request timed out', 'oauth')).toBe( + 'Request timed out' + ) }) it('continues showing discovered tools for healthy connections', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts index f9d8d4896af..495cea5f9ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts @@ -7,14 +7,17 @@ interface NamedTool { export function getServerToolsLabel( tools: NamedTool[], connectionStatus?: McpServer['connectionStatus'], - lastError?: McpServer['lastError'] + lastError?: McpServer['lastError'], + authType?: McpServer['authType'] ): string { if (connectionStatus === 'error') { return lastError?.trim() || 'Unable to connect' } if (connectionStatus === 'disconnected') { - return lastError?.trim() || 'Not Connected' + return ( + lastError?.trim() || (authType === 'oauth' ? 'OAuth authorization required' : 'Not Connected') + ) } const count = tools.length diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index c568f425d61..198a67e5c4c 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockLogger, mockSdkConnect, mockSdkListTools } = vi.hoisted(() => ({ @@ -265,6 +266,55 @@ describe('McpClient notification handler', () => { expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(secret) }) + it('does not misclassify rejected static headers as an OAuth authorization flow', async () => { + mockSdkConnect.mockRejectedValueOnce(new UnauthorizedError('Static token rejected')) + const client = new McpClient({ + config: { + ...createConfig(), + authType: 'headers', + headers: { Authorization: 'Bearer rejected-static-token' }, + }, + securityPolicy: { requireConsent: false, auditLevel: 'basic' }, + }) + + await expect(client.connect()).rejects.toBeInstanceOf(UnauthorizedError) + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to connect'), + expect.objectContaining({ outcome: 'unauthorized' }) + ) + expect(client.getStatus().lastError).toBe('Authentication failed') + }) + + it('logs tools/list failures without echoed credentials or session identifiers', async () => { + const secret = 'opaque-tools-list-credential' + mockSdkListTools.mockRejectedValueOnce(new Error(`Upstream rejected ${secret}`)) + const client = new McpClient({ + config: { + ...createConfig(), + authType: 'headers', + headers: { 'X-Custom-Credential': secret }, + }, + securityPolicy: { requireConsent: false, auditLevel: 'basic' }, + }) + + await client.connect() + await expect(client.listTools()).rejects.toThrow(secret) + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to list tools'), + expect.objectContaining({ + phase: 'tools/list', + serverId: 'server-1', + sessionIdPresent: true, + error: expect.objectContaining({ name: 'Error' }), + }) + ) + const logged = JSON.stringify(mockLogger.error.mock.calls) + expect(logged).not.toContain(secret) + expect(logged).not.toContain('test-session') + }) + it('passes configured headers for OAuth transports as well as header auth transports', () => { const authProvider = {} as unknown as NonNullable new McpClient({ diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 50a90355c14..bc3d32c6dde 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -9,11 +9,11 @@ import { ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js' import { createLogger } from '@sim/logger' -import { describeError, getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { createPinnedFetch } from '@/lib/core/security/input-validation.server' -import { sanitizeForLogging } from '@/lib/core/security/redaction' +import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' +import { createPinnedMcpFetch } from '@/lib/mcp/pinned-fetch' import { type McpClientOptions, McpConnectionError, @@ -43,10 +43,16 @@ type ConnectionOutcome = | 'cancelled' | 'error' -function classifyConnectionOutcome(error: unknown): ConnectionOutcome { - if (error instanceof McpOauthRedirectRequired || error instanceof UnauthorizedError) { +function classifyConnectionOutcome( + error: unknown, + authType: McpServerConfig['authType'] +): ConnectionOutcome { + if (error instanceof McpOauthRedirectRequired) { return 'authorization_required' } + if (error instanceof UnauthorizedError) { + return authType === 'oauth' ? 'authorization_required' : 'unauthorized' + } const message = getErrorMessage(error, '').toLowerCase() if (message.includes('connection attempt cancelled')) return 'cancelled' if (message.includes('timeout') || message.includes('timed out')) return 'timeout' @@ -92,7 +98,7 @@ export class McpClient { this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), { authProvider: useOauth ? this.authProvider : undefined, requestInit: { headers: this.config.headers }, - ...(resolvedIP ? { fetch: createPinnedFetch(resolvedIP) } : {}), + ...(resolvedIP ? { fetch: createPinnedMcpFetch(resolvedIP) } : {}), }) this.client = new Client( @@ -109,7 +115,9 @@ export class McpClient { this.client.onerror = (error) => { logger.warn(`MCP transport error for ${this.config.name}`, { serverId: this.config.id, - error: sanitizeForLogging(getErrorMessage(error, 'Unknown transport error'), 200), + phase: 'transport', + sessionIdPresent: Boolean(this.transport.sessionId), + error: getMcpSafeErrorDiagnostics(error), }) } } @@ -178,25 +186,21 @@ export class McpClient { } catch (error) { this.isConnected = false const errorMessage = getErrorMessage(error, 'Unknown error') - const describedError = describeError(error) - const outcome = classifyConnectionOutcome(error) + const outcome = classifyConnectionOutcome(error, this.config.authType) logger.error(`Failed to connect to MCP server ${this.config.name}`, { ...diagnostics, durationMs: Date.now() - startedAt, - error: { - name: sanitizeForLogging(describedError.name, 100), - code: describedError.code ? sanitizeForLogging(describedError.code, 100) : undefined, - errno: describedError.errno ? sanitizeForLogging(describedError.errno, 100) : undefined, - syscall: describedError.syscall - ? sanitizeForLogging(describedError.syscall, 100) - : undefined, - }, + error: getMcpSafeErrorDiagnostics(error), outcome, }) if (outcome === 'authorization_required') { this.connectionStatus.lastError = undefined throw error } + if (error instanceof UnauthorizedError) { + this.connectionStatus.lastError = 'Authentication failed' + throw error + } this.connectionStatus.lastError = errorMessage throw new McpConnectionError(errorMessage, this.config.name) } @@ -236,6 +240,7 @@ export class McpClient { MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS ) const maxTotalTimeoutMs = MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS + const startedAt = Date.now() try { const result: ListToolsResult = await this.client.listTools(undefined, { @@ -265,7 +270,15 @@ export class McpClient { serverName: this.config.name, })) } catch (error) { - logger.error(`Failed to list tools from server ${this.config.name}:`, error) + logger.error(`Failed to list tools from server ${this.config.name}`, { + serverId: this.config.id, + phase: 'tools/list', + durationMs: Date.now() - startedAt, + idleTimeoutMs, + maxTotalTimeoutMs, + sessionIdPresent: Boolean(this.transport.sessionId), + error: getMcpSafeErrorDiagnostics(error), + }) throw error } } diff --git a/apps/sim/lib/mcp/error-diagnostics.test.ts b/apps/sim/lib/mcp/error-diagnostics.test.ts new file mode 100644 index 00000000000..2badd603c05 --- /dev/null +++ b/apps/sim/lib/mcp/error-diagnostics.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { getMcpSafeErrorDiagnostics } from './error-diagnostics' + +describe('getMcpSafeErrorDiagnostics', () => { + it('redacts and truncates structural fields without emitting message text', () => { + const secret = 'sk-abcdefghijklmnopqrstuvwxyz1234567890' + const error = Object.assign(new Error(`message contains ${secret}`), { + name: `Bearer ${secret}`, + code: `code-${'c'.repeat(200)}`, + errno: `api_key: "${secret}"`, + syscall: `Bearer ${secret}`, + sessionId: secret, + }) + + const diagnostics = getMcpSafeErrorDiagnostics(error) + + expect(diagnostics).toEqual({ + name: expect.any(String), + code: expect.any(String), + errno: expect.any(String), + syscall: expect.any(String), + }) + expect(diagnostics).not.toHaveProperty('message') + expect(diagnostics).not.toHaveProperty('causeChain') + expect(diagnostics).not.toHaveProperty('sessionId') + for (const value of Object.values(diagnostics)) { + expect(value).not.toContain(secret) + expect(value?.length).toBeLessThanOrEqual(100) + } + expect(diagnostics.name).toContain('[REDACTED]') + expect(diagnostics.errno).toContain('[REDACTED]') + expect(diagnostics.syscall).toContain('[REDACTED]') + expect(diagnostics.code).toHaveLength(100) + }) +}) diff --git a/apps/sim/lib/mcp/error-diagnostics.ts b/apps/sim/lib/mcp/error-diagnostics.ts new file mode 100644 index 00000000000..ce3b6bac7b4 --- /dev/null +++ b/apps/sim/lib/mcp/error-diagnostics.ts @@ -0,0 +1,25 @@ +import { describeError } from '@sim/utils/errors' +import { sanitizeForLogging } from '@/lib/core/security/redaction' + +const MAX_DIAGNOSTIC_FIELD_LENGTH = 100 + +export interface McpSafeErrorDiagnostics { + name: string + code: string | undefined + errno: string | undefined + syscall: string | undefined +} + +/** Returns bounded structural error fields without messages, causes, or session identifiers. */ +export function getMcpSafeErrorDiagnostics(error: unknown): McpSafeErrorDiagnostics { + const described = describeError(error) + const sanitizeOptional = (value: string | undefined) => + value === undefined ? undefined : sanitizeForLogging(value, MAX_DIAGNOSTIC_FIELD_LENGTH) + + return { + name: sanitizeForLogging(described.name, MAX_DIAGNOSTIC_FIELD_LENGTH), + code: sanitizeOptional(described.code), + errno: sanitizeOptional(described.errno), + syscall: sanitizeOptional(described.syscall), + } +} diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts new file mode 100644 index 00000000000..1bfee2875a7 --- /dev/null +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + dbChainMock, + dbChainMockFns, + drizzleOrmMock, + encryptionMock, + loggerMock, + posthogServerMock, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockClearCache, mockOauthCredsChanged } = vi.hoisted(() => ({ + mockClearCache: vi.fn(), + mockOauthCredsChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@sim/db', () => ({ + ...dbChainMock, + mcpServers: schemaMock.mcpServers, +})) +vi.mock('@sim/db/schema', () => ({ + mcpServerOauth: schemaMock.mcpServerOauth, +})) +vi.mock('@sim/logger', () => loggerMock) +vi.mock('@sim/utils/id', () => ({ generateId: vi.fn() })) +vi.mock('drizzle-orm', () => drizzleOrmMock) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/mcp/domain-check', () => ({ + McpDnsResolutionError: class extends Error {}, + McpDomainNotAllowedError: class extends Error {}, + McpSsrfError: class extends Error {}, + validateMcpDomain: vi.fn(), + validateMcpServerSsrf: vi.fn(), +})) +vi.mock('@/lib/mcp/oauth', () => ({ + detectMcpAuthType: vi.fn(), + oauthCredsChanged: mockOauthCredsChanged, + revokeMcpOauthTokens: vi.fn(), +})) +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { clearCache: mockClearCache }, +})) +vi.mock('@/lib/mcp/utils', () => ({ generateMcpServerId: vi.fn() })) +vi.mock('@/lib/posthog/server', () => posthogServerMock) + +import { performUpdateMcpServer } from '@/lib/mcp/orchestration/server-lifecycle' + +describe('MCP server lifecycle orchestration', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockOauthCredsChanged.mockResolvedValue(false) + }) + + it('clears the workspace cache when an OAuth client ID implicitly changes the auth type', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + url: 'https://example.com/mcp', + authType: 'headers', + oauthClientId: 'client-1', + oauthClientSecret: null, + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + url: 'https://example.com/mcp', + authType: 'oauth', + }, + ]) + + const result = await performUpdateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + oauthClientId: 'client-1', + oauthClientIdProvided: true, + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ authType: 'oauth' })) + // Flipping to OAuth must reset the server to disconnected — it hasn't + // completed an auth flow, so it can't remain 'connected'. + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ connectionStatus: 'disconnected', lastConnected: null }) + ) + expect(mockClearCache).toHaveBeenCalledWith('workspace-1') + }) +}) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index d8d2d73115a..56a5c96c0f2 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -351,7 +351,11 @@ export async function performUpdateMcpServer( }) const shouldClearOauth = urlChanged || credsChanged const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType - if (shouldClearOauth && resolvedAuthType === 'oauth') { + const authTypeChanged = resolvedAuthType !== currentServer.authType + // Switching a server to OAuth (via creds/URL change or a plain authType flip) + // must reset it to disconnected: an OAuth server has not completed its auth + // flow, so it can't legitimately remain 'connected' (matches the create path). + if ((shouldClearOauth || authTypeChanged) && resolvedAuthType === 'oauth') { updateData.connectionStatus = 'disconnected' updateData.lastConnected = null } @@ -384,6 +388,8 @@ export async function performUpdateMcpServer( const shouldClearCache = urlChanged || credsChanged || + params.transport !== undefined || + authTypeChanged || params.enabled !== undefined || params.headers !== undefined || params.timeout !== undefined || From f085ef3703f414b2e68b371d13a212d111766b52 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 12:20:54 -0700 Subject: [PATCH 3/6] fix(mcp): reset connection on any auth-type flip, scope h2 to live transport --- apps/sim/lib/mcp/client.ts | 3 -- apps/sim/lib/mcp/oauth/probe.test.ts | 4 +- apps/sim/lib/mcp/oauth/probe.ts | 10 ++-- .../orchestration/server-lifecycle.test.ts | 48 +++++++++++++++++-- .../lib/mcp/orchestration/server-lifecycle.ts | 7 ++- apps/sim/lib/mcp/pinned-fetch.test.ts | 4 +- apps/sim/lib/mcp/pinned-fetch.ts | 15 +++--- 7 files changed, 65 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index bc3d32c6dde..9e0297bf097 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -111,7 +111,6 @@ export class McpClient { } ) - // Observe out-of-band transport errors the SDK would otherwise drop silently. this.client.onerror = (error) => { logger.warn(`MCP transport error for ${this.config.name}`, { serverId: this.config.id, @@ -230,8 +229,6 @@ export class McpClient { } const configuredTimeout = this.config.timeout - // Idle timeout honors the per-server config but never exceeds the absolute - // discovery ceiling, so tools/list can't hang the UI past that cap. const idleTimeoutMs = Math.min( configuredTimeout !== undefined && Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? Math.floor(configuredTimeout) diff --git a/apps/sim/lib/mcp/oauth/probe.test.ts b/apps/sim/lib/mcp/oauth/probe.test.ts index ff5d96525a7..d691f1178c7 100644 --- a/apps/sim/lib/mcp/oauth/probe.test.ts +++ b/apps/sim/lib/mcp/oauth/probe.test.ts @@ -15,8 +15,10 @@ const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, m } }) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + createPinnedFetch: mockCreatePinnedFetch, +})) vi.mock('@/lib/mcp/pinned-fetch', () => ({ - createPinnedMcpFetch: mockCreatePinnedFetch, createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch, })) diff --git a/apps/sim/lib/mcp/oauth/probe.ts b/apps/sim/lib/mcp/oauth/probe.ts index 431480ae0f6..d6aac4d19c1 100644 --- a/apps/sim/lib/mcp/oauth/probe.ts +++ b/apps/sim/lib/mcp/oauth/probe.ts @@ -1,8 +1,9 @@ import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js' import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' +import { createPinnedFetch } from '@/lib/core/security/input-validation.server' import { isLoopbackHostname } from '@/lib/core/utils/urls' -import { createPinnedMcpFetch, createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' +import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' import type { McpAuthType } from '@/lib/mcp/types' const logger = createLogger('McpOauthProbe') @@ -33,7 +34,7 @@ export async function detectMcpAuthType( } const probeFetch: FetchLike = resolvedIP - ? createPinnedMcpFetch(resolvedIP) + ? createPinnedFetch(resolvedIP) : createSsrfGuardedMcpFetch() const controller = new AbortController() @@ -67,10 +68,7 @@ export async function detectMcpAuthType( if (res.status === 401) { const params = extractWWWAuthenticateParams(res) - // Per RFC 9728, an OAuth-protected resource signals OAuth via - // `resource_metadata=...` in WWW-Authenticate. `scope=...` is also an - // OAuth-specific hint. A bare `error="invalid_token"` is generic Bearer - // and used by plain API-key servers too, so it must not classify as OAuth. + // RFC 9728: resource_metadata / scope signal OAuth; a bare invalid_token is generic Bearer (API-key servers use it too). if (params.resourceMetadataUrl || params.scope) { return 'oauth' } diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index 1bfee2875a7..e57a9acc45d 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -88,11 +88,53 @@ describe('MCP server lifecycle orchestration', () => { expect(result.success).toBe(true) expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ authType: 'oauth' })) - // Flipping to OAuth must reset the server to disconnected — it hasn't - // completed an auth flow, so it can't remain 'connected'. + // Flipping to OAuth must reset to disconnected — it hasn't completed an auth flow. expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ connectionStatus: 'disconnected', lastConnected: null }) + expect.objectContaining({ + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + }) ) expect(mockClearCache).toHaveBeenCalledWith('workspace-1') }) + + it('resets an OAuth server to disconnected when its auth type flips to headers', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + url: 'https://example.com/mcp', + authType: 'oauth', + oauthClientId: 'client-1', + oauthClientSecret: 'secret-1', + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + url: 'https://example.com/mcp', + authType: 'headers', + }, + ]) + + const result = await performUpdateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + authType: 'headers', + }) + + expect(result.success).toBe(true) + // Flipping away from OAuth must reset too — no stale 'connected'/lastError until re-discovery. + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + authType: 'headers', + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + }) + ) + }) }) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index 56a5c96c0f2..bcd8a022896 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -352,12 +352,11 @@ export async function performUpdateMcpServer( const shouldClearOauth = urlChanged || credsChanged const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType const authTypeChanged = resolvedAuthType !== currentServer.authType - // Switching a server to OAuth (via creds/URL change or a plain authType flip) - // must reset it to disconnected: an OAuth server has not completed its auth - // flow, so it can't legitimately remain 'connected' (matches the create path). - if ((shouldClearOauth || authTypeChanged) && resolvedAuthType === 'oauth') { + // An auth-type flip (either direction) or OAuth creds/URL change invalidates the connection: reset and clear stale state. + if (authTypeChanged || (shouldClearOauth && resolvedAuthType === 'oauth')) { updateData.connectionStatus = 'disconnected' updateData.lastConnected = null + updateData.lastError = null } if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId) diff --git a/apps/sim/lib/mcp/pinned-fetch.test.ts b/apps/sim/lib/mcp/pinned-fetch.test.ts index 3dc7701d6a7..64354ee708b 100644 --- a/apps/sim/lib/mcp/pinned-fetch.test.ts +++ b/apps/sim/lib/mcp/pinned-fetch.test.ts @@ -31,7 +31,7 @@ describe('createSsrfGuardedMcpFetch', () => { await fetchLike('https://attacker.example/revoke', { method: 'POST' }) expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke') - expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true }) + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', { method: 'POST', }) @@ -54,7 +54,7 @@ describe('createSsrfGuardedMcpFetch', () => { await fetchLike(new URL('https://attacker.example/discover')) expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover') - expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true }) + expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10') }) it('falls back to global fetch when validation returns no IP', async () => { diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index 4188d348cac..6cda2cea6af 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -3,12 +3,13 @@ import { createPinnedFetch } from '@/lib/core/security/input-validation.server' import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' /** - * Pinned fetch for all MCP traffic. MCP servers are commonly deployed behind - * HTTP/2 fronts (CDNs, cloud LBs), and undici's Agent is h1.1-only unless opted - * into h2 via ALPN — so every MCP connection enables it. This is the single - * source of the "MCP implies h2" decision; the base `createPinnedFetch` keeps - * its h1.1 default for non-MCP consumers. Pinning is unaffected: the pinned - * lookup forces the socket to `resolvedIP` regardless of negotiated protocol. + * Pinned fetch for the long-lived MCP transport, which reuses one Agent across + * a connection's requests. MCP servers are commonly behind HTTP/2 fronts (CDNs, + * cloud LBs), and undici's Agent is h1.1-only unless opted into h2 via ALPN, so + * the transport enables it. h2 is *not* used for one-shot flows (OAuth discovery, + * auth-type probe), where a per-request Agent would leave idle h2 sessions with + * no reuse benefit. Pinning is unaffected: the pinned lookup forces the socket to + * `resolvedIP` regardless of negotiated protocol. */ export function createPinnedMcpFetch(resolvedIP: string): typeof fetch { return createPinnedFetch(resolvedIP, { allowH2: true }) @@ -37,7 +38,7 @@ export function createSsrfGuardedMcpFetch(): FetchLike { return (async (url, init) => { const target = typeof url === 'string' ? url : url.href const resolvedIP = await validateMcpServerSsrf(target) - const pinnedFetch: FetchLike = resolvedIP ? createPinnedMcpFetch(resolvedIP) : globalThis.fetch + const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch return pinnedFetch(url, init) }) satisfies FetchLike } From ba80c97cb1ef34096b1560a653e7f3362d4091e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 12:49:10 -0700 Subject: [PATCH 4/6] fix(mcp): close pinned h2 Agent on disconnect and revoke OAuth tokens on auth-type change --- .../core/security/input-validation.server.ts | 15 +++++++++++- apps/sim/lib/mcp/client.ts | 11 ++++++++- .../orchestration/server-lifecycle.test.ts | 7 ++++-- .../lib/mcp/orchestration/server-lifecycle.ts | 4 +++- apps/sim/lib/mcp/pinned-fetch.ts | 24 +++++++++++++++---- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index e3074a2accd..dc9764990ba 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -444,6 +444,19 @@ export function createPinnedFetch( resolvedIP: string, options?: { allowH2?: boolean } ): typeof fetch { + return createPinnedFetchWithDispatcher(resolvedIP, options).fetch +} + +/** + * Same as {@link createPinnedFetch} but also returns the underlying `Agent` so a + * caller with a defined connection lifetime (e.g. a long-lived MCP transport) can + * tear the Agent down on close instead of waiting for its idle timeout. Closing + * the Agent is what releases any pooled keep-alive / HTTP/2 sockets it holds. + */ +export function createPinnedFetchWithDispatcher( + resolvedIP: string, + options?: { allowH2?: boolean } +): { fetch: typeof fetch; dispatcher: Agent } { const dispatcher = new Agent({ allowH2: options?.allowH2 ?? false, connect: { lookup: createPinnedLookup(resolvedIP) }, @@ -459,7 +472,7 @@ export function createPinnedFetch( return response as unknown as Response } - return pinned + return { fetch: pinned, dispatcher } } /** diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 9e0297bf097..3ebdd36a7f6 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -73,6 +73,7 @@ export class McpClient { private onToolsChanged?: McpToolsChangedCallback private authProvider?: McpClientOptions['authProvider'] private isConnected = false + private closePinnedTransport?: () => Promise constructor(options: McpClientOptions) { this.config = options.config @@ -95,10 +96,12 @@ export class McpClient { throw new McpError('OAuth MCP server requires an authProvider') } const useOauth = this.config.authType === 'oauth' + const pinned = resolvedIP ? createPinnedMcpFetch(resolvedIP) : undefined + this.closePinnedTransport = pinned?.close this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), { authProvider: useOauth ? this.authProvider : undefined, requestInit: { headers: this.config.headers }, - ...(resolvedIP ? { fetch: createPinnedMcpFetch(resolvedIP) } : {}), + ...(pinned ? { fetch: pinned.fetch } : {}), }) this.client = new Client( @@ -214,6 +217,12 @@ export class McpClient { logger.warn(`Error during disconnect from ${this.config.name}:`, error) } + try { + await this.closePinnedTransport?.() + } catch (error) { + logger.warn(`Error closing pinned transport for ${this.config.name}:`, error) + } + this.isConnected = false this.connectionStatus.connected = false logger.info(`Disconnected from MCP server: ${this.config.name}`) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index e57a9acc45d..b95f6103291 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -14,9 +14,10 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockClearCache, mockOauthCredsChanged } = vi.hoisted(() => ({ +const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens } = vi.hoisted(() => ({ mockClearCache: vi.fn(), mockOauthCredsChanged: vi.fn(), + mockRevokeOauthTokens: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) @@ -41,7 +42,7 @@ vi.mock('@/lib/mcp/domain-check', () => ({ vi.mock('@/lib/mcp/oauth', () => ({ detectMcpAuthType: vi.fn(), oauthCredsChanged: mockOauthCredsChanged, - revokeMcpOauthTokens: vi.fn(), + revokeMcpOauthTokens: mockRevokeOauthTokens, })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { clearCache: mockClearCache }, @@ -136,5 +137,7 @@ describe('MCP server lifecycle orchestration', () => { lastError: null, }) ) + // ...and revoke the now-orphaned OAuth tokens rather than leaving them stored and valid. + expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1') }) }) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index bcd8a022896..e0a29488fd3 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -349,9 +349,11 @@ export async function performUpdateMcpServer( currentClientId: currentServer.oauthClientId, currentEncryptedClientSecret: currentServer.oauthClientSecret, }) - const shouldClearOauth = urlChanged || credsChanged const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType const authTypeChanged = resolvedAuthType !== currentServer.authType + // Turning OAuth off must revoke and delete its now-orphaned tokens, not just reset the connection. + const oauthDisabled = currentServer.authType === 'oauth' && resolvedAuthType !== 'oauth' + const shouldClearOauth = urlChanged || credsChanged || oauthDisabled // An auth-type flip (either direction) or OAuth creds/URL change invalidates the connection: reset and clear stale state. if (authTypeChanged || (shouldClearOauth && resolvedAuthType === 'oauth')) { updateData.connectionStatus = 'disconnected' diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index 6cda2cea6af..f8300149529 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -1,7 +1,18 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' -import { createPinnedFetch } from '@/lib/core/security/input-validation.server' +import { + createPinnedFetch, + createPinnedFetchWithDispatcher, +} from '@/lib/core/security/input-validation.server' import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' +/** Pinned fetch for the live MCP transport, plus a handle to release its sockets. */ +export interface PinnedMcpFetch { + /** Pinned fetch to hand to the MCP transport's `fetch` option. */ + fetch: typeof fetch + /** Tears down the underlying HTTP/2 Agent; call when the MCP client disconnects. */ + close: () => Promise +} + /** * Pinned fetch for the long-lived MCP transport, which reuses one Agent across * a connection's requests. MCP servers are commonly behind HTTP/2 fronts (CDNs, @@ -9,10 +20,15 @@ import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' * the transport enables it. h2 is *not* used for one-shot flows (OAuth discovery, * auth-type probe), where a per-request Agent would leave idle h2 sessions with * no reuse benefit. Pinning is unaffected: the pinned lookup forces the socket to - * `resolvedIP` regardless of negotiated protocol. + * `resolvedIP` regardless of negotiated protocol. The returned `close` binds the + * Agent's teardown to the transport lifecycle so h2 sessions don't linger past + * disconnect. */ -export function createPinnedMcpFetch(resolvedIP: string): typeof fetch { - return createPinnedFetch(resolvedIP, { allowH2: true }) +export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch { + const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP, { + allowH2: true, + }) + return { fetch: pinnedFetch, close: () => dispatcher.destroy() } } /** From 4e3451bbe28111beba502acea1dbbf4dd6ca1b17 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 12:55:33 -0700 Subject: [PATCH 5/6] fix(mcp): release pinned Agent on failed connect and point DCR error at client-id/secret setup --- .../sim/app/api/mcp/oauth/start/route.test.ts | 2 +- apps/sim/app/api/mcp/oauth/start/route.ts | 2 +- apps/sim/lib/mcp/client.test.ts | 35 ++++++++++++++++++- apps/sim/lib/mcp/client.ts | 20 ++++++++--- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts index 555e2d2fa4d..6b0df17c66e 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts @@ -176,7 +176,7 @@ describe('MCP OAuth start route', () => { expect(response.status).toBe(422) expect(body.error).toBe( - "This server doesn't support OAuth client registration. Configure a token instead." + "This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead." ) }) }) diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts index 4354257a1e0..882befca8d7 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -26,7 +26,7 @@ const logger = createLogger('McpOauthStartAPI') const OAUTH_START_TTL_MS = 10 * 60 * 1000 const MAX_SURFACED_ERROR_LENGTH = 250 const DCR_UNSUPPORTED_MESSAGE = - "This server doesn't support OAuth client registration. Configure a token instead." + "This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead." function isDynamicClientRegistrationUnsupported(error: unknown): boolean { return getErrorMessage(error, '') diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index 198a67e5c4c..8f10ee5b4e1 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -4,7 +4,7 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLogger, mockSdkConnect, mockSdkListTools } = vi.hoisted(() => ({ +const { mockLogger, mockSdkConnect, mockSdkListTools, mockPinnedClose } = vi.hoisted(() => ({ mockLogger: { debug: vi.fn(), error: vi.fn(), @@ -13,12 +13,17 @@ const { mockLogger, mockSdkConnect, mockSdkListTools } = vi.hoisted(() => ({ }, mockSdkConnect: vi.fn().mockResolvedValue(undefined), mockSdkListTools: vi.fn().mockResolvedValue({ tools: [] }), + mockPinnedClose: vi.fn().mockResolvedValue(undefined), })) vi.mock('@sim/logger', () => ({ createLogger: () => mockLogger, })) +vi.mock('@/lib/mcp/pinned-fetch', () => ({ + createPinnedMcpFetch: vi.fn(() => ({ fetch: vi.fn(), close: mockPinnedClose })), +})) + /** * Capture the notification handler registered via `client.setNotificationHandler()`. * This lets us simulate the MCP SDK delivering a `tools/list_changed` notification. @@ -266,6 +271,34 @@ describe('McpClient notification handler', () => { expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(secret) }) + it('closes the pinned transport Agent when connect fails', async () => { + mockSdkConnect.mockRejectedValueOnce(new Error('connect boom')) + const client = new McpClient({ + config: createConfig(), + securityPolicy: { requireConsent: false, auditLevel: 'basic' }, + resolvedIP: '203.0.113.10', + }) + + // A failed connect discards the client without a disconnect(), so the Agent + // must be released on the failure path or its h2 sockets leak. + await expect(client.connect()).rejects.toThrow() + + expect(mockPinnedClose).toHaveBeenCalledTimes(1) + }) + + it('closes the pinned transport Agent on disconnect', async () => { + const client = new McpClient({ + config: createConfig(), + securityPolicy: { requireConsent: false, auditLevel: 'basic' }, + resolvedIP: '203.0.113.10', + }) + + await client.connect() + await client.disconnect() + + expect(mockPinnedClose).toHaveBeenCalledTimes(1) + }) + it('does not misclassify rejected static headers as an OAuth authorization flow', async () => { mockSdkConnect.mockRejectedValueOnce(new UnauthorizedError('Static token rejected')) const client = new McpClient({ diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 3ebdd36a7f6..631d76fa13a 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -162,6 +162,7 @@ export class McpClient { await this.client.close().catch((error) => { logger.warn(`Error closing cancelled connection to ${this.config.name}:`, error) }) + await this.closeTransportAgent() throw new McpConnectionError('Connection attempt cancelled', this.config.name) } @@ -187,6 +188,8 @@ export class McpClient { }) } catch (error) { this.isConnected = false + // A failed connect discards this client without a disconnect(), so release the Agent here. + await this.closeTransportAgent() const errorMessage = getErrorMessage(error, 'Unknown error') const outcome = classifyConnectionOutcome(error, this.config.authType) logger.error(`Failed to connect to MCP server ${this.config.name}`, { @@ -217,15 +220,24 @@ export class McpClient { logger.warn(`Error during disconnect from ${this.config.name}:`, error) } + await this.closeTransportAgent() + + this.isConnected = false + this.connectionStatus.connected = false + logger.info(`Disconnected from MCP server: ${this.config.name}`) + } + + /** + * Tears down the pinned transport's HTTP/2 Agent, releasing its sockets. Must run + * on every terminal path — successful disconnect, and failed or cancelled connect — + * since a failed `connect()` discards this client without a `disconnect()` call. + */ + private async closeTransportAgent(): Promise { try { await this.closePinnedTransport?.() } catch (error) { logger.warn(`Error closing pinned transport for ${this.config.name}:`, error) } - - this.isConnected = false - this.connectionStatus.connected = false - logger.info(`Disconnected from MCP server: ${this.config.name}`) } getStatus(): McpConnectionStatus { From b856239dce2897e214436d96482ad673087d65e6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 13:04:25 -0700 Subject: [PATCH 6/6] fix(mcp): make pinned Agent teardown idempotent to avoid double-destroy on cancel/failed connect --- apps/sim/lib/mcp/client.test.ts | 16 ++++++++++++++++ apps/sim/lib/mcp/client.ts | 9 +++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index 8f10ee5b4e1..dda637bbe88 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -299,6 +299,22 @@ describe('McpClient notification handler', () => { expect(mockPinnedClose).toHaveBeenCalledTimes(1) }) + it('does not destroy the pinned transport Agent twice when a failed connect is followed by disconnect', async () => { + mockSdkConnect.mockRejectedValueOnce(new Error('connect boom')) + const client = new McpClient({ + config: createConfig(), + securityPolicy: { requireConsent: false, auditLevel: 'basic' }, + resolvedIP: '203.0.113.10', + }) + + await expect(client.connect()).rejects.toThrow() + // The caller (e.g. withConnectTimeout) may still call disconnect() afterward; + // teardown must be idempotent so the Agent is destroyed exactly once. + await client.disconnect() + + expect(mockPinnedClose).toHaveBeenCalledTimes(1) + }) + it('does not misclassify rejected static headers as an OAuth authorization flow', async () => { mockSdkConnect.mockRejectedValueOnce(new UnauthorizedError('Static token rejected')) const client = new McpClient({ diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 631d76fa13a..a70353a80f8 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -162,7 +162,7 @@ export class McpClient { await this.client.close().catch((error) => { logger.warn(`Error closing cancelled connection to ${this.config.name}:`, error) }) - await this.closeTransportAgent() + // The Agent is released by the shared catch below, which this throw enters. throw new McpConnectionError('Connection attempt cancelled', this.config.name) } @@ -231,10 +231,15 @@ export class McpClient { * Tears down the pinned transport's HTTP/2 Agent, releasing its sockets. Must run * on every terminal path — successful disconnect, and failed or cancelled connect — * since a failed `connect()` discards this client without a `disconnect()` call. + * Idempotent: the handle is cleared before use so repeat calls (a failed connect + * followed by the caller's `disconnect()`) never destroy the same Agent twice. */ private async closeTransportAgent(): Promise { + const close = this.closePinnedTransport + if (!close) return + this.closePinnedTransport = undefined try { - await this.closePinnedTransport?.() + await close() } catch (error) { logger.warn(`Error closing pinned transport for ${this.config.name}:`, error) }