chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Capture the notification handler registered via `client.setNotificationHandler()`.
* This lets us simulate the MCP SDK delivering a `tools/list_changed` notification.
*/
let capturedNotificationHandler: (() => Promise<void>) | null = null
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: vi.fn().mockImplementation(
class {
constructor() {
Object.assign(this, {
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getServerVersion: vi.fn().mockReturnValue('2025-06-18'),
getServerCapabilities: vi.fn().mockReturnValue({ tools: { listChanged: true } }),
setNotificationHandler: vi
.fn()
.mockImplementation((_schema: unknown, handler: () => Promise<void>) => {
capturedNotificationHandler = handler
}),
listTools: vi.fn().mockResolvedValue({ tools: [] }),
})
}
}
),
}))
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPClientTransport: vi.fn().mockImplementation(
class {
onclose: null = null
sessionId = 'test-session'
}
),
}))
vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
ToolListChangedNotificationSchema: { method: 'notifications/tools/list_changed' },
}))
vi.mock('@/lib/core/execution-limits', () => ({
getMaxExecutionTimeout: vi.fn().mockReturnValue(30000),
DEFAULT_EXECUTION_TIMEOUT_MS: 30000,
}))
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { McpClient } from './client'
import type { McpClientOptions, McpServerConfig } from './types'
function createConfig(): McpServerConfig {
return {
id: 'server-1',
name: 'Test Server',
transport: 'streamable-http',
url: 'https://test.example.com/mcp',
}
}
describe('McpClient notification handler', () => {
beforeEach(() => {
capturedNotificationHandler = null
vi.clearAllMocks()
})
it('fires onToolsChanged when a notification arrives while connected', async () => {
const onToolsChanged = vi.fn()
const client = new McpClient({
config: createConfig(),
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
onToolsChanged,
})
await client.connect()
expect(capturedNotificationHandler).not.toBeNull()
await capturedNotificationHandler!()
expect(onToolsChanged).toHaveBeenCalledTimes(1)
expect(onToolsChanged).toHaveBeenCalledWith('server-1')
})
it('suppresses notifications after disconnect', async () => {
const onToolsChanged = vi.fn()
const client = new McpClient({
config: createConfig(),
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
onToolsChanged,
})
await client.connect()
expect(capturedNotificationHandler).not.toBeNull()
await client.disconnect()
await capturedNotificationHandler!()
expect(onToolsChanged).not.toHaveBeenCalled()
})
it('does not register a notification handler when onToolsChanged is not provided', async () => {
const client = new McpClient({
config: createConfig(),
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
})
await client.connect()
expect(capturedNotificationHandler).toBeNull()
})
it('passes configured headers for OAuth transports as well as header auth transports', () => {
const authProvider = {} as unknown as NonNullable<McpClientOptions['authProvider']>
new McpClient({
config: {
...createConfig(),
authType: 'oauth',
headers: { 'X-Sim-Via': 'workflow' },
},
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
authProvider,
})
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('https://test.example.com/mcp'),
{
authProvider,
requestInit: { headers: { 'X-Sim-Via': 'workflow' } },
}
)
})
})
+303
View File
@@ -0,0 +1,303 @@
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import {
LATEST_PROTOCOL_VERSION,
type ListToolsResult,
SUPPORTED_PROTOCOL_VERSIONS,
type Tool,
ToolListChangedNotificationSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
import {
type McpClientOptions,
McpConnectionError,
type McpConnectionStatus,
type McpConsentRequest,
type McpConsentResponse,
McpError,
type McpSecurityPolicy,
type McpServerConfig,
type McpTool,
type McpToolCall,
type McpToolResult,
type McpToolsChangedCallback,
type McpVersionInfo,
} from '@/lib/mcp/types'
import { MCP_CLIENT_CONSTANTS } from '@/lib/mcp/utils'
const logger = createLogger('McpClient')
interface McpClientConnectOptions {
isCancelled?: () => boolean
}
export class McpClient {
private client: Client
private transport: StreamableHTTPClientTransport
private config: McpServerConfig
private connectionStatus: McpConnectionStatus
private securityPolicy: McpSecurityPolicy
private onToolsChanged?: McpToolsChangedCallback
private authProvider?: McpClientOptions['authProvider']
private isConnected = false
constructor(options: McpClientOptions) {
this.config = options.config
this.securityPolicy = options.securityPolicy ?? {
requireConsent: true,
auditLevel: 'basic',
maxToolExecutionsPerHour: 1000,
}
this.onToolsChanged = options.onToolsChanged
this.authProvider = options.authProvider
const resolvedIP = options.resolvedIP
this.connectionStatus = { connected: false }
if (!this.config.url) {
throw new McpError('URL required for Streamable HTTP transport')
}
if (this.config.authType === 'oauth' && this.authProvider == null) {
throw new McpError('OAuth MCP server requires an authProvider')
}
const useOauth = this.config.authType === 'oauth'
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
authProvider: useOauth ? this.authProvider : undefined,
requestInit: { headers: this.config.headers },
...(resolvedIP ? { fetch: createPinnedFetch(resolvedIP) } : {}),
})
this.client = new Client(
{
name: 'sim-platform',
version: '1.0.0',
},
{
capabilities: {},
}
)
}
/**
* Initialize connection to MCP server.
* If an `onToolsChanged` callback was provided, registers a notification handler
* for `notifications/tools/list_changed` after connecting.
*/
async connect(options: McpClientConnectOptions = {}): Promise<void> {
logger.info(`Connecting to MCP server: ${this.config.name} (${this.config.transport})`)
try {
await this.client.connect(this.transport)
if (options.isCancelled?.()) {
await this.client.close().catch((error) => {
logger.warn(`Error closing cancelled connection to ${this.config.name}:`, error)
})
throw new McpConnectionError('Connection attempt cancelled', this.config.name)
}
this.isConnected = true
this.connectionStatus.connected = true
this.connectionStatus.lastConnected = new Date()
if (this.onToolsChanged) {
this.client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
if (!this.isConnected) return
logger.info(`[${this.config.name}] Received tools/list_changed notification`)
this.onToolsChanged?.(this.config.id)
})
logger.info(`[${this.config.name}] Registered tools/list_changed notification handler`)
}
const serverVersion = this.client.getServerVersion()
logger.info(`Successfully connected to MCP server: ${this.config.name}`, {
protocolVersion: serverVersion,
})
} catch (error) {
this.isConnected = false
if (error instanceof McpOauthRedirectRequired || error instanceof UnauthorizedError) {
this.connectionStatus.lastError = undefined
throw error
}
const errorMessage = getErrorMessage(error, 'Unknown error')
this.connectionStatus.lastError = errorMessage
logger.error(`Failed to connect to MCP server ${this.config.name}:`, error)
throw new McpConnectionError(errorMessage, this.config.name)
}
}
async disconnect(): Promise<void> {
logger.info(`Disconnecting from MCP server: ${this.config.name}`)
try {
await this.client.close()
} catch (error) {
logger.warn(`Error during disconnect from ${this.config.name}:`, error)
}
this.isConnected = false
this.connectionStatus.connected = false
logger.info(`Disconnected from MCP server: ${this.config.name}`)
}
getStatus(): McpConnectionStatus {
return { ...this.connectionStatus }
}
async listTools(): Promise<McpTool[]> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.name)
}
try {
const result: ListToolsResult = await this.client.listTools(undefined, {
timeout: MCP_CLIENT_CONSTANTS.LIST_TOOLS_TIMEOUT_MS,
})
if (!result.tools || !Array.isArray(result.tools)) {
logger.warn(`Invalid tools response from server ${this.config.name}:`, result)
return []
}
return result.tools.map((tool: Tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema as McpTool['inputSchema'],
serverId: this.config.id,
serverName: this.config.name,
}))
} catch (error) {
logger.error(`Failed to list tools from server ${this.config.name}:`, error)
throw error
}
}
async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.name)
}
const consentRequest: McpConsentRequest = {
type: 'tool_execution',
context: {
serverId: this.config.id,
serverName: this.config.name,
action: toolCall.name,
description: `Execute tool '${toolCall.name}' on ${this.config.name}`,
dataAccess: Object.keys(toolCall.arguments || {}),
sideEffects: ['tool_execution'],
},
expires: Date.now() + 5 * 60 * 1000,
}
const consentResponse = await this.requestConsent(consentRequest)
if (!consentResponse.granted) {
throw new McpError(`User consent denied for tool execution: ${toolCall.name}`, -32000, {
consentAuditId: consentResponse.auditId,
})
}
try {
logger.info(`Calling tool ${toolCall.name} on server ${this.config.name}`, {
consentAuditId: consentResponse.auditId,
protocolVersion: this.getNegotiatedVersion(),
})
const sdkResult = await this.client.callTool(
{ name: toolCall.name, arguments: toolCall.arguments },
undefined,
{ timeout: getMaxExecutionTimeout() }
)
return sdkResult as McpToolResult
} catch (error) {
logger.error(`Failed to call tool ${toolCall.name} on server ${this.config.name}:`, error)
throw error
}
}
async ping(): Promise<{ _meta?: Record<string, any> }> {
if (!this.isConnected) {
throw new McpConnectionError('Not connected to server', this.config.name)
}
try {
logger.info(`[${this.config.name}] Sending ping to server`)
const response = await this.client.ping()
logger.info(`[${this.config.name}] Ping successful`)
return response
} catch (error) {
logger.error(`[${this.config.name}] Ping failed:`, error)
throw error
}
}
hasListChangedCapability(): boolean {
return !!this.client.getServerCapabilities()?.tools?.listChanged
}
/** Chains with the SDK's internal onclose handler so its cleanup still runs. */
onClose(callback: () => void): void {
const existingHandler = this.transport.onclose
this.transport.onclose = () => {
existingHandler?.()
callback()
}
}
getConfig(): McpServerConfig {
return { ...this.config }
}
static getVersionInfo(): McpVersionInfo {
return {
supported: [...SUPPORTED_PROTOCOL_VERSIONS],
preferred: LATEST_PROTOCOL_VERSION,
}
}
getNegotiatedVersion(): string | undefined {
const serverVersion = this.client.getServerVersion()
return typeof serverVersion === 'string' ? serverVersion : undefined
}
getSessionId(): string | undefined {
return this.transport.sessionId
}
async requestConsent(consentRequest: McpConsentRequest): Promise<McpConsentResponse> {
if (!this.securityPolicy.requireConsent) {
return { granted: true, auditId: `audit-${Date.now()}` }
}
const { serverId, serverName, action, sideEffects } = consentRequest.context
if (this.securityPolicy.blockedOrigins?.includes(this.config.url || '')) {
logger.warn(`Tool execution blocked: Server ${serverName} is in blocked origins`)
return {
granted: false,
auditId: `audit-blocked-${Date.now()}`,
}
}
if (this.securityPolicy.auditLevel === 'detailed') {
logger.info(`Consent requested for ${action} on ${serverName}`, {
serverId,
action,
sideEffects,
timestamp: new Date().toISOString(),
})
}
return {
granted: true,
expires: consentRequest.expires,
auditId: `audit-${serverId}-${Date.now()}`,
}
}
}
+393
View File
@@ -0,0 +1,393 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
interface MockMcpClient {
connect: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
hasListChangedCapability: ReturnType<typeof vi.fn>
onClose: ReturnType<typeof vi.fn>
}
/** Deferred promise to control when `client.connect()` resolves. */
function createDeferred<T = void>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
function serverConfig(id: string, name = `Server ${id}`) {
return {
id,
name,
transport: 'streamable-http' as const,
url: `https://${id}.example.com/mcp`,
}
}
const {
MockMcpClientConstructor,
mockOnToolsChanged,
mockPublishToolsChanged,
mockGetOrCreateOauthRow,
} = vi.hoisted(() => ({
MockMcpClientConstructor: vi.fn(),
mockOnToolsChanged: vi.fn(() => vi.fn()),
mockPublishToolsChanged: vi.fn(),
mockGetOrCreateOauthRow: vi.fn(),
}))
vi.mock('@/lib/core/config/env-flags', () => ({ isTest: false }))
vi.mock('@/lib/mcp/pubsub', () => ({
mcpPubSub: {
onToolsChanged: mockOnToolsChanged,
publishToolsChanged: mockPublishToolsChanged,
},
}))
vi.mock('@/lib/mcp/client', () => ({
McpClient: MockMcpClientConstructor,
}))
vi.mock('@/lib/mcp/oauth', () => ({
getOrCreateOauthRow: mockGetOrCreateOauthRow,
loadPreregisteredClient: vi.fn(),
SimMcpOauthProvider: vi.fn().mockImplementation(
class {
constructor(value: object) {
Object.assign(this, value)
}
}
),
}))
import { McpConnectionManager } from '@/lib/mcp/connection-manager'
describe('McpConnectionManager', () => {
let manager: McpConnectionManager | null = null
beforeEach(() => {
vi.clearAllMocks()
mockGetOrCreateOauthRow.mockResolvedValue({
id: 'oauth-row-1',
mcpServerId: 'server-oauth',
userId: 'authorizer-1',
workspaceId: 'ws-1',
clientInformation: null,
tokens: { access_token: 'workspace-token', token_type: 'Bearer' },
codeVerifier: null,
state: null,
updatedAt: new Date(),
})
})
afterEach(() => {
manager?.dispose()
manager = null
vi.useRealTimers()
})
function createFreshManager(): McpConnectionManager {
const mgr = new McpConnectionManager()
manager = mgr
return mgr
}
describe('concurrent connect() guard', () => {
it('creates only one client when two connect() calls race for the same serverId', async () => {
const deferred = createDeferred()
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
const instance: MockMcpClient = {
connect: vi.fn().mockImplementation(() => deferred.promise),
disconnect: vi.fn().mockResolvedValue(undefined),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn(),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
const config = serverConfig('server-1')
const p1 = mgr.connect(config, 'user-1', 'ws-1')
const p2 = mgr.connect(config, 'user-1', 'ws-1')
deferred.resolve()
const [r1, r2] = await Promise.all([p1, p2])
expect(instances).toHaveLength(1)
expect(r1.supportsListChanged).toBe(true)
expect(r2.supportsListChanged).toBe(false)
})
it('shares OAuth managed connections across workspace users for the same server', async () => {
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
const instance: MockMcpClient = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn(),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
const config = { ...serverConfig('server-oauth'), authType: 'oauth' as const }
const r1 = await mgr.connect(config, 'user-1', 'ws-1')
const r2 = await mgr.connect(config, 'user-2', 'ws-1')
expect(instances).toHaveLength(1)
expect(r1.supportsListChanged).toBe(true)
expect(r2.supportsListChanged).toBe(true)
expect(mockGetOrCreateOauthRow).toHaveBeenCalledTimes(1)
expect(mockGetOrCreateOauthRow).toHaveBeenCalledWith({
mcpServerId: 'server-oauth',
userId: 'user-1',
workspaceId: 'ws-1',
})
})
it('allows a new connect() after a previous one completes', async () => {
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
const instance: MockMcpClient = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
hasListChangedCapability: vi.fn().mockReturnValue(false),
onClose: vi.fn(),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
const config = serverConfig('server-2')
const r1 = await mgr.connect(config, 'user-1', 'ws-1')
expect(r1.supportsListChanged).toBe(false)
const r2 = await mgr.connect(config, 'user-1', 'ws-1')
expect(r2.supportsListChanged).toBe(false)
expect(instances).toHaveLength(2)
})
it('cleans up connectingServers when connect() throws', async () => {
let callCount = 0
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
callCount++
const instance: MockMcpClient = {
connect:
callCount === 1
? vi.fn().mockRejectedValue(new Error('Connection refused'))
: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn(),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
const config = serverConfig('server-3')
const r1 = await mgr.connect(config, 'user-1', 'ws-1')
expect(r1.supportsListChanged).toBe(false)
const r2 = await mgr.connect(config, 'user-1', 'ws-1')
expect(r2.supportsListChanged).toBe(true)
expect(instances).toHaveLength(2)
})
it('marks timed-out connect attempts as cancelled for late completions', async () => {
vi.useFakeTimers()
const deferred = createDeferred()
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
const instance: MockMcpClient = {
connect: vi.fn().mockImplementation(() => deferred.promise),
disconnect: vi.fn().mockResolvedValue(undefined),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn(),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
const resultPromise = mgr.connect(serverConfig('server-timeout'), 'user-1', 'ws-1')
await vi.advanceTimersByTimeAsync(15_000)
const result = await resultPromise
const connectOptions = instances[0].connect.mock.calls[0][0]
expect(result.supportsListChanged).toBe(false)
expect(connectOptions.isCancelled()).toBe(true)
expect(instances[0].disconnect).toHaveBeenCalled()
deferred.resolve()
})
})
describe('dispose', () => {
it('rejects new connections after dispose', async () => {
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
Object.assign(this, {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn(),
})
}
}
)
const mgr = createFreshManager()
mgr.dispose()
const result = await mgr.connect(serverConfig('server-4'), 'user-1', 'ws-1')
expect(result.supportsListChanged).toBe(false)
})
})
describe('intentional disconnect cleanup', () => {
it('does not reconnect when disconnectServer closes a managed client', async () => {
vi.useFakeTimers()
let closeHandler: (() => void) | undefined
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
const instance: MockMcpClient = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockImplementation(async () => {
closeHandler?.()
}),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn().mockImplementation((handler: () => void) => {
closeHandler = handler
}),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
await mgr.connect(serverConfig('server-5'), 'user-1', 'ws-1')
await mgr.disconnectServer('server-5')
await vi.advanceTimersByTimeAsync(2_000)
expect(instances).toHaveLength(1)
expect(mgr.hasConnection('server-5')).toBe(false)
})
it('does not reconnect when close fires after disconnect resolves', async () => {
vi.useFakeTimers()
let closeHandler: (() => void) | undefined
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
const instance: MockMcpClient = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn().mockImplementation((handler: () => void) => {
closeHandler = handler
}),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
await mgr.connect(serverConfig('server-7'), 'user-1', 'ws-1')
await mgr.disconnectServer('server-7')
closeHandler?.()
await vi.advanceTimersByTimeAsync(2_000)
expect(instances).toHaveLength(1)
expect(mgr.hasConnection('server-7')).toBe(false)
})
it('does not reconnect idle connections after cleanup disconnects them', async () => {
vi.useFakeTimers()
const closeHandlers: Array<() => void> = []
const instances: MockMcpClient[] = []
MockMcpClientConstructor.mockImplementation(
class {
constructor() {
const instance: MockMcpClient = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockImplementation(async () => {
closeHandlers.at(-1)?.()
}),
hasListChangedCapability: vi.fn().mockReturnValue(true),
onClose: vi.fn().mockImplementation((handler: () => void) => {
closeHandlers.push(handler)
}),
}
instances.push(instance)
Object.assign(this, instance)
}
}
)
const mgr = createFreshManager()
await mgr.connect(serverConfig('server-6'), 'user-1', 'ws-1')
await vi.advanceTimersByTimeAsync(35 * 60 * 1000)
expect(instances).toHaveLength(1)
expect(mgr.hasConnection('server-6')).toBe(false)
})
})
})
+473
View File
@@ -0,0 +1,473 @@
/**
* MCP Connection Manager
*
* Maintains persistent connections to MCP servers that support
* `notifications/tools/list_changed`. When a notification arrives,
* the manager invalidates the tools cache and emits a ToolsChangedEvent
* so the frontend SSE endpoint can push updates to browsers.
*
* Servers that do not support `listChanged` fall back to the existing
* stale-time cache approach — no persistent connection is kept.
*/
import { createLogger } from '@sim/logger'
import { backoffWithJitter } from '@sim/utils/retry'
import { isTest } from '@/lib/core/config/env-flags'
import { McpClient } from '@/lib/mcp/client'
import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import type {
ManagedConnectionState,
McpClientOptions,
McpServerConfig,
McpToolsChangedCallback,
ToolsChangedEvent,
} from '@/lib/mcp/types'
const logger = createLogger('McpConnectionManager')
const MAX_CONNECTIONS = 50
const MAX_RECONNECT_ATTEMPTS = 10
const BASE_RECONNECT_DELAY_MS = 1000
const CONNECT_TIMEOUT_MS = 15_000
const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
type ToolsChangedListener = (event: ToolsChangedEvent) => void
async function withConnectTimeout(client: McpClient, serverName: string): Promise<void> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
let timedOut = false
const connectPromise = client.connect({ isCancelled: () => timedOut })
try {
await Promise.race([
connectPromise,
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
timedOut = true
reject(new Error(`Timed out connecting to MCP server ${serverName}`))
}, CONNECT_TIMEOUT_MS)
}),
])
} catch (error) {
if (timedOut) {
void connectPromise.catch(() => {})
}
await client.disconnect().catch(() => {})
throw error
} finally {
if (timeoutId) clearTimeout(timeoutId)
}
}
/**
* Cache key for managed connections.
* MCP servers are workspace-owned, so OAuth/header/no-auth connections are
* keyed by server and share the same workspace-scoped server credentials.
*/
function connectionKey(config: McpServerConfig): string {
return config.id
}
export class McpConnectionManager {
private connections = new Map<string, McpClient>()
private states = new Map<string, ManagedConnectionState>()
private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>()
private listeners = new Set<ToolsChangedListener>()
private connectingServers = new Set<string>()
private idleCheckTimer: ReturnType<typeof setInterval> | null = null
private disposed = false
private unsubscribePubSub?: () => void
constructor() {
if (mcpPubSub) {
this.unsubscribePubSub = mcpPubSub.onToolsChanged((event) => {
this.notifyLocalListeners(event)
})
}
}
/**
* Subscribe to tools-changed events from any managed connection.
* Returns an unsubscribe function.
*/
subscribe(listener: ToolsChangedListener): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
/**
* Establish a persistent connection to an MCP server.
* If the server supports `listChanged`, the connection is kept alive
* and notifications are forwarded to subscribers.
*
* If the server does NOT support `listChanged`, the client is disconnected
* immediately — there's nothing to listen for.
*/
async connect(
config: McpServerConfig,
userId: string,
workspaceId: string,
resolvedIP?: string | null
): Promise<{ supportsListChanged: boolean }> {
if (this.disposed) {
logger.warn('Connection manager is disposed, ignoring connect request')
return { supportsListChanged: false }
}
const key = connectionKey(config)
if (this.connections.has(key) || this.connectingServers.has(key)) {
logger.info(`[${config.name}] Already has a managed connection or is connecting, skipping`)
const state = this.states.get(key)
return { supportsListChanged: state?.supportsListChanged ?? false }
}
if (this.connections.size >= MAX_CONNECTIONS) {
logger.warn(`Max connections (${MAX_CONNECTIONS}) reached, cannot connect to ${config.name}`)
return { supportsListChanged: false }
}
this.connectingServers.add(key)
try {
const onToolsChanged: McpToolsChangedCallback = () => {
this.handleToolsChanged(key)
}
let authProvider: McpClientOptions['authProvider']
if (config.authType === 'oauth') {
const row = await getOrCreateOauthRow({
mcpServerId: config.id,
userId,
workspaceId,
})
if (!row.tokens) {
logger.info(
`[${config.name}] OAuth server has no workspace tokens — skipping persistent connection until authorized`
)
return { supportsListChanged: false }
}
const preregistered = await loadPreregisteredClient(config.id)
authProvider = new SimMcpOauthProvider({ row, preregistered })
}
const client = new McpClient({
config,
securityPolicy: {
requireConsent: false,
auditLevel: 'basic',
maxToolExecutionsPerHour: 1000,
},
onToolsChanged,
resolvedIP: resolvedIP ?? undefined,
authProvider,
})
try {
await withConnectTimeout(client, config.name)
} catch (error) {
logger.error(`[${config.name}] Failed to connect for persistent monitoring:`, error)
return { supportsListChanged: false }
}
const supportsListChanged = client.hasListChangedCapability()
if (!supportsListChanged) {
logger.info(
`[${config.name}] Server does not support listChanged — disconnecting (fallback to cache)`
)
await client.disconnect()
return { supportsListChanged: false }
}
this.clearReconnectTimer(key)
this.connections.set(key, client)
this.states.set(key, {
serverId: config.id,
serverName: config.name,
workspaceId,
userId,
connected: true,
supportsListChanged: true,
reconnectAttempts: 0,
lastActivity: Date.now(),
})
client.onClose(() => {
this.handleDisconnect(config, userId, workspaceId)
})
this.ensureIdleCheck()
logger.info(`[${config.name}] Persistent connection established (listChanged supported)`)
return { supportsListChanged: true }
} finally {
this.connectingServers.delete(key)
}
}
/**
* Disconnect a managed connection by internal cache key.
*/
private async disconnectByKey(key: string): Promise<void> {
this.clearReconnectTimer(key)
const client = this.connections.get(key)
if (client) {
this.connections.delete(key)
this.states.delete(key)
try {
await client.disconnect()
} catch (error) {
logger.warn(`Error disconnecting managed client ${key}:`, error)
}
} else {
this.states.delete(key)
}
logger.info(`Managed connection removed: ${key}`)
}
/**
* Disconnect the managed connection for the given server.
*/
async disconnectServer(serverId: string): Promise<void> {
const keys: string[] = []
for (const [key, state] of this.states) {
if (state.serverId === serverId) keys.push(key)
}
await Promise.all(keys.map((key) => this.disconnectByKey(key)))
}
/**
* Check whether a managed connection exists for the given server.
*/
hasConnection(serverId: string): boolean {
for (const state of this.states.values()) {
if (state.serverId === serverId) return true
}
return false
}
/**
* Get all managed connection states (for diagnostics).
*/
getAllStates(): ManagedConnectionState[] {
return [...this.states.values()]
}
/**
* Dispose all connections and timers.
*/
dispose(): void {
this.disposed = true
this.unsubscribePubSub?.()
for (const timer of this.reconnectTimers.values()) {
clearTimeout(timer)
}
this.reconnectTimers.clear()
if (this.idleCheckTimer) {
clearInterval(this.idleCheckTimer)
this.idleCheckTimer = null
}
const disconnects = [...this.connections.entries()].map(async ([id, client]) => {
try {
await client.disconnect()
} catch (error) {
logger.warn(`Error disconnecting ${id} during dispose:`, error)
}
})
Promise.allSettled(disconnects).then(() => {
logger.info('Connection manager disposed')
})
this.connections.clear()
this.states.clear()
this.listeners.clear()
this.connectingServers.clear()
}
/**
* Notify only process-local listeners.
* Called by the pub/sub subscription (receives events from all processes).
*/
private notifyLocalListeners(event: ToolsChangedEvent): void {
for (const listener of this.listeners) {
try {
listener(event)
} catch (error) {
logger.error('Error in tools-changed listener:', error)
}
}
}
/**
* Handle a tools/list_changed notification from an external MCP server.
* Publishes to pub/sub so all processes are notified.
*/
private handleToolsChanged(key: string): void {
const state = this.states.get(key)
if (!state) return
state.lastActivity = Date.now()
const event: ToolsChangedEvent = {
serverId: state.serverId,
serverName: state.serverName,
workspaceId: state.workspaceId,
timestamp: Date.now(),
}
logger.info(`[${state.serverName}] Tools changed — publishing to pub/sub`)
mcpPubSub?.publishToolsChanged(event)
}
private handleDisconnect(config: McpServerConfig, userId: string, workspaceId: string): void {
const key = connectionKey(config)
const state = this.states.get(key)
if (!state || this.disposed) return
state.connected = false
this.connections.delete(key)
logger.warn(`[${config.name}] Persistent connection lost, scheduling reconnect`)
this.scheduleReconnect(config, userId, workspaceId)
}
private scheduleReconnect(config: McpServerConfig, userId: string, workspaceId: string): void {
const key = connectionKey(config)
const state = this.states.get(key)
if (!state || this.disposed) return
if (state.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
logger.error(
`[${config.name}] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached — giving up`
)
this.states.delete(key)
return
}
state.reconnectAttempts++
const delay = backoffWithJitter(state.reconnectAttempts, null, {
baseMs: BASE_RECONNECT_DELAY_MS,
maxMs: 60_000,
})
logger.info(
`[${config.name}] Reconnecting in ${delay}ms (attempt ${state.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`
)
this.clearReconnectTimer(key)
const timer = setTimeout(async () => {
this.reconnectTimers.delete(key)
if (this.disposed) return
const currentState = this.states.get(key)
if (currentState?.connected) {
logger.info(
`[${config.name}] Connection already re-established externally, skipping reconnect`
)
return
}
const attempts = state.reconnectAttempts
this.connections.delete(key)
this.states.delete(key)
try {
const result = await this.connect(config, userId, workspaceId)
if (result.supportsListChanged) {
logger.info(`[${config.name}] Reconnected successfully`)
} else {
this.restoreReconnectState(config, userId, workspaceId, attempts)
this.scheduleReconnect(config, userId, workspaceId)
}
} catch (error) {
logger.error(`[${config.name}] Reconnect failed:`, error)
this.restoreReconnectState(config, userId, workspaceId, attempts)
this.scheduleReconnect(config, userId, workspaceId)
}
}, delay)
this.reconnectTimers.set(key, timer)
}
private clearReconnectTimer(key: string): void {
const timer = this.reconnectTimers.get(key)
if (timer) {
clearTimeout(timer)
this.reconnectTimers.delete(key)
}
}
/**
* Restore minimal state so `scheduleReconnect` can check attempts and continue the retry loop.
*/
private restoreReconnectState(
config: McpServerConfig,
userId: string,
workspaceId: string,
reconnectAttempts: number
): void {
const key = connectionKey(config)
if (!this.states.has(key)) {
this.states.set(key, {
serverId: config.id,
serverName: config.name,
workspaceId,
userId,
connected: false,
supportsListChanged: false,
reconnectAttempts,
lastActivity: Date.now(),
})
}
}
private ensureIdleCheck(): void {
if (this.idleCheckTimer) return
this.idleCheckTimer = setInterval(() => {
const now = Date.now()
for (const [key, state] of this.states) {
if (now - state.lastActivity > IDLE_TIMEOUT_MS) {
logger.info(
`[${state.serverName}] Idle timeout reached, disconnecting managed connection`
)
this.disconnectByKey(key)
}
}
if (this.states.size === 0 && this.idleCheckTimer) {
clearInterval(this.idleCheckTimer)
this.idleCheckTimer = null
}
}, IDLE_CHECK_INTERVAL_MS)
}
}
type McpManagerGlobal = typeof globalThis & {
_mcpConnectionManager?: McpConnectionManager | null
}
const _g = globalThis as McpManagerGlobal
if (!('_mcpConnectionManager' in _g)) {
_g._mcpConnectionManager = isTest ? null : new McpConnectionManager()
}
export const mcpConnectionManager: McpConnectionManager | null = _g._mcpConnectionManager ?? null
+11
View File
@@ -0,0 +1,11 @@
export const MAX_MCP_TOOLS_PER_SERVER = 100
export const MAX_MCP_SERVERS_PER_WORKFLOW = 100
export const MCP_TOOL_BRIDGE_HEADER = 'X-Sim-MCP-Tool-Call'
export const MCP_TOOL_BRIDGE_ACTOR_HEADER = 'X-Sim-MCP-Tool-Actor'
export const MAX_MCP_PARAMETER_SCHEMA_BYTES = 2 * 1024 * 1024
export const MAX_MCP_TOOL_DESCRIPTION_BYTES = 64 * 1024
export const MAX_MCP_TOOL_NAME_BYTES = 256
export const MAX_MCP_TOOLS_LIST_RESPONSE_BYTES = 10 * 1024 * 1024
export const MAX_MCP_WORKFLOW_RESPONSE_BYTES = 10 * 1024 * 1024
export const MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES = MAX_MCP_PARAMETER_SCHEMA_BYTES
export const MAX_MCP_SERVER_TOOLS_METADATA_BYTES = MAX_MCP_TOOLS_LIST_RESPONSE_BYTES
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
const DEPRECATION_MESSAGE = 'Copilot MCP has been deprecated and is no longer available.'
/**
* Standard 410 response for the deprecated Copilot MCP surface. Used by the
* copilot MCP resource route and its copilot-specific OAuth discovery routes.
*/
export function copilotMcpDeprecatedResponse(): NextResponse {
return NextResponse.json(
{ error: 'gone', message: DEPRECATION_MESSAGE },
{
status: 410,
headers: { 'Cache-Control': 'no-store' },
}
)
}
/**
* JSON-RPC flavored 410 response for the deprecated Copilot MCP `POST` endpoint,
* so MCP clients surface a clean error envelope instead of an opaque body.
*/
export function copilotMcpDeprecatedJsonRpcResponse(): NextResponse {
return NextResponse.json(
{
jsonrpc: '2.0',
id: null,
error: { code: -32000, message: DEPRECATION_MESSAGE },
},
{
status: 410,
headers: { 'Cache-Control': 'no-store' },
}
)
}
+524
View File
@@ -0,0 +1,524 @@
/**
* @vitest-environment node
*/
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetAllowedMcpDomainsFromEnv, mockDnsLookup, hostedFlag } = vi.hoisted(() => ({
mockGetAllowedMcpDomainsFromEnv: vi.fn<() => string[] | null>(),
mockDnsLookup: vi.fn(),
hostedFlag: { value: false },
}))
vi.mock('@/lib/core/config/env-flags', () => ({
getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv,
get isHosted() {
return hostedFlag.value
},
}))
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
inputValidationMockFns.mockIsPrivateOrReservedIP.mockImplementation((ip: string) => {
if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true
if (ip.startsWith('172.')) {
const second = Number.parseInt(ip.split('.')[1], 10)
if (second >= 16 && second <= 31) return true
}
if (ip.startsWith('169.254.')) return true
if (ip.startsWith('127.') || ip === '::1') return true
if (ip === '0.0.0.0') return true
return false
})
vi.mock('dns/promises', () => ({
default: { lookup: mockDnsLookup },
}))
vi.mock('@/executor/utils/reference-validation', () => ({
createEnvVarPattern: () => /\{\{([^}]+)\}\}/g,
}))
import {
isMcpDomainAllowed,
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
validateMcpDomain,
validateMcpServerSsrf,
} from './domain-check'
describe('McpDomainNotAllowedError', () => {
it.concurrent('creates error with correct name and message', () => {
const error = new McpDomainNotAllowedError('evil.com')
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(McpDomainNotAllowedError)
expect(error.name).toBe('McpDomainNotAllowedError')
expect(error.message).toContain('evil.com')
})
})
describe('isMcpDomainAllowed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('when no allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
})
it('allows any URL', () => {
expect(isMcpDomainAllowed('https://any-server.com/mcp')).toBe(true)
})
it('allows undefined URL', () => {
expect(isMcpDomainAllowed(undefined)).toBe(true)
})
it('allows empty string URL', () => {
expect(isMcpDomainAllowed('')).toBe(true)
})
it('allows env var URLs', () => {
expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true)
})
it('allows URLs with env vars anywhere', () => {
expect(isMcpDomainAllowed('https://server.com/{{PATH}}')).toBe(true)
})
})
describe('when allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com'])
})
describe('basic domain matching', () => {
it('allows URLs on the allowlist', () => {
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true)
})
it('allows URLs with paths on allowlisted domains', () => {
expect(isMcpDomainAllowed('https://allowed.com/deep/path/to/mcp')).toBe(true)
})
it('allows URLs with query params on allowlisted domains', () => {
expect(isMcpDomainAllowed('https://allowed.com/mcp?key=value&foo=bar')).toBe(true)
})
it('allows URLs with ports on allowlisted domains', () => {
expect(isMcpDomainAllowed('https://allowed.com:8080/mcp')).toBe(true)
})
it('allows HTTP URLs on allowlisted domains', () => {
expect(isMcpDomainAllowed('http://allowed.com/mcp')).toBe(true)
})
it('matches case-insensitively', () => {
expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true)
expect(isMcpDomainAllowed('https://Allowed.Com/mcp')).toBe(true)
})
it('rejects URLs not on the allowlist', () => {
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
})
it('rejects subdomains of allowed domains', () => {
expect(isMcpDomainAllowed('https://sub.allowed.com/mcp')).toBe(false)
})
it('rejects URLs with allowed domain in path only', () => {
expect(isMcpDomainAllowed('https://evil.com/allowed.com/mcp')).toBe(false)
})
})
describe('fail-closed behavior', () => {
it('rejects undefined URL', () => {
expect(isMcpDomainAllowed(undefined)).toBe(false)
})
it('rejects empty string URL', () => {
expect(isMcpDomainAllowed('')).toBe(false)
})
it('rejects malformed URLs', () => {
expect(isMcpDomainAllowed('not-a-url')).toBe(false)
})
it('rejects URLs with no protocol', () => {
expect(isMcpDomainAllowed('allowed.com/mcp')).toBe(false)
})
})
describe('env var handling — hostname bypass', () => {
it('allows entirely env var URL', () => {
expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true)
})
it('allows env var URL with whitespace', () => {
expect(isMcpDomainAllowed(' {{MCP_SERVER_URL}} ')).toBe(true)
})
it('allows multiple env vars composing the entire URL', () => {
expect(isMcpDomainAllowed('{{PROTOCOL}}{{HOST}}{{PATH}}')).toBe(true)
})
it('allows env var in hostname portion', () => {
expect(isMcpDomainAllowed('https://{{MCP_HOST}}/mcp')).toBe(true)
})
it('allows env var as subdomain', () => {
expect(isMcpDomainAllowed('https://{{TENANT}}.company.com/mcp')).toBe(true)
})
it('allows env var in port (authority)', () => {
expect(isMcpDomainAllowed('https://{{HOST}}:{{PORT}}/mcp')).toBe(true)
})
it('allows env var as the full authority', () => {
expect(isMcpDomainAllowed('https://{{MCP_HOST}}:{{MCP_PORT}}/api/mcp')).toBe(true)
})
})
describe('env var handling — no bypass when only in path/query', () => {
it('rejects disallowed domain with env var in path', () => {
expect(isMcpDomainAllowed('https://evil.com/{{MCP_PATH}}')).toBe(false)
})
it('rejects disallowed domain with env var in query', () => {
expect(isMcpDomainAllowed('https://evil.com/mcp?key={{API_KEY}}')).toBe(false)
})
it('rejects disallowed domain with env var in fragment', () => {
expect(isMcpDomainAllowed('https://evil.com/mcp#{{SECTION}}')).toBe(false)
})
it('allows allowlisted domain with env var in path', () => {
expect(isMcpDomainAllowed('https://allowed.com/{{MCP_PATH}}')).toBe(true)
})
it('allows allowlisted domain with env var in query', () => {
expect(isMcpDomainAllowed('https://allowed.com/mcp?key={{API_KEY}}')).toBe(true)
})
it('rejects disallowed domain with env var in both path and query', () => {
expect(isMcpDomainAllowed('https://evil.com/{{PATH}}?token={{TOKEN}}&key={{KEY}}')).toBe(
false
)
})
it('rejects disallowed domain with env var in query but no path', () => {
expect(isMcpDomainAllowed('https://evil.com?token={{SECRET}}')).toBe(false)
})
it('rejects disallowed domain with env var in fragment but no path', () => {
expect(isMcpDomainAllowed('https://evil.com#{{SECTION}}')).toBe(false)
})
})
describe('env var security edge cases', () => {
it('rejects URL with env var only after allowed domain in path', () => {
expect(isMcpDomainAllowed('https://evil.com/allowed.com/{{VAR}}')).toBe(false)
})
it('rejects URL trying to use env var to sneak past domain check via userinfo', () => {
// https://evil.com@allowed.com would have hostname "allowed.com" per URL spec,
// but https://{{VAR}}@evil.com has env var in authority so it bypasses
expect(isMcpDomainAllowed('https://{{VAR}}@evil.com/mcp')).toBe(true)
})
})
})
})
describe('validateMcpDomain', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('when no allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
})
it('does not throw for any URL', () => {
expect(() => validateMcpDomain('https://any-server.com/mcp')).not.toThrow()
})
it('does not throw for undefined URL', () => {
expect(() => validateMcpDomain(undefined)).not.toThrow()
})
it('does not throw for empty string', () => {
expect(() => validateMcpDomain('')).not.toThrow()
})
})
describe('when allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com'])
})
describe('basic validation', () => {
it('does not throw for allowed URLs', () => {
expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow()
})
it('throws McpDomainNotAllowedError for disallowed URLs', () => {
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError)
})
it('throws for undefined URL (fail-closed)', () => {
expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError)
})
it('throws for malformed URLs', () => {
expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError)
})
it('includes the rejected domain in the error message', () => {
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/)
})
it('includes "(empty)" in error for undefined URL', () => {
expect(() => validateMcpDomain(undefined)).toThrow(/\(empty\)/)
})
})
describe('env var handling', () => {
it('does not throw for entirely env var URL', () => {
expect(() => validateMcpDomain('{{MCP_SERVER_URL}}')).not.toThrow()
})
it('does not throw for env var in hostname', () => {
expect(() => validateMcpDomain('https://{{MCP_HOST}}/mcp')).not.toThrow()
})
it('does not throw for env var in authority', () => {
expect(() => validateMcpDomain('https://{{HOST}}:{{PORT}}/mcp')).not.toThrow()
})
it('throws for disallowed URL with env var only in path', () => {
expect(() => validateMcpDomain('https://evil.com/{{MCP_PATH}}')).toThrow(
McpDomainNotAllowedError
)
})
it('throws for disallowed URL with env var only in query', () => {
expect(() => validateMcpDomain('https://evil.com/mcp?key={{API_KEY}}')).toThrow(
McpDomainNotAllowedError
)
})
it('does not throw for allowed URL with env var in path', () => {
expect(() => validateMcpDomain('https://allowed.com/{{PATH}}')).not.toThrow()
})
it('throws for disallowed URL with env var in query but no path', () => {
expect(() => validateMcpDomain('https://evil.com?token={{SECRET}}')).toThrow(
McpDomainNotAllowedError
)
})
it('throws for disallowed URL with env var in fragment but no path', () => {
expect(() => validateMcpDomain('https://evil.com#{{SECTION}}')).toThrow(
McpDomainNotAllowedError
)
})
})
})
})
describe('validateMcpServerSsrf', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
hostedFlag.value = false
})
it('returns null for undefined URL', async () => {
await expect(validateMcpServerSsrf(undefined)).resolves.toBeNull()
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('returns null and skips validation for env var URLs', async () => {
await expect(validateMcpServerSsrf('{{MCP_SERVER_URL}}')).resolves.toBeNull()
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('returns null and skips validation for URLs with env var in hostname', async () => {
await expect(validateMcpServerSsrf('https://{{MCP_HOST}}/mcp')).resolves.toBeNull()
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('returns null for localhost URLs without DNS lookup', async () => {
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).resolves.toBeNull()
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('returns null for 127.0.0.1 literal without DNS lookup', async () => {
await expect(validateMcpServerSsrf('http://127.0.0.1:8080/mcp')).resolves.toBeNull()
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('returns resolved IP for URLs that resolve to public IPs', async () => {
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34')
})
it('returns resolved IP for HTTP URLs on non-localhost hosts', async () => {
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
await expect(validateMcpServerSsrf('http://example.com:3000/mcp')).resolves.toBe(
'93.184.216.34'
)
})
it('returns the literal IP for a public IPv4 literal so the caller pins it', async () => {
await expect(validateMcpServerSsrf('http://93.184.216.34:8080/mcp')).resolves.toBe(
'93.184.216.34'
)
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('returns the literal IP for a public IPv6 literal (brackets stripped)', async () => {
await expect(
validateMcpServerSsrf('http://[2606:2800:220:1:248:1893:25c8:1946]/mcp')
).resolves.toBe('2606:2800:220:1:248:1893:25c8:1946')
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('throws McpSsrfError for cloud metadata IP literal', async () => {
await expect(validateMcpServerSsrf('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(
McpSsrfError
)
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('throws McpSsrfError for RFC-1918 IP literal', async () => {
await expect(validateMcpServerSsrf('http://10.0.0.1/mcp')).rejects.toThrow(McpSsrfError)
})
it('throws McpSsrfError for 192.168.x.x IP literal', async () => {
await expect(validateMcpServerSsrf('http://192.168.1.1/mcp')).rejects.toThrow(McpSsrfError)
})
it('throws McpSsrfError for URLs resolving to private IPs', async () => {
mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' })
await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError)
})
it('throws McpSsrfError for URLs resolving to link-local IPs', async () => {
mockDnsLookup.mockResolvedValue({ address: '169.254.169.254' })
await expect(validateMcpServerSsrf('https://metadata.internal/latest')).rejects.toThrow(
McpSsrfError
)
})
it('throws McpDnsResolutionError when DNS lookup fails', async () => {
mockDnsLookup.mockRejectedValue(new Error('ENOTFOUND'))
await expect(validateMcpServerSsrf('https://nonexistent.invalid/mcp')).rejects.toThrow(
McpDnsResolutionError
)
})
it('returns resolved IP for URLs resolving to loopback on self-hosted (localhost alias)', async () => {
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).resolves.toBe('127.0.0.1')
})
it('throws for malformed URLs', async () => {
await expect(validateMcpServerSsrf('not-a-url')).rejects.toThrow(McpSsrfError)
})
describe('hosted environment', () => {
beforeEach(() => {
hostedFlag.value = true
})
it('rejects localhost URLs on hosted', async () => {
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).rejects.toThrow(McpSsrfError)
})
it('rejects 127.0.0.1 URLs on hosted', async () => {
await expect(validateMcpServerSsrf('http://127.0.0.1:8080/mcp')).rejects.toThrow(McpSsrfError)
})
it('rejects [::1] URLs on hosted', async () => {
await expect(validateMcpServerSsrf('http://[::1]:8080/mcp')).rejects.toThrow(McpSsrfError)
})
it('rejects URLs resolving to loopback on hosted', async () => {
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).rejects.toThrow(
McpSsrfError
)
})
it('returns resolved IP for public IP resolutions on hosted', async () => {
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34')
})
it('pins public IP literals on hosted so redirects cannot escape', async () => {
await expect(validateMcpServerSsrf('http://93.184.216.34/mcp')).resolves.toBe('93.184.216.34')
expect(mockDnsLookup).not.toHaveBeenCalled()
})
it('skips loopback check on hosted when allowlist is configured', async () => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['localhost'])
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).resolves.toBeNull()
})
it('still blocks RFC-1918 IP literals on hosted (regression)', async () => {
await expect(validateMcpServerSsrf('http://10.0.0.1/mcp')).rejects.toThrow(McpSsrfError)
await expect(validateMcpServerSsrf('http://192.168.1.1/mcp')).rejects.toThrow(McpSsrfError)
})
it('still blocks cloud metadata IP on hosted (regression)', async () => {
await expect(
validateMcpServerSsrf('http://169.254.169.254/latest/meta-data/')
).rejects.toThrow(McpSsrfError)
})
it('still blocks DNS resolutions to private IPs on hosted (regression)', async () => {
mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' })
await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError)
})
it('still skips env var hostnames on hosted', async () => {
await expect(validateMcpServerSsrf('{{MCP_SERVER_URL}}')).resolves.toBeNull()
await expect(validateMcpServerSsrf('https://{{MCP_HOST}}/mcp')).resolves.toBeNull()
expect(mockDnsLookup).not.toHaveBeenCalled()
})
})
describe('self-hosted environment (regression)', () => {
beforeEach(() => {
hostedFlag.value = false
})
it('still allows localhost URLs (returns null, no pinning needed)', async () => {
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).resolves.toBeNull()
})
it('still allows 127.0.0.1 URLs (returns null, no pinning needed)', async () => {
await expect(validateMcpServerSsrf('http://127.0.0.1:8080/mcp')).resolves.toBeNull()
})
it('returns resolved loopback IP for DNS aliases (caller pins)', async () => {
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
await expect(validateMcpServerSsrf('http://my-local-alias/mcp')).resolves.toBe('127.0.0.1')
})
})
it('skips all checks when ALLOWED_MCP_DOMAINS is configured', async () => {
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['internal.corp'])
await expect(validateMcpServerSsrf('http://10.0.0.1/mcp')).resolves.toBeNull()
await expect(
validateMcpServerSsrf('http://169.254.169.254/latest/meta-data/')
).resolves.toBeNull()
expect(mockDnsLookup).not.toHaveBeenCalled()
})
})
+215
View File
@@ -0,0 +1,215 @@
import dns from 'dns/promises'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import * as ipaddr from 'ipaddr.js'
import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/env-flags'
import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
const logger = createLogger('McpDomainCheck')
export class McpDomainNotAllowedError extends Error {
constructor(domain: string) {
super(`MCP server domain "${domain}" is not allowed by the server's ALLOWED_MCP_DOMAINS policy`)
this.name = 'McpDomainNotAllowedError'
}
}
export class McpSsrfError extends Error {
constructor(message: string) {
super(message)
this.name = 'McpSsrfError'
}
}
export class McpDnsResolutionError extends Error {
constructor(hostname: string) {
super(`MCP server URL hostname "${hostname}" could not be resolved`)
this.name = 'McpDnsResolutionError'
}
}
/**
* Core domain check. Returns null if the URL is allowed, or the hostname/url
* string to use in the rejection error.
*/
function checkMcpDomain(url: string): string | null {
const allowedDomains = getAllowedMcpDomainsFromEnv()
if (allowedDomains === null) return null
try {
const hostname = new URL(url).hostname.toLowerCase()
return allowedDomains.includes(hostname) ? null : hostname
} catch {
return url
}
}
/**
* Returns true if the URL's hostname contains an env var reference,
* meaning domain validation must be deferred until env var resolution.
* Only bypasses validation when the hostname itself is unresolvable —
* env vars in the path/query do NOT bypass the domain check.
*/
function hasEnvVarInHostname(url: string): boolean {
// If the entire URL is an env var reference, hostname is unknown
if (url.trim().replace(createEnvVarPattern(), '').trim() === '') return true
try {
// Extract the authority portion (between :// and the first /, ?, or # per RFC 3986)
const protocolEnd = url.indexOf('://')
if (protocolEnd === -1) return createEnvVarPattern().test(url)
const afterProtocol = url.substring(protocolEnd + 3)
const authorityEnd = afterProtocol.search(/[/?#]/)
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
return createEnvVarPattern().test(authority)
} catch {
return createEnvVarPattern().test(url)
}
}
/**
* Returns true if the URL's domain is allowed (or no restriction is configured).
* URLs with env var references in the hostname are allowed — they will be
* validated after resolution at execution time.
*/
export function isMcpDomainAllowed(url: string | undefined): boolean {
if (!url) {
return getAllowedMcpDomainsFromEnv() === null
}
if (hasEnvVarInHostname(url)) return true
return checkMcpDomain(url) === null
}
/**
* Throws McpDomainNotAllowedError if the URL's domain is not in the allowlist.
* URLs with env var references in the hostname are skipped — they will be
* validated after resolution at execution time.
*/
export function validateMcpDomain(url: string | undefined): void {
if (!url) {
if (getAllowedMcpDomainsFromEnv() !== null) {
throw new McpDomainNotAllowedError('(empty)')
}
return
}
if (hasEnvVarInHostname(url)) return
const rejected = checkMcpDomain(url)
if (rejected !== null) {
throw new McpDomainNotAllowedError(rejected)
}
}
/**
* Returns true if the IP is a loopback address (full 127.0.0.0/8 range, or ::1).
*/
function isLoopbackIP(ip: string): boolean {
try {
if (!ipaddr.isValid(ip)) return false
return ipaddr.process(ip).range() === 'loopback'
} catch {
return false
}
}
/**
* Returns true if the hostname is localhost or a loopback IP literal.
* Expects IPv6 brackets to already be stripped.
*/
function isLocalhostHostname(hostname: string): boolean {
const clean = hostname.toLowerCase()
if (clean === 'localhost') return true
return ipaddr.isValid(clean) && isLoopbackIP(clean)
}
/**
* Validates an MCP server URL against SSRF attacks by resolving DNS and
* rejecting private/reserved IP ranges (RFC-1918, link-local, cloud metadata).
*
* Only active when ALLOWED_MCP_DOMAINS is **not configured**. When an admin
* has set an explicit domain allowlist, they control which domains are
* reachable and private-network MCP servers are legitimate. Applying SSRF
* blocking on top of an admin-curated list would break self-hosted
* deployments where MCP servers run on internal networks.
*
* Does NOT enforce protocol (HTTP is allowed) or block service ports — MCP
* servers legitimately run on HTTP and on arbitrary ports.
*
* Localhost/loopback is allowed for local dev MCP servers in self-hosted
* deployments, but blocked on the hosted environment (sim.ai) where users
* must not be able to reach the server's own loopback interface.
* URLs with env var references in the hostname are skipped — they will be
* validated after resolution at execution time.
*
* Returns the IP address to pin subsequent connections to (the resolved IP for
* hostnames, or the literal itself for public IP-literal URLs) so the caller can
* prevent DNS-rebinding TOCTOU attacks and stop redirects from escaping to
* internal hosts. Pinning matters for IP literals too: without it the transport
* uses the default fetch, which follows an attacker-controlled 3xx redirect to a
* private/metadata address. Returns null only when pinning is unnecessary or
* impossible: no URL, allowlist-only mode, env-var hostnames (validated later),
* and localhost on self-hosted (no rebinding risk against a fixed loopback).
*
* @throws McpSsrfError if the URL resolves to a blocked IP address
*/
export async function validateMcpServerSsrf(url: string | undefined): Promise<string | null> {
if (!url) return null
if (getAllowedMcpDomainsFromEnv() !== null) return null
if (hasEnvVarInHostname(url)) return null
let hostname: string
try {
hostname = new URL(url).hostname
} catch {
throw new McpSsrfError('MCP server URL is not a valid URL')
}
const cleanHostname =
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
if (isLocalhostHostname(cleanHostname)) {
if (isHosted) {
throw new McpSsrfError('MCP server URL cannot point to a loopback address')
}
return null
}
if (ipaddr.isValid(cleanHostname)) {
if (isPrivateOrReservedIP(cleanHostname)) {
throw new McpSsrfError('MCP server URL cannot point to a private or reserved IP address')
}
// Public IP literal: pin to this exact address so the caller's pinned fetch
// (createPinnedFetch) keeps every redirect hop on it. Returning null here
// would fall back to the default fetch, which follows a 3xx redirect to a
// private/metadata host and escapes SSRF controls.
return cleanHostname
}
let address: string
try {
const lookup = await dns.lookup(cleanHostname, { verbatim: true })
address = lookup.address
} catch (error) {
logger.warn('DNS lookup failed for MCP server URL', {
hostname,
error: toError(error).message,
})
throw new McpDnsResolutionError(cleanHostname)
}
if (isLoopbackIP(address)) {
if (isHosted) {
logger.warn('MCP server URL resolves to loopback address', {
hostname,
resolvedIP: address,
})
throw new McpSsrfError('MCP server URL resolves to a loopback address')
}
} else if (isPrivateOrReservedIP(address)) {
logger.warn('MCP server URL resolves to blocked IP address', {
hostname,
resolvedIP: address,
})
throw new McpSsrfError('MCP server URL resolves to a blocked IP address')
}
return address
}
+270
View File
@@ -0,0 +1,270 @@
import { createLogger } from '@sim/logger'
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
import { toError } from '@sim/utils/errors'
import type { NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
assertContentLengthWithinLimit,
isPayloadSizeLimitError,
readStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { createMcpErrorResponse } from '@/lib/mcp/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('McpAuthMiddleware')
const MAX_MCP_MANAGEMENT_BODY_BYTES = 10 * 1024 * 1024
const parsedBodies = new WeakMap<NextRequest, unknown>()
export type McpPermissionLevel = 'read' | 'write' | 'admin'
export interface McpAuthContext {
userId: string
userName?: string | null
userEmail?: string | null
workspaceId: string
requestId: string
}
export type McpRouteHandler<TParams = Record<string, string>> = (
request: NextRequest,
context: McpAuthContext,
routeContext: { params: Promise<TParams> }
) => Promise<NextResponse>
interface AuthResult {
success: true
context: McpAuthContext
}
interface AuthFailure {
success: false
errorResponse: NextResponse
}
type AuthValidationResult = AuthResult | AuthFailure
class McpBodyReadError extends Error {
constructor(
readonly kind: 'aborted' | 'payload_too_large' | 'invalid_json',
readonly cause: unknown
) {
super(toError(cause).message)
this.name = 'McpBodyReadError'
}
}
export async function readMcpJsonBodyWithLimit(request: NextRequest): Promise<unknown> {
const cached = parsedBodies.get(request)
if (cached !== undefined) return cached
try {
assertContentLengthWithinLimit(
request.headers,
MAX_MCP_MANAGEMENT_BODY_BYTES,
'MCP management request body'
)
const buffer = await readStreamToBufferWithLimit(request.body, {
maxBytes: MAX_MCP_MANAGEMENT_BODY_BYTES,
label: 'MCP management request body',
signal: request.signal,
})
const body = buffer.byteLength > 0 ? JSON.parse(buffer.toString('utf-8')) : {}
parsedBodies.set(request, body)
return body
} catch (error) {
if (request.signal.aborted) {
throw new McpBodyReadError('aborted', error)
}
if (isPayloadSizeLimitError(error)) {
throw new McpBodyReadError('payload_too_large', error)
}
if (error instanceof SyntaxError) {
throw new McpBodyReadError('invalid_json', error)
}
throw error
}
}
export function mcpBodyReadErrorResponse(
error: unknown,
request?: NextRequest
): NextResponse | null {
if (!(error instanceof McpBodyReadError)) {
return null
}
if (error.kind === 'aborted' || request?.signal.aborted) {
return createMcpErrorResponse(error.cause, 'Client cancelled request', 499)
}
if (error.kind === 'payload_too_large') {
return createMcpErrorResponse(
error.cause,
'MCP management request body exceeds maximum size',
413
)
}
return createMcpErrorResponse(error.cause, 'Invalid request body', 400)
}
/**
* Validates MCP authentication and authorization
*/
async function validateMcpAuth(
request: NextRequest,
permissionLevel: McpPermissionLevel
): Promise<AuthValidationResult> {
const requestId = generateRequestId()
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error}`)
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error(auth.error || 'Authentication required'),
'Authentication failed',
401
),
}
}
let workspaceId: string | null = null
const { searchParams } = new URL(request.url)
workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
try {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
const body = await readMcpJsonBodyWithLimit(request)
const bodyWorkspaceId =
body && typeof body === 'object' && 'workspaceId' in body
? (body as { workspaceId?: unknown }).workspaceId
: undefined
workspaceId = typeof bodyWorkspaceId === 'string' ? bodyWorkspaceId : null
}
} catch (error) {
const errorResponse = mcpBodyReadErrorResponse(error, request)
if (errorResponse) return { success: false, errorResponse }
}
}
if (!workspaceId) {
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error('workspaceId is required'),
'Missing required parameter',
400
),
}
}
const userPermissions = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId)
if (!userPermissions) {
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error('Access denied to workspace'),
'Insufficient permissions',
403
),
}
}
const hasRequiredPermission = checkPermissionLevel(userPermissions, permissionLevel)
if (!hasRequiredPermission) {
const permissionError = getPermissionErrorMessage(permissionLevel)
return {
success: false,
errorResponse: createMcpErrorResponse(
new Error(permissionError),
'Insufficient permissions',
403
),
}
}
return {
success: true,
context: {
userId: auth.userId,
userName: auth.userName,
userEmail: auth.userEmail,
workspaceId,
requestId,
},
}
} catch (error) {
logger.error(`[${requestId}] Error during MCP auth validation:`, error)
return {
success: false,
errorResponse: createMcpErrorResponse(
toError(error),
'Authentication validation failed',
500
),
}
}
}
/**
* Check if user has required permission level
*/
function checkPermissionLevel(userPermission: string, requiredLevel: McpPermissionLevel): boolean {
return permissionSatisfies(userPermission as PermissionType, requiredLevel)
}
/**
* Get appropriate error message for permission level
*/
function getPermissionErrorMessage(permissionLevel: McpPermissionLevel): string {
switch (permissionLevel) {
case 'read':
return 'Workspace access required for MCP operations'
case 'write':
return 'Write or admin permission required for MCP server management'
case 'admin':
return 'Admin permission required for MCP server administration'
default:
return 'Insufficient permissions for MCP operation'
}
}
/**
* Higher-order function that wraps MCP route handlers with authentication middleware
*
* @param permissionLevel - Required permission level ('read', 'write', or 'admin')
* @returns Middleware wrapper function
*
*/
export function withMcpAuth<TParams = Record<string, string>>(
permissionLevel: McpPermissionLevel = 'read'
) {
return function middleware(handler: McpRouteHandler<TParams>) {
return async function wrappedHandler(
request: NextRequest,
routeContext: { params: Promise<TParams> }
): Promise<NextResponse> {
const authResult = await validateMcpAuth(request, permissionLevel)
if (!authResult.success) {
return (authResult as AuthFailure).errorResponse
}
try {
return await handler(request, (authResult as AuthResult).context, routeContext)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(
`[${(authResult as AuthResult).context.requestId}] Error in MCP route handler:`,
error
)
return createMcpErrorResponse(toError(error), 'Internal server error', 500)
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* @vitest-environment node
*/
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch } = vi.hoisted(() => ({
mockAuth: vi.fn(),
mockCreateSsrfGuardedMcpFetch: vi.fn(),
mockGuardedFetch: vi.fn(),
}))
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
auth: mockAuth,
}))
vi.mock('@/lib/mcp/pinned-fetch', () => ({
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
}))
import { mcpAuthGuarded } from '@/lib/mcp/oauth/auth'
describe('mcpAuthGuarded', () => {
const provider = {} as OAuthClientProvider
beforeEach(() => {
vi.clearAllMocks()
mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch)
mockAuth.mockResolvedValue('AUTHORIZED')
})
it('defaults fetchFn to the SSRF-guarded fetch', async () => {
await mcpAuthGuarded(provider, { serverUrl: 'https://mcp.example.com/mcp' })
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
expect(mockAuth).toHaveBeenCalledWith(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: mockGuardedFetch,
})
})
it('lets a caller-supplied fetchFn override the default', async () => {
const overrideFetch = vi.fn()
await mcpAuthGuarded(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: overrideFetch,
})
expect(mockAuth).toHaveBeenCalledWith(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: overrideFetch,
})
})
it('falls back to the SSRF-guarded fetch when fetchFn is explicitly undefined', async () => {
await mcpAuthGuarded(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: undefined,
})
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
expect(mockAuth).toHaveBeenCalledWith(provider, {
serverUrl: 'https://mcp.example.com/mcp',
fetchFn: mockGuardedFetch,
})
})
})
+19
View File
@@ -0,0 +1,19 @@
import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
type McpAuthOptions = Parameters<typeof auth>[1]
/**
* Wraps the MCP SDK's `auth()` and defaults `fetchFn` to the SSRF-guarded
* fetch. Every URL touched during an MCP OAuth exchange — discovery,
* authorization, token, and revocation endpoints — can come from
* attacker-controllable authorization-server metadata, so callers must not
* be able to omit the guard by forgetting to pass `fetchFn` explicitly.
* Pass `fetchFn` in `options` to override (e.g. in tests).
*/
export function mcpAuthGuarded(
provider: OAuthClientProvider,
options: McpAuthOptions
): ReturnType<typeof auth> {
return auth(provider, { ...options, fetchFn: options.fetchFn ?? createSsrfGuardedMcpFetch() })
}
@@ -0,0 +1,23 @@
/**
* Reasons surfaced from the OAuth callback popup back to the parent window via
* `window.opener.postMessage`. Consumed by the popup hook to render user-facing
* status messages and by the callback route to discriminate failure modes.
*/
export type McpOauthCallbackReason =
| 'authorized'
| 'provider_error'
| 'missing_params'
| 'unauthenticated'
| 'invalid_state'
| 'user_mismatch'
| 'server_gone'
| 'insecure_url'
| 'token_exchange_failed'
| 'unknown'
export interface McpOauthCallbackMessage {
type: 'mcp-oauth'
ok: boolean
serverId?: string
reason?: McpOauthCallbackReason
}
+39
View File
@@ -0,0 +1,39 @@
import { decryptSecret } from '@/lib/core/security/encryption'
interface OauthCredsDiffParams {
incomingClientId: string | null | undefined
incomingClientIdProvided: boolean
incomingClientSecret: string | null | undefined
incomingClientSecretProvided: boolean
currentClientId: string | null | undefined
currentEncryptedClientSecret: string | null | undefined
}
/**
* Detect whether OAuth client credentials on an MCP server row have changed.
* Decrypt failure (corrupted ciphertext, rotated key) is treated as a change so
* admins can overwrite an unusable stored secret instead of getting a 500.
*/
export async function oauthCredsChanged(params: OauthCredsDiffParams): Promise<boolean> {
const clientIdChanged =
params.incomingClientIdProvided &&
(params.incomingClientId || null) !== (params.currentClientId ?? null)
let clientSecretChanged = false
if (params.incomingClientSecretProvided) {
if (!params.incomingClientSecret) {
clientSecretChanged = params.currentEncryptedClientSecret != null
} else if (!params.currentEncryptedClientSecret) {
clientSecretChanged = true
} else {
try {
const { decrypted } = await decryptSecret(params.currentEncryptedClientSecret)
clientSecretChanged = decrypted !== params.incomingClientSecret
} catch {
clientSecretChanged = true
}
}
}
return clientIdChanged || clientSecretChanged
}
+31
View File
@@ -0,0 +1,31 @@
export { mcpAuthGuarded } from './auth'
export type {
McpOauthCallbackMessage,
McpOauthCallbackReason,
} from './callback-reasons'
export { oauthCredsChanged } from './creds-diff'
export { detectMcpAuthType } from './probe'
export {
loadPreregisteredClient,
McpOauthRedirectRequired,
type PreregisteredClient,
SimMcpOauthProvider,
} from './provider'
export { revokeMcpOauthTokens } from './revoke'
export {
clearClient,
clearState,
clearTokens,
clearVerifier,
getOrCreateOauthRow,
loadOauthRow,
loadOauthRowByState,
type McpOauthRow,
saveClientInformation,
saveCodeVerifier,
saveState,
saveTokens,
setOauthRowUser,
withMcpOauthRefreshLock,
} from './storage'
export { assertSafeOauthServerUrl, McpOauthInsecureUrlError } from './url-validation'
+110
View File
@@ -0,0 +1,110 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, mockGuardedFetch } =
vi.hoisted(() => {
const mockPinnedFetch = vi.fn()
const mockGuardedFetch = vi.fn()
return {
mockPinnedFetch,
mockGuardedFetch,
mockCreatePinnedFetch: vi.fn(() => mockPinnedFetch),
mockCreateSsrfGuardedMcpFetch: vi.fn(() => mockGuardedFetch),
}
})
vi.mock('@/lib/core/security/input-validation.server', () => ({
createPinnedFetch: mockCreatePinnedFetch,
}))
vi.mock('@/lib/mcp/pinned-fetch', () => ({
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
}))
import { detectMcpAuthType } from '@/lib/mcp/oauth/probe'
function makeResponse(init: { status?: number; headers?: Record<string, string> }): Response {
const status = init.status ?? 200
return {
status,
ok: status >= 200 && status < 300,
headers: new Headers(init.headers ?? {}),
} as unknown as Response
}
describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () => {
let globalFetchSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
globalFetchSpy = vi.fn()
vi.stubGlobal('fetch', globalFetchSpy)
})
it('pins the probe to the pre-validated IP when resolvedIP is supplied', async () => {
mockPinnedFetch.mockResolvedValue(makeResponse({ status: 200 }))
const authType = await detectMcpAuthType('https://rebind.example.com/mcp', '203.0.113.10')
expect(authType).toBe('none')
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled()
expect(mockPinnedFetch).toHaveBeenCalledTimes(1)
// The unpinned global fetch must never be used — that was the SSRF sink.
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('falls back to the SSRF-guarded fetch when no resolvedIP is supplied', async () => {
mockGuardedFetch.mockResolvedValue(makeResponse({ status: 200 }))
const authType = await detectMcpAuthType('https://example.com/mcp')
expect(authType).toBe('none')
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockGuardedFetch).toHaveBeenCalledTimes(1)
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('classifies an RFC 9728 OAuth challenge as oauth via the pinned fetch', async () => {
mockPinnedFetch.mockResolvedValue(
makeResponse({
status: 401,
headers: {
'www-authenticate':
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
},
})
)
const authType = await detectMcpAuthType('https://example.com/mcp', '203.0.113.10')
expect(authType).toBe('oauth')
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('does not probe (no network call) for non-https, non-loopback URLs', async () => {
const authType = await detectMcpAuthType('http://example.com/mcp', '203.0.113.10')
expect(authType).toBe('headers')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled()
expect(globalFetchSpy).not.toHaveBeenCalled()
})
it('reuses the pinned fetch for best-effort session cleanup (DELETE)', async () => {
mockPinnedFetch
.mockResolvedValueOnce(makeResponse({ status: 200, headers: { 'mcp-session-id': 'sess-1' } }))
.mockResolvedValueOnce(makeResponse({ status: 200 }))
const authType = await detectMcpAuthType('https://example.com/mcp', '203.0.113.10')
expect(authType).toBe('none')
// POST probe + DELETE cleanup, both through the pinned fetch.
await vi.waitFor(() => expect(mockPinnedFetch).toHaveBeenCalledTimes(2))
const deleteCall = mockPinnedFetch.mock.calls[1]
expect(deleteCall[1]).toMatchObject({ method: 'DELETE' })
expect(globalFetchSpy).not.toHaveBeenCalled()
})
})
+116
View File
@@ -0,0 +1,116 @@
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 type { McpAuthType } from '@/lib/mcp/types'
const logger = createLogger('McpOauthProbe')
const PROBE_TIMEOUT_MS = 5000
/**
* Probes an MCP server URL to classify its auth requirement.
*
* The probe must never re-resolve DNS independently of the caller's SSRF
* validation, or it re-opens the DNS-rebinding window. When the caller passes a
* pre-validated `resolvedIP` the connection is pinned to it; otherwise an
* SSRF-guarded fetch validates and pins each request itself.
*/
export async function detectMcpAuthType(
url: string,
resolvedIP?: string | null
): Promise<McpAuthType> {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return 'headers'
}
const isLoopbackHttp = parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)
if (parsed.protocol !== 'https:' && !isLoopbackHttp) {
return 'headers'
}
const probeFetch: FetchLike = resolvedIP
? createPinnedFetch(resolvedIP)
: createSsrfGuardedMcpFetch()
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
const res = await probeFetch(url, {
method: 'POST',
redirect: 'manual',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'sim-platform-probe', version: '1.0.0' },
},
}),
signal: controller.signal,
})
const sessionId = res.headers.get('mcp-session-id')
if (sessionId) {
void closeMcpSession(url, sessionId, probeFetch)
}
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.
if (params.resourceMetadataUrl || params.scope) {
return 'oauth'
}
return 'headers'
}
if (res.ok) return 'none'
return 'headers'
} catch (e) {
logger.warn(`Probe failed for ${url}`, e)
return 'headers'
} finally {
clearTimeout(timer)
}
}
/**
* Best-effort DELETE to release the streamable-HTTP session the probe just
* allocated. Reuses the probe's pinned fetch so this cleanup hop stays pinned.
* Failures are ignored — the session will expire on the server side.
*/
async function closeMcpSession(
url: string,
sessionId: string,
probeFetch: FetchLike
): Promise<void> {
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
await probeFetch(url, {
method: 'DELETE',
headers: { 'Mcp-Session-Id': sessionId },
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
} catch {
// Ignore — best-effort cleanup
}
}
+179
View File
@@ -0,0 +1,179 @@
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
clearClient,
clearState,
clearTokens,
clearVerifier,
type McpOauthRow,
saveClientInformation as saveClientInformationDb,
saveCodeVerifier as saveCodeVerifierDb,
saveState,
saveTokens as saveTokensDb,
} from '@/lib/mcp/oauth/storage'
const logger = createLogger('SimMcpOauthProvider')
export class McpOauthRedirectRequired extends Error {
constructor(public readonly authorizationUrl: string) {
super('MCP OAuth redirect required')
this.name = 'McpOauthRedirectRequired'
}
}
export interface PreregisteredClient {
clientId: string
clientSecret?: string
}
interface SimMcpOauthProviderInit {
row: McpOauthRow
scope?: string
/**
* Optional user-supplied client credentials. When provided, the SDK skips
* Dynamic Client Registration and uses these for the auth/token exchange.
*/
preregistered?: PreregisteredClient
}
export class SimMcpOauthProvider implements OAuthClientProvider {
private row: McpOauthRow
private readonly scope?: string
private readonly preregistered?: PreregisteredClient
constructor({ row, scope, preregistered }: SimMcpOauthProviderInit) {
this.row = row
this.scope = scope
this.preregistered = preregistered
}
get redirectUrl(): string {
return `${getBaseUrl().replace(/\/$/, '')}/api/mcp/oauth/callback`
}
get clientMetadata(): OAuthClientMetadata {
const meta: OAuthClientMetadata = {
client_name: 'Sim',
redirect_uris: [this.redirectUrl],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: this.preregistered?.clientSecret ? 'client_secret_post' : 'none',
}
if (this.scope) meta.scope = this.scope
return meta
}
async state(): Promise<string> {
const state = generateId()
await saveState(this.row.id, state)
return state
}
clientInformation(): OAuthClientInformationMixed | undefined {
if (this.row.clientInformation) return this.row.clientInformation
if (this.preregistered) {
return {
client_id: this.preregistered.clientId,
client_secret: this.preregistered.clientSecret,
redirect_uris: [this.redirectUrl],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: this.preregistered.clientSecret ? 'client_secret_post' : 'none',
}
}
return undefined
}
async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
if (this.preregistered) return
await saveClientInformationDb(this.row.id, info)
this.row.clientInformation = info
}
tokens(): OAuthTokens | undefined {
return this.row.tokens ?? undefined
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await saveTokensDb(this.row.id, tokens)
this.row.tokens = tokens
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
throw new McpOauthRedirectRequired(authorizationUrl.toString())
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
await saveCodeVerifierDb(this.row.id, codeVerifier)
this.row.codeVerifier = codeVerifier
}
async codeVerifier(): Promise<string> {
if (!this.row.codeVerifier) {
throw new Error('No PKCE code verifier saved for this MCP OAuth session')
}
return this.row.codeVerifier
}
async invalidateCredentials(
scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'
): Promise<void> {
if (scope === 'all' || scope === 'client') {
await clearClient(this.row.id)
this.row.clientInformation = null
}
if (scope === 'all' || scope === 'tokens') {
await clearTokens(this.row.id)
this.row.tokens = null
}
if (scope === 'all' || scope === 'verifier') {
await clearVerifier(this.row.id)
await clearState(this.row.id)
this.row.codeVerifier = null
}
}
get rowId(): string {
return this.row.id
}
}
export async function loadPreregisteredClient(
serverId: string
): Promise<PreregisteredClient | undefined> {
const [row] = await db
.select({
clientId: mcpServers.oauthClientId,
clientSecret: mcpServers.oauthClientSecret,
})
.from(mcpServers)
.where(eq(mcpServers.id, serverId))
.limit(1)
if (!row?.clientId) return undefined
let clientSecret: string | undefined
if (row.clientSecret) {
try {
const { decrypted } = await decryptSecret(row.clientSecret)
clientSecret = decrypted
} catch (error) {
logger.error('Failed to decrypt preregistered MCP OAuth client secret', {
serverId,
error: toError(error).message,
})
throw new Error('Failed to decrypt preregistered MCP OAuth client secret')
}
}
return { clientId: row.clientId, clientSecret }
}
+149
View File
@@ -0,0 +1,149 @@
/**
* @vitest-environment node
*
* Regression test: `revokeMcpOauthTokens` must route both metadata discovery
* and the RFC 7009 revocation POST through the SSRF-guarded fetch, since
* `revocation_endpoint` comes from attacker-controlled server metadata. Uses
* the real `createSsrfGuardedMcpFetch` so it fails if revoke.ts regresses to a
* raw `fetch`.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const BLOCKED_ENDPOINT = 'http://169.254.170.2/v2/credentials/'
const PUBLIC_SERVER_URL = 'https://mcp.attacker.com'
const PUBLIC_SERVER_IP = '203.0.113.10'
const {
mockUndiciFetch,
mockValidateMcpServerSsrf,
mockDiscoverOAuthServerInfo,
mockLoadOauthRow,
mockDecryptSecret,
mockDbSelect,
} = vi.hoisted(() => ({
mockUndiciFetch: vi.fn(),
mockValidateMcpServerSsrf: vi.fn(),
mockDiscoverOAuthServerInfo: vi.fn(),
mockLoadOauthRow: vi.fn(),
mockDecryptSecret: vi.fn(),
mockDbSelect: vi.fn(),
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
createPinnedFetch: vi.fn(() => mockUndiciFetch),
}))
vi.mock('@/lib/mcp/domain-check', () => ({
validateMcpServerSsrf: mockValidateMcpServerSsrf,
}))
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
discoverOAuthServerInfo: mockDiscoverOAuthServerInfo,
}))
vi.mock('@/lib/mcp/oauth/storage', () => ({
loadOauthRow: mockLoadOauthRow,
}))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
}))
vi.mock('@sim/db', () => ({
db: { select: mockDbSelect },
}))
import { revokeMcpOauthTokens } from './revoke'
function wireServerRow(row: Record<string, unknown>) {
const builder = {
from: () => builder,
where: () => builder,
limit: () => Promise.resolve([row]),
}
mockDbSelect.mockReturnValue(builder)
}
describe('revokeMcpOauthTokens — SSRF guard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLoadOauthRow.mockResolvedValue({
tokens: { access_token: 'access-secret', refresh_token: 'refresh-secret' },
clientInformation: { client_id: 'client-123' },
})
wireServerRow({
url: PUBLIC_SERVER_URL,
oauthClientId: 'client-123',
oauthClientSecret: null,
})
mockDiscoverOAuthServerInfo.mockResolvedValue({
authorizationServerMetadata: {
issuer: PUBLIC_SERVER_URL,
revocation_endpoint: BLOCKED_ENDPOINT,
},
})
mockUndiciFetch.mockResolvedValue(new Response('ok'))
// Catches a regression to raw globalThis.fetch without hitting the network.
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok'))
// Public server host resolves; the revocation endpoint is blocked.
mockValidateMcpServerSsrf.mockImplementation(async (target: string) => {
if (target.startsWith(BLOCKED_ENDPOINT) || target.includes('169.254.')) {
throw new Error('MCP server URL resolves to a blocked IP address')
}
return PUBLIC_SERVER_IP
})
})
it('routes metadata discovery through the SSRF-guarded fetch', async () => {
await revokeMcpOauthTokens('server-1')
expect(mockDiscoverOAuthServerInfo).toHaveBeenCalledTimes(1)
const [, options] = mockDiscoverOAuthServerInfo.mock.calls[0]
expect(typeof options?.fetchFn).toBe('function')
})
it('validates the attacker-controlled revocation_endpoint before issuing the request', async () => {
await revokeMcpOauthTokens('server-1')
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(BLOCKED_ENDPOINT)
})
it('never issues an outbound request to the blocked revocation endpoint', async () => {
await revokeMcpOauthTokens('server-1')
const allCalls = [
...mockUndiciFetch.mock.calls,
...(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls,
]
for (const call of allCalls) {
const target = typeof call[0] === 'string' ? call[0] : String(call[0])
expect(target).not.toContain('169.254.170.2')
}
})
it('swallows the SSRF rejection — revocation is best-effort and never throws', async () => {
await expect(revokeMcpOauthTokens('server-1')).resolves.toBeUndefined()
})
it('still issues the revocation POST when the endpoint resolves to a public IP', async () => {
const publicEndpoint = 'https://mcp.attacker.com/oauth/revoke'
mockDiscoverOAuthServerInfo.mockResolvedValue({
authorizationServerMetadata: {
issuer: PUBLIC_SERVER_URL,
revocation_endpoint: publicEndpoint,
},
})
await revokeMcpOauthTokens('server-1')
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(publicEndpoint)
const revokeCalls = mockUndiciFetch.mock.calls.filter((call) => {
const target = typeof call[0] === 'string' ? call[0] : String(call[0])
return target === publicEndpoint
})
expect(revokeCalls.length).toBeGreaterThan(0)
expect(revokeCalls[0][1]).toMatchObject({ method: 'POST' })
})
})
+110
View File
@@ -0,0 +1,110 @@
import { discoverOAuthServerInfo } from '@modelcontextprotocol/sdk/client/auth.js'
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { loadOauthRow } from '@/lib/mcp/oauth/storage'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
const logger = createLogger('McpOauthRevoke')
const REVOKE_TIMEOUT_MS = 5000
/**
* Best-effort RFC 7009 revocation of tokens at the authorization server.
* Never throws — revocation is advisory and must not block disconnect/delete flows.
*/
export async function revokeMcpOauthTokens(mcpServerId: string): Promise<void> {
try {
const row = await loadOauthRow({ mcpServerId })
if (!row?.tokens) return
const [server] = await db
.select({
url: mcpServers.url,
oauthClientId: mcpServers.oauthClientId,
oauthClientSecret: mcpServers.oauthClientSecret,
})
.from(mcpServers)
.where(eq(mcpServers.id, mcpServerId))
.limit(1)
if (!server?.url) return
const ssrfGuardedFetch = createSsrfGuardedMcpFetch()
const info = await discoverOAuthServerInfo(server.url, { fetchFn: ssrfGuardedFetch }).catch(
() => undefined
)
const metadata = info?.authorizationServerMetadata as
| (Record<string, unknown> & { revocation_endpoint?: string })
| undefined
const revocationEndpoint = metadata?.revocation_endpoint
if (!revocationEndpoint) return
const clientInfo = row.clientInformation
const clientId = clientInfo?.client_id ?? server.oauthClientId ?? undefined
if (!clientId) return
let clientSecret = clientInfo?.client_secret
if (!clientSecret && server.oauthClientSecret) {
try {
const { decrypted } = await decryptSecret(server.oauthClientSecret)
clientSecret = decrypted
} catch {
clientSecret = undefined
}
}
const tokensToRevoke: Array<{ token: string; hint: 'refresh_token' | 'access_token' }> = []
if (row.tokens.refresh_token) {
tokensToRevoke.push({ token: row.tokens.refresh_token, hint: 'refresh_token' })
}
if (row.tokens.access_token) {
tokensToRevoke.push({ token: row.tokens.access_token, hint: 'access_token' })
}
for (const { token, hint } of tokensToRevoke) {
await postRevoke(revocationEndpoint, token, hint, clientId, clientSecret, ssrfGuardedFetch)
}
} catch (error) {
logger.warn(`Token revocation failed for server ${mcpServerId}`, {
error: toError(error).message,
})
}
}
async function postRevoke(
endpoint: string,
token: string,
hint: 'refresh_token' | 'access_token',
clientId: string,
clientSecret: string | undefined,
fetchFn: FetchLike
): Promise<void> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS)
try {
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
}
const params = new URLSearchParams({ token, token_type_hint: hint })
if (clientSecret) {
headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`
} else {
params.set('client_id', clientId)
}
const res = await fetchFn(endpoint, {
method: 'POST',
headers,
body: params.toString(),
signal: controller.signal,
})
if (!res.ok) {
logger.info(`Revocation returned ${res.status} for ${hint}; treating as best-effort`)
}
} finally {
clearTimeout(timer)
}
}
+292
View File
@@ -0,0 +1,292 @@
/**
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
encryptionMock,
encryptionMockFns,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAcquireLock, mockReleaseLock, mockExtendLock } = vi.hoisted(() => ({
mockAcquireLock: vi.fn(),
mockReleaseLock: vi.fn(),
mockExtendLock: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/core/config/redis', () => ({
acquireLock: mockAcquireLock,
releaseLock: mockReleaseLock,
extendLock: mockExtendLock,
}))
import {
getOrCreateOauthRow,
loadOauthRow,
setOauthRowUser,
withMcpOauthRefreshLock,
} from './storage'
describe('MCP OAuth storage', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: '{}' })
encryptionMockFns.mockEncryptSecret.mockResolvedValue({
encrypted: 'encrypted',
iv: 'iv',
})
})
it('loads OAuth state by MCP server, independent of the requesting user', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'authorizer-1',
workspaceId: 'workspace-1',
clientInformation: null,
tokens: null,
codeVerifier: null,
state: null,
updatedAt: new Date(),
},
])
const row = await loadOauthRow({ mcpServerId: 'server-1' })
expect(row).toMatchObject({
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'authorizer-1',
workspaceId: 'workspace-1',
})
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1)
})
it('reuses the existing workspace OAuth row instead of creating a per-user row', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'oauth-row-1',
mcpServerId: 'server-1',
userId: 'authorizer-1',
workspaceId: 'workspace-1',
clientInformation: null,
tokens: null,
codeVerifier: null,
state: null,
updatedAt: new Date(),
},
])
const row = await getOrCreateOauthRow({
mcpServerId: 'server-1',
userId: 'different-user',
workspaceId: 'workspace-1',
})
expect(row.id).toBe('oauth-row-1')
expect(row.userId).toBe('authorizer-1')
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('records the latest authorizing user without changing row ownership', async () => {
await setOauthRowUser('oauth-row-1', 'user-2')
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-2',
updatedAt: expect.any(Date),
})
)
})
})
describe('withMcpOauthRefreshLock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAcquireLock.mockReset()
mockReleaseLock.mockReset()
mockExtendLock.mockReset()
mockReleaseLock.mockResolvedValue(true)
mockExtendLock.mockResolvedValue(true)
})
it('serializes concurrent in-process callers, each running its own fn()', async () => {
mockAcquireLock.mockResolvedValue(true)
let active = 0
let maxActive = 0
const fn = vi.fn(async () => {
active++
maxActive = Math.max(maxActive, active)
await new Promise((r) => setTimeout(r, 1))
active--
return 'tokens'
})
const results = await Promise.all([
withMcpOauthRefreshLock('row-serial', fn),
withMcpOauthRefreshLock('row-serial', fn),
withMcpOauthRefreshLock('row-serial', fn),
])
expect(results).toEqual(['tokens', 'tokens', 'tokens'])
// Each caller gets its own fn() invocation — critical because fn() returns
// a stateful McpClient that can't be shared across consumers.
expect(fn).toHaveBeenCalledTimes(3)
// But never two at the same time within a process.
expect(maxActive).toBe(1)
expect(mockAcquireLock).toHaveBeenCalledTimes(3)
expect(mockReleaseLock).toHaveBeenCalledTimes(3)
})
it('serializes cross-process callers: follower polls until leader releases', async () => {
// First acquire fails (another process holds it), second succeeds.
mockAcquireLock.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const fn = vi.fn(async () => 'fresh')
const result = await withMcpOauthRefreshLock('row-mutex', fn)
expect(result).toBe('fresh')
expect(mockAcquireLock).toHaveBeenCalledTimes(2)
expect(fn).toHaveBeenCalledTimes(1)
})
it('falls open when Redis is unavailable on acquire', async () => {
mockAcquireLock.mockRejectedValueOnce(new Error('Redis connection refused'))
const fn = vi.fn(async () => 'uncoordinated')
const result = await withMcpOauthRefreshLock('row-redis-down', fn)
expect(result).toBe('uncoordinated')
expect(fn).toHaveBeenCalledTimes(1)
expect(mockReleaseLock).not.toHaveBeenCalled()
})
it('releases the lock even when fn throws', async () => {
mockAcquireLock.mockResolvedValue(true)
const fn = vi.fn(async () => {
throw new Error('refresh failed')
})
await expect(withMcpOauthRefreshLock('row-throws', fn)).rejects.toThrow('refresh failed')
expect(mockReleaseLock).toHaveBeenCalledTimes(1)
})
it('does not surface releaseLock failures to the caller', async () => {
mockAcquireLock.mockResolvedValue(true)
mockReleaseLock.mockRejectedValueOnce(new Error('release failed'))
const fn = vi.fn(async () => 'value')
const result = await withMcpOauthRefreshLock('row-release-fail', fn)
expect(result).toBe('value')
})
it('uses per-row lock keys so different rows do not serialize', async () => {
mockAcquireLock.mockResolvedValue(true)
const fn = vi.fn(async () => 'ok')
await Promise.all([withMcpOauthRefreshLock('row-a', fn), withMcpOauthRefreshLock('row-b', fn)])
expect(mockAcquireLock).toHaveBeenCalledTimes(2)
const keys = mockAcquireLock.mock.calls.map((c) => c[0])
expect(keys).toContain('mcp:oauth:refresh:row-a')
expect(keys).toContain('mcp:oauth:refresh:row-b')
})
it('throws when the lock is held longer than the max wait (does not race)', async () => {
vi.useFakeTimers()
try {
// Acquire always fails — another process holds the lock with watchdog extension.
mockAcquireLock.mockResolvedValue(false)
const fn = vi.fn(async () => 'should-not-run')
const pending = withMcpOauthRefreshLock('row-deadline', fn)
// Attach the rejection expectation before draining so Vitest doesn't see
// an unhandled rejection while timers advance.
const assertion = expect(pending).rejects.toThrow(/held longer than/)
await vi.advanceTimersByTimeAsync(31_000)
await assertion
expect(fn).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('bounds the queue wait: callers stalled behind a wedged link reject without running fn', async () => {
vi.useFakeTimers()
try {
mockAcquireLock.mockResolvedValue(true)
let resolveFirst: (value: string) => void
const hungFn = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveFirst = resolve
})
)
const queuedFn = vi.fn(async () => 'second')
const first = withMcpOauthRefreshLock('row-stall', hungFn)
const second = withMcpOauthRefreshLock('row-stall', queuedFn)
const secondAssertion = expect(second).rejects.toThrow(/stalled for/)
await vi.advanceTimersByTimeAsync(90_000)
await secondAssertion
expect(queuedFn).not.toHaveBeenCalled()
// The wedged link is untouched by the queue deadline (its own fn keeps
// the lock, protecting a possibly mid-rotation refresh) and the row
// heals once it settles — including skipping the abandoned link's fn.
resolveFirst!('first')
await expect(first).resolves.toBe('first')
await expect(withMcpOauthRefreshLock('row-stall', async () => 'healed')).resolves.toBe(
'healed'
)
expect(queuedFn).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('extends the lock TTL while fn() is running so long refreshes do not lose the lock', async () => {
vi.useFakeTimers()
try {
mockAcquireLock.mockResolvedValue(true)
let resolveFn: (v: string) => void
const fn = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveFn = resolve
})
)
const pending = withMcpOauthRefreshLock('row-watchdog', fn)
// Advance time past two extend intervals (5s + 5s = 10s).
await vi.advanceTimersByTimeAsync(11_000)
expect(mockExtendLock.mock.calls.length).toBeGreaterThanOrEqual(2)
for (const call of mockExtendLock.mock.calls) {
expect(call[0]).toBe('mcp:oauth:refresh:row-watchdog')
}
resolveFn!('done')
await expect(pending).resolves.toBe('done')
// Watchdog must stop once fn() settles — no more extend calls.
const extendCallsAtFinish = mockExtendLock.mock.calls.length
await vi.advanceTimersByTimeAsync(20_000)
expect(mockExtendLock.mock.calls.length).toBe(extendCallsAtFinish)
} finally {
vi.useRealTimers()
}
})
})
+366
View File
@@ -0,0 +1,366 @@
import { createHash } from 'node:crypto'
import type {
OAuthClientInformationMixed,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import { db } from '@sim/db'
import { mcpServerOauth } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId, generateShortId } from '@sim/utils/id'
import { and, eq, gt } from 'drizzle-orm'
import { acquireLock, extendLock, releaseLock } from '@/lib/core/config/redis'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
const logger = createLogger('McpOauthStorage')
function hashState(state: string): string {
return createHash('sha256').update(state).digest('hex')
}
const STATE_TTL_MS = 10 * 60 * 1000
export interface McpOauthRow {
id: string
mcpServerId: string
userId: string | null
workspaceId: string
clientInformation: OAuthClientInformationMixed | null
tokens: OAuthTokens | null
codeVerifier: string | null
state: string | null
stateCreatedAt: Date | null
updatedAt: Date
}
async function encryptTokens(tokens: OAuthTokens): Promise<string> {
const { encrypted } = await encryptSecret(JSON.stringify(tokens))
return encrypted
}
async function encryptClientInformation(info: OAuthClientInformationMixed): Promise<string> {
const { encrypted } = await encryptSecret(JSON.stringify(info))
return encrypted
}
/**
* Returns `null` and clears the column when decryption fails (e.g. key rotation)
* so the next call triggers a fresh OAuth flow instead of a 500.
*/
async function safeDecrypt<T>(
rowId: string,
column: 'tokens' | 'clientInformation' | 'codeVerifier',
encrypted: string,
decode: (decrypted: string) => T
): Promise<T | null> {
try {
const { decrypted } = await decryptSecret(encrypted)
return decode(decrypted)
} catch (error) {
logger.warn(`Failed to decrypt ${column} for OAuth row ${rowId}; clearing column`, {
error: toError(error).message,
})
await db
.update(mcpServerOauth)
.set({ [column]: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
return null
}
}
export async function getOrCreateOauthRow(params: {
mcpServerId: string
userId: string
workspaceId: string
}): Promise<McpOauthRow> {
const existing = await loadOauthRow(params)
if (existing) return existing
const id = generateId()
try {
await db.insert(mcpServerOauth).values({
id,
mcpServerId: params.mcpServerId,
userId: params.userId,
workspaceId: params.workspaceId,
})
} catch (error) {
const winner = await loadOauthRow(params)
if (winner) return winner
throw error
}
return {
id,
mcpServerId: params.mcpServerId,
userId: params.userId,
workspaceId: params.workspaceId,
clientInformation: null,
tokens: null,
codeVerifier: null,
state: null,
stateCreatedAt: null,
updatedAt: new Date(),
}
}
type RawOauthRow = typeof mcpServerOauth.$inferSelect
async function mapOauthRow(row: RawOauthRow): Promise<McpOauthRow> {
return {
id: row.id,
mcpServerId: row.mcpServerId,
userId: row.userId,
workspaceId: row.workspaceId,
clientInformation: row.clientInformation
? await safeDecrypt(
row.id,
'clientInformation',
row.clientInformation,
(d) => JSON.parse(d) as OAuthClientInformationMixed
)
: null,
tokens: row.tokens
? await safeDecrypt(row.id, 'tokens', row.tokens, (d) => JSON.parse(d) as OAuthTokens)
: null,
codeVerifier: row.codeVerifier
? await safeDecrypt(row.id, 'codeVerifier', row.codeVerifier, (d) => d)
: null,
state: row.state,
stateCreatedAt: row.stateCreatedAt,
updatedAt: row.updatedAt,
}
}
export async function loadOauthRow(params: { mcpServerId: string }): Promise<McpOauthRow | null> {
const [row] = await db
.select()
.from(mcpServerOauth)
.where(eq(mcpServerOauth.mcpServerId, params.mcpServerId))
.limit(1)
if (!row) return null
return mapOauthRow(row)
}
export async function setOauthRowUser(rowId: string, userId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ userId, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function loadOauthRowByState(state: string): Promise<McpOauthRow | null> {
const [row] = await db
.select()
.from(mcpServerOauth)
.where(
and(
eq(mcpServerOauth.state, hashState(state)),
gt(mcpServerOauth.stateCreatedAt, new Date(Date.now() - STATE_TTL_MS))
)
)
.limit(1)
if (!row) return null
return mapOauthRow(row)
}
export async function saveClientInformation(
rowId: string,
info: OAuthClientInformationMixed
): Promise<void> {
const encrypted = await encryptClientInformation(info)
await db
.update(mcpServerOauth)
.set({ clientInformation: encrypted, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function saveTokens(rowId: string, tokens: OAuthTokens): Promise<void> {
const encrypted = await encryptTokens(tokens)
await db
.update(mcpServerOauth)
.set({ tokens: encrypted, lastRefreshedAt: new Date(), updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function saveCodeVerifier(rowId: string, verifier: string): Promise<void> {
const { encrypted } = await encryptSecret(verifier)
await db
.update(mcpServerOauth)
.set({ codeVerifier: encrypted, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function saveState(rowId: string, state: string): Promise<void> {
const now = new Date()
await db
.update(mcpServerOauth)
.set({ state: hashState(state), stateCreatedAt: now, updatedAt: now })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearTokens(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ tokens: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearClient(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ clientInformation: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearVerifier(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ codeVerifier: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
export async function clearState(rowId: string): Promise<void> {
await db
.update(mcpServerOauth)
.set({ state: null, stateCreatedAt: null, updatedAt: new Date() })
.where(eq(mcpServerOauth.id, rowId))
}
/**
* Serialize OAuth row access across all callers, in-process AND across
* processes. Refresh tokens rotate (RFC 6749 §6, MCP §2.3.3), so two concurrent
* refreshes against the same row would race and one would receive
* `invalid_grant`, wiping credentials.
*
* Two-tier serialization (each caller runs its OWN `fn()` — callers consume
* `McpClient` instances that can't be shared, unlike a scalar access token):
* 1) In-process: per-row Promise chain. Concurrent callers queue; each
* runs `fn()` after the previous settles. The queue wait is bounded —
* a caller whose turn does not arrive within
* {@link REFRESH_QUEUE_WAIT_TIMEOUT_MS} rejects without ever running
* its `fn()`, so a wedged link cannot accumulate callers indefinitely.
* 2) Cross-process: Redis mutex (`acquireLock` / `releaseLock`) with a TTL
* watchdog that periodically extends the lock while `fn()` runs, so
* long-running refreshes don't drop the lock and let another process
* race onto the same refresh.
*
* Falls open if Redis is unavailable — `acquireLock` no-ops, but in-process
* serialization still holds within a single Node process.
*/
const REFRESH_LOCK_TTL_SEC = 15
const REFRESH_LOCK_EXTEND_INTERVAL_MS = 5_000
const REFRESH_POLL_INTERVAL_MS = 100
const REFRESH_MAX_WAIT_MS = 30_000
/**
* Deadline on the in-process QUEUE WAIT only — the time a caller spends
* waiting for its turn behind queued predecessors. Without it, one hung
* link wedges every subsequent caller for that row until process restart.
* Sized to survive one legitimately slow predecessor: up to
* REFRESH_MAX_WAIT_MS of cross-process lock contention plus the MCP SDK's
* 60s initialize timeout. Deliberately NOT applied to the caller's own
* `fn()` run — aborting a running `fn()` would orphan a connected
* `McpClient` and abandon a possibly mid-rotation refresh; `fn()` is
* bounded by its own SDK/HTTP/Redis timeouts instead.
*/
const REFRESH_QUEUE_WAIT_TIMEOUT_MS = 90_000
const inflightChains = new Map<string, Promise<unknown>>()
export async function withMcpOauthRefreshLock<T>(rowId: string, fn: () => Promise<T>): Promise<T> {
const lockKey = `mcp:oauth:refresh:${rowId}`
const prev = inflightChains.get(lockKey) ?? Promise.resolve()
const prevSettled = prev.catch(() => undefined)
let queueTimedOut = false
const next = prevSettled.then(() => {
if (queueTimedOut) {
throw new Error(`MCP OAuth refresh queue for ${rowId} abandoned after timeout`)
}
return runWithRedisMutex(lockKey, rowId, fn)
})
inflightChains.set(lockKey, next)
const cleanup = () => {
if (inflightChains.get(lockKey) === next) inflightChains.delete(lockKey)
}
next.then(cleanup, cleanup)
let queueTimer: ReturnType<typeof setTimeout> | undefined
const queueDeadline = new Promise<never>((_, reject) => {
queueTimer = setTimeout(() => {
queueTimedOut = true
reject(
new Error(
`MCP OAuth refresh queue for ${rowId} stalled for ${REFRESH_QUEUE_WAIT_TIMEOUT_MS}ms`
)
)
}, REFRESH_QUEUE_WAIT_TIMEOUT_MS)
queueTimer.unref?.()
})
try {
await Promise.race([prevSettled, queueDeadline])
} finally {
clearTimeout(queueTimer)
}
return next
}
async function runWithRedisMutex<T>(
lockKey: string,
rowId: string,
fn: () => Promise<T>
): Promise<T> {
const ownerToken = generateShortId()
const deadline = Date.now() + REFRESH_MAX_WAIT_MS
while (true) {
let acquired = false
try {
acquired = await acquireLock(lockKey, ownerToken, REFRESH_LOCK_TTL_SEC)
} catch (error) {
logger.warn('Redis unavailable, running OAuth flow uncoordinated', {
rowId,
error: toError(error).message,
})
return fn()
}
if (acquired) {
const watchdog = setInterval(() => {
extendLock(lockKey, ownerToken, REFRESH_LOCK_TTL_SEC).catch((error) => {
logger.warn('Refresh lock extend failed', {
rowId,
error: toError(error).message,
})
})
}, REFRESH_LOCK_EXTEND_INTERVAL_MS)
try {
return await fn()
} finally {
clearInterval(watchdog)
await releaseLock(lockKey, ownerToken).catch((error) => {
logger.warn('Refresh lock release failed (will expire via TTL)', {
rowId,
error: toError(error).message,
})
})
}
}
if (Date.now() >= deadline) {
// Lock still held by another process AND its watchdog is keeping it
// alive — falling open would let us refresh concurrently and race the
// rotating refresh token. Throw and let the caller decide whether to
// retry; the Redis-down path remains the only branch that runs `fn()`
// uncoordinated (no coordination available there).
throw new Error(
`MCP OAuth refresh lock for ${rowId} held longer than ${REFRESH_MAX_WAIT_MS}ms`
)
}
await sleep(REFRESH_POLL_INTERVAL_MS)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { isLoopbackHostname } from '@/lib/core/utils/urls'
export class McpOauthInsecureUrlError extends Error {
constructor(url: string) {
super(`MCP OAuth requires https for non-loopback hosts: ${url}`)
this.name = 'McpOauthInsecureUrlError'
}
}
/**
* MCP spec §2.1 and RFC 8252 §7.3: OAuth flows must run over https, with
* http allowed only for loopback addresses during local development.
*/
export function assertSafeOauthServerUrl(rawUrl: string | null | undefined): URL {
if (!rawUrl) throw new McpOauthInsecureUrlError(String(rawUrl))
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
throw new McpOauthInsecureUrlError(rawUrl)
}
if (parsed.protocol === 'https:') return parsed
if (parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)) return parsed
throw new McpOauthInsecureUrlError(rawUrl)
}
+31
View File
@@ -0,0 +1,31 @@
export {
type McpServerOrchestrationErrorCode,
type PerformCreateMcpServerParams,
type PerformDeleteMcpServerParams,
type PerformMcpServerResult,
type PerformUpdateMcpServerParams,
performCreateMcpServer,
performDeleteMcpServer,
performUpdateMcpServer,
} from './server-lifecycle'
export {
type PerformCreateWorkflowMcpServerParams,
type PerformCreateWorkflowMcpServerResult,
type PerformCreateWorkflowMcpToolParams,
type PerformCreateWorkflowMcpToolResult,
type PerformDeleteWorkflowMcpServerParams,
type PerformDeleteWorkflowMcpServerResult,
type PerformDeleteWorkflowMcpToolParams,
type PerformDeleteWorkflowMcpToolResult,
type PerformUpdateWorkflowMcpServerParams,
type PerformUpdateWorkflowMcpServerResult,
type PerformUpdateWorkflowMcpToolParams,
type PerformUpdateWorkflowMcpToolResult,
performCreateWorkflowMcpServer,
performCreateWorkflowMcpTool,
performDeleteWorkflowMcpServer,
performDeleteWorkflowMcpTool,
performUpdateWorkflowMcpServer,
performUpdateWorkflowMcpTool,
type WorkflowMcpOrchestrationErrorCode,
} from './workflow-mcp-lifecycle'
@@ -0,0 +1,469 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db, mcpServers } from '@sim/db'
import { mcpServerOauth } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { encryptSecret } from '@/lib/core/security/encryption'
import {
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
validateMcpDomain,
validateMcpServerSsrf,
} from '@/lib/mcp/domain-check'
import { detectMcpAuthType, oauthCredsChanged, revokeMcpOauthTokens } from '@/lib/mcp/oauth'
import { mcpService } from '@/lib/mcp/service'
import type { McpAuthType } from '@/lib/mcp/types'
import { generateMcpServerId } from '@/lib/mcp/utils'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('McpServerOrchestration')
export type McpServerOrchestrationErrorCode = 'not_found' | 'forbidden' | 'bad_gateway' | 'internal'
type McpServerTransport = (typeof mcpServers.$inferInsert)['transport']
interface ActorMetadata {
actorName?: string | null
actorEmail?: string | null
request?: NextRequest
}
export interface PerformCreateMcpServerParams extends ActorMetadata {
workspaceId: string
userId: string
name: string
description?: string | null
transport?: McpServerTransport
url: string
headers?: Record<string, string>
timeout?: number
retries?: number
enabled?: boolean
source?: string
authType?: McpAuthType
oauthClientId?: string | null
oauthClientIdProvided?: boolean
oauthClientSecret?: string | null
oauthClientSecretProvided?: boolean
}
export interface PerformUpdateMcpServerParams extends ActorMetadata {
workspaceId: string
userId: string
serverId: string
name?: string
description?: string | null
transport?: McpServerTransport
url?: string
headers?: Record<string, string>
timeout?: number
retries?: number
enabled?: boolean
authType?: McpAuthType
oauthClientId?: string | null
oauthClientIdProvided?: boolean
oauthClientSecret?: string | null
oauthClientSecretProvided?: boolean
}
export interface PerformDeleteMcpServerParams extends ActorMetadata {
workspaceId: string
userId: string
serverId: string
source?: string
}
export interface PerformMcpServerResult {
success: boolean
error?: string
errorCode?: McpServerOrchestrationErrorCode
serverId?: string
server?: typeof mcpServers.$inferSelect
updated?: boolean
authType?: McpAuthType
}
type ValidateMcpServerUrlResult =
| { ok: true; resolvedIP: string | null }
| { ok: false; result: PerformMcpServerResult }
async function validateMcpServerUrl(url: string): Promise<ValidateMcpServerUrlResult> {
try {
validateMcpDomain(url)
const resolvedIP = await validateMcpServerSsrf(url)
return { ok: true, resolvedIP }
} catch (error) {
if (error instanceof McpDomainNotAllowedError || error instanceof McpSsrfError) {
return { ok: false, result: { success: false, error: error.message, errorCode: 'forbidden' } }
}
if (error instanceof McpDnsResolutionError) {
return {
ok: false,
result: { success: false, error: error.message, errorCode: 'bad_gateway' },
}
}
throw error
}
}
export async function performCreateMcpServer(
params: PerformCreateMcpServerParams
): Promise<PerformMcpServerResult> {
const validation = await validateMcpServerUrl(params.url)
if (!validation.ok) return validation.result
const validatedIP = validation.resolvedIP
const transport = params.transport || 'streamable-http'
const timeout = params.timeout || 30000
const retries = params.retries || 3
const enabled = params.enabled !== false
const serverId = params.url ? generateMcpServerId(params.workspaceId, params.url) : generateId()
const oauthClientSecretEncrypted = params.oauthClientSecret
? (await encryptSecret(params.oauthClientSecret)).encrypted
: null
const oauthClientId = params.oauthClientId || null
const hasHeaders = params.headers && Object.keys(params.headers).length > 0
try {
const [existingServer] = await db
.select({
id: mcpServers.id,
deletedAt: mcpServers.deletedAt,
url: mcpServers.url,
authType: mcpServers.authType,
oauthClientId: mcpServers.oauthClientId,
oauthClientSecret: mcpServers.oauthClientSecret,
})
.from(mcpServers)
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId)))
.limit(1)
const urlChanged = existingServer ? existingServer.url !== params.url : true
let resolvedAuthType: McpAuthType = params.authType ?? 'headers'
if (!params.authType) {
if (existingServer && !urlChanged) {
resolvedAuthType = (existingServer.authType ?? 'headers') as McpAuthType
} else if (params.url && !hasHeaders) {
try {
resolvedAuthType = await detectMcpAuthType(params.url, validatedIP)
} catch (e) {
logger.warn('Probe failed, defaulting to headers', { url: params.url, error: e })
resolvedAuthType = 'headers'
}
}
}
if (params.oauthClientId) resolvedAuthType = 'oauth'
if (existingServer) {
const credsChanged = await oauthCredsChanged({
incomingClientId: oauthClientId,
incomingClientIdProvided: params.oauthClientIdProvided ?? false,
incomingClientSecret: params.oauthClientSecret,
incomingClientSecretProvided: params.oauthClientSecretProvided ?? false,
currentClientId: existingServer.oauthClientId,
currentEncryptedClientSecret: existingServer.oauthClientSecret,
})
const isRevival = existingServer.deletedAt !== null
const shouldClearOauth = urlChanged || credsChanged || isRevival
if (shouldClearOauth) await revokeMcpOauthTokens(serverId)
await db.transaction(async (tx) => {
if (shouldClearOauth) {
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId))
}
const updateValues: Record<string, unknown> = {
name: params.name,
description: params.description,
transport,
url: params.url,
authType: resolvedAuthType,
headers: params.headers || {},
timeout,
retries,
enabled,
updatedAt: new Date(),
deletedAt: null,
}
if (resolvedAuthType === 'oauth') {
if (shouldClearOauth) {
updateValues.connectionStatus = 'disconnected'
updateValues.lastConnected = null
}
} else {
updateValues.connectionStatus = 'connected'
updateValues.lastConnected = new Date()
}
if (params.oauthClientIdProvided) updateValues.oauthClientId = oauthClientId
if (params.oauthClientSecretProvided) {
updateValues.oauthClientSecret = oauthClientSecretEncrypted
}
await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId))
})
await mcpService.clearCache(params.workspaceId)
return { success: true, serverId, updated: true, authType: resolvedAuthType }
}
await db.insert(mcpServers).values({
id: serverId,
workspaceId: params.workspaceId,
createdBy: params.userId,
name: params.name,
description: params.description,
transport,
url: params.url,
authType: resolvedAuthType,
oauthClientId,
oauthClientSecret: oauthClientSecretEncrypted,
headers: params.headers || {},
timeout,
retries,
enabled,
connectionStatus: resolvedAuthType === 'oauth' ? 'disconnected' : 'connected',
lastConnected: resolvedAuthType === 'oauth' ? null : new Date(),
createdAt: new Date(),
updatedAt: new Date(),
})
await mcpService.clearCache(params.workspaceId)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.mcpServerAdded({
serverId,
serverName: params.name,
transport,
workspaceId: params.workspaceId,
})
} catch {}
const source =
params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined
captureServerEvent(
params.userId,
'mcp_server_connected',
{ workspace_id: params.workspaceId, server_name: params.name, transport, source },
{
groups: { workspace: params.workspaceId },
setOnce: { first_mcp_connected_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: params.name,
description: `Added MCP server "${params.name}"`,
metadata: {
serverName: params.name,
transport,
url: params.url,
timeout,
retries,
source,
},
request: params.request,
})
return { success: true, serverId, updated: false, authType: resolvedAuthType }
} catch (error) {
logger.error('Failed to create MCP server', { error })
return { success: false, error: 'Failed to register MCP server', errorCode: 'internal' }
}
}
export async function performUpdateMcpServer(
params: PerformUpdateMcpServerParams
): Promise<PerformMcpServerResult> {
if (params.url) {
const validation = await validateMcpServerUrl(params.url)
if (!validation.ok) return validation.result
}
const oauthClientSecretEncrypted =
params.oauthClientSecretProvided && params.oauthClientSecret
? (await encryptSecret(params.oauthClientSecret)).encrypted
: null
const updateData: Partial<typeof mcpServers.$inferInsert> = { updatedAt: new Date() }
if (params.name !== undefined) updateData.name = params.name
if (params.description !== undefined) updateData.description = params.description
if (params.transport !== undefined) updateData.transport = params.transport
if (params.url !== undefined) updateData.url = params.url
if (params.headers !== undefined) updateData.headers = params.headers
if (params.timeout !== undefined) updateData.timeout = params.timeout
if (params.retries !== undefined) updateData.retries = params.retries
if (params.enabled !== undefined) updateData.enabled = params.enabled
if (params.authType !== undefined) updateData.authType = params.authType
if (params.oauthClientIdProvided) updateData.oauthClientId = params.oauthClientId || null
if (params.oauthClientSecretProvided) {
updateData.oauthClientSecret = oauthClientSecretEncrypted
}
try {
const [currentServer] = await db
.select({
url: mcpServers.url,
authType: mcpServers.authType,
oauthClientId: mcpServers.oauthClientId,
oauthClientSecret: mcpServers.oauthClientSecret,
})
.from(mcpServers)
.where(
and(
eq(mcpServers.id, params.serverId),
eq(mcpServers.workspaceId, params.workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
if (!currentServer) return { success: false, error: 'Server not found', errorCode: 'not_found' }
if (
params.oauthClientId &&
currentServer.authType !== 'oauth' &&
updateData.authType === undefined
) {
updateData.authType = 'oauth'
}
const urlChanged = params.url !== undefined && currentServer.url !== params.url
const credsChanged = await oauthCredsChanged({
incomingClientId: params.oauthClientId,
incomingClientIdProvided: params.oauthClientIdProvided ?? false,
incomingClientSecret: params.oauthClientSecret,
incomingClientSecretProvided: params.oauthClientSecretProvided ?? false,
currentClientId: currentServer.oauthClientId,
currentEncryptedClientSecret: currentServer.oauthClientSecret,
})
const shouldClearOauth = urlChanged || credsChanged
const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType
if (shouldClearOauth && resolvedAuthType === 'oauth') {
updateData.connectionStatus = 'disconnected'
updateData.lastConnected = null
}
if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId)
const server = await db.transaction(async (tx) => {
const [updated] = await tx
.update(mcpServers)
.set(updateData)
.where(
and(
eq(mcpServers.id, params.serverId),
eq(mcpServers.workspaceId, params.workspaceId),
isNull(mcpServers.deletedAt)
)
)
.returning()
if (!updated) return null
if (shouldClearOauth) {
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, params.serverId))
}
return updated
})
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
const shouldClearCache =
urlChanged ||
credsChanged ||
params.enabled !== undefined ||
params.headers !== undefined ||
params.timeout !== undefined ||
params.retries !== undefined
if (shouldClearCache) await mcpService.clearCache(params.workspaceId)
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: params.serverId,
resourceName: server.name || params.serverId,
description: `Updated MCP server "${server.name || params.serverId}"`,
metadata: {
serverName: server.name,
transport: server.transport,
url: server.url,
updatedFields: Object.keys(updateData).filter((key) => key !== 'updatedAt'),
},
request: params.request,
})
return { success: true, server }
} catch (error) {
logger.error('Failed to update MCP server', { error })
return { success: false, error: 'Failed to update MCP server', errorCode: 'internal' }
}
}
export async function performDeleteMcpServer(
params: PerformDeleteMcpServerParams
): Promise<PerformMcpServerResult> {
try {
await revokeMcpOauthTokens(params.serverId)
const [server] = await db
.delete(mcpServers)
.where(
and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId))
)
.returning()
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
await mcpService.clearCache(params.workspaceId)
const source =
params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined
captureServerEvent(
params.userId,
'mcp_server_disconnected',
{ workspace_id: params.workspaceId, server_name: server.name, source },
{ groups: { workspace: params.workspaceId } }
)
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: params.serverId,
resourceName: server.name,
description: `Removed MCP server "${server.name}"`,
metadata: {
serverName: server.name,
transport: server.transport,
url: server.url,
source,
},
request: params.request,
})
return { success: true, server }
} catch (error) {
logger.error('Failed to delete MCP server', { error })
return { success: false, error: 'Failed to delete MCP server', errorCode: 'internal' }
}
}
@@ -0,0 +1,203 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/audit', () => ({
AuditAction: {
MCP_SERVER_UPDATED: 'mcp_server_updated',
MCP_TOOL_UPDATED: 'mcp_tool_updated',
},
AuditResourceType: {
MCP_SERVER: 'mcp_server',
MCP_TOOL: 'mcp_tool',
},
recordAudit: vi.fn(),
}))
vi.mock('@sim/db', () => ({
...dbChainMock,
workflow: schemaMock.workflow,
workflowMcpServer: schemaMock.workflowMcpServer,
workflowMcpTool: schemaMock.workflowMcpTool,
}))
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
asc: vi.fn(),
eq: vi.fn(),
inArray: vi.fn(),
isNull: vi.fn(),
ne: vi.fn(),
sql: Object.assign(vi.fn(), { raw: vi.fn((value: string) => value) }),
}))
vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: undefined }))
vi.mock('@/lib/workflows/triggers/trigger-utils.server', () => ({
hasValidStartBlock: vi.fn(),
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
generateParameterSchemaForWorkflow: vi.fn().mockResolvedValue({ type: 'object', properties: {} }),
}))
import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants'
import {
performCreateWorkflowMcpServer,
performUpdateWorkflowMcpTool,
} from '@/lib/mcp/orchestration/workflow-mcp-lifecycle'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
describe('workflow MCP lifecycle orchestration', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('rejects over-limit workflow server creation before inserting a server row', async () => {
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
name: 'Too Many Tools',
workflowIds: Array.from(
{ length: MAX_MCP_TOOLS_PER_SERVER + 1 },
(_, index) => `wf-${index}`
),
})
expect(result).toMatchObject({
success: false,
errorCode: 'validation',
})
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('rejects duplicate workflow IDs before inserting a server row', async () => {
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
name: 'Duplicate Tools',
workflowIds: ['wf-1', 'wf-1'],
})
expect(result).toMatchObject({
success: false,
errorCode: 'validation',
})
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('rechecks deployed workflow state inside the create transaction', async () => {
dbChainMockFns.where.mockResolvedValueOnce([
{
id: 'wf-1',
name: 'Workflow',
description: null,
isDeployed: true,
workspaceId: 'workspace-1',
deployedAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
])
vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true)
dbChainMockFns.for.mockResolvedValueOnce([
{
id: 'wf-1',
name: 'Workflow',
description: null,
isDeployed: false,
workspaceId: 'workspace-1',
deployedAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
])
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
name: 'Server',
workflowIds: ['wf-1'],
})
expect(result).toMatchObject({
success: false,
errorCode: 'validation',
})
expect(dbChainMockFns.transaction).toHaveBeenCalled()
expect(dbChainMockFns.for).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('rejects workflow MCP server fan-out above the per-workflow limit', async () => {
vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true)
dbChainMockFns.where.mockResolvedValueOnce([
{
id: 'wf-1',
name: 'Workflow',
description: null,
isDeployed: true,
workspaceId: 'workspace-1',
deployedAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
])
dbChainMockFns.for.mockResolvedValueOnce([
{
id: 'wf-1',
name: 'Workflow',
description: null,
isDeployed: true,
workspaceId: 'workspace-1',
deployedAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
])
dbChainMockFns.groupBy.mockResolvedValueOnce([{ workflowId: 'wf-1', serverCount: 100 }])
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
name: 'Server',
workflowIds: ['wf-1'],
})
expect(result).toMatchObject({
success: false,
errorCode: 'validation',
})
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('allows updating tool metadata when an unchanged stored schema exceeds the new cap', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'server-1' }]).mockResolvedValueOnce([
{
id: 'tool-1',
toolName: 'tool_a',
toolDescription: null,
parameterSchemaBytes: MAX_MCP_PARAMETER_SCHEMA_BYTES + 1,
},
])
dbChainMockFns.returning.mockResolvedValueOnce([
{
id: 'tool-1',
serverId: 'server-1',
toolName: 'tool_a',
toolDescription: 'Updated description',
},
])
const result = await performUpdateWorkflowMcpTool({
workspaceId: 'workspace-1',
userId: 'user-1',
serverId: 'server-1',
toolId: 'tool-1',
toolDescription: 'Updated description',
})
expect(result).toMatchObject({
success: true,
tool: {
toolDescription: 'Updated description',
},
})
expect(dbChainMockFns.update).toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCreatePinnedFetch, mockValidateMcpServerSsrf, sentinelFetch } = vi.hoisted(() => ({
mockCreatePinnedFetch: vi.fn(),
mockValidateMcpServerSsrf: vi.fn(),
sentinelFetch: vi.fn(),
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
createPinnedFetch: mockCreatePinnedFetch,
}))
vi.mock('@/lib/mcp/domain-check', () => ({
validateMcpServerSsrf: mockValidateMcpServerSsrf,
}))
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
describe('createSsrfGuardedMcpFetch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreatePinnedFetch.mockReturnValue(sentinelFetch)
sentinelFetch.mockResolvedValue(new Response('ok'))
})
it('validates each request URL and pins to the resolved IP', async () => {
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
const fetchLike = createSsrfGuardedMcpFetch()
await fetchLike('https://attacker.example/revoke', { method: 'POST' })
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke')
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', {
method: 'POST',
})
})
it('rejects URLs that resolve to blocked IPs without issuing the request', async () => {
mockValidateMcpServerSsrf.mockRejectedValue(new Error('blocked'))
const fetchLike = createSsrfGuardedMcpFetch()
await expect(
fetchLike('http://169.254.169.254/latest/meta-data/', { method: 'POST' })
).rejects.toThrow('blocked')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(sentinelFetch).not.toHaveBeenCalled()
})
it('accepts URL objects and validates their href', async () => {
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
const fetchLike = createSsrfGuardedMcpFetch()
await fetchLike(new URL('https://attacker.example/discover'))
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover')
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
})
it('falls back to global fetch when validation returns no IP', async () => {
mockValidateMcpServerSsrf.mockResolvedValue(null)
const fetchLike = createSsrfGuardedMcpFetch()
await fetchLike('https://allowed.internal/mcp')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
})
})
+31
View File
@@ -0,0 +1,31 @@
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'
/**
* Builds a `FetchLike` that validates every outbound request URL against the
* MCP SSRF policy before issuing it, then pins the connection to the resolved
* IP. Unlike the live transport — where the server URL is validated once up
* front — OAuth discovery and RFC 7009 revocation follow URLs taken verbatim
* from attacker-controllable authorization-server metadata
* (`authorization_servers`, `token_endpoint`, `revocation_endpoint`, …). Each
* such hop must be re-validated, so this guard runs `validateMcpServerSsrf`
* per request and rejects private/reserved/loopback targets (honoring
* `ALLOWED_MCP_DOMAINS` and self-hosted localhost rules).
*
* Note: a caller-provided `AbortSignal` in `init` only bounds the HTTP request,
* not the validation DNS lookup — Node's `dns.lookup` does not accept a signal,
* so a hanging resolution can extend the overall call past the caller's timeout
* by up to the OS DNS timeout. Acceptable here because all consumers are
* best-effort, non-blocking flows (OAuth discovery and RFC 7009 revocation).
*
* @throws McpSsrfError if a request URL resolves to a blocked IP address
*/
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
return pinnedFetch(url, init)
}) satisfies FetchLike
}
+90
View File
@@ -0,0 +1,90 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockToolsChannel, mockWorkflowToolsChannel } = vi.hoisted(() => {
const mockToolsChannel = {
publish: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
dispose: vi.fn(),
}
const mockWorkflowToolsChannel = {
publish: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
dispose: vi.fn(),
}
return { mockToolsChannel, mockWorkflowToolsChannel }
})
vi.mock('@/lib/events/pubsub', () => ({
createPubSubChannel: vi.fn((config: { label: string }) => {
if (config.label === 'mcp-tools') return mockToolsChannel
if (config.label === 'mcp-workflow-tools') return mockWorkflowToolsChannel
return null
}),
}))
import { mcpPubSub } from '@/lib/mcp/pubsub'
beforeEach(() => {
vi.clearAllMocks()
})
describe('RedisMcpPubSub', () => {
it('delegates publishToolsChanged to the tools channel', () => {
const event = {
serverId: 'srv-1',
serverName: 'Test',
workspaceId: 'ws-1',
timestamp: Date.now(),
}
mcpPubSub.publishToolsChanged(event)
expect(mockToolsChannel.publish).toHaveBeenCalledWith(event)
})
it('delegates publishWorkflowToolsChanged to the workflow tools channel', () => {
const event = {
workflowId: 'wf-1',
workspaceId: 'ws-1',
timestamp: Date.now(),
}
mcpPubSub.publishWorkflowToolsChanged(event)
expect(mockWorkflowToolsChannel.publish).toHaveBeenCalledWith(event)
})
it('delegates onToolsChanged to the tools channel subscribe', () => {
const handler = vi.fn()
const mockUnsub = vi.fn()
mockToolsChannel.subscribe.mockReturnValueOnce(mockUnsub)
const unsub = mcpPubSub.onToolsChanged(handler)
expect(mockToolsChannel.subscribe).toHaveBeenCalledWith(handler)
expect(unsub).toBe(mockUnsub)
})
it('delegates onWorkflowToolsChanged to the workflow tools channel subscribe', () => {
const handler = vi.fn()
const mockUnsub = vi.fn()
mockWorkflowToolsChannel.subscribe.mockReturnValueOnce(mockUnsub)
const unsub = mcpPubSub.onWorkflowToolsChanged(handler)
expect(mockWorkflowToolsChannel.subscribe).toHaveBeenCalledWith(handler)
expect(unsub).toBe(mockUnsub)
})
describe('dispose', () => {
it('calls dispose on both channels', () => {
mcpPubSub.dispose()
expect(mockToolsChannel.dispose).toHaveBeenCalledTimes(1)
expect(mockWorkflowToolsChannel.dispose).toHaveBeenCalledTimes(1)
})
})
})
+67
View File
@@ -0,0 +1,67 @@
/**
* MCP Pub/Sub Adapter
*
* Broadcasts MCP notification events across processes using Redis Pub/Sub.
* Gracefully falls back to process-local EventEmitter when Redis is unavailable.
*
* Two channels:
* - `mcp:tools_changed` — external MCP server sent a listChanged notification
* (published by connection manager, consumed by events SSE endpoint)
* - `mcp:workflow_tools_changed` — workflow CRUD modified a workflow MCP server's tools
* (published by serve route, consumed by serve route on other processes to push to local SSE clients)
*/
import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub'
import type { ToolsChangedEvent, WorkflowToolsChangedEvent } from '@/lib/mcp/types'
interface McpPubSubAdapter {
publishToolsChanged(event: ToolsChangedEvent): void
publishWorkflowToolsChanged(event: WorkflowToolsChangedEvent): void
onToolsChanged(handler: (event: ToolsChangedEvent) => void): () => void
onWorkflowToolsChanged(handler: (event: WorkflowToolsChangedEvent) => void): () => void
dispose(): void
}
type McpPubSubGlobal = typeof globalThis & {
_mcpToolsChannel?: PubSubChannel<ToolsChangedEvent> | null
_mcpWorkflowToolsChannel?: PubSubChannel<WorkflowToolsChangedEvent> | null
}
const g = globalThis as McpPubSubGlobal
if (!('_mcpToolsChannel' in g)) {
g._mcpToolsChannel =
typeof window !== 'undefined'
? null
: createPubSubChannel<ToolsChangedEvent>({
channel: 'mcp:tools_changed',
label: 'mcp-tools',
})
}
if (!('_mcpWorkflowToolsChannel' in g)) {
g._mcpWorkflowToolsChannel =
typeof window !== 'undefined'
? null
: createPubSubChannel<WorkflowToolsChangedEvent>({
channel: 'mcp:workflow_tools_changed',
label: 'mcp-workflow-tools',
})
}
const toolsChannel = g._mcpToolsChannel
const workflowToolsChannel = g._mcpWorkflowToolsChannel
export const mcpPubSub: McpPubSubAdapter | null =
typeof window !== 'undefined' || !toolsChannel || !workflowToolsChannel
? null
: {
publishToolsChanged: (event) => toolsChannel.publish(event),
publishWorkflowToolsChanged: (event) => workflowToolsChannel.publish(event),
onToolsChanged: (handler) => toolsChannel.subscribe(handler),
onWorkflowToolsChanged: (handler) => workflowToolsChannel.subscribe(handler),
dispose: () => {
toolsChannel.dispose()
workflowToolsChannel.dispose()
},
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Server-only MCP config resolution utilities.
* This file contains functions that require server-side dependencies (database access).
* Do NOT import this file in client components.
*/
import { createLogger } from '@sim/logger'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import type { McpServerConfig } from '@/lib/mcp/types'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
const logger = createLogger('McpResolveConfig')
export interface ResolveMcpConfigOptions {
/** If true, throws an error when env vars are missing. Default: true */
strict?: boolean
}
/**
* Resolve environment variables in MCP server config (url, headers).
* Shared utility used by both MCP service and test-connection endpoint.
*
* @param config - MCP server config with potential {{ENV_VAR}} patterns
* @param userId - User ID to fetch environment variables for
* @param workspaceId - Workspace ID for workspace-specific env vars
* @param options - Resolution options (strict mode throws on missing vars)
* @returns Resolved config with env vars replaced
*/
export async function resolveMcpConfigEnvVars(
config: McpServerConfig,
userId: string,
workspaceId?: string,
options: ResolveMcpConfigOptions = {}
): Promise<{ config: McpServerConfig; missingVars: string[] }> {
const { strict = true } = options
const allMissingVars: string[] = []
let envVars: Record<string, string> = {}
try {
envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
} catch (error) {
logger.error('Failed to fetch environment variables for MCP config:', error)
return { config, missingVars: [] }
}
const resolveValue = (value: string): string => {
const missingVars: string[] = []
const resolved = resolveEnvVarReferences(value, envVars, {
missingKeys: missingVars,
}) as string
allMissingVars.push(...missingVars)
return resolved
}
const resolvedConfig = { ...config }
if (resolvedConfig.url) {
resolvedConfig.url = resolveValue(resolvedConfig.url)
}
if (resolvedConfig.headers) {
const resolvedHeaders: Record<string, string> = {}
for (const [key, value] of Object.entries(resolvedConfig.headers)) {
resolvedHeaders[key] = resolveValue(value)
}
resolvedConfig.headers = resolvedHeaders
}
// Handle missing vars based on strict mode
if (allMissingVars.length > 0) {
const uniqueMissing = Array.from(new Set(allMissingVars))
if (strict) {
throw new Error(
`Missing required environment variable${uniqueMissing.length > 1 ? 's' : ''}: ${uniqueMissing.join(', ')}. ` +
`Please set ${uniqueMissing.length > 1 ? 'these variables' : 'this variable'} in your workspace or personal environment settings.`
)
}
uniqueMissing.forEach((envKey) => {
logger.warn(`Environment variable "${envKey}" not found in MCP config`)
})
}
return { config: resolvedConfig, missingVars: allMissingVars }
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { isWorkflowMcpServerLockTimeout } from '@/lib/mcp/server-locks'
describe('MCP server locks', () => {
it('detects Postgres lock timeout errors', () => {
const error = Object.assign(new Error('canceling statement due to lock timeout'), {
code: '55P03',
})
expect(isWorkflowMcpServerLockTimeout(error)).toBe(true)
})
})
+21
View File
@@ -0,0 +1,21 @@
import { getPostgresErrorCode } from '@sim/utils/errors'
import { sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
const MCP_SERVER_LOCK_TIMEOUT_MS = 3_000
const LOCK_NOT_AVAILABLE_SQLSTATE = '55P03'
export async function setWorkflowMcpTransactionLockTimeout(tx: DbOrTx): Promise<void> {
await tx.execute(
sql`select set_config('lock_timeout', ${`${MCP_SERVER_LOCK_TIMEOUT_MS}ms`}, true)`
)
}
export async function acquireWorkflowMcpServerLock(tx: DbOrTx, serverId: string): Promise<void> {
await setWorkflowMcpTransactionLockTimeout(tx)
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${serverId}, 0))`)
}
export function isWorkflowMcpServerLockTimeout(error: unknown): boolean {
return getPostgresErrorCode(error) === LOCK_NOT_AVAILABLE_SQLSTATE
}
+369
View File
@@ -0,0 +1,369 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
MockMcpClient,
mockListTools,
mockConnect,
mockDisconnect,
mockGetWorkspaceServersRows,
mockResolveEnvVars,
mockValidateDomain,
mockValidateSsrf,
mockIsDomainAllowed,
mockCacheAdapter,
} = vi.hoisted(() => {
const mockListTools = vi.fn()
const mockConnect = vi.fn()
const mockDisconnect = vi.fn()
// In-memory cache adapter so the service never touches the real Redis the
// local .env points at (unreachable in CI/sandbox → hangs). Honors TTL via
// an expiry timestamp so negative-cache assertions behave like production.
const cacheStore = new Map<string, { tools: unknown[]; expiry: number }>()
const mockCacheAdapter = {
get: async (key: string) => {
const entry = cacheStore.get(key)
if (!entry) return null
if (entry.expiry <= Date.now()) {
cacheStore.delete(key)
return null
}
return entry
},
set: async (key: string, tools: unknown[], ttlMs: number) => {
cacheStore.set(key, { tools, expiry: Date.now() + ttlMs })
},
delete: async (key: string) => {
cacheStore.delete(key)
},
clear: async () => {
cacheStore.clear()
},
dispose: () => {},
}
return {
mockCacheAdapter,
MockMcpClient: vi.fn().mockImplementation(
class {
constructor() {
Object.assign(this, {
connect: mockConnect,
disconnect: mockDisconnect,
listTools: mockListTools,
hasListChangedCapability: vi.fn(() => false),
onClose: vi.fn(),
getNegotiatedVersion: vi.fn(() => '2025-06-18'),
})
}
}
),
mockListTools,
mockConnect,
mockDisconnect,
mockGetWorkspaceServersRows: vi.fn(),
mockResolveEnvVars: vi.fn(),
mockValidateDomain: vi.fn(),
mockValidateSsrf: vi.fn(),
mockIsDomainAllowed: vi.fn(() => true),
}
})
vi.mock('@sim/db', () => {
// `where(...)` resolves to the workspace's rows AND exposes `.limit()` for
// chains like `getServerConfig` that do `select().from().where().limit(1)`.
const where = (...args: unknown[]) => {
const rowsPromise = Promise.resolve(mockGetWorkspaceServersRows(...args))
const thenable = Object.assign(rowsPromise, {
limit: (n: number) => rowsPromise.then((rows) => rows.slice(0, n)),
})
return thenable
}
const setter = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
return {
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({ where }),
}),
update: vi.fn().mockReturnValue({ set: setter }),
insert: vi.fn(),
delete: vi.fn(),
},
}
})
vi.mock('@/lib/mcp/client', () => ({
McpClient: MockMcpClient,
}))
vi.mock('@/lib/mcp/connection-manager', () => ({
mcpConnectionManager: null,
}))
vi.mock('@/lib/mcp/domain-check', () => ({
isMcpDomainAllowed: (...args: unknown[]) => mockIsDomainAllowed(...args),
validateMcpDomain: (...args: unknown[]) => mockValidateDomain(...args),
validateMcpServerSsrf: (...args: unknown[]) => mockValidateSsrf(...args),
}))
vi.mock('@/lib/mcp/oauth', () => ({
getOrCreateOauthRow: vi.fn(),
loadPreregisteredClient: vi.fn(),
SimMcpOauthProvider: vi.fn(),
withMcpOauthRefreshLock: vi.fn(),
}))
vi.mock('@/lib/mcp/resolve-config', () => ({
resolveMcpConfigEnvVars: (...args: unknown[]) => mockResolveEnvVars(...args),
}))
vi.mock('@/lib/mcp/storage', () => ({
createMcpCacheAdapter: () => mockCacheAdapter,
getMcpCacheType: () => 'memory',
}))
import { mcpService } from '@/lib/mcp/service'
import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
const WORKSPACE_ID = 'workspace-test'
const USER_ID = 'user-test'
function dbRow(id: string, name: string, overrides: Record<string, unknown> = {}) {
return {
id,
name,
description: null,
transport: 'streamable-http',
url: `https://${id}.example.com/mcp`,
authType: 'headers',
workspaceId: WORKSPACE_ID,
headers: {},
timeout: 30000,
retries: 3,
enabled: true,
deletedAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
}
}
function tool(name: string, serverId: string) {
return {
name,
description: name,
inputSchema: { type: 'object' },
serverId,
serverName: serverId,
}
}
describe('McpService.discoverTools per-server caching', () => {
beforeEach(async () => {
vi.clearAllMocks()
// `clearAllMocks` does not drain `.mockResolvedValueOnce` queues; reset
// listTools so a previous test's unconsumed mock doesn't leak into the next.
mockListTools.mockReset()
mockIsDomainAllowed.mockReturnValue(true)
mockValidateSsrf.mockResolvedValue('1.2.3.4')
mockValidateDomain.mockImplementation(() => undefined)
mockResolveEnvVars.mockImplementation((config: { url: string }) =>
Promise.resolve({ config: { ...config, url: config.url }, missingVars: [] })
)
mockConnect.mockResolvedValue(undefined)
mockDisconnect.mockResolvedValue(undefined)
// The McpService singleton holds cache state across imports.
await mcpService.clearCache()
})
it('caches each server independently after first discovery', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')])
mockListTools
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
.mockResolvedValueOnce([tool('b1', 'mcp-b')])
const first = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(first.map((t) => t.name).sort()).toEqual(['a1', 'b1'])
expect(mockListTools).toHaveBeenCalledTimes(2)
mockListTools.mockClear()
const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(second.map((t) => t.name).sort()).toEqual(['a1', 'b1'])
expect(mockListTools).not.toHaveBeenCalled()
})
it("one server failing does not poison another server's cache", async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')])
mockListTools
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
.mockRejectedValueOnce(new Error('Request timed out'))
const first = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(first.map((t) => t.name)).toEqual(['a1'])
mockListTools.mockClear()
// a1's positive cache is intact (the failure didn't poison it). b is now
// negative-cached so it's skipped instead of re-blocking — see
// "negative-caches a failed server so the next discoverTools skips it"
// below for the full assertion.
const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(second.map((t) => t.name)).toEqual(['a1'])
expect(mockListTools).not.toHaveBeenCalled()
})
it("forceRefresh bypasses every server's cache", async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')])
mockListTools
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
.mockResolvedValueOnce([tool('b1', 'mcp-b')])
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(mockListTools).toHaveBeenCalledTimes(2)
mockListTools.mockClear()
mockListTools
.mockResolvedValueOnce([tool('a2', 'mcp-a')])
.mockResolvedValueOnce([tool('b2', 'mcp-b')])
const refreshed = await mcpService.discoverTools(USER_ID, WORKSPACE_ID, true)
expect(refreshed.map((t) => t.name).sort()).toEqual(['a2', 'b2'])
expect(mockListTools).toHaveBeenCalledTimes(2)
})
it('OAuth-pending is treated as a soft skip without poisoning cache', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')])
mockListTools
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
.mockRejectedValueOnce(new McpOauthAuthorizationRequiredError('mcp-b', 'B'))
const first = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(first.map((t) => t.name)).toEqual(['a1'])
mockListTools.mockClear()
mockListTools.mockRejectedValueOnce(new McpOauthAuthorizationRequiredError('mcp-b', 'B'))
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(mockListTools).toHaveBeenCalledTimes(1)
})
it('returns empty array immediately when workspace has no servers', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([])
const result = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(result).toEqual([])
expect(mockListTools).not.toHaveBeenCalled()
expect(MockMcpClient).not.toHaveBeenCalled()
})
it('clearCache(workspaceId) drops cached tools so next call re-fetches', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(mockListTools).toHaveBeenCalledTimes(1)
await mcpService.clearCache(WORKSPACE_ID)
mockListTools.mockClear()
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(mockListTools).toHaveBeenCalledTimes(1)
})
it('isolates caches across workspaces', async () => {
const otherWorkspaceId = 'workspace-other'
mockGetWorkspaceServersRows
.mockResolvedValueOnce([dbRow('mcp-a', 'A')])
.mockResolvedValueOnce([dbRow('mcp-a', 'A', { workspaceId: otherWorkspaceId })])
mockListTools
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
.mockResolvedValueOnce([tool('a-other', 'mcp-a')])
const first = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
const second = await mcpService.discoverTools(USER_ID, otherWorkspaceId)
expect(first.map((t) => t.name)).toEqual(['a1'])
expect(second.map((t) => t.name)).toEqual(['a-other'])
expect(mockListTools).toHaveBeenCalledTimes(2)
})
it('discoverServerTools primes the per-server cache for follow-up discoverTools', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)
expect(tools.map((t) => t.name)).toEqual(['a1'])
expect(mockListTools).toHaveBeenCalledTimes(1)
mockListTools.mockClear()
const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(second.map((t) => t.name)).toEqual(['a1'])
expect(mockListTools).not.toHaveBeenCalled()
})
it('negative-caches a failed server so the next discoverTools skips it', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')])
mockListTools
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
.mockRejectedValueOnce(new Error('Request timed out'))
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(mockListTools).toHaveBeenCalledTimes(2)
mockListTools.mockClear()
// Second call: a1 is success-cached, b is failure-cached. Neither should
// hit the live transport — the slow server no longer blocks the response.
const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(second.map((t) => t.name)).toEqual(['a1'])
expect(mockListTools).not.toHaveBeenCalled()
})
it('successful discoverServerTools clears the negative cache', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
mockListTools.mockRejectedValueOnce(new Error('Request timed out'))
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
'Request timed out'
)
// After the failure the negative cache is set, so the next default call
// short-circuits without re-paying the listTools timeout.
mockListTools.mockClear()
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
'cooldown'
)
expect(mockListTools).not.toHaveBeenCalled()
// Reconnecting via the explicit-refresh path (refresh button / OAuth
// callback) bypasses both caches and brings the server back to live.
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
expect(tools.map((t) => t.name)).toEqual(['a1'])
// discoverTools now sees the cleared negative cache + primed positive cache.
mockListTools.mockClear()
const after = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(after.map((t) => t.name)).toEqual(['a1'])
expect(mockListTools).not.toHaveBeenCalled()
})
it('does not negative-cache OAuth-required errors', async () => {
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
mockListTools.mockRejectedValueOnce(new McpOauthAuthorizationRequiredError('mcp-a', 'A'))
await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(mockListTools).toHaveBeenCalledTimes(1)
// Second call must still attempt the live transport — OAuth re-auth has
// its own pathway and a stale negative cache would make reconnects
// silently fail until the TTL expired.
mockListTools.mockClear()
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
const after = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
expect(after.map((t) => t.name)).toEqual(['a1'])
expect(mockListTools).toHaveBeenCalledTimes(1)
})
})
+834
View File
@@ -0,0 +1,834 @@
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { and, eq, isNull } from 'drizzle-orm'
import { isTest } from '@/lib/core/config/env-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { McpClient } from '@/lib/mcp/client'
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
import {
isMcpDomainAllowed,
validateMcpDomain,
validateMcpServerSsrf,
} from '@/lib/mcp/domain-check'
import {
getOrCreateOauthRow,
loadPreregisteredClient,
SimMcpOauthProvider,
withMcpOauthRefreshLock,
} from '@/lib/mcp/oauth'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import {
createMcpCacheAdapter,
getMcpCacheType,
type McpCacheStorageAdapter,
} from '@/lib/mcp/storage'
import {
McpConnectionError,
McpOauthAuthorizationRequiredError,
type McpServerConfig,
type McpServerStatusConfig,
type McpServerSummary,
type McpTool,
type McpToolCall,
type McpToolResult,
type McpTransport,
} from '@/lib/mcp/types'
import { MCP_CLIENT_CONSTANTS, MCP_CONSTANTS } from '@/lib/mcp/utils'
const logger = createLogger('McpService')
function serverCacheKey(workspaceId: string, serverId: string): string {
return `workspace:${workspaceId}:server:${serverId}`
}
function failureCacheKey(workspaceId: string, serverId: string): string {
return `workspace:${workspaceId}:server:${serverId}:failure`
}
const FAILURE_CACHE_SENTINEL: McpTool[] = []
type DiscoveryOutcome =
| { kind: 'cached'; tools: McpTool[] }
| {
kind: 'fetched'
tools: McpTool[]
resolvedConfig: McpServerConfig
resolvedIP: string | null
}
| { kind: 'oauth-pending' }
| { kind: 'unhealthy' }
// originalError preserves the type so markServerUnhealthy's instanceof
// exemption survives the getErrorMessage call.
| { kind: 'error'; message: string; originalError: unknown }
class McpService {
private cacheAdapter: McpCacheStorageAdapter
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT
private unsubscribeConnectionManager?: () => void
// Keyed on (workspaceId, serverId, userId) — OAuth-scoped tokens vary per user.
private inflightServerDiscovery = new Map<string, Promise<McpTool[]>>()
constructor() {
this.cacheAdapter = createMcpCacheAdapter()
logger.info(`MCP Service initialized with ${getMcpCacheType()} cache`)
if (mcpConnectionManager) {
this.unsubscribeConnectionManager = mcpConnectionManager.subscribe((event) => {
this.cacheAdapter
.delete(serverCacheKey(event.workspaceId, event.serverId))
.catch((err) =>
logger.warn(`Failed to invalidate cache for ${event.serverName} on listChanged:`, err)
)
this.cacheAdapter
.delete(failureCacheKey(event.workspaceId, event.serverId))
.catch((err) =>
logger.warn(
`Failed to invalidate failure cache for ${event.serverName} on listChanged:`,
err
)
)
})
}
}
dispose(): void {
this.unsubscribeConnectionManager?.()
this.cacheAdapter.dispose()
logger.info('MCP Service disposed')
}
private async resolveConfigEnvVars(
config: McpServerConfig,
userId: string,
workspaceId?: string
): Promise<{ config: McpServerConfig; resolvedIP: string | null }> {
const { config: resolvedConfig } = await resolveMcpConfigEnvVars(config, userId, workspaceId, {
strict: true,
})
validateMcpDomain(resolvedConfig.url)
const resolvedIP = await validateMcpServerSsrf(resolvedConfig.url)
return { config: resolvedConfig, resolvedIP }
}
private async getServerConfig(
serverId: string,
workspaceId: string
): Promise<McpServerConfig | null> {
const [server] = await db
.select()
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
eq(mcpServers.enabled, true),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
if (!server) {
return null
}
if (!isMcpDomainAllowed(server.url || undefined)) {
return null
}
return {
id: server.id,
name: server.name,
description: server.description || undefined,
transport: 'streamable-http' as const,
url: server.url || undefined,
authType: (server.authType as McpServerConfig['authType']) ?? 'headers',
workspaceId: server.workspaceId,
headers: (server.headers as Record<string, string>) || {},
timeout: server.timeout || 30000,
retries: server.retries || 3,
enabled: server.enabled,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
}
}
private async getWorkspaceServers(workspaceId: string): Promise<McpServerConfig[]> {
const whereConditions = [
eq(mcpServers.workspaceId, workspaceId),
eq(mcpServers.enabled, true),
isNull(mcpServers.deletedAt),
]
const servers = await db
.select()
.from(mcpServers)
.where(and(...whereConditions))
return servers
.map((server) => ({
id: server.id,
name: server.name,
description: server.description || undefined,
transport: server.transport as McpTransport,
url: server.url || undefined,
authType: (server.authType as McpServerConfig['authType']) ?? 'headers',
workspaceId: server.workspaceId,
headers: (server.headers as Record<string, string>) || {},
timeout: server.timeout || 30000,
retries: server.retries || 3,
enabled: server.enabled,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
}))
.filter((config) => isMcpDomainAllowed(config.url))
}
private async createClient(
config: McpServerConfig,
resolvedIP: string | null,
userId?: string
): Promise<McpClient> {
const securityPolicy = {
requireConsent: true,
auditLevel: 'basic' as const,
maxToolExecutionsPerHour: 1000,
allowedOrigins: config.url ? [new URL(config.url).origin] : undefined,
}
if (config.authType !== 'oauth') {
const client = new McpClient({
config,
securityPolicy,
resolvedIP: resolvedIP ?? undefined,
})
await client.connect()
return client
}
if (!userId || !config.workspaceId) {
throw new Error('OAuth MCP server requires both userId and workspaceId')
}
const workspaceId = config.workspaceId
// Load the row inside the refresh lock so concurrent callers observe tokens
// written by a predecessor refresh, rather than a stale snapshot. Without
// this, the second caller's provider would hold a rotated-out refresh token
// and the SDK would trip `invalid_grant`. The lock is keyed on serverId
// since the row is per-server.
return withMcpOauthRefreshLock(config.id, async () => {
const row = await getOrCreateOauthRow({
mcpServerId: config.id,
userId,
workspaceId,
})
if (!row.tokens) {
throw new McpOauthAuthorizationRequiredError(config.id, config.name)
}
const preregistered = await loadPreregisteredClient(config.id)
const authProvider = new SimMcpOauthProvider({ row, preregistered })
const client = new McpClient({
config,
securityPolicy,
authProvider,
resolvedIP: resolvedIP ?? undefined,
})
await client.connect()
return client
})
}
/**
* Execute a tool on a specific server with retry logic for session errors.
* Retries once on session-related errors (400, 404, session ID issues).
*/
async executeTool(
userId: string,
serverId: string,
toolCall: McpToolCall,
workspaceId: string,
extraHeaders?: Record<string, string>
): Promise<McpToolResult> {
const requestId = generateRequestId()
const maxRetries = 2
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
logger.info(
`[${requestId}] Executing MCP tool ${toolCall.name} on server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`
)
const config = await this.getServerConfig(serverId, workspaceId)
if (!config) {
throw new Error(`Server ${serverId} not found or not accessible`)
}
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders }
}
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
try {
const result = await client.callTool(toolCall)
logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`)
return result
} finally {
await client.disconnect()
}
} catch (error) {
if (this.isSessionError(error) && attempt < maxRetries - 1) {
logger.warn(
`[${requestId}] Session error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`,
error
)
await sleep(100)
continue
}
throw error
}
}
throw new Error(`Failed to execute tool ${toolCall.name} after ${maxRetries} attempts`)
}
/** MCP spec: server returns 404 for unknown session id, 400 for malformed header. */
private isSessionError(error: unknown): boolean {
if (error instanceof StreamableHTTPError) {
return error.code === 404 || error.code === 400
}
return false
}
private async updateServerStatus(
serverId: string,
workspaceId: string,
success: boolean,
error?: string,
toolCount?: number
): Promise<void> {
try {
const [currentServer] = await db
.select({ statusConfig: mcpServers.statusConfig })
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
const storedConfig = currentServer?.statusConfig as Partial<McpServerStatusConfig> | null
const currentConfig: McpServerStatusConfig = {
consecutiveFailures:
typeof storedConfig?.consecutiveFailures === 'number'
? storedConfig.consecutiveFailures
: 0,
lastSuccessfulDiscovery: storedConfig?.lastSuccessfulDiscovery ?? null,
}
const now = new Date()
if (success) {
await db
.update(mcpServers)
.set({
connectionStatus: 'connected',
lastConnected: now,
lastError: null,
toolCount: toolCount ?? 0,
lastToolsRefresh: now,
statusConfig: {
consecutiveFailures: 0,
lastSuccessfulDiscovery: now.toISOString(),
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
} else {
const newFailures = currentConfig.consecutiveFailures + 1
const isErrorState = newFailures >= MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES
await db
.update(mcpServers)
.set({
connectionStatus: isErrorState ? 'error' : 'disconnected',
lastError: error || 'Unknown error',
statusConfig: {
consecutiveFailures: newFailures,
lastSuccessfulDiscovery: currentConfig.lastSuccessfulDiscovery,
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
if (isErrorState) {
logger.warn(
`Server ${serverId} marked as error after ${newFailures} consecutive failures`
)
}
}
} catch (err) {
logger.error(`Failed to update server status for ${serverId}:`, err)
}
}
/**
* Negative-cache a discovery failure. OAuth-required errors are exempt so
* reconnects retry immediately.
*/
private async markServerUnhealthy(
workspaceId: string,
serverId: string,
error: unknown
): Promise<void> {
if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) {
return
}
try {
await this.cacheAdapter.set(
failureCacheKey(workspaceId, serverId),
FAILURE_CACHE_SENTINEL,
MCP_CLIENT_CONSTANTS.FAILURE_CACHE_TTL_MS
)
} catch (err) {
logger.warn(`Failed to write failure cache for server ${serverId}:`, err)
}
}
private async isServerUnhealthy(workspaceId: string, serverId: string): Promise<boolean> {
try {
const entry = await this.cacheAdapter.get(failureCacheKey(workspaceId, serverId))
return entry !== null
} catch {
return false
}
}
private async clearServerFailure(workspaceId: string, serverId: string): Promise<void> {
try {
await this.cacheAdapter.delete(failureCacheKey(workspaceId, serverId))
} catch (err) {
logger.warn(`Failed to clear failure cache for server ${serverId}:`, err)
}
}
async discoverTools(
userId: string,
workspaceId: string,
forceRefresh = false
): Promise<McpTool[]> {
const requestId = generateRequestId()
try {
logger.info(`[${requestId}] Discovering MCP tools for workspace ${workspaceId}`)
const servers = await this.getWorkspaceServers(workspaceId)
if (servers.length === 0) {
logger.info(`[${requestId}] No servers found for workspace ${workspaceId}`)
return []
}
const outcomes = await Promise.all(
servers.map(async (config): Promise<DiscoveryOutcome> => {
const cacheKey = serverCacheKey(workspaceId, config.id)
if (!forceRefresh) {
try {
const cached = await this.cacheAdapter.get(cacheKey)
if (cached) return { kind: 'cached', tools: cached.tools }
} catch (error) {
logger.warn(
`[${requestId}] Cache read failed for ${config.name}, proceeding with discovery:`,
error
)
}
if (await this.isServerUnhealthy(workspaceId, config.id)) {
logger.info(
`[${requestId}] Skipping recently-failed server ${config.name} (negative-cache hit)`
)
return { kind: 'unhealthy' }
}
}
try {
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
try {
const tools = await client.listTools()
logger.debug(
`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`
)
return { kind: 'fetched', tools, resolvedConfig, resolvedIP }
} finally {
await client.disconnect()
}
} catch (error) {
if (
error instanceof McpOauthAuthorizationRequiredError ||
error instanceof UnauthorizedError
) {
return { kind: 'oauth-pending' }
}
return {
kind: 'error',
message: getErrorMessage(error, 'Unknown error'),
originalError: error,
}
}
})
)
const allTools: McpTool[] = []
const cacheWrites: Promise<unknown>[] = []
const deferredSideEffects: Promise<unknown>[] = []
const liveConnections: Array<{
resolvedConfig: McpServerConfig
resolvedIP: string | null
}> = []
let cachedCount = 0
let fetchedCount = 0
let failedCount = 0
outcomes.forEach((outcome, index) => {
const server = servers[index]
if (outcome.kind === 'cached') {
cachedCount++
allTools.push(...outcome.tools)
return
}
if (outcome.kind === 'fetched') {
fetchedCount++
allTools.push(...outcome.tools)
deferredSideEffects.push(
this.updateServerStatus(server.id, workspaceId, true, undefined, outcome.tools.length)
)
cacheWrites.push(
this.cacheAdapter
.set(serverCacheKey(workspaceId, server.id), outcome.tools, this.cacheTimeout)
.catch((err) =>
logger.warn(`[${requestId}] Cache write failed for ${server.name}:`, err)
)
)
deferredSideEffects.push(this.clearServerFailure(workspaceId, server.id))
liveConnections.push({
resolvedConfig: outcome.resolvedConfig,
resolvedIP: outcome.resolvedIP,
})
return
}
if (outcome.kind === 'oauth-pending') {
// Mark disconnected so the UI surfaces the re-auth button.
logger.info(`[${requestId}] Skipping server ${server.name}: OAuth authorization pending`)
deferredSideEffects.push(
db
.update(mcpServers)
.set({
connectionStatus: 'disconnected',
lastError: null,
updatedAt: new Date(),
})
.where(eq(mcpServers.id, server.id))
.then(() => undefined)
.catch((err) => {
logger.warn(`[${requestId}] Failed to mark server ${server.id} disconnected:`, err)
})
)
return
}
if (outcome.kind === 'unhealthy') {
// Status was persisted on the original failure; nothing to re-write.
failedCount++
return
}
failedCount++
logger.warn(
`[${requestId}] Failed to discover tools from server ${server.name}: ${outcome.message}`
)
deferredSideEffects.push(
this.updateServerStatus(server.id, workspaceId, false, outcome.message),
this.markServerUnhealthy(workspaceId, server.id, outcome.originalError),
this.cacheAdapter
.delete(serverCacheKey(workspaceId, server.id))
.catch((err) =>
logger.warn(`[${requestId}] Cache delete failed for ${server.name}:`, err)
)
)
})
// Await cache writes so a follow-up discoverTools sees consistent state.
await Promise.allSettled(cacheWrites)
// Each deferred side-effect self-logs failures, so we just mark the
// promises as handled to avoid unhandled-rejection warnings.
for (const p of deferredSideEffects) p.catch(() => {})
if (mcpConnectionManager) {
for (const conn of liveConnections) {
mcpConnectionManager
.connect(conn.resolvedConfig, userId, workspaceId, conn.resolvedIP)
.catch((err) => {
logger.warn(
`[${requestId}] Persistent connection failed for ${conn.resolvedConfig.name}:`,
err
)
})
}
}
logger.info(
`[${requestId}] Discovered ${allTools.length} tools from ${servers.length} servers (cached=${cachedCount} fetched=${fetchedCount} failed=${failedCount})`
)
return allTools
} catch (error) {
logger.error(`[${requestId}] Failed to discover MCP tools for user ${userId}:`, error)
throw error
}
}
/**
* Discover tools from one server. Cache-aside by default; pass
* `forceRefresh: true` from explicit-refresh paths (refresh button, OAuth
* callback) to bypass both positive and negative caches. Concurrent callers
* for the same `(workspaceId, serverId, userId, forceRefresh)` share one
* upstream request.
*/
async discoverServerTools(
userId: string,
serverId: string,
workspaceId: string,
forceRefresh = false
): Promise<McpTool[]> {
const inflightKey = `${workspaceId}:${serverId}:${userId}:${forceRefresh ? 'force' : 'cache'}`
const existing = this.inflightServerDiscovery.get(inflightKey)
if (existing) return existing
const promise = this.discoverServerToolsImpl(
userId,
serverId,
workspaceId,
forceRefresh
).finally(() => {
this.inflightServerDiscovery.delete(inflightKey)
})
this.inflightServerDiscovery.set(inflightKey, promise)
return promise
}
private async discoverServerToolsImpl(
userId: string,
serverId: string,
workspaceId: string,
forceRefresh: boolean
): Promise<McpTool[]> {
const requestId = generateRequestId()
const maxRetries = 2
if (!forceRefresh) {
try {
const cached = await this.cacheAdapter.get(serverCacheKey(workspaceId, serverId))
if (cached) {
logger.debug(`[${requestId}] Cache hit for server ${serverId}`)
return cached.tools
}
} catch (error) {
logger.warn(`[${requestId}] Cache read failed for server ${serverId}:`, error)
}
if (await this.isServerUnhealthy(workspaceId, serverId)) {
logger.info(`[${requestId}] Skipping recently-failed server ${serverId} (negative-cache)`)
throw new McpConnectionError(
'Server recently failed and is in cooldown — try again shortly.',
serverId
)
}
}
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
logger.info(
`[${requestId}] Discovering tools from server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`
)
const config = await this.getServerConfig(serverId, workspaceId)
if (!config) {
throw new Error(`Server ${serverId} not found or not accessible`)
}
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
try {
const tools = await client.listTools()
logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`)
await Promise.allSettled([
this.cacheAdapter
.set(serverCacheKey(workspaceId, serverId), tools, this.cacheTimeout)
.catch((err) =>
logger.warn(`[${requestId}] Cache write failed for ${config.name}:`, err)
),
this.clearServerFailure(workspaceId, serverId),
this.updateServerStatus(serverId, workspaceId, true, undefined, tools.length),
])
return tools
} finally {
await client.disconnect()
}
} catch (error) {
if (this.isSessionError(error) && attempt < maxRetries - 1) {
logger.warn(
`[${requestId}] Session error discovering tools from server ${serverId}, retrying (attempt ${attempt + 1}):`,
error
)
await sleep(100)
continue
}
// Drop positive cache so a follow-up doesn't return stale tools.
await Promise.allSettled([
this.cacheAdapter
.delete(serverCacheKey(workspaceId, serverId))
.catch((err) =>
logger.warn(`[${requestId}] Cache delete failed for ${serverId}:`, err)
),
this.markServerUnhealthy(workspaceId, serverId, error),
])
throw error
}
}
throw new Error(`Failed to discover tools from server ${serverId} after ${maxRetries} attempts`)
}
async getServerSummaries(userId: string, workspaceId: string): Promise<McpServerSummary[]> {
const requestId = generateRequestId()
try {
logger.info(`[${requestId}] Getting server summaries for workspace ${workspaceId}`)
const servers = await this.getWorkspaceServers(workspaceId)
const summaries: McpServerSummary[] = []
for (const config of servers) {
try {
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
const tools = await client.listTools()
await client.disconnect()
summaries.push({
id: config.id,
name: config.name,
url: config.url,
transport: config.transport,
status: 'connected',
toolCount: tools.length,
lastSeen: new Date(),
error: undefined,
})
} catch (error) {
if (
error instanceof McpOauthAuthorizationRequiredError ||
error instanceof UnauthorizedError
) {
summaries.push({
id: config.id,
name: config.name,
url: config.url,
transport: config.transport,
status: 'disconnected',
toolCount: 0,
lastSeen: undefined,
error: undefined,
})
continue
}
summaries.push({
id: config.id,
name: config.name,
url: config.url,
transport: config.transport,
status: 'error',
toolCount: 0,
lastSeen: undefined,
error: getErrorMessage(error, 'Connection failed'),
})
}
}
return summaries
} catch (error) {
logger.error(`[${requestId}] Failed to get server summaries for user ${userId}:`, error)
throw error
}
}
async clearCache(workspaceId?: string): Promise<void> {
try {
if (workspaceId) {
// No enabled/deletedAt filter so disabled and soft-deleted rows are
// cleared too. Hard-deleted rows are gone from the table; their keys
// expire via TTL.
const rows = await db
.select({ id: mcpServers.id })
.from(mcpServers)
.where(eq(mcpServers.workspaceId, workspaceId))
await Promise.allSettled(
rows.flatMap((r) => [
this.cacheAdapter.delete(serverCacheKey(workspaceId, r.id)),
this.cacheAdapter.delete(failureCacheKey(workspaceId, r.id)),
])
)
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId} (${rows.length} servers)`)
} else {
await this.cacheAdapter.clear()
logger.debug('Cleared all MCP tool cache')
}
} catch (error) {
logger.warn('Failed to clear cache:', error)
}
}
}
export const mcpService = new McpService()
/**
* Setup process signal handlers for graceful shutdown
*/
export function setupMcpServiceCleanup() {
if (isTest) {
return
}
const cleanup = () => {
mcpService.dispose()
}
process.on('SIGTERM', cleanup)
process.on('SIGINT', cleanup)
return () => {
process.removeListener('SIGTERM', cleanup)
process.removeListener('SIGINT', cleanup)
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Shared MCP utilities - safe for both client and server.
* No server-side dependencies (database, fs, etc.) should be imported here.
*/
import { isMcpTool, MCP } from '@/executor/constants'
/**
* Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors.
* Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste.
*/
export function sanitizeForHttp(value: string): string {
return value
.replace(/[\u2028\u2029\u200B-\u200D\uFEFF]/g, '')
.replace(/[\x00-\x1F\x7F]/g, '')
.trim()
}
/**
* Sanitizes all header key-value pairs for HTTP usage.
*/
export function sanitizeHeaders(
headers: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!headers) return headers
return Object.fromEntries(
Object.entries(headers)
.map(([key, value]) => [sanitizeForHttp(key), sanitizeForHttp(value)])
.filter(([key, value]) => key !== '' && value !== '')
)
}
/**
* Client-safe MCP constants
* Note: CLIENT_TIMEOUT should match DEFAULT_EXECUTION_TIMEOUT_MS from @/lib/core/execution-limits
* (5 minutes = 300 seconds for free tier). Keep in sync if that value changes.
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 5 * 60 * 1000, // 5 minutes - matches DEFAULT_EXECUTION_TIMEOUT_MS
MAX_RETRIES: 3,
RECONNECT_DELAY: 1000,
} as const
/**
* Create standardized MCP tool ID from server ID and tool name
*/
export function createMcpToolId(serverId: string, toolName: string): string {
const normalizedServerId = isMcpTool(serverId) ? serverId : `${MCP.TOOL_PREFIX}${serverId}`
return `${normalizedServerId}-${toolName}`
}
+14
View File
@@ -0,0 +1,14 @@
import type { McpTool } from '@/lib/mcp/types'
export interface McpCacheEntry {
tools: McpTool[]
expiry: number // Unix timestamp ms
}
export interface McpCacheStorageAdapter {
get(key: string): Promise<McpCacheEntry | null>
set(key: string, tools: McpTool[], ttlMs: number): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
dispose(): void
}
+53
View File
@@ -0,0 +1,53 @@
import { createLogger } from '@sim/logger'
import { getRedisClient } from '@/lib/core/config/redis'
import type { McpCacheStorageAdapter } from './adapter'
import { MemoryMcpCache } from './memory-cache'
import { RedisMcpCache } from './redis-cache'
const logger = createLogger('McpCacheFactory')
let cachedAdapter: McpCacheStorageAdapter | null = null
/**
* Create MCP cache storage adapter.
* Uses Redis if available, falls back to in-memory cache.
*
* Unlike rate-limiting (which fails if Redis is configured but unavailable),
* MCP caching gracefully falls back to memory since it's an optimization.
*/
export function createMcpCacheAdapter(): McpCacheStorageAdapter {
if (cachedAdapter) {
return cachedAdapter
}
const redis = getRedisClient()
if (redis) {
logger.info('MCP cache: Using Redis')
cachedAdapter = new RedisMcpCache(redis)
} else {
logger.info('MCP cache: Using in-memory (Redis not configured)')
cachedAdapter = new MemoryMcpCache()
}
return cachedAdapter
}
/**
* Get the current adapter type for logging/debugging
*/
export function getMcpCacheType(): 'redis' | 'memory' {
const redis = getRedisClient()
return redis ? 'redis' : 'memory'
}
/**
* Reset the cached adapter.
* Only use for testing purposes.
*/
export function resetMcpCacheAdapter(): void {
if (cachedAdapter) {
cachedAdapter.dispose()
cachedAdapter = null
}
}
+2
View File
@@ -0,0 +1,2 @@
export type { McpCacheStorageAdapter } from './adapter'
export { createMcpCacheAdapter, getMcpCacheType } from './factory'
@@ -0,0 +1,367 @@
import { sleep } from '@sim/utils/helpers'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { McpTool } from '@/lib/mcp/types'
import { MemoryMcpCache } from './memory-cache'
describe('MemoryMcpCache', () => {
let cache: MemoryMcpCache
const createTool = (name: string): McpTool => ({
name,
description: `Test tool: ${name}`,
inputSchema: { type: 'object' },
serverId: 'server-1',
serverName: 'Test Server',
})
beforeEach(() => {
cache = new MemoryMcpCache()
})
afterEach(() => {
cache.dispose()
})
describe('get', () => {
it('returns null for non-existent key', async () => {
const result = await cache.get('non-existent-key')
expect(result).toBeNull()
})
it('returns cached entry when valid', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 60000)
const result = await cache.get('key-1')
expect(result).not.toBeNull()
expect(result?.tools).toEqual(tools)
})
it('returns null for expired entry', async () => {
const tools = [createTool('tool-1')]
// Set with 0 TTL so it expires immediately
await cache.set('key-1', tools, 0)
// Wait a tiny bit to ensure expiry
await sleep(5)
const result = await cache.get('key-1')
expect(result).toBeNull()
})
it('removes expired entry from cache on get', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 1) // 1ms TTL
// Wait for expiry
await sleep(10)
// First get should return null and remove entry
await cache.get('key-1')
// Entry should be removed (internal state)
const result = await cache.get('key-1')
expect(result).toBeNull()
})
it('returns a copy of tools to prevent mutation', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 60000)
const result1 = await cache.get('key-1')
const result2 = await cache.get('key-1')
expect(result1).not.toBe(result2)
expect(result1?.tools).toEqual(result2?.tools)
})
})
describe('set', () => {
it('stores tools with correct expiry', async () => {
const tools = [createTool('tool-1')]
const ttl = 60000
const beforeSet = Date.now()
await cache.set('key-1', tools, ttl)
const afterSet = Date.now()
const result = await cache.get('key-1')
expect(result).not.toBeNull()
expect(result?.expiry).toBeGreaterThanOrEqual(beforeSet + ttl)
expect(result?.expiry).toBeLessThanOrEqual(afterSet + ttl)
})
it('overwrites existing entry with same key', async () => {
const tools1 = [createTool('tool-1')]
const tools2 = [createTool('tool-2'), createTool('tool-3')]
await cache.set('key-1', tools1, 60000)
await cache.set('key-1', tools2, 60000)
const result = await cache.get('key-1')
expect(result?.tools).toEqual(tools2)
expect(result?.tools.length).toBe(2)
})
it('handles empty tools array', async () => {
await cache.set('key-1', [], 60000)
const result = await cache.get('key-1')
expect(result).not.toBeNull()
expect(result?.tools).toEqual([])
})
it('handles multiple keys', async () => {
const tools1 = [createTool('tool-1')]
const tools2 = [createTool('tool-2')]
await cache.set('key-1', tools1, 60000)
await cache.set('key-2', tools2, 60000)
const result1 = await cache.get('key-1')
const result2 = await cache.get('key-2')
expect(result1?.tools).toEqual(tools1)
expect(result2?.tools).toEqual(tools2)
})
})
describe('delete', () => {
it('removes entry from cache', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 60000)
await cache.delete('key-1')
const result = await cache.get('key-1')
expect(result).toBeNull()
})
it('does not throw for non-existent key', async () => {
// Should complete without throwing
await cache.delete('non-existent')
// If we get here, it worked
expect(true).toBe(true)
})
it('does not affect other entries', async () => {
const tools1 = [createTool('tool-1')]
const tools2 = [createTool('tool-2')]
await cache.set('key-1', tools1, 60000)
await cache.set('key-2', tools2, 60000)
await cache.delete('key-1')
const result1 = await cache.get('key-1')
const result2 = await cache.get('key-2')
expect(result1).toBeNull()
expect(result2?.tools).toEqual(tools2)
})
})
describe('clear', () => {
it('removes all entries from cache', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 60000)
await cache.set('key-2', tools, 60000)
await cache.set('key-3', tools, 60000)
await cache.clear()
expect(await cache.get('key-1')).toBeNull()
expect(await cache.get('key-2')).toBeNull()
expect(await cache.get('key-3')).toBeNull()
})
it('works on empty cache', async () => {
// Should complete without throwing
await cache.clear()
// If we get here, it worked
expect(true).toBe(true)
})
})
describe('dispose', () => {
it('clears the cache', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 60000)
cache.dispose()
const result = await cache.get('key-1')
expect(result).toBeNull()
})
it('can be called multiple times', () => {
cache.dispose()
expect(() => cache.dispose()).not.toThrow()
})
})
describe('eviction policy', () => {
it('evicts oldest entries when max size is exceeded', async () => {
// Create a cache and add more entries than MAX_CACHE_SIZE (1000)
const tools = [createTool('tool')]
// Add 1005 entries (5 over the limit of 1000)
for (let i = 0; i < 1005; i++) {
await cache.set(`key-${i}`, tools, 60000)
}
// The oldest entries (first 5) should be evicted
expect(await cache.get('key-0')).toBeNull()
expect(await cache.get('key-1')).toBeNull()
expect(await cache.get('key-2')).toBeNull()
expect(await cache.get('key-3')).toBeNull()
expect(await cache.get('key-4')).toBeNull()
// Newer entries should still exist
expect(await cache.get('key-1004')).not.toBeNull()
expect(await cache.get('key-1000')).not.toBeNull()
})
})
describe('TTL behavior', () => {
it('entry is valid before expiry', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 10000) // 10 seconds
// Should be valid immediately
const result = await cache.get('key-1')
expect(result).not.toBeNull()
})
it('entry expires with very short TTL', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 1) // 1 millisecond
// Wait past expiry
await sleep(10)
const result = await cache.get('key-1')
expect(result).toBeNull()
})
it('supports long TTL', async () => {
const tools = [createTool('tool-1')]
const oneHour = 60 * 60 * 1000
await cache.set('key-1', tools, oneHour)
// Should be valid immediately
const result = await cache.get('key-1')
expect(result).not.toBeNull()
expect(result?.expiry).toBeGreaterThan(Date.now())
})
})
describe('complex tool data', () => {
it('handles tools with complex schemas', async () => {
const complexTool: McpTool = {
name: 'complex-tool',
description: 'A tool with complex schema',
inputSchema: {
type: 'object',
properties: {
config: {
type: 'object',
properties: {
nested: {
type: 'array',
items: { type: 'string' },
},
},
},
},
required: ['config'],
},
serverId: 'server-1',
serverName: 'Test Server',
}
await cache.set('key-1', [complexTool], 60000)
const result = await cache.get('key-1')
expect(result?.tools[0]).toEqual(complexTool)
})
it('handles tools with special characters in names', async () => {
const tools = [
createTool('tool/with/slashes'),
createTool('tool:with:colons'),
createTool('tool.with.dots'),
]
await cache.set('workspace:user-123', tools, 60000)
const result = await cache.get('workspace:user-123')
expect(result?.tools).toEqual(tools)
})
it('handles large number of tools', async () => {
const tools: McpTool[] = []
for (let i = 0; i < 100; i++) {
tools.push(createTool(`tool-${i}`))
}
await cache.set('key-1', tools, 60000)
const result = await cache.get('key-1')
expect(result?.tools.length).toBe(100)
expect(result?.tools[0].name).toBe('tool-0')
expect(result?.tools[99].name).toBe('tool-99')
})
})
describe('concurrent operations', () => {
it('handles concurrent reads', async () => {
const tools = [createTool('tool-1')]
await cache.set('key-1', tools, 60000)
const results = await Promise.all([
cache.get('key-1'),
cache.get('key-1'),
cache.get('key-1'),
])
results.forEach((result) => {
expect(result).not.toBeNull()
expect(result?.tools).toEqual(tools)
})
})
it('handles concurrent writes to different keys', async () => {
const tools = [createTool('tool')]
await Promise.all([
cache.set('key-1', tools, 60000),
cache.set('key-2', tools, 60000),
cache.set('key-3', tools, 60000),
])
expect(await cache.get('key-1')).not.toBeNull()
expect(await cache.get('key-2')).not.toBeNull()
expect(await cache.get('key-3')).not.toBeNull()
})
it('handles read after immediate write', async () => {
const tools = [createTool('tool-1')]
// Write then immediately read
await cache.set('key-1', tools, 60000)
const result = await cache.get('key-1')
expect(result).not.toBeNull()
expect(result?.tools).toEqual(tools)
})
})
})
+103
View File
@@ -0,0 +1,103 @@
import { createLogger } from '@sim/logger'
import type { McpTool } from '@/lib/mcp/types'
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
const logger = createLogger('McpMemoryCache')
export class MemoryMcpCache implements McpCacheStorageAdapter {
private cache = new Map<string, McpCacheEntry>()
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE
private cleanupInterval: NodeJS.Timeout | null = null
constructor() {
this.startPeriodicCleanup()
}
private startPeriodicCleanup(): void {
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredEntries()
},
5 * 60 * 1000 // 5 minutes
)
// Don't keep Node process alive just for cache cleanup
this.cleanupInterval.unref()
}
private cleanupExpiredEntries(): void {
const now = Date.now()
const expiredKeys: string[] = []
this.cache.forEach((entry, key) => {
if (entry.expiry <= now) {
expiredKeys.push(key)
}
})
expiredKeys.forEach((key) => this.cache.delete(key))
if (expiredKeys.length > 0) {
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
}
}
private evictIfNeeded(): void {
if (this.cache.size <= this.maxCacheSize) {
return
}
// Evict oldest entries (by insertion order - Map maintains order)
const entriesToRemove = this.cache.size - this.maxCacheSize
const keys = Array.from(this.cache.keys()).slice(0, entriesToRemove)
keys.forEach((key) => this.cache.delete(key))
logger.debug(`Evicted ${entriesToRemove} cache entries`)
}
async get(key: string): Promise<McpCacheEntry | null> {
const entry = this.cache.get(key)
const now = Date.now()
if (!entry || entry.expiry <= now) {
if (entry) {
this.cache.delete(key)
}
return null
}
// Return copy to prevent caller from mutating cache
return {
tools: entry.tools,
expiry: entry.expiry,
}
}
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
const now = Date.now()
const entry: McpCacheEntry = {
tools,
expiry: now + ttlMs,
}
this.cache.set(key, entry)
this.evictIfNeeded()
}
async delete(key: string): Promise<void> {
this.cache.delete(key)
}
async clear(): Promise<void> {
this.cache.clear()
}
dispose(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.cache.clear()
logger.info('Memory cache disposed')
}
}
+96
View File
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import type Redis from 'ioredis'
import type { McpTool } from '@/lib/mcp/types'
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
const logger = createLogger('McpRedisCache')
const REDIS_KEY_PREFIX = 'mcp:tools:'
export class RedisMcpCache implements McpCacheStorageAdapter {
constructor(private redis: Redis) {}
private getKey(key: string): string {
return `${REDIS_KEY_PREFIX}${key}`
}
async get(key: string): Promise<McpCacheEntry | null> {
try {
const redisKey = this.getKey(key)
const data = await this.redis.get(redisKey)
if (!data) {
return null
}
try {
return JSON.parse(data) as McpCacheEntry
} catch {
// Corrupted data - delete and treat as miss
logger.warn('Corrupted cache entry, deleting:', redisKey)
await this.redis.del(redisKey)
return null
}
} catch (error) {
logger.error('Redis cache get error:', error)
throw error
}
}
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
try {
const now = Date.now()
const entry: McpCacheEntry = {
tools,
expiry: now + ttlMs,
}
await this.redis.set(this.getKey(key), JSON.stringify(entry), 'PX', ttlMs)
} catch (error) {
logger.error('Redis cache set error:', error)
throw error
}
}
async delete(key: string): Promise<void> {
try {
await this.redis.del(this.getKey(key))
} catch (error) {
logger.error('Redis cache delete error:', error)
throw error
}
}
async clear(): Promise<void> {
try {
let cursor = '0'
let deletedCount = 0
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH',
`${REDIS_KEY_PREFIX}*`,
'COUNT',
100
)
cursor = nextCursor
if (keys.length > 0) {
await this.redis.del(...keys)
deletedCount += keys.length
}
} while (cursor !== '0')
logger.debug(`Cleared ${deletedCount} MCP cache entries from Redis`)
} catch (error) {
logger.error('Redis cache clear error:', error)
throw error
}
}
dispose(): void {
// Redis client is managed externally, nothing to dispose
logger.info('Redis cache adapter disposed')
}
}
+185
View File
@@ -0,0 +1,185 @@
import { workflowMcpTool } from '@sim/db'
import { and, eq, isNull, ne, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import {
MAX_MCP_PARAMETER_SCHEMA_BYTES,
MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES,
MAX_MCP_SERVER_TOOLS_METADATA_BYTES,
MAX_MCP_TOOL_DESCRIPTION_BYTES,
MAX_MCP_TOOL_NAME_BYTES,
} from '@/lib/mcp/constants'
function utf8Size(value: string): number {
return Buffer.byteLength(value, 'utf-8')
}
function jsonSize(value: unknown): number | null {
try {
const json = JSON.stringify(value)
return typeof json === 'string' ? utf8Size(json) : null
} catch {
return null
}
}
export interface McpToolMetadataSizes {
toolNameBytes: number
toolDescriptionBytes: number
parameterSchemaBytes: number
}
export interface McpToolMetadataUsage {
schemaBytes: number
metadataBytes: number
}
export interface McpToolMetadataUsageRow extends McpToolMetadataSizes {
id: string
}
export function getMcpToolMetadataSizes(metadata: {
toolName?: string | null
toolDescription?: string | null
parameterSchema?: unknown
}): McpToolMetadataSizes {
return {
toolNameBytes: metadata.toolName ? utf8Size(metadata.toolName) : 0,
toolDescriptionBytes: metadata.toolDescription ? utf8Size(metadata.toolDescription) : 0,
parameterSchemaBytes:
metadata.parameterSchema !== undefined
? (jsonSize(metadata.parameterSchema) ?? MAX_MCP_PARAMETER_SCHEMA_BYTES + 1)
: 0,
}
}
export function addMcpToolMetadataUsage(
usage: McpToolMetadataUsage,
tool: {
toolName?: string | null
toolDescription?: string | null
parameterSchema?: unknown
}
): McpToolMetadataUsage {
const sizes = getMcpToolMetadataSizes(tool)
return {
schemaBytes: usage.schemaBytes + sizes.parameterSchemaBytes,
metadataBytes:
usage.metadataBytes +
sizes.toolNameBytes +
sizes.toolDescriptionBytes +
sizes.parameterSchemaBytes,
}
}
export function addMcpToolMetadataUsageRow(
usage: McpToolMetadataUsage,
row: McpToolMetadataUsageRow
): McpToolMetadataUsage {
return {
schemaBytes: usage.schemaBytes + row.parameterSchemaBytes,
metadataBytes:
usage.metadataBytes + row.toolNameBytes + row.toolDescriptionBytes + row.parameterSchemaBytes,
}
}
export function subtractMcpToolMetadataUsageRow(
usage: McpToolMetadataUsage,
row?: McpToolMetadataUsageRow
): McpToolMetadataUsage {
if (!row) return usage
return {
schemaBytes: usage.schemaBytes - row.parameterSchemaBytes,
metadataBytes:
usage.metadataBytes - row.toolNameBytes - row.toolDescriptionBytes - row.parameterSchemaBytes,
}
}
export function getMcpToolMetadataUsageFromRows(
rows: McpToolMetadataUsageRow[]
): McpToolMetadataUsage {
return rows.reduce(addMcpToolMetadataUsageRow, { schemaBytes: 0, metadataBytes: 0 })
}
export function createMcpToolMetadataUsageRow(tool: {
id: string
toolName: string
toolDescription: string | null
parameterSchema: unknown
}): McpToolMetadataUsageRow {
return { id: tool.id, ...getMcpToolMetadataSizes(tool) }
}
export function validateMcpServerToolMetadataBudget(usage: McpToolMetadataUsage): string | null {
if (usage.schemaBytes > MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES) {
return `MCP server tool schemas exceed maximum size of ${MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES} bytes`
}
if (usage.metadataBytes > MAX_MCP_SERVER_TOOLS_METADATA_BYTES) {
return `MCP server tool metadata exceeds maximum size of ${MAX_MCP_SERVER_TOOLS_METADATA_BYTES} bytes`
}
return null
}
export function exceedsMcpServerToolMetadataBudget(
usage: McpToolMetadataUsage,
tool: { toolName: string; toolDescription: string | null; parameterSchema: unknown }
): boolean {
return validateMcpServerToolMetadataBudget(addMcpToolMetadataUsage(usage, tool)) !== null
}
export async function getMcpServerToolMetadataUsageRows(
tx: DbOrTx,
serverId: string,
excludeToolId?: string
): Promise<McpToolMetadataUsageRow[]> {
const rows = await tx
.select({
id: workflowMcpTool.id,
toolNameBytes: sql<number>`octet_length(${workflowMcpTool.toolName})`,
toolDescriptionBytes: sql<number>`coalesce(octet_length(${workflowMcpTool.toolDescription}), 0)`,
parameterSchemaBytes: sql<number>`octet_length(${workflowMcpTool.parameterSchema}::text)`,
})
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.serverId, serverId),
isNull(workflowMcpTool.archivedAt),
excludeToolId ? ne(workflowMcpTool.id, excludeToolId) : undefined
)
)
return rows.map((row) => ({
id: row.id,
toolNameBytes: Number(row.toolNameBytes) || 0,
toolDescriptionBytes: Number(row.toolDescriptionBytes) || 0,
parameterSchemaBytes: Number(row.parameterSchemaBytes) || 0,
}))
}
export function validateMcpToolMetadataForStorage(metadata: {
toolName?: string | null
toolDescription?: string | null
parameterSchema?: unknown
}): string | null {
if (metadata.toolName && utf8Size(metadata.toolName) > MAX_MCP_TOOL_NAME_BYTES) {
return `Tool name exceeds maximum size of ${MAX_MCP_TOOL_NAME_BYTES} bytes`
}
if (
metadata.toolDescription &&
utf8Size(metadata.toolDescription) > MAX_MCP_TOOL_DESCRIPTION_BYTES
) {
return `Tool description exceeds maximum size of ${MAX_MCP_TOOL_DESCRIPTION_BYTES} bytes`
}
if (metadata.parameterSchema !== undefined) {
const parameterSchemaBytes = jsonSize(metadata.parameterSchema)
if (parameterSchemaBytes === null) {
return 'Tool parameter schema must be JSON serializable'
}
if (parameterSchemaBytes > MAX_MCP_PARAMETER_SCHEMA_BYTES) {
return `Tool parameter schema exceeds maximum size of ${MAX_MCP_PARAMETER_SCHEMA_BYTES} bytes`
}
}
return null
}
+371
View File
@@ -0,0 +1,371 @@
import { describe, expect, it } from 'vitest'
import {
type DiscoveredTool,
getIssueBadgeLabel,
getMcpToolIssue,
hasSchemaChanged,
isToolUnavailable,
type McpToolIssue,
type ServerState,
} from './tool-validation'
import type { StoredMcpToolReference } from './types'
describe('hasSchemaChanged', () => {
it.concurrent('returns false when both schemas are undefined', () => {
expect(hasSchemaChanged(undefined, undefined)).toBe(false)
})
it.concurrent('returns false when stored schema is undefined', () => {
expect(hasSchemaChanged(undefined, { type: 'object' })).toBe(false)
})
it.concurrent('returns false when server schema is undefined', () => {
expect(hasSchemaChanged({ type: 'object' }, undefined)).toBe(false)
})
it.concurrent('returns false for identical schemas', () => {
const schema = { type: 'object' as const, properties: { name: { type: 'string' } } }
expect(hasSchemaChanged(schema, { ...schema })).toBe(false)
})
it.concurrent('returns false when only description differs', () => {
const stored = {
type: 'object' as const,
properties: { name: { type: 'string' } },
description: 'Old description',
}
const server = {
type: 'object' as const,
properties: { name: { type: 'string' } },
description: 'New description',
}
expect(hasSchemaChanged(stored, server)).toBe(false)
})
it.concurrent('returns true when properties differ', () => {
const stored = { type: 'object' as const, properties: { name: { type: 'string' } } }
const server = { type: 'object' as const, properties: { id: { type: 'number' } } }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns true when required fields differ', () => {
const stored = { type: 'object' as const, properties: {}, required: ['name'] }
const server = { type: 'object' as const, properties: {}, required: ['id'] }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns false for deep equal schemas with different key order', () => {
const stored = { type: 'object' as const, properties: { a: 1, b: 2 } }
const server = { properties: { b: 2, a: 1 }, type: 'object' as const }
expect(hasSchemaChanged(stored, server)).toBe(false)
})
it.concurrent('returns true when nested properties differ', () => {
const stored = {
type: 'object' as const,
properties: { config: { type: 'object', properties: { enabled: { type: 'boolean' } } } },
}
const server = {
type: 'object' as const,
properties: { config: { type: 'object', properties: { enabled: { type: 'string' } } } },
}
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns true when additional properties setting differs', () => {
const stored = { type: 'object' as const, additionalProperties: true }
const server = { type: 'object' as const, additionalProperties: false }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('ignores description at property level', () => {
const stored = {
type: 'object' as const,
properties: { name: { type: 'string', description: 'Old' } },
}
const server = {
type: 'object' as const,
properties: { name: { type: 'string', description: 'New' } },
}
// Only top-level description is ignored, not nested ones
expect(hasSchemaChanged(stored, server)).toBe(true)
})
})
describe('getMcpToolIssue', () => {
const createStoredTool = (
overrides?: Partial<StoredMcpToolReference>
): StoredMcpToolReference => ({
serverId: 'server-1',
serverUrl: 'https://api.example.com/mcp',
toolName: 'test-tool',
schema: { type: 'object' },
...overrides,
})
const createServerState = (overrides?: Partial<ServerState>): ServerState => ({
id: 'server-1',
url: 'https://api.example.com/mcp',
connectionStatus: 'connected',
...overrides,
})
const createDiscoveredTool = (overrides?: Partial<DiscoveredTool>): DiscoveredTool => ({
serverId: 'server-1',
name: 'test-tool',
inputSchema: { type: 'object' },
...overrides,
})
describe('server_not_found', () => {
it.concurrent('returns server_not_found when server does not exist', () => {
const storedTool = createStoredTool()
const servers: ServerState[] = []
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_not_found', message: 'Server not found' })
})
it.concurrent('returns server_not_found when server ID does not match', () => {
const storedTool = createStoredTool({ serverId: 'server-1' })
const servers = [createServerState({ id: 'server-2' })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_not_found', message: 'Server not found' })
})
})
describe('server_error', () => {
it.concurrent('returns server_error when server has error status', () => {
const storedTool = createStoredTool()
const servers = [
createServerState({ connectionStatus: 'error', lastError: 'Connection refused' }),
]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Connection refused' })
})
it.concurrent('returns server_error with default message when lastError is undefined', () => {
const storedTool = createStoredTool()
const servers = [createServerState({ connectionStatus: 'error', lastError: undefined })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Server connection error' })
})
it.concurrent('returns server_error when server is disconnected', () => {
const storedTool = createStoredTool()
const servers = [createServerState({ connectionStatus: 'disconnected' })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Server not connected' })
})
it.concurrent('returns server_error when connection status is undefined', () => {
const storedTool = createStoredTool()
const servers = [createServerState({ connectionStatus: undefined })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Server not connected' })
})
})
describe('url_changed', () => {
it.concurrent('returns url_changed when server URL has changed', () => {
const storedTool = createStoredTool({ serverUrl: 'https://old.example.com/mcp' })
const servers = [createServerState({ url: 'https://new.example.com/mcp' })]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({
type: 'url_changed',
message: 'Server URL changed',
})
})
it.concurrent('does not return url_changed when stored URL is undefined', () => {
const storedTool = createStoredTool({ serverUrl: undefined })
const servers = [createServerState({ url: 'https://new.example.com/mcp' })]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
it.concurrent('does not return url_changed when server URL is undefined', () => {
const storedTool = createStoredTool({ serverUrl: 'https://old.example.com/mcp' })
const servers = [createServerState({ url: undefined })]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
})
describe('tool_not_found', () => {
it.concurrent('returns tool_not_found when tool does not exist on server', () => {
const storedTool = createStoredTool({ toolName: 'missing-tool' })
const servers = [createServerState()]
const tools = [createDiscoveredTool({ name: 'other-tool' })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
})
it.concurrent('returns tool_not_found when tool exists on different server', () => {
const storedTool = createStoredTool({ serverId: 'server-1', toolName: 'test-tool' })
const servers = [createServerState({ id: 'server-1' })]
const tools = [createDiscoveredTool({ serverId: 'server-2', name: 'test-tool' })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
})
it.concurrent('returns tool_not_found when no tools are discovered', () => {
const storedTool = createStoredTool()
const servers = [createServerState()]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
})
})
describe('schema_changed', () => {
it.concurrent('returns schema_changed when tool schema has changed', () => {
const storedTool = createStoredTool({
schema: { type: 'object', properties: { name: { type: 'string' } } },
})
const servers = [createServerState()]
const tools = [
createDiscoveredTool({
inputSchema: { type: 'object', properties: { id: { type: 'number' } } },
}),
]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'schema_changed', message: 'Tool schema changed' })
})
it.concurrent('does not return schema_changed when stored schema is undefined', () => {
const storedTool = createStoredTool({ schema: undefined })
const servers = [createServerState()]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
it.concurrent('does not return schema_changed when server schema is undefined', () => {
const storedTool = createStoredTool({ schema: { type: 'object' } })
const servers = [createServerState()]
const tools = [createDiscoveredTool({ inputSchema: undefined })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
})
describe('no issues', () => {
it.concurrent('returns null when everything is valid', () => {
const storedTool = createStoredTool()
const servers = [createServerState()]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
it.concurrent('returns null when schemas match exactly', () => {
const schema = { type: 'object' as const, properties: { name: { type: 'string' } } }
const storedTool = createStoredTool({ schema })
const servers = [createServerState()]
const tools = [createDiscoveredTool({ inputSchema: schema })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
})
})
describe('getIssueBadgeLabel', () => {
it.concurrent('returns "stale" for schema_changed', () => {
const issue: McpToolIssue = { type: 'schema_changed', message: 'Schema changed' }
expect(getIssueBadgeLabel(issue)).toBe('stale')
})
it.concurrent('returns "stale" for url_changed', () => {
const issue: McpToolIssue = { type: 'url_changed', message: 'URL changed' }
expect(getIssueBadgeLabel(issue)).toBe('stale')
})
it.concurrent('returns "unavailable" for server_not_found', () => {
const issue: McpToolIssue = { type: 'server_not_found', message: 'Server not found' }
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
})
it.concurrent('returns "unavailable" for server_error', () => {
const issue: McpToolIssue = { type: 'server_error', message: 'Server error' }
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
})
it.concurrent('returns "unavailable" for tool_not_found', () => {
const issue: McpToolIssue = { type: 'tool_not_found', message: 'Tool not found' }
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
})
})
describe('isToolUnavailable', () => {
it.concurrent('returns false for null', () => {
expect(isToolUnavailable(null)).toBe(false)
})
it.concurrent('returns true for server_not_found', () => {
const issue: McpToolIssue = { type: 'server_not_found', message: 'Server not found' }
expect(isToolUnavailable(issue)).toBe(true)
})
it.concurrent('returns true for server_error', () => {
const issue: McpToolIssue = { type: 'server_error', message: 'Server error' }
expect(isToolUnavailable(issue)).toBe(true)
})
it.concurrent('returns true for tool_not_found', () => {
const issue: McpToolIssue = { type: 'tool_not_found', message: 'Tool not found' }
expect(isToolUnavailable(issue)).toBe(true)
})
it.concurrent('returns false for schema_changed', () => {
const issue: McpToolIssue = { type: 'schema_changed', message: 'Schema changed' }
expect(isToolUnavailable(issue)).toBe(false)
})
it.concurrent('returns false for url_changed', () => {
const issue: McpToolIssue = { type: 'url_changed', message: 'URL changed' }
expect(isToolUnavailable(issue)).toBe(false)
})
})
+120
View File
@@ -0,0 +1,120 @@
import { isEqual, omit } from 'es-toolkit'
import type { McpToolSchema, StoredMcpToolReference } from '@/lib/mcp/types'
export type McpToolIssueType =
| 'server_not_found'
| 'server_error'
| 'tool_not_found'
| 'schema_changed'
| 'url_changed'
export interface McpToolIssue {
type: McpToolIssueType
message: string
}
export interface ServerState {
id: string
url?: string
connectionStatus?: 'connected' | 'disconnected' | 'error'
lastError?: string
}
export interface DiscoveredTool {
serverId: string
name: string
inputSchema?: McpToolSchema
}
export function hasSchemaChanged(
storedSchema: McpToolSchema | undefined,
serverSchema: McpToolSchema | undefined
): boolean {
if (!storedSchema || !serverSchema) return false
const storedWithoutDesc = omit(storedSchema, ['description'])
const serverWithoutDesc = omit(serverSchema, ['description'])
return !isEqual(storedWithoutDesc, serverWithoutDesc)
}
/**
* Validates server-level connectivity for an MCP server.
* Checks: server existence, connection status, URL changes.
*/
export function getMcpServerIssue(
serverId: string,
serverUrl: string | undefined,
servers: ServerState[]
): McpToolIssue | null {
const server = servers.find((s) => s.id === serverId)
if (!server) {
return { type: 'server_not_found', message: 'Server not found' }
}
if (server.connectionStatus === 'error') {
return { type: 'server_error', message: server.lastError || 'Server connection error' }
}
if (server.connectionStatus !== 'connected') {
return { type: 'server_error', message: 'Server not connected' }
}
if (serverUrl && server.url && serverUrl !== server.url) {
return { type: 'url_changed', message: 'Server URL changed' }
}
return null
}
export function getMcpToolIssue(
storedTool: StoredMcpToolReference,
servers: ServerState[],
discoveredTools: DiscoveredTool[]
): McpToolIssue | null {
const { serverId, serverUrl, toolName, schema } = storedTool
const serverIssue = getMcpServerIssue(serverId, serverUrl, servers)
if (serverIssue) return serverIssue
const serverTool = discoveredTools.find((t) => t.serverId === serverId && t.name === toolName)
if (!serverTool) {
return { type: 'tool_not_found', message: 'Tool not found on server' }
}
if (schema && serverTool.inputSchema) {
if (hasSchemaChanged(schema, serverTool.inputSchema)) {
return { type: 'schema_changed', message: 'Tool schema changed' }
}
}
return null
}
export function getIssueBadgeLabel(issue: McpToolIssue): string {
switch (issue.type) {
case 'schema_changed':
case 'url_changed':
return 'stale'
default:
return 'unavailable'
}
}
export function getIssueBadgeVariant(issue: McpToolIssue): 'amber' | 'red' {
switch (issue.type) {
case 'schema_changed':
case 'url_changed':
return 'amber'
default:
return 'red'
}
}
export function isToolUnavailable(issue: McpToolIssue | null): boolean {
if (!issue) return false
return (
issue.type === 'server_not_found' ||
issue.type === 'server_error' ||
issue.type === 'tool_not_found'
)
}
+247
View File
@@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest'
import { McpConnectionError, McpError } from './types'
describe('McpError', () => {
it.concurrent('creates error with message only', () => {
const error = new McpError('Something went wrong')
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(McpError)
expect(error.message).toBe('Something went wrong')
expect(error.name).toBe('McpError')
expect(error.code).toBeUndefined()
expect(error.data).toBeUndefined()
})
it.concurrent('creates error with message and code', () => {
const error = new McpError('Not found', 404)
expect(error.message).toBe('Not found')
expect(error.code).toBe(404)
expect(error.data).toBeUndefined()
})
it.concurrent('creates error with message, code, and data', () => {
const errorData = { field: 'name', reason: 'required' }
const error = new McpError('Validation failed', 400, errorData)
expect(error.message).toBe('Validation failed')
expect(error.code).toBe(400)
expect(error.data).toEqual(errorData)
})
it.concurrent('preserves error name in stack trace', () => {
const error = new McpError('Test error')
expect(error.stack).toContain('McpError')
})
it.concurrent('can be caught as Error', () => {
expect(() => {
throw new McpError('Test error')
}).toThrow(Error)
})
it.concurrent('can be caught as McpError', () => {
expect(() => {
throw new McpError('Test error')
}).toThrow(McpError)
})
it.concurrent('handles null code and data', () => {
const error = new McpError('Error', undefined, undefined)
expect(error.code).toBeUndefined()
expect(error.data).toBeUndefined()
})
it.concurrent('handles zero code', () => {
const error = new McpError('Error', 0)
expect(error.code).toBe(0)
})
it.concurrent('handles negative code', () => {
const error = new McpError('RPC error', -32600)
expect(error.code).toBe(-32600)
})
it.concurrent('handles complex data object', () => {
const complexData = {
errors: [
{ field: 'name', message: 'Required' },
{ field: 'email', message: 'Invalid format' },
],
metadata: {
requestId: 'abc123',
timestamp: Date.now(),
},
}
const error = new McpError('Multiple validation errors', 400, complexData)
expect(error.data).toEqual(complexData)
expect((error.data as typeof complexData).errors).toHaveLength(2)
})
it.concurrent('handles array as data', () => {
const arrayData = ['error1', 'error2', 'error3']
const error = new McpError('Multiple errors', 500, arrayData)
expect(error.data).toEqual(arrayData)
})
it.concurrent('handles string as data', () => {
const error = new McpError('Error with details', 500, 'Additional details')
expect(error.data).toBe('Additional details')
})
})
describe('McpConnectionError', () => {
it.concurrent('creates error with message and server name', () => {
const error = new McpConnectionError('Connection refused', 'My MCP Server')
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(McpError)
expect(error).toBeInstanceOf(McpConnectionError)
expect(error.name).toBe('McpConnectionError')
expect(error.message).toBe('Failed to connect to "My MCP Server": Connection refused')
})
it.concurrent('formats message correctly with server name', () => {
const error = new McpConnectionError('timeout', 'Production Server')
expect(error.message).toBe('Failed to connect to "Production Server": timeout')
})
it.concurrent('handles empty message', () => {
const error = new McpConnectionError('', 'Test Server')
expect(error.message).toBe('Failed to connect to "Test Server": ')
})
it.concurrent('handles empty server name', () => {
const error = new McpConnectionError('Connection failed', '')
expect(error.message).toBe('Failed to connect to "": Connection failed')
})
it.concurrent('handles server name with special characters', () => {
const error = new McpConnectionError('Error', 'Server "with" quotes')
expect(error.message).toBe('Failed to connect to "Server "with" quotes": Error')
})
it.concurrent('can be caught as Error', () => {
expect(() => {
throw new McpConnectionError('Error', 'Server')
}).toThrow(Error)
})
it.concurrent('can be caught as McpError', () => {
expect(() => {
throw new McpConnectionError('Error', 'Server')
}).toThrow(McpError)
})
it.concurrent('can be caught as McpConnectionError', () => {
expect(() => {
throw new McpConnectionError('Error', 'Server')
}).toThrow(McpConnectionError)
})
it.concurrent('inherits code and data properties as undefined', () => {
const error = new McpConnectionError('Error', 'Server')
expect(error.code).toBeUndefined()
expect(error.data).toBeUndefined()
})
it.concurrent('preserves error name in stack trace', () => {
const error = new McpConnectionError('Test error', 'Test Server')
expect(error.stack).toContain('McpConnectionError')
})
it.concurrent('handles various error messages', () => {
const testCases = [
{ message: 'ECONNREFUSED', server: 'localhost' },
{ message: 'ETIMEDOUT', server: 'remote-server.com' },
{ message: 'ENOTFOUND', server: 'unknown-host' },
{ message: 'SSL certificate error', server: 'secure-server.com' },
{ message: 'HTTP 503 Service Unavailable', server: 'api.example.com' },
]
testCases.forEach(({ message, server }) => {
const error = new McpConnectionError(message, server)
expect(error.message).toContain(message)
expect(error.message).toContain(server)
})
})
it.concurrent('handles unicode in server name', () => {
const error = new McpConnectionError('Error', 'Server with emoji')
expect(error.message).toBe('Failed to connect to "Server with emoji": Error')
})
it.concurrent('handles very long server names', () => {
const longName = 'a'.repeat(1000)
const error = new McpConnectionError('Error', longName)
expect(error.message).toContain(longName)
})
it.concurrent('handles very long error messages', () => {
const longMessage = 'Error: '.repeat(100)
const error = new McpConnectionError(longMessage, 'Server')
expect(error.message).toContain(longMessage)
})
})
describe('Error hierarchy', () => {
it.concurrent('McpConnectionError extends McpError', () => {
const error = new McpConnectionError('Error', 'Server')
expect(Object.getPrototypeOf(Object.getPrototypeOf(error))).toBe(McpError.prototype)
})
it.concurrent('McpError extends Error', () => {
const error = new McpError('Error')
expect(Object.getPrototypeOf(Object.getPrototypeOf(error))).toBe(Error.prototype)
})
it.concurrent('instanceof checks work correctly', () => {
const mcpError = new McpError('MCP error')
const connectionError = new McpConnectionError('Connection error', 'Server')
// McpError checks
expect(mcpError instanceof Error).toBe(true)
expect(mcpError instanceof McpError).toBe(true)
expect(mcpError instanceof McpConnectionError).toBe(false)
// McpConnectionError checks
expect(connectionError instanceof Error).toBe(true)
expect(connectionError instanceof McpError).toBe(true)
expect(connectionError instanceof McpConnectionError).toBe(true)
})
it.concurrent('errors can be differentiated in catch block', () => {
const handleError = (error: Error): string => {
if (error instanceof McpConnectionError) {
return 'connection'
}
if (error instanceof McpError) {
return 'mcp'
}
return 'generic'
}
expect(handleError(new McpConnectionError('Error', 'Server'))).toBe('connection')
expect(handleError(new McpError('Error'))).toBe('mcp')
expect(handleError(new Error('Error'))).toBe('generic')
})
})
+246
View File
@@ -0,0 +1,246 @@
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
export type McpTransport = 'streamable-http'
/** `oauth` uses the SDK's authProvider; `headers` is a static map; `none` is unauthenticated. */
export type McpAuthType = 'none' | 'headers' | 'oauth'
export interface McpServerStatusConfig {
consecutiveFailures: number
lastSuccessfulDiscovery: string | null
}
export interface McpServerConfig {
id: string
name: string
description?: string
transport: McpTransport
url?: string
authType?: McpAuthType
/**
* Required when `authType === 'oauth'` — identifies whose stored tokens
* to use when establishing the connection. Omit for header / none auth.
*/
userId?: string
workspaceId?: string
headers?: Record<string, string>
timeout?: number
retries?: number
enabled?: boolean
statusConfig?: McpServerStatusConfig
createdAt?: string
updatedAt?: string
}
export interface McpVersionInfo {
supported: string[]
preferred: string
}
export interface McpConsentRequest {
type: 'tool_execution' | 'resource_access' | 'data_sharing'
context: {
serverId: string
serverName: string
action: string
description?: string
dataAccess?: string[]
sideEffects?: string[]
}
expires?: number
}
export interface McpConsentResponse {
granted: boolean
expires?: number
restrictions?: Record<string, unknown>
auditId?: string
}
export interface McpSecurityPolicy {
requireConsent: boolean
allowedOrigins?: string[]
blockedOrigins?: string[]
maxToolExecutionsPerHour?: number
auditLevel: 'none' | 'basic' | 'detailed'
}
export interface McpToolSchemaProperty {
type?: string | string[]
description?: string
items?: McpToolSchemaProperty | McpToolSchemaProperty[]
properties?: Record<string, McpToolSchemaProperty>
required?: string[]
enum?: unknown[]
default?: unknown
[key: string]: unknown
}
/** Typed view of the SDK's `Tool.inputSchema` (which is `Record<string, unknown>`). */
export interface McpToolSchema {
type: 'object'
properties?: Record<string, McpToolSchemaProperty>
required?: string[]
description?: string
[key: string]: unknown
}
/** SDK `Tool` plus the server context Sim tracks. */
export interface McpTool extends Pick<Tool, 'name' | 'description'> {
inputSchema: McpToolSchema
serverId: string
serverName: string
}
export interface McpToolCall {
name: string
arguments: Record<string, unknown>
}
export interface McpToolResult {
content?: Array<{
type: 'text' | 'image' | 'resource'
text?: string
data?: string
mimeType?: string
}>
isError?: boolean
[key: string]: unknown
}
export interface McpConnectionStatus {
connected: boolean
lastConnected?: Date
lastError?: string
}
export class McpError extends Error {
constructor(
message: string,
public code?: number,
public data?: unknown
) {
super(message)
this.name = 'McpError'
}
}
export class McpConnectionError extends McpError {
constructor(message: string, serverName: string) {
super(`Failed to connect to "${serverName}": ${message}`)
this.name = 'McpConnectionError'
}
}
/**
* Thrown when an OAuth-protected MCP server is reachable but the current
* user has not yet authorized Sim. This is a benign "pending" state, not a
* connection failure — callers should surface a re-auth prompt rather than
* marking the server as errored.
*/
export class McpOauthAuthorizationRequiredError extends McpError {
constructor(
public readonly serverId: string,
serverName: string
) {
super(`OAuth authorization required for "${serverName}"`)
this.name = 'McpOauthAuthorizationRequiredError'
}
}
export interface McpServerSummary {
id: string
name: string
url?: string
transport?: McpTransport
status: 'connected' | 'disconnected' | 'error'
toolCount: number
resourceCount?: number
promptCount?: number
lastSeen?: Date
error?: string
}
/**
* Callback invoked when an MCP server sends a `notifications/tools/list_changed` notification.
*/
export type McpToolsChangedCallback = (serverId: string) => void
/**
* Options for creating an McpClient with notification support.
*/
export interface McpClientOptions {
config: McpServerConfig
securityPolicy?: McpSecurityPolicy
onToolsChanged?: McpToolsChangedCallback
/**
* Pre-resolved IP address to pin all transport HTTP connections to. When
* set, the SDK transport uses a custom fetch backed by an undici Agent with
* a fixed DNS lookup, preventing DNS-rebinding (TOCTOU) attacks between
* URL validation and connection. Should be supplied by callers that have
* just validated the URL via `validateMcpServerSsrf`.
*/
resolvedIP?: string
/**
* SDK-compatible OAuth client provider. When provided, the underlying
* StreamableHTTPClientTransport delegates token discovery, refresh, and
* 401 recovery to it. Should be supplied for `authType === 'oauth'`
* server configs.
*/
authProvider?: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider
}
export interface ToolsChangedEvent {
serverId: string
serverName: string
workspaceId: string
timestamp: number
}
export interface ManagedConnectionState {
serverId: string
serverName: string
workspaceId: string
userId: string
connected: boolean
supportsListChanged: boolean
reconnectAttempts: number
lastActivity: number
}
export interface WorkflowToolsChangedEvent {
serverId: string
workspaceId: string
}
export interface McpApiResponse<T = unknown> {
success: boolean
data?: T
error?: string
}
export interface McpToolDiscoveryResponse {
tools: McpTool[]
totalCount: number
byServer: Record<string, number>
}
/**
* MCP tool reference stored in workflow blocks (for validation).
* Minimal version used for comparing against discovered tools.
*/
export interface StoredMcpToolReference {
serverId: string
serverUrl?: string
toolName: string
schema?: McpToolSchema
}
/**
* Full stored MCP tool with workflow context (for API responses).
* Extended version that includes which workflow the tool is used in.
*/
export interface StoredMcpTool extends StoredMcpToolReference {
workflowId: string
workflowName: string
}
+417
View File
@@ -0,0 +1,417 @@
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { describe, expect, it } from 'vitest'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
import {
categorizeError,
createMcpToolId,
generateMcpServerId,
MCP_CLIENT_CONSTANTS,
MCP_CONSTANTS,
parseMcpToolId,
validateRequiredFields,
validateStringParam,
} from './utils'
describe('generateMcpServerId', () => {
const workspaceId = 'ws-test-123'
const url = 'https://my-mcp-server.com/mcp'
it.concurrent('produces deterministic IDs for the same input', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, url)
expect(id1).toBe(id2)
})
it.concurrent('normalizes trailing slashes', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, `${url}/`)
const id3 = generateMcpServerId(workspaceId, `${url}//`)
expect(id1).toBe(id2)
expect(id1).toBe(id3)
})
it.concurrent('is case insensitive for URL', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, 'https://MY-MCP-SERVER.com/mcp')
const id3 = generateMcpServerId(workspaceId, 'HTTPS://My-Mcp-Server.COM/MCP')
expect(id1).toBe(id2)
expect(id1).toBe(id3)
})
it.concurrent('ignores query parameters', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, `${url}?token=abc123`)
const id3 = generateMcpServerId(workspaceId, `${url}?foo=bar&baz=qux`)
expect(id1).toBe(id2)
expect(id1).toBe(id3)
})
it.concurrent('ignores fragments', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, `${url}#section`)
expect(id1).toBe(id2)
})
it.concurrent('produces different IDs for different workspaces', () => {
const id1 = generateMcpServerId('ws-123', url)
const id2 = generateMcpServerId('ws-456', url)
expect(id1).not.toBe(id2)
})
it.concurrent('produces different IDs for different URLs', () => {
const id1 = generateMcpServerId(workspaceId, 'https://server1.com/mcp')
const id2 = generateMcpServerId(workspaceId, 'https://server2.com/mcp')
expect(id1).not.toBe(id2)
})
it.concurrent('produces IDs in the correct format', () => {
const id = generateMcpServerId(workspaceId, url)
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
})
it.concurrent('handles URLs with ports', () => {
const id1 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp')
const id2 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp/')
expect(id1).toBe(id2)
expect(id1).toMatch(/^mcp-[a-f0-9]{8}$/)
})
it.concurrent('handles invalid URLs gracefully', () => {
const id = generateMcpServerId(workspaceId, 'not-a-valid-url')
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
})
})
describe('MCP_CONSTANTS', () => {
it.concurrent('has correct execution timeout', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(DEFAULT_EXECUTION_TIMEOUT_MS)
})
it.concurrent('has correct cache timeout (5 minutes)', () => {
expect(MCP_CONSTANTS.CACHE_TIMEOUT).toBe(5 * 60 * 1000)
})
it.concurrent('has correct default retries', () => {
expect(MCP_CONSTANTS.DEFAULT_RETRIES).toBe(3)
})
it.concurrent('has correct default connection timeout', () => {
expect(MCP_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT).toBe(30000)
})
it.concurrent('has correct max cache size', () => {
expect(MCP_CONSTANTS.MAX_CACHE_SIZE).toBe(1000)
})
it.concurrent('has correct max consecutive failures', () => {
expect(MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES).toBe(3)
})
})
describe('MCP_CLIENT_CONSTANTS', () => {
it.concurrent('has correct client timeout', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(DEFAULT_EXECUTION_TIMEOUT_MS)
})
it.concurrent('has correct auto refresh interval (5 minutes)', () => {
expect(MCP_CLIENT_CONSTANTS.AUTO_REFRESH_INTERVAL).toBe(5 * 60 * 1000)
})
})
describe('validateStringParam', () => {
it.concurrent('returns valid for non-empty string', () => {
const result = validateStringParam('test-value', 'testParam')
expect(result.isValid).toBe(true)
})
it.concurrent('returns invalid for empty string', () => {
const result = validateStringParam('', 'testParam')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('testParam is required and must be a string')
}
})
it.concurrent('returns invalid for null', () => {
const result = validateStringParam(null, 'testParam')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('testParam is required and must be a string')
}
})
it.concurrent('returns invalid for undefined', () => {
const result = validateStringParam(undefined, 'testParam')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('testParam is required and must be a string')
}
})
it.concurrent('returns invalid for number', () => {
const result = validateStringParam(123, 'testParam')
expect(result.isValid).toBe(false)
})
it.concurrent('returns invalid for object', () => {
const result = validateStringParam({ foo: 'bar' }, 'testParam')
expect(result.isValid).toBe(false)
})
it.concurrent('returns invalid for array', () => {
const result = validateStringParam(['test'], 'testParam')
expect(result.isValid).toBe(false)
})
it.concurrent('includes param name in error message', () => {
const result = validateStringParam(null, 'customParamName')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toContain('customParamName')
}
})
})
describe('validateRequiredFields', () => {
it.concurrent('returns valid when all required fields are present', () => {
const body = { field1: 'value1', field2: 'value2', field3: 'value3' }
const result = validateRequiredFields(body, ['field1', 'field2'])
expect(result.isValid).toBe(true)
})
it.concurrent('returns invalid when a required field is missing', () => {
const body = { field1: 'value1' }
const result = validateRequiredFields(body, ['field1', 'field2'])
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('Missing required fields: field2')
}
})
it.concurrent('returns invalid with multiple missing fields', () => {
const body = { field1: 'value1' }
const result = validateRequiredFields(body, ['field1', 'field2', 'field3'])
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('Missing required fields: field2, field3')
}
})
it.concurrent('returns valid with empty required fields array', () => {
const body = { field1: 'value1' }
const result = validateRequiredFields(body, [])
expect(result.isValid).toBe(true)
})
it.concurrent('returns invalid when body is empty and fields are required', () => {
const body = {}
const result = validateRequiredFields(body, ['field1'])
expect(result.isValid).toBe(false)
})
it.concurrent('considers null values as present', () => {
const body = { field1: null }
const result = validateRequiredFields(body, ['field1'])
expect(result.isValid).toBe(true)
})
it.concurrent('considers undefined values as present when key exists', () => {
const body = { field1: undefined }
const result = validateRequiredFields(body, ['field1'])
expect(result.isValid).toBe(true)
})
})
describe('categorizeError', () => {
it.concurrent('returns 408 for timeout errors', () => {
const error = new Error('Request timeout occurred')
const result = categorizeError(error)
expect(result.status).toBe(408)
expect(result.message).toBe('Request timed out')
})
it.concurrent('returns 408 for timeout in message (case insensitive)', () => {
const error = new Error('Operation TIMEOUT')
const result = categorizeError(error)
expect(result.status).toBe(408)
})
it.concurrent('returns 404 for not found errors', () => {
const error = new Error('Resource not found')
const result = categorizeError(error)
expect(result.status).toBe(404)
expect(result.message).toBe('Resource not found')
})
it.concurrent('returns 404 for not accessible errors', () => {
const error = new Error('Server not accessible')
const result = categorizeError(error)
expect(result.status).toBe(404)
expect(result.message).toBe('Resource not found')
})
it.concurrent('returns 401 for authentication errors', () => {
const error = new Error('Authentication failed')
const result = categorizeError(error)
expect(result.status).toBe(401)
expect(result.message).toBe('Authentication required')
})
it.concurrent('returns 401 for unauthorized errors', () => {
const error = new Error('Unauthorized access attempt')
const result = categorizeError(error)
expect(result.status).toBe(401)
expect(result.message).toBe('Authentication required')
})
it.concurrent('returns 400 for invalid input errors', () => {
const error = new Error('Invalid parameter provided')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid request parameters')
})
it.concurrent('returns 400 for missing required errors', () => {
const error = new Error('Missing required field: name')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid request parameters')
})
it.concurrent('returns 400 for validation errors', () => {
const error = new Error('Validation failed for input')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid request parameters')
})
it.concurrent('returns 503 for cooldown errors', () => {
const error = new Error('Server recently failed and is in cooldown — try again shortly.')
const result = categorizeError(error)
expect(result.status).toBe(503)
expect(result.message).toBe('Server temporarily unavailable')
})
it.concurrent('returns 500 for generic errors', () => {
const error = new Error('Something went wrong')
const result = categorizeError(error)
expect(result.status).toBe(500)
expect(result.message).toBe('Internal server error')
})
it.concurrent('returns 500 for non-Error objects', () => {
const result = categorizeError('string error')
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
it.concurrent('returns 401 for McpOauthAuthorizationRequiredError via instanceof', () => {
const error = new McpOauthAuthorizationRequiredError('mcp-a', 'A')
const result = categorizeError(error)
expect(result.status).toBe(401)
expect(result.message).toBe('Authentication required')
})
it.concurrent('returns 401 for SDK UnauthorizedError via instanceof', () => {
const error = new UnauthorizedError('token expired')
const result = categorizeError(error)
expect(result.status).toBe(401)
})
it.concurrent('returns 503 for McpConnectionError with cooldown message', () => {
const error = new McpConnectionError('Server in cooldown — try again shortly.', 'mcp-a')
const result = categorizeError(error)
expect(result.status).toBe(503)
})
it.concurrent('returns 502 for other McpConnectionError', () => {
const error = new McpConnectionError('connect ECONNREFUSED', 'mcp-a')
const result = categorizeError(error)
expect(result.status).toBe(502)
expect(result.message).toBe('Connection failed')
})
it.concurrent('returns 500 for null', () => {
const result = categorizeError(null)
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
it.concurrent('returns 500 for undefined', () => {
const result = categorizeError(undefined)
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
it.concurrent('returns 500 for objects that are not Error instances', () => {
const result = categorizeError({ message: 'fake error' })
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
})
describe('createMcpToolId', () => {
it.concurrent('creates tool ID from server ID and tool name', () => {
const toolId = createMcpToolId('mcp-12345678', 'my-tool')
expect(toolId).toBe('mcp-12345678-my-tool')
})
it.concurrent('adds mcp- prefix if server ID does not have it', () => {
const toolId = createMcpToolId('12345678', 'my-tool')
expect(toolId).toBe('mcp-12345678-my-tool')
})
it.concurrent('does not double-prefix if server ID already has mcp-', () => {
const toolId = createMcpToolId('mcp-server123', 'tool-name')
expect(toolId).toBe('mcp-server123-tool-name')
})
it.concurrent('handles tool names with hyphens', () => {
const toolId = createMcpToolId('mcp-server', 'my-complex-tool-name')
expect(toolId).toBe('mcp-server-my-complex-tool-name')
})
it.concurrent('handles empty tool name', () => {
const toolId = createMcpToolId('mcp-server', '')
expect(toolId).toBe('mcp-server-')
})
})
describe('parseMcpToolId', () => {
it.concurrent('parses valid MCP tool ID', () => {
const result = parseMcpToolId('mcp-12345678-my-tool')
expect(result.serverId).toBe('mcp-12345678')
expect(result.toolName).toBe('my-tool')
})
it.concurrent('parses tool name with hyphens', () => {
const result = parseMcpToolId('mcp-server123-my-complex-tool-name')
expect(result.serverId).toBe('mcp-server123')
expect(result.toolName).toBe('my-complex-tool-name')
})
it.concurrent('throws error for invalid format without mcp prefix', () => {
expect(() => parseMcpToolId('invalid-tool-id')).toThrow(
'Invalid MCP tool ID format: invalid-tool-id'
)
})
it.concurrent('throws error for tool ID with less than 3 parts', () => {
expect(() => parseMcpToolId('mcp-only')).toThrow('Invalid MCP tool ID format: mcp-only')
})
it.concurrent('throws error for empty string', () => {
expect(() => parseMcpToolId('')).toThrow('Invalid MCP tool ID format: ')
})
it.concurrent('throws error for single part', () => {
expect(() => parseMcpToolId('mcp')).toThrow('Invalid MCP tool ID format: mcp')
})
it.concurrent('handles tool name with multiple hyphens correctly', () => {
const result = parseMcpToolId('mcp-abc-tool-with-many-parts')
expect(result.serverId).toBe('mcp-abc')
expect(result.toolName).toBe('tool-with-many-parts')
})
})
+247
View File
@@ -0,0 +1,247 @@
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { NextResponse } from 'next/server'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import {
type McpApiResponse,
McpConnectionError,
McpOauthAuthorizationRequiredError,
} from '@/lib/mcp/types'
import { isMcpTool, MCP } from '@/executor/constants'
export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
CACHE_TIMEOUT: 5 * 60 * 1000,
DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000,
MAX_CACHE_SIZE: 1000,
MAX_CONSECUTIVE_FAILURES: 3,
} as const
/**
* Core MCP tool parameter keys that are metadata, not user-entered test values.
* These should be preserved when cleaning up params during schema updates.
*/
export const MCP_TOOL_CORE_PARAMS = new Set(['serverId', 'serverUrl', 'toolName', 'serverName'])
/**
* Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors.
* Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste.
*/
export function sanitizeForHttp(value: string): string {
return value
.replace(/[\u2028\u2029\u200B-\u200D\uFEFF]/g, '')
.replace(/[\x00-\x1F\x7F]/g, '')
.trim()
}
/**
* Sanitizes all header key-value pairs for HTTP usage.
*/
export function sanitizeHeaders(
headers: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!headers) return headers
return Object.fromEntries(
Object.entries(headers)
.map(([key, value]) => [sanitizeForHttp(key), sanitizeForHttp(value)])
.filter(([key, value]) => key !== '' && value !== '')
)
}
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
LIST_TOOLS_TIMEOUT_MS: 10_000,
FAILURE_CACHE_TTL_MS: 120_000,
} as const
/**
* Create standardized MCP error response.
* Always returns the defaultMessage to clients to prevent leaking internal error details.
* Callers are responsible for logging the original error before calling this function.
*/
export function createMcpErrorResponse(
_error: unknown,
defaultMessage: string,
status = 500
): NextResponse {
const response: McpApiResponse = {
success: false,
error: defaultMessage,
}
return NextResponse.json(response, { status })
}
/**
* Create standardized MCP success response
*/
export function createMcpSuccessResponse<T>(data: T, status = 200): NextResponse {
const response: McpApiResponse<T> = {
success: true,
data,
}
return NextResponse.json(response, { status })
}
/**
* Maps MCP orchestration error codes to safe HTTP statuses.
*/
export function mcpOrchestrationStatus(errorCode: string | undefined): number {
if (errorCode === 'validation') return 400
if (errorCode === 'forbidden') return 403
if (errorCode === 'not_found') return 404
if (errorCode === 'conflict') return 409
if (errorCode === 'bad_gateway') return 502
return 500
}
/**
* Validate string parameter
* Consolidates parameter validation logic found across routes
*/
export function validateStringParam(
value: unknown,
paramName: string
): { isValid: true } | { isValid: false; error: string } {
if (!value || typeof value !== 'string') {
return {
isValid: false,
error: `${paramName} is required and must be a string`,
}
}
return { isValid: true }
}
/**
* Validate required fields in request body
*/
export function validateRequiredFields(
body: Record<string, unknown>,
requiredFields: string[]
): { isValid: true } | { isValid: false; error: string } {
const missingFields = requiredFields.filter((field) => !(field in body))
if (missingFields.length > 0) {
return {
isValid: false,
error: `Missing required fields: ${missingFields.join(', ')}`,
}
}
return { isValid: true }
}
/**
* Enhanced error categorization for more specific HTTP status codes.
* Returns safe, generic messages to prevent leaking internal details.
*/
export function categorizeError(error: unknown): { message: string; status: number } {
if (!(error instanceof Error)) {
return { message: 'Unknown error occurred', status: 500 }
}
// Typed dispatch first — our own classes carry definitive intent.
if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) {
return { message: 'Authentication required', status: 401 }
}
if (error instanceof McpConnectionError) {
if (error.message.toLowerCase().includes('cooldown')) {
return { message: 'Server temporarily unavailable', status: 503 }
}
return { message: 'Connection failed', status: 502 }
}
// Fall back to substring matching for SDK / third-party errors we don't
// own a typed class for.
const msg = error.message.toLowerCase()
if (msg.includes('timeout')) {
return { message: 'Request timed out', status: 408 }
}
if (msg.includes('cooldown')) {
return { message: 'Server temporarily unavailable', status: 503 }
}
if (msg.includes('not found') || msg.includes('not accessible')) {
return { message: 'Resource not found', status: 404 }
}
if (msg.includes('authentication') || msg.includes('unauthorized')) {
return { message: 'Authentication required', status: 401 }
}
if (msg.includes('invalid') || msg.includes('missing required') || msg.includes('validation')) {
return { message: 'Invalid request parameters', status: 400 }
}
return { message: 'Internal server error', status: 500 }
}
/**
* Create standardized MCP tool ID from server ID and tool name
*/
export function createMcpToolId(serverId: string, toolName: string): string {
const normalizedServerId = isMcpTool(serverId) ? serverId : `${MCP.TOOL_PREFIX}${serverId}`
return `${normalizedServerId}-${toolName}`
}
/**
* Parse MCP tool ID to extract server ID and tool name
*/
export function parseMcpToolId(toolId: string): { serverId: string; toolName: string } {
const parts = toolId.split('-')
if (parts.length < 3 || parts[0] !== 'mcp') {
throw new Error(`Invalid MCP tool ID format: ${toolId}. Expected: mcp-serverId-toolName`)
}
const serverId = `${parts[0]}-${parts[1]}`
const toolName = parts.slice(2).join('-')
return { serverId, toolName }
}
/**
* Generate a deterministic MCP server ID based on workspace and URL.
*
* This ensures that re-adding the same MCP server (same URL in the same workspace)
* produces the same ID, preventing "server not found" errors when workflows
* reference the old server ID.
*
* The ID is a hash of: workspaceId + normalized URL
* Format: mcp-<8 char hash>
*/
export function generateMcpServerId(workspaceId: string, url: string): string {
const normalizedUrl = normalizeUrlForHashing(url)
const input = `${workspaceId}:${normalizedUrl}`
const hash = simpleHash(input)
return `mcp-${hash}`
}
/**
* Normalize URL for consistent hashing.
* - Converts to lowercase
* - Removes trailing slashes
* - Removes query parameters and fragments
*/
function normalizeUrlForHashing(url: string): string {
try {
const parsed = new URL(url)
const normalized = `${parsed.origin}${parsed.pathname}`.toLowerCase().replace(/\/+$/, '')
return normalized
} catch {
return url.toLowerCase().trim().replace(/\/+$/, '')
}
}
/**
* Simple deterministic hash function that produces an 8-character hex string.
* Uses a variant of djb2 hash algorithm.
*/
function simpleHash(str: string): string {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) + hash + str.charCodeAt(i)
hash = hash >>> 0
}
return hash.toString(16).padStart(8, '0').slice(0, 8)
}
+392
View File
@@ -0,0 +1,392 @@
import { db, workflowMcpServer, workflowMcpTool } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, asc, eq, gt, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { MAX_MCP_SERVERS_PER_WORKFLOW } from '@/lib/mcp/constants'
import { acquireWorkflowMcpServerLock } from '@/lib/mcp/server-locks'
import {
addMcpToolMetadataUsageRow,
createMcpToolMetadataUsageRow,
exceedsMcpServerToolMetadataBudget,
getMcpServerToolMetadataUsageRows,
getMcpToolMetadataUsageFromRows,
type McpToolMetadataUsage,
type McpToolMetadataUsageRow,
subtractMcpToolMetadataUsageRow,
validateMcpToolMetadataForStorage,
} from '@/lib/mcp/tool-limits'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import type { InputFormatField } from '@/lib/workflows/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { mcpPubSub } from './pubsub'
import {
applyDescriptionOverrides,
extractInputFormatFromBlocks,
generateToolInputSchema,
pruneOverridesToSchema,
} from './workflow-tool-schema'
const logger = createLogger('WorkflowMcpSync')
const EMPTY_SCHEMA: Record<string, unknown> = Object.freeze({ type: 'object', properties: {} })
const MCP_SYNC_TOOLS_PAGE_SIZE = 100
class WorkflowMcpServerFanoutError extends Error {
constructor(workflowId: string) {
super(
`Workflow ${workflowId} is exposed on more than ${MAX_MCP_SERVERS_PER_WORKFLOW} MCP servers`
)
this.name = 'WorkflowMcpServerFanoutError'
}
}
interface WorkflowMcpToolSyncRow {
id: string
serverId: string
toolName: string
toolDescription: string | null
parameterDescriptionOverrides: Record<string, string>
}
interface ServerMetadataUsageState {
usageByToolId: Map<string, McpToolMetadataUsageRow>
serverUsage: McpToolMetadataUsage
}
async function listWorkflowMcpToolSyncPage(
tx: DbOrTx,
workflowId: string,
afterToolId?: string,
serverIds?: string[]
): Promise<WorkflowMcpToolSyncRow[]> {
return tx
.select({
id: workflowMcpTool.id,
serverId: workflowMcpTool.serverId,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
})
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.workflowId, workflowId),
isNull(workflowMcpTool.archivedAt),
serverIds && serverIds.length > 0
? inArray(workflowMcpTool.serverId, serverIds)
: undefined,
afterToolId ? gt(workflowMcpTool.id, afterToolId) : undefined
)
)
.orderBy(asc(workflowMcpTool.id))
.limit(MCP_SYNC_TOOLS_PAGE_SIZE + 1)
}
async function collectWorkflowMcpToolServerIds(
tx: DbOrTx,
workflowId: string
): Promise<Array<{ serverId: string }>> {
const serverIds = new Set<string>()
let afterToolId: string | undefined
while (true) {
const page = await listWorkflowMcpToolSyncPage(tx, workflowId, afterToolId)
if (page.length === 0) break
const pageTools = page.slice(0, MCP_SYNC_TOOLS_PAGE_SIZE)
for (const tool of pageTools) {
serverIds.add(tool.serverId)
if (serverIds.size > MAX_MCP_SERVERS_PER_WORKFLOW) {
throw new WorkflowMcpServerFanoutError(workflowId)
}
}
if (page.length <= MCP_SYNC_TOOLS_PAGE_SIZE) break
afterToolId = pageTools.at(-1)?.id
}
return [...serverIds].sort().map((serverId) => ({ serverId }))
}
/**
* Generate MCP tool parameter schema from workflow blocks.
*/
export function generateSchemaFromBlocks(blocks: Record<string, unknown>): Record<string, unknown> {
const inputFormat = extractInputFormatFromBlocks(blocks)
if (!inputFormat || inputFormat.length === 0) {
return EMPTY_SCHEMA
}
return { ...generateToolInputSchema(inputFormat) }
}
/**
* Load a workflow's active deployed state and generate its MCP parameter schema.
* Workflows with no inputs or no active deployment use an empty object schema.
*/
export async function generateParameterSchemaForWorkflow(
workflowId: string
): Promise<Record<string, unknown>> {
const deployed = await loadDeployedWorkflowState(workflowId)
if (!deployed?.blocks) return EMPTY_SCHEMA
return generateSchemaFromBlocks(deployed.blocks as Record<string, unknown>)
}
/**
* Load a workflow's active deployed state and return its start-trigger input
* format fields. Shared so callers (e.g. the copilot `deploy_mcp` tool) can
* build a parameter schema from the same input source the deploy modal uses.
*/
export async function getDeployedWorkflowInputFormat(
workflowId: string
): Promise<InputFormatField[]> {
const deployed = await loadDeployedWorkflowState(workflowId)
if (!deployed?.blocks) return []
return extractInputFormatFromBlocks(deployed.blocks as Record<string, unknown>) ?? []
}
interface SyncOptionsBase {
workflowId: string
requestId: string
/** Context for logging (e.g., 'deploy', 'revert', 'activate') */
context?: string
throwOnError?: boolean
}
/**
* Callers running inside a transaction must preload the workflow state:
* loading it lazily would issue queries on the global pool while the
* transaction already holds a pooled connection.
*
* Server notification is strictly post-commit. The standalone arm notifies
* after its own transaction commits (`notify` defaults to true); the `tx` arm
* never notifies — publishing before the caller's transaction commits would
* announce state that may still roll back, so the transaction owner notifies
* after commit (see deployment-outbox).
*/
type SyncOptions = SyncOptionsBase &
(
| { tx: DbOrTx; state: { blocks?: Record<string, unknown> }; notify?: false }
| { tx?: undefined; state?: { blocks?: Record<string, unknown> }; notify?: boolean }
)
/**
* Sync MCP tools for a workflow with the latest parameter schema.
* - If the workflow has no start block, removes all MCP tools
* - Otherwise, updates all MCP tools with the current schema
*
* @param options.workflowId - The workflow ID to sync
* @param options.requestId - Request ID for logging
* @param options.state - Optional workflow state (if not provided, loads from DB)
* @param options.context - Optional context for log messages
*/
export async function syncMcpToolsForWorkflow(
options: SyncOptions
): Promise<Array<{ serverId: string }>> {
if (!options.tx) {
let state = options.state
if (!state) {
try {
state = await loadDeployedWorkflowState(options.workflowId)
} catch (error) {
logger.error(
`[${options.requestId}] Error loading deployed state for MCP tool sync (${options.context ?? 'sync'}):`,
error
)
if (options.throwOnError) throw error
return []
}
}
const resolvedState = state
const tools = await db.transaction((tx) =>
syncMcpToolsForWorkflow({ ...options, state: resolvedState, tx, notify: false })
)
if (options.notify ?? true) notifyMcpToolServers(tools)
return tools
}
const { workflowId, requestId, state, context = 'sync', tx, throwOnError = false } = options
try {
if (!hasValidStartBlockInState(state as WorkflowState | null)) {
return await removeMcpToolsForWorkflow(workflowId, requestId, tx, true)
}
const generatedParameterSchema = state.blocks
? generateSchemaFromBlocks(state.blocks)
: EMPTY_SCHEMA
const schemaLimitError = validateMcpToolMetadataForStorage({
parameterSchema: generatedParameterSchema,
})
if (schemaLimitError) {
throw new Error(schemaLimitError)
}
const baseParameterSchema = generatedParameterSchema
const affectedServerIds = new Set<string>()
const lockedServers = await collectWorkflowMcpToolServerIds(tx, workflowId)
if (lockedServers.length === 0) return []
for (const { serverId } of lockedServers) {
await acquireWorkflowMcpServerLock(tx, serverId)
affectedServerIds.add(serverId)
}
const lockedServerIds = [...affectedServerIds]
const usageStateByServer = new Map<string, ServerMetadataUsageState>()
for (const { serverId } of lockedServers) {
const rows = await getMcpServerToolMetadataUsageRows(tx, serverId)
usageStateByServer.set(serverId, {
usageByToolId: new Map(rows.map((row) => [row.id, row])),
serverUsage: getMcpToolMetadataUsageFromRows(rows),
})
}
let syncedToolCount = 0
let afterToolId: string | undefined
while (true) {
const page = await listWorkflowMcpToolSyncPage(tx, workflowId, afterToolId, lockedServerIds)
if (page.length === 0) break
const pageTools = page.slice(0, MCP_SYNC_TOOLS_PAGE_SIZE)
const toolsByServer = new Map<string, WorkflowMcpToolSyncRow[]>()
for (const tool of pageTools) {
affectedServerIds.add(tool.serverId)
const serverTools = toolsByServer.get(tool.serverId) ?? []
serverTools.push(tool)
toolsByServer.set(tool.serverId, serverTools)
}
for (const [serverId, serverTools] of [...toolsByServer].sort(([left], [right]) =>
left.localeCompare(right)
)) {
const usageState = usageStateByServer.get(serverId)
if (!usageState) {
throw new Error(`Missing locked MCP server usage state for server ${serverId}`)
}
for (const tool of serverTools) {
const existingUsage = subtractMcpToolMetadataUsageRow(
usageState.serverUsage,
usageState.usageByToolId.get(tool.id)
)
const prunedOverrides = pruneOverridesToSchema(
tool.parameterDescriptionOverrides,
baseParameterSchema
)
const mergedSchema = applyDescriptionOverrides(baseParameterSchema, prunedOverrides)
const shouldUseEmptySchema = exceedsMcpServerToolMetadataBudget(existingUsage, {
toolName: tool.toolName,
toolDescription: tool.toolDescription,
parameterSchema: mergedSchema,
})
const schemaForTool = shouldUseEmptySchema ? EMPTY_SCHEMA : mergedSchema
const updatedUsageRow = createMcpToolMetadataUsageRow({
id: tool.id,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
parameterSchema: schemaForTool,
})
usageState.usageByToolId.set(tool.id, updatedUsageRow)
usageState.serverUsage = addMcpToolMetadataUsageRow(existingUsage, updatedUsageRow)
await tx
.update(workflowMcpTool)
.set({
parameterSchema: schemaForTool,
parameterDescriptionOverrides: prunedOverrides,
updatedAt: new Date(),
})
.where(eq(workflowMcpTool.id, tool.id))
}
}
syncedToolCount += pageTools.length
if (page.length <= MCP_SYNC_TOOLS_PAGE_SIZE) break
afterToolId = pageTools.at(-1)?.id
}
logger.info(
`[${requestId}] Synced ${syncedToolCount} MCP tool(s) for workflow (${context}): ${workflowId}`
)
return [...affectedServerIds].map((serverId) => ({ serverId }))
} catch (error) {
logger.error(`[${requestId}] Error syncing MCP tools (${context}):`, error)
if (throwOnError) throw error
return []
}
}
/**
* Remove all MCP tools for a workflow (used when undeploying).
* Queries affected tools before deleting so their servers can be notified.
*
* Server notification is strictly post-commit: the standalone path notifies
* after the transaction opened here commits; when `tx` is provided the
* transaction owner notifies after commit using the returned server ids.
*/
export async function removeMcpToolsForWorkflow(
workflowId: string,
requestId: string,
tx?: DbOrTx,
throwOnError = false
): Promise<Array<{ serverId: string }>> {
if (!tx) {
const tools = await db.transaction((transaction) =>
removeMcpToolsForWorkflow(workflowId, requestId, transaction, throwOnError)
)
notifyMcpToolServers(tools)
return tools
}
try {
const tools = await collectWorkflowMcpToolServerIds(tx, workflowId)
if (tools.length === 0) return []
for (const { serverId } of tools) {
await acquireWorkflowMcpServerLock(tx, serverId)
}
await tx.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(`[${requestId}] Removed MCP tools for workflow: ${workflowId}`)
return tools
} catch (error) {
logger.error(`[${requestId}] Error removing MCP tools:`, error)
if (throwOnError) throw error
return []
}
}
/**
* Publish pubsub events for each unique server affected by a tool change.
* Resolves workspace IDs from the server table so callers don't need to pass them.
*/
export function notifyMcpToolServers(tools: Array<{ serverId: string }>): void {
if (!mcpPubSub) return
const uniqueServerIds = [...new Set(tools.map((t) => t.serverId))]
void (async () => {
try {
const servers = await db
.select({ id: workflowMcpServer.id, workspaceId: workflowMcpServer.workspaceId })
.from(workflowMcpServer)
.where(
and(inArray(workflowMcpServer.id, uniqueServerIds), isNull(workflowMcpServer.deletedAt))
)
for (const server of servers) {
mcpPubSub.publishWorkflowToolsChanged({
serverId: server.id,
workspaceId: server.workspaceId,
})
}
} catch (error) {
logger.error('Error notifying affected servers:', error)
}
})()
}
+331
View File
@@ -0,0 +1,331 @@
import { z } from 'zod'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import type { McpToolSchema } from './types'
/**
* Extended property definition for workflow tool schemas.
* More specific than the generic McpToolSchema properties.
*/
export interface McpToolProperty {
[key: string]: unknown
type: string
description?: string
items?: McpToolProperty
properties?: Record<string, McpToolProperty>
}
/**
* Extended MCP tool schema with typed properties (for workflow tool generation).
* Extends the base McpToolSchema with more specific property types.
*/
export interface McpToolInputSchema extends McpToolSchema {
properties: Record<string, McpToolProperty>
}
export interface McpToolDefinition {
name: string
description: string
inputSchema: McpToolInputSchema
}
/**
* File item Zod schema for MCP file inputs.
* This is the single source of truth for file structure.
*/
export const fileItemZodSchema = z.object({
name: z.string().describe('File name'),
data: z.string().describe('Base64 encoded file content'),
mimeType: z.string().describe('MIME type of the file'),
})
/**
* Convert InputFormatField type to Zod schema
*/
function fieldTypeToZod(fieldType: string | undefined, isRequired: boolean): z.ZodTypeAny {
let zodType: z.ZodTypeAny
switch (fieldType) {
case 'string':
zodType = z.string()
break
case 'number':
zodType = z.number()
break
case 'boolean':
zodType = z.boolean()
break
case 'object':
zodType = z.record(z.string(), z.any())
break
case 'array':
zodType = z.array(z.any())
break
case 'files':
zodType = z.array(fileItemZodSchema)
break
default:
zodType = z.string()
}
return isRequired ? zodType : zodType.optional()
}
/**
* Generate Zod schema shape from InputFormatField array.
* This is used directly by the MCP server for tool registration.
*/
export function generateToolZodSchema(inputFormat: InputFormatField[]): z.ZodRawShape | undefined {
if (!inputFormat || inputFormat.length === 0) {
return undefined
}
const shape: Record<string, z.ZodTypeAny> = {}
for (const field of inputFormat) {
if (!field.name) continue
const zodType = fieldTypeToZod(field.type, true)
shape[field.name] = field.name ? zodType.describe(field.name) : zodType
}
return Object.keys(shape).length > 0 ? shape : undefined
}
/**
* Map InputFormatField type to JSON Schema type (for database storage)
*/
function mapFieldTypeToJsonSchemaType(fieldType: string | undefined): string {
switch (fieldType) {
case 'string':
return 'string'
case 'number':
return 'number'
case 'boolean':
return 'boolean'
case 'object':
return 'object'
case 'array':
return 'array'
case 'files':
return 'array'
default:
return 'string'
}
}
/**
* Sanitize a workflow name to be a valid MCP tool name.
* Tool names should be lowercase, alphanumeric with underscores.
*/
export function sanitizeToolName(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9\s_-]/g, '')
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.substring(0, 64) || 'workflow_tool'
)
}
/**
* Generate MCP tool input schema from InputFormatField array.
* This converts the workflow's input format definition to JSON Schema format
* that MCP clients can use to understand tool parameters.
*/
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolInputSchema {
const properties: Record<string, McpToolProperty> = {}
const required: string[] = []
for (const field of inputFormat) {
if (!field.name) continue
const fieldName = field.name
const fieldType = mapFieldTypeToJsonSchemaType(field.type)
const property: McpToolProperty = {
type: fieldType,
// Use custom description if provided, otherwise use field name
description: field.description?.trim() || fieldName,
}
// Handle array types
if (fieldType === 'array') {
if (field.type === 'file[]') {
property.items = {
type: 'object',
properties: {
name: { type: 'string', description: 'File name' },
url: { type: 'string', description: 'File URL' },
type: { type: 'string', description: 'MIME type' },
size: { type: 'number', description: 'File size in bytes' },
},
}
// Use custom description if provided, otherwise use default
if (!field.description?.trim()) {
property.description = 'Array of file objects'
}
} else {
property.items = { type: 'string' }
}
}
properties[fieldName] = property
// All fields are considered required by default
// (in the future, we could add an optional flag to InputFormatField)
required.push(fieldName)
}
return {
type: 'object',
properties,
required: required.length > 0 ? required : undefined,
}
}
/**
* Overlay sparse per-parameter description overrides onto a base schema produced by
* `generateToolInputSchema`. Only keys present in both `overrides` and the base are applied;
* overrides for fields no longer in the Start block are ignored. An empty override falls back to
* the field name, matching the base converter's "no description" behavior.
*/
export function applyDescriptionOverrides(
baseSchema: Record<string, unknown>,
overrides: Record<string, string> | null | undefined
): Record<string, unknown> {
if (!overrides || Object.keys(overrides).length === 0) return baseSchema
const baseProperties = baseSchema.properties as Record<string, McpToolProperty> | undefined
if (!baseProperties) return baseSchema
const properties: Record<string, McpToolProperty> = {}
for (const [name, property] of Object.entries(baseProperties)) {
const override = overrides[name]
properties[name] =
typeof override === 'string'
? { ...property, description: override.trim() || name }
: property
}
return { ...baseSchema, properties }
}
/**
* Drop override entries whose parameter no longer exists in the base schema, so the stored override
* map never accumulates or resurrects descriptions for removed Start-block inputs.
*/
export function pruneOverridesToSchema(
overrides: Record<string, string>,
baseSchema: Record<string, unknown>
): Record<string, string> {
const baseProperties = (baseSchema.properties ?? {}) as Record<string, unknown>
const pruned: Record<string, string> = {}
for (const [name, value] of Object.entries(overrides)) {
if (name in baseProperties) pruned[name] = value
}
return pruned
}
/**
* Derive the sparse description-override map between a full schema and the Start-block base: keep
* only fields whose description is a real custom value (present, not equal to the field name, and
* different from the base). Used to migrate a legacy full `parameterSchema` payload into overrides
* during the transition window.
*/
export function extractDescriptionOverrides(
schema: Record<string, unknown> | null | undefined,
baseSchema: Record<string, unknown>
): Record<string, string> {
const overrides: Record<string, string> = {}
const schemaProperties = schema?.properties as
| Record<string, { description?: unknown }>
| undefined
if (!schemaProperties) return overrides
const baseProperties = (baseSchema.properties ?? {}) as Record<string, McpToolProperty>
for (const [name, property] of Object.entries(schemaProperties)) {
if (!(name in baseProperties)) continue
const description = typeof property?.description === 'string' ? property.description.trim() : ''
if (!description || description === name) continue
const baseDescription =
typeof baseProperties[name]?.description === 'string'
? (baseProperties[name].description as string)
: ''
if (description !== baseDescription) overrides[name] = description
}
return overrides
}
const DEFAULT_WORKFLOW_DESCRIPTIONS = new Set([
'new workflow',
'your first workflow - start building here!',
])
/**
* Returns the workflow description when it is a real, user-meaningful value, or `null` for empty or
* placeholder defaults (so callers can fall back to a derived description). Shared by the serve
* layer and the deploy UI so both treat the same values as "no description".
*/
export function getMeaningfulWorkflowDescription(
description: string | null | undefined,
workflowName?: string | null
): string | null {
const trimmed = description?.trim()
if (!trimmed) return null
if (DEFAULT_WORKFLOW_DESCRIPTIONS.has(trimmed.toLowerCase())) return null
if (workflowName && trimmed === workflowName.trim()) return null
return trimmed
}
/**
* Generate a complete MCP tool definition from workflow metadata and input format.
*/
export function generateToolDefinition(
workflowName: string,
workflowDescription: string | undefined | null,
inputFormat: InputFormatField[],
customToolName?: string,
customDescription?: string
): McpToolDefinition {
return {
name: customToolName || sanitizeToolName(workflowName),
description: customDescription || workflowDescription || `Execute ${workflowName} workflow`,
inputSchema: generateToolInputSchema(inputFormat),
}
}
/**
* Extract input format from a workflow's blocks.
* Looks for any valid start block and extracts its inputFormat configuration.
*/
export function extractInputFormatFromBlocks(
blocks: Record<string, unknown>
): InputFormatField[] | null {
// Look for any valid start block
for (const [, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockObj = block as Record<string, unknown>
const blockType = blockObj.type as string
if (isInputDefinitionTrigger(blockType)) {
// Try to get inputFormat from subBlocks.inputFormat.value
const subBlocks = blockObj.subBlocks as Record<string, { value?: unknown }> | undefined
const subBlockValue = subBlocks?.inputFormat?.value
// Try legacy config.params.inputFormat
const config = blockObj.config as Record<string, unknown> | undefined
const params = config?.params as Record<string, unknown> | undefined
const paramsValue = params?.inputFormat
const normalized = normalizeInputFormatValue(subBlockValue ?? paramsValue)
return normalized.length > 0 ? normalized : null
}
}
return null
}