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
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:
@@ -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' } },
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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' },
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user