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..6b0df17c66e 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 automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or 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..882befca8d7 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 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, '') + .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/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 7f81039622c..dc9764990ba 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -433,9 +433,34 @@ 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, + 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 createPinnedFetch(resolvedIP: string): typeof fetch { - const dispatcher = new Agent({ connect: { lookup: createPinnedLookup(resolvedIP) } }) +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) }, + }) 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) @@ -447,7 +472,7 @@ export function createPinnedFetch(resolvedIP: string): typeof fetch { return response as unknown as Response } - return pinned + return { fetch: pinned, dispatcher } } /** 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/client.test.ts b/apps/sim/lib/mcp/client.test.ts index c568f425d61..dda637bbe88 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -1,9 +1,10 @@ /** * @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(() => ({ +const { mockLogger, mockSdkConnect, mockSdkListTools, mockPinnedClose } = vi.hoisted(() => ({ mockLogger: { debug: vi.fn(), error: vi.fn(), @@ -12,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. @@ -265,6 +271,99 @@ 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 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({ + 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..a70353a80f8 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' @@ -67,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 @@ -89,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: createPinnedFetch(resolvedIP) } : {}), + ...(pinned ? { fetch: pinned.fetch } : {}), }) this.client = new Client( @@ -105,11 +114,12 @@ 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, - error: sanitizeForLogging(getErrorMessage(error, 'Unknown transport error'), 200), + phase: 'transport', + sessionIdPresent: Boolean(this.transport.sessionId), + error: getMcpSafeErrorDiagnostics(error), }) } } @@ -152,6 +162,7 @@ export class McpClient { await this.client.close().catch((error) => { logger.warn(`Error closing cancelled connection to ${this.config.name}:`, error) }) + // The Agent is released by the shared catch below, which this throw enters. throw new McpConnectionError('Connection attempt cancelled', this.config.name) } @@ -177,26 +188,24 @@ 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 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) } @@ -211,11 +220,31 @@ 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. + * 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 close() + } catch (error) { + logger.warn(`Error closing pinned transport for ${this.config.name}:`, error) + } + } + getStatus(): McpConnectionStatus { return { ...this.connectionStatus } } @@ -226,8 +255,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) @@ -236,6 +263,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 +293,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/oauth/probe.ts b/apps/sim/lib/mcp/oauth/probe.ts index 4343f27870a..d6aac4d19c1 100644 --- a/apps/sim/lib/mcp/oauth/probe.ts +++ b/apps/sim/lib/mcp/oauth/probe.ts @@ -68,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 new file mode 100644 index 00000000000..b95f6103291 --- /dev/null +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -0,0 +1,143 @@ +/** + * @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, mockRevokeOauthTokens } = vi.hoisted(() => ({ + mockClearCache: vi.fn(), + mockOauthCredsChanged: vi.fn(), + mockRevokeOauthTokens: 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: mockRevokeOauthTokens, +})) +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 to disconnected — it hasn't completed an auth flow. + expect(dbChainMockFns.set).toHaveBeenCalledWith( + 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, + }) + ) + // ...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 d8d2d73115a..e0a29488fd3 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -349,11 +349,16 @@ export async function performUpdateMcpServer( currentClientId: currentServer.oauthClientId, currentEncryptedClientSecret: currentServer.oauthClientSecret, }) - const shouldClearOauth = urlChanged || credsChanged const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType - if (shouldClearOauth && resolvedAuthType === 'oauth') { + 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' updateData.lastConnected = null + updateData.lastError = null } if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId) @@ -384,6 +389,8 @@ export async function performUpdateMcpServer( const shouldClearCache = urlChanged || credsChanged || + params.transport !== undefined || + authTypeChanged || params.enabled !== undefined || params.headers !== undefined || params.timeout !== undefined || diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index 3184a0da7a9..f8300149529 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -1,7 +1,36 @@ 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, + * 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. 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): PinnedMcpFetch { + const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP, { + allowH2: true, + }) + return { fetch: pinnedFetch, close: () => dispatcher.destroy() } +} + /** * Builds a `FetchLike` that validates every outbound request URL against the * MCP SSRF policy before issuing it, then pins the connection to the resolved