Files
simstudioai--sim/apps/sim/lib/mcp/workflow-mcp-sync.ts
T
wehub-resource-sync d25d482dc2
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

393 lines
13 KiB
TypeScript

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)
}
})()
}