Files
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

268 lines
8.5 KiB
TypeScript

import { db } from '@sim/db'
import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { mcpServerIdParamsSchema } from '@/lib/api/contracts/mcp'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpServerStatusConfig, McpTool, McpToolSchema } from '@/lib/mcp/types'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
MCP_TOOL_CORE_PARAMS,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServerRefreshAPI')
export const dynamic = 'force-dynamic'
/** Schema stored in workflow blocks includes description from the tool. */
type StoredToolSchema = McpToolSchema & { description?: string }
interface StoredTool {
type: string
title: string
toolId: string
params: {
serverId: string
serverUrl?: string
toolName: string
serverName?: string
}
schema?: StoredToolSchema
[key: string]: unknown
}
interface SyncResult {
updatedCount: number
updatedWorkflowIds: string[]
}
interface ServerMetadata {
url?: string
name?: string
}
/**
* Syncs tool schemas and server metadata from discovered MCP tools to all
* workflow blocks using those tools. Updates stored serverUrl/serverName
* when the server's details have changed, preventing stale badges after
* a server URL edit.
*/
async function syncToolSchemasToWorkflows(
workspaceId: string,
serverId: string,
tools: McpTool[],
requestId: string,
serverMeta?: ServerMetadata
): Promise<SyncResult> {
const toolsByName = new Map(tools.map((t) => [t.name, t]))
const workspaceWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const workflowIds = workspaceWorkflows.map((w) => w.id)
if (workflowIds.length === 0) return { updatedCount: 0, updatedWorkflowIds: [] }
const agentBlocks = await db
.select({
id: workflowBlocks.id,
workflowId: workflowBlocks.workflowId,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.type, 'agent'), inArray(workflowBlocks.workflowId, workflowIds)))
const updatedWorkflowIds = new Set<string>()
for (const block of agentBlocks) {
const subBlocks = block.subBlocks as Record<string, unknown> | null
if (!subBlocks) continue
const toolsSubBlock = subBlocks.tools as { value?: StoredTool[] } | undefined
if (!toolsSubBlock?.value || !Array.isArray(toolsSubBlock.value)) continue
let hasUpdates = false
const updatedTools = toolsSubBlock.value.map((tool) => {
if (tool.type !== 'mcp' || tool.params?.serverId !== serverId) {
return tool
}
const freshTool = toolsByName.get(tool.params.toolName)
if (!freshTool) return tool
const newSchema: StoredToolSchema = {
...freshTool.inputSchema,
description: freshTool.description,
}
const schemasMatch = JSON.stringify(tool.schema) === JSON.stringify(newSchema)
const urlChanged = serverMeta?.url != null && tool.params.serverUrl !== serverMeta.url
const nameChanged = serverMeta?.name != null && tool.params.serverName !== serverMeta.name
if (!schemasMatch || urlChanged || nameChanged) {
hasUpdates = true
const validParamKeys = new Set(Object.keys(newSchema.properties || {}))
const cleanedParams: Record<string, unknown> = {}
for (const [key, value] of Object.entries(tool.params || {})) {
if (MCP_TOOL_CORE_PARAMS.has(key) || validParamKeys.has(key)) {
cleanedParams[key] = value
}
}
if (urlChanged) cleanedParams.serverUrl = serverMeta.url
if (nameChanged) cleanedParams.serverName = serverMeta.name
return { ...tool, schema: newSchema, params: cleanedParams }
}
return tool
})
if (hasUpdates) {
const updatedSubBlocks = {
...subBlocks,
tools: { ...toolsSubBlock, value: updatedTools },
}
await db
.update(workflowBlocks)
.set({ subBlocks: updatedSubBlocks, updatedAt: new Date() })
.where(eq(workflowBlocks.id, block.id))
updatedWorkflowIds.add(block.workflowId)
}
}
if (updatedWorkflowIds.size > 0) {
logger.info(
`[${requestId}] Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}`
)
}
return {
updatedCount: updatedWorkflowIds.size,
updatedWorkflowIds: Array.from(updatedWorkflowIds),
}
}
export const POST = withRouteHandler(
withMcpAuth<{ id: string }>('read')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
try {
const paramsValidation = mcpServerIdParamsSchema.safeParse(await params)
if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error)
const { id: serverId } = paramsValidation.data
logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`)
const [server] = await db
.select()
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
'Server not found',
404
)
}
let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error'
let toolCount = 0
let lastError: string | null = null
let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] }
let discoveredTools: McpTool[] = []
const currentStatusConfig: McpServerStatusConfig =
(server.statusConfig as McpServerStatusConfig | null) ?? {
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
}
try {
discoveredTools = await mcpService.discoverServerTools(
userId,
serverId,
workspaceId,
true
)
connectionStatus = 'connected'
toolCount = discoveredTools.length
logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`)
syncResult = await syncToolSchemasToWorkflows(
workspaceId,
serverId,
discoveredTools,
requestId,
{ url: server.url ?? undefined, name: server.name ?? undefined }
)
} catch (error) {
connectionStatus = 'error'
lastError =
error instanceof Error
? error.message.split('\n')[0].slice(0, 200)
: 'Connection failed'
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
}
const now = new Date()
const newStatusConfig =
connectionStatus === 'connected'
? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() }
: {
consecutiveFailures: currentStatusConfig.consecutiveFailures + 1,
lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery,
}
const [refreshedServer] = await db
.update(mcpServers)
.set({
lastToolsRefresh: now,
connectionStatus,
lastError,
lastConnected: connectionStatus === 'connected' ? now : server.lastConnected,
toolCount,
statusConfig: newStatusConfig,
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
.returning()
if (connectionStatus === 'connected') {
await mcpService.clearCache(workspaceId)
}
return createMcpSuccessResponse({
status: connectionStatus,
toolCount,
lastConnected: refreshedServer?.lastConnected?.toISOString() || null,
error: lastError,
workflowsUpdated: syncResult.updatedCount,
updatedWorkflowIds: syncResult.updatedWorkflowIds,
})
} catch (error) {
logger.error(`[${requestId}] Error refreshing MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500)
}
}
)
)