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..2ac09d8b300 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 Authorization required when an OAuth refresh finishes disconnected', () => { + expect( + getRefreshActionState({ + mutationStatus: 'success', + connectionStatus: 'disconnected', + authType: 'oauth', + workflowsUpdated: 0, + }) + ).toEqual({ + text: '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..7f37d47d9d4 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: '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..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/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/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/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 || 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 }