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