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
}