diff --git a/apps/sim/app/api/mcp/discover/route.ts b/apps/sim/app/api/mcp/discover/route.ts new file mode 100644 index 0000000000..600e9362f6 --- /dev/null +++ b/apps/sim/app/api/mcp/discover/route.ts @@ -0,0 +1,88 @@ +import { db } from '@sim/db' +import { permissions, workflowMcpServer, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('McpDiscoverAPI') + +export const dynamic = 'force-dynamic' + +/** + * Discover all MCP servers available to the authenticated user. + */ +export async function GET(request: NextRequest) { + try { + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!auth.success || !auth.userId) { + return NextResponse.json( + { success: false, error: 'Authentication required. Provide X-API-Key header.' }, + { status: 401 } + ) + } + + const userId = auth.userId + + const userWorkspacePermissions = await db + .select({ entityId: permissions.entityId }) + .from(permissions) + .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) + + const workspaceIds = userWorkspacePermissions.map((w) => w.entityId) + + if (workspaceIds.length === 0) { + return NextResponse.json({ success: true, servers: [] }) + } + + const servers = await db + .select({ + id: workflowMcpServer.id, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + workspaceId: workflowMcpServer.workspaceId, + workspaceName: workspace.name, + createdAt: workflowMcpServer.createdAt, + toolCount: sql`( + SELECT COUNT(*)::int + FROM "workflow_mcp_tool" + WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id" + )`.as('tool_count'), + }) + .from(workflowMcpServer) + .leftJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id)) + .where(sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`) + .orderBy(workflowMcpServer.name) + + const baseUrl = getBaseUrl() + + const formattedServers = servers.map((server) => ({ + id: server.id, + name: server.name, + description: server.description, + workspace: { id: server.workspaceId, name: server.workspaceName }, + toolCount: server.toolCount || 0, + createdAt: server.createdAt, + url: `${baseUrl}/api/mcp/serve/${server.id}`, + })) + + logger.info(`User ${userId} discovered ${formattedServers.length} MCP servers`) + + return NextResponse.json({ + success: true, + servers: formattedServers, + authentication: { + method: 'API Key', + header: 'X-API-Key', + }, + }) + } catch (error) { + logger.error('Error discovering MCP servers:', error) + return NextResponse.json( + { success: false, error: 'Failed to discover MCP servers' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts new file mode 100644 index 0000000000..cc9ec0272f --- /dev/null +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -0,0 +1,306 @@ +/** + * MCP Serve Endpoint - Implements MCP protocol for workflow servers using SDK types. + */ + +import { + type CallToolResult, + ErrorCode, + type InitializeResult, + isJSONRPCNotification, + isJSONRPCRequest, + type JSONRPCError, + type JSONRPCMessage, + type JSONRPCResponse, + type ListToolsResult, + type RequestId, +} from '@modelcontextprotocol/sdk/types.js' +import { db } from '@sim/db' +import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('WorkflowMcpServeAPI') + +export const dynamic = 'force-dynamic' + +interface RouteParams { + serverId: string +} + +function createResponse(id: RequestId, result: unknown): JSONRPCResponse { + return { + jsonrpc: '2.0', + id, + result: result as JSONRPCResponse['result'], + } +} + +function createError(id: RequestId, code: ErrorCode | number, message: string): JSONRPCError { + return { + jsonrpc: '2.0', + id, + error: { code, message }, + } +} + +async function getServer(serverId: string) { + const [server] = await db + .select({ + id: workflowMcpServer.id, + name: workflowMcpServer.name, + workspaceId: workflowMcpServer.workspaceId, + }) + .from(workflowMcpServer) + .where(eq(workflowMcpServer.id, serverId)) + .limit(1) + + return server +} + +export async function GET(request: NextRequest, { params }: { params: Promise }) { + const { serverId } = await params + + try { + const server = await getServer(serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + return NextResponse.json({ + name: server.name, + version: '1.0.0', + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + }) + } catch (error) { + logger.error('Error getting MCP server info:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest, { params }: { params: Promise }) { + const { serverId } = await params + + try { + const server = await getServer(serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const message = body as JSONRPCMessage + + if (isJSONRPCNotification(message)) { + logger.info(`Received notification: ${message.method}`) + return new NextResponse(null, { status: 202 }) + } + + if (!isJSONRPCRequest(message)) { + return NextResponse.json( + createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'), + { + status: 400, + } + ) + } + + const { id, method, params: rpcParams } = message + const apiKey = + request.headers.get('X-API-Key') || + request.headers.get('Authorization')?.replace('Bearer ', '') + + switch (method) { + case 'initialize': { + const result: InitializeResult = { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: server.name, version: '1.0.0' }, + } + return NextResponse.json(createResponse(id, result)) + } + + case 'ping': + return NextResponse.json(createResponse(id, {})) + + case 'tools/list': + return handleToolsList(id, serverId) + + case 'tools/call': + return handleToolsCall( + id, + serverId, + rpcParams as { name: string; arguments?: Record }, + apiKey + ) + + default: + return NextResponse.json( + createError(id, ErrorCode.MethodNotFound, `Method not found: ${method}`), + { + status: 404, + } + ) + } + } catch (error) { + logger.error('Error handling MCP request:', error) + return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), { + status: 500, + }) + } +} + +async function handleToolsList(id: RequestId, serverId: string): Promise { + try { + const tools = await db + .select({ + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchema: workflowMcpTool.parameterSchema, + }) + .from(workflowMcpTool) + .where(eq(workflowMcpTool.serverId, serverId)) + + const result: ListToolsResult = { + tools: tools.map((tool) => { + const schema = tool.parameterSchema as { + type?: string + properties?: Record + required?: string[] + } | null + return { + name: tool.toolName, + description: tool.toolDescription || `Execute workflow: ${tool.toolName}`, + inputSchema: { + type: 'object' as const, + properties: schema?.properties || {}, + ...(schema?.required && schema.required.length > 0 && { required: schema.required }), + }, + } + }), + } + + return NextResponse.json(createResponse(id, result)) + } catch (error) { + logger.error('Error listing tools:', error) + return NextResponse.json(createError(id, ErrorCode.InternalError, 'Failed to list tools'), { + status: 500, + }) + } +} + +async function handleToolsCall( + id: RequestId, + serverId: string, + params: { name: string; arguments?: Record } | undefined, + apiKey?: string | null +): Promise { + try { + if (!params?.name) { + return NextResponse.json(createError(id, ErrorCode.InvalidParams, 'Tool name required'), { + status: 400, + }) + } + + const [tool] = await db + .select({ + toolName: workflowMcpTool.toolName, + workflowId: workflowMcpTool.workflowId, + }) + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.serverId, serverId), eq(workflowMcpTool.toolName, params.name))) + .limit(1) + if (!tool) { + return NextResponse.json( + createError(id, ErrorCode.InvalidParams, `Tool not found: ${params.name}`), + { + status: 404, + } + ) + } + + const [wf] = await db + .select({ isDeployed: workflow.isDeployed }) + .from(workflow) + .where(eq(workflow.id, tool.workflowId)) + .limit(1) + + if (!wf?.isDeployed) { + return NextResponse.json( + createError(id, ErrorCode.InternalError, 'Workflow is not deployed'), + { + status: 400, + } + ) + } + + const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute` + const headers: Record = { 'Content-Type': 'application/json' } + if (apiKey) headers['X-API-Key'] = apiKey + + logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) + + const response = await fetch(executeUrl, { + method: 'POST', + headers, + body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }), + signal: AbortSignal.timeout(300000), // 5 minute timeout + }) + + const executeResult = await response.json() + + if (!response.ok) { + return NextResponse.json( + createError( + id, + ErrorCode.InternalError, + executeResult.error || 'Workflow execution failed' + ), + { status: 500 } + ) + } + + const result: CallToolResult = { + content: [ + { type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) }, + ], + isError: !executeResult.success, + } + + return NextResponse.json(createResponse(id, result)) + } catch (error) { + logger.error('Error calling tool:', error) + return NextResponse.json(createError(id, ErrorCode.InternalError, 'Tool execution failed'), { + status: 500, + }) + } +} + +export async function DELETE(request: NextRequest, { params }: { params: Promise }) { + const { serverId } = await params + + try { + const server = await getServer(serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + logger.info(`MCP session terminated for server ${serverId}`) + return new NextResponse(null, { status: 204 }) + } catch (error) { + logger.error('Error handling MCP DELETE request:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 2e3474e68d..3524c03c38 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -12,20 +12,12 @@ const logger = createLogger('McpServerRefreshAPI') export const dynamic = 'force-dynamic' -/** - * POST - Refresh an MCP server connection (requires any workspace permission) - */ export const POST = withMcpAuth<{ id: string }>('read')( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { const { id: serverId } = await params try { - logger.info( - `[${requestId}] Refreshing MCP server: ${serverId} in workspace: ${workspaceId}`, - { - userId, - } - ) + logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) const [server] = await db .select() @@ -61,9 +53,7 @@ export const POST = withMcpAuth<{ id: string }>('read')( const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) connectionStatus = 'connected' toolCount = tools.length - logger.info( - `[${requestId}] Successfully connected to server ${serverId}, discovered ${toolCount} tools` - ) + logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`) } catch (error) { connectionStatus = 'error' lastError = error instanceof Error ? error.message : 'Connection test failed' @@ -94,14 +84,7 @@ export const POST = withMcpAuth<{ id: string }>('read')( .returning() if (connectionStatus === 'connected') { - logger.info( - `[${requestId}] Successfully refreshed MCP server: ${serverId} (${toolCount} tools)` - ) await mcpService.clearCache(workspaceId) - } else { - logger.warn( - `[${requestId}] Refresh completed for MCP server ${serverId} but connection failed: ${lastError}` - ) } return createMcpSuccessResponse({ diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index fc986ccc9f..e7b2d9f1d3 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -5,7 +5,6 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('McpServerAPI') @@ -27,24 +26,6 @@ export const PATCH = withMcpAuth<{ id: string }>('write')( updates: Object.keys(body).filter((k) => k !== 'workspaceId'), }) - // Validate URL if being updated - if ( - body.url && - (body.transport === 'http' || - body.transport === 'sse' || - body.transport === 'streamable-http') - ) { - const urlValidation = validateMcpServerUrl(body.url) - if (!urlValidation.isValid) { - return createMcpErrorResponse( - new Error(`Invalid MCP server URL: ${urlValidation.error}`), - 'Invalid server URL', - 400 - ) - } - body.url = urlValidation.normalizedUrl - } - // Remove workspaceId from body to prevent it from being updated const { workspaceId: _, ...updateData } = body diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index d8ca7c93ff..4ba367d133 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -5,8 +5,6 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' -import type { McpTransport } from '@/lib/mcp/types' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse, @@ -17,13 +15,6 @@ const logger = createLogger('McpServersAPI') export const dynamic = 'force-dynamic' -/** - * Check if transport type requires a URL - */ -function isUrlBasedTransport(transport: McpTransport): boolean { - return transport === 'streamable-http' -} - /** * GET - List all registered MCP servers for the workspace */ @@ -81,18 +72,6 @@ export const POST = withMcpAuth('write')( ) } - if (isUrlBasedTransport(body.transport) && body.url) { - const urlValidation = validateMcpServerUrl(body.url) - if (!urlValidation.isValid) { - return createMcpErrorResponse( - new Error(`Invalid MCP server URL: ${urlValidation.error}`), - 'Invalid server URL', - 400 - ) - } - body.url = urlValidation.normalizedUrl - } - const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID() const [existingServer] = await db diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index cc52ec88e4..3332397535 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -4,7 +4,6 @@ import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { McpClient } from '@/lib/mcp/client' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import type { McpServerConfig, McpTransport } from '@/lib/mcp/types' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { REFERENCE } from '@/executor/constants' import { createEnvVarPattern } from '@/executor/utils/reference-validation' @@ -89,24 +88,12 @@ export const POST = withMcpAuth('write')( ) } - if (isUrlBasedTransport(body.transport)) { - if (!body.url) { - return createMcpErrorResponse( - new Error('URL is required for HTTP-based transports'), - 'Missing required URL', - 400 - ) - } - - const urlValidation = validateMcpServerUrl(body.url) - if (!urlValidation.isValid) { - return createMcpErrorResponse( - new Error(`Invalid MCP server URL: ${urlValidation.error}`), - 'Invalid server URL', - 400 - ) - } - body.url = urlValidation.normalizedUrl + if (isUrlBasedTransport(body.transport) && !body.url) { + return createMcpErrorResponse( + new Error('URL is required for HTTP-based transports'), + 'Missing required URL', + 400 + ) } let resolvedUrl = body.url diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index de88cbb28b..b62470274a 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -9,9 +9,6 @@ const logger = createLogger('McpToolDiscoveryAPI') export const dynamic = 'force-dynamic' -/** - * GET - Discover all tools from user's MCP servers - */ export const GET = withMcpAuth('read')( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { @@ -19,18 +16,11 @@ export const GET = withMcpAuth('read')( const serverId = searchParams.get('serverId') const forceRefresh = searchParams.get('refresh') === 'true' - logger.info(`[${requestId}] Discovering MCP tools for user ${userId}`, { - serverId, - workspaceId, - forceRefresh, - }) + logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh }) - let tools - if (serverId) { - tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) - } else { - tools = await mcpService.discoverTools(userId, workspaceId, forceRefresh) - } + const tools = serverId + ? await mcpService.discoverServerTools(userId, serverId, workspaceId) + : await mcpService.discoverTools(userId, workspaceId, forceRefresh) const byServer: Record = {} for (const tool of tools) { @@ -55,9 +45,6 @@ export const GET = withMcpAuth('read')( } ) -/** - * POST - Refresh tool discovery for specific servers - */ export const POST = withMcpAuth('read')( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { @@ -72,10 +59,7 @@ export const POST = withMcpAuth('read')( ) } - logger.info( - `[${requestId}] Refreshing tool discovery for user ${userId}, servers:`, - serverIds - ) + logger.info(`[${requestId}] Refreshing tools for ${serverIds.length} servers`) const results = await Promise.allSettled( serverIds.map(async (serverId: string) => { @@ -99,7 +83,8 @@ export const POST = withMcpAuth('read')( } }) - const responseData = { + logger.info(`[${requestId}] Refresh completed: ${successes.length}/${serverIds.length}`) + return createMcpSuccessResponse({ refreshed: successes, failed: failures, summary: { @@ -107,12 +92,7 @@ export const POST = withMcpAuth('read')( successful: successes.length, failed: failures.length, }, - } - - logger.info( - `[${requestId}] Tool discovery refresh completed: ${successes.length}/${serverIds.length} successful` - ) - return createMcpSuccessResponse(responseData) + }) } catch (error) { logger.error(`[${requestId}] Error refreshing tool discovery:`, error) const { message, status } = categorizeError(error) diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts index 09519aa677..b4f434eb27 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { withMcpAuth } from '@/lib/mcp/middleware' +import type { McpToolSchema } from '@/lib/mcp/types' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('McpStoredToolsAPI') @@ -16,27 +17,16 @@ interface StoredMcpTool { serverId: string serverUrl?: string toolName: string - schema?: Record + schema?: McpToolSchema } -/** - * GET - Get all stored MCP tools from workflows in the workspace - * - * Scans all workflows in the workspace and extracts MCP tools that have been - * added to agent blocks. Returns the stored state of each tool for comparison - * against current server state. - */ export const GET = withMcpAuth('read')( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`) - // Get all workflows in workspace const workflows = await db - .select({ - id: workflow.id, - name: workflow.name, - }) + .select({ id: workflow.id, name: workflow.name }) .from(workflow) .where(eq(workflow.workspaceId, workspaceId)) @@ -47,12 +37,8 @@ export const GET = withMcpAuth('read')( return createMcpSuccessResponse({ tools: [] }) } - // Get all agent blocks from these workflows const agentBlocks = await db - .select({ - workflowId: workflowBlocks.workflowId, - subBlocks: workflowBlocks.subBlocks, - }) + .select({ workflowId: workflowBlocks.workflowId, subBlocks: workflowBlocks.subBlocks }) .from(workflowBlocks) .where(eq(workflowBlocks.type, 'agent')) @@ -81,7 +67,7 @@ export const GET = withMcpAuth('read')( serverId: params.serverId as string, serverUrl: params.serverUrl as string | undefined, toolName: params.toolName as string, - schema: tool.schema as Record | undefined, + schema: tool.schema as McpToolSchema | undefined, }) } } diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts new file mode 100644 index 0000000000..62266b817a --- /dev/null +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -0,0 +1,155 @@ +import { db } from '@sim/db' +import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('WorkflowMcpServerAPI') + +export const dynamic = 'force-dynamic' + +interface RouteParams { + id: string +} + +/** + * GET - Get a specific workflow MCP server with its tools + */ +export const GET = withMcpAuth('read')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = await params + + logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) + + const [server] = await db + .select({ + id: workflowMcpServer.id, + workspaceId: workflowMcpServer.workspaceId, + createdBy: workflowMcpServer.createdBy, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + createdAt: workflowMcpServer.createdAt, + updatedAt: workflowMcpServer.updatedAt, + }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const tools = await db + .select() + .from(workflowMcpTool) + .where(eq(workflowMcpTool.serverId, serverId)) + + logger.info( + `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` + ) + + return createMcpSuccessResponse({ server, tools }) + } catch (error) { + logger.error(`[${requestId}] Error getting workflow MCP server:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to get workflow MCP server'), + 'Failed to get workflow MCP server', + 500 + ) + } + } +) + +/** + * PATCH - Update a workflow MCP server + */ +export const PATCH = withMcpAuth('write')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = await params + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) + + const [existingServer] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .limit(1) + + if (!existingServer) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const updateData: Record = { + updatedAt: new Date(), + } + + if (body.name !== undefined) { + updateData.name = body.name.trim() + } + if (body.description !== undefined) { + updateData.description = body.description?.trim() || null + } + + const [updatedServer] = await db + .update(workflowMcpServer) + .set(updateData) + .where(eq(workflowMcpServer.id, serverId)) + .returning() + + logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) + + return createMcpSuccessResponse({ server: updatedServer }) + } catch (error) { + logger.error(`[${requestId}] Error updating workflow MCP server:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to update workflow MCP server'), + 'Failed to update workflow MCP server', + 500 + ) + } + } +) + +/** + * DELETE - Delete a workflow MCP server and all its tools + */ +export const DELETE = withMcpAuth('admin')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = await params + + logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`) + + const [deletedServer] = await db + .delete(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .returning() + + if (!deletedServer) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) + + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + } catch (error) { + logger.error(`[${requestId}] Error deleting workflow MCP server:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to delete workflow MCP server'), + 'Failed to delete workflow MCP server', + 500 + ) + } + } +) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts new file mode 100644 index 0000000000..4398bd4e53 --- /dev/null +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -0,0 +1,176 @@ +import { db } from '@sim/db' +import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' + +const logger = createLogger('WorkflowMcpToolAPI') + +export const dynamic = 'force-dynamic' + +interface RouteParams { + id: string + toolId: string +} + +/** + * GET - Get a specific tool + */ +export const GET = withMcpAuth('read')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId, toolId } = await params + + logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) + + // Verify server exists and belongs to workspace + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const [tool] = await db + .select() + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) + .limit(1) + + if (!tool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) + } + + return createMcpSuccessResponse({ tool }) + } catch (error) { + logger.error(`[${requestId}] Error getting tool:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to get tool'), + 'Failed to get tool', + 500 + ) + } + } +) + +/** + * PATCH - Update a tool's configuration + */ +export const PATCH = withMcpAuth('write')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId, toolId } = await params + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) + + // Verify server exists and belongs to workspace + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const [existingTool] = await db + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) + .limit(1) + + if (!existingTool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) + } + + const updateData: Record = { + updatedAt: new Date(), + } + + if (body.toolName !== undefined) { + updateData.toolName = sanitizeToolName(body.toolName) + } + if (body.toolDescription !== undefined) { + updateData.toolDescription = body.toolDescription?.trim() || null + } + if (body.parameterSchema !== undefined) { + updateData.parameterSchema = body.parameterSchema + } + + const [updatedTool] = await db + .update(workflowMcpTool) + .set(updateData) + .where(eq(workflowMcpTool.id, toolId)) + .returning() + + logger.info(`[${requestId}] Successfully updated tool ${toolId}`) + + return createMcpSuccessResponse({ tool: updatedTool }) + } catch (error) { + logger.error(`[${requestId}] Error updating tool:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to update tool'), + 'Failed to update tool', + 500 + ) + } + } +) + +/** + * DELETE - Remove a tool from an MCP server + */ +export const DELETE = withMcpAuth('write')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId, toolId } = await params + + logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) + + // Verify server exists and belongs to workspace + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const [deletedTool] = await db + .delete(workflowMcpTool) + .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) + .returning() + + if (!deletedTool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) + } + + logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) + + return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) + } catch (error) { + logger.error(`[${requestId}] Error deleting tool:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to delete tool'), + 'Failed to delete tool', + 500 + ) + } + } +) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts new file mode 100644 index 0000000000..5c39098b0f --- /dev/null +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -0,0 +1,223 @@ +import { db } from '@sim/db' +import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' + +const logger = createLogger('WorkflowMcpToolsAPI') + +/** + * Check if a workflow has a valid start block by loading from database + */ +async function hasValidStartBlock(workflowId: string): Promise { + try { + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + return hasValidStartBlockInState(normalizedData) + } catch (error) { + logger.warn('Error checking for start block:', error) + return false + } +} + +export const dynamic = 'force-dynamic' + +interface RouteParams { + id: string +} + +/** + * GET - List all tools for a workflow MCP server + */ +export const GET = withMcpAuth('read')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = await params + + logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) + + // Verify server exists and belongs to workspace + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + // Get tools with workflow details + const tools = await db + .select({ + id: workflowMcpTool.id, + serverId: workflowMcpTool.serverId, + workflowId: workflowMcpTool.workflowId, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchema: workflowMcpTool.parameterSchema, + createdAt: workflowMcpTool.createdAt, + updatedAt: workflowMcpTool.updatedAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + isDeployed: workflow.isDeployed, + }) + .from(workflowMcpTool) + .leftJoin(workflow, eq(workflowMcpTool.workflowId, workflow.id)) + .where(eq(workflowMcpTool.serverId, serverId)) + + logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) + + return createMcpSuccessResponse({ tools }) + } catch (error) { + logger.error(`[${requestId}] Error listing tools:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to list tools'), + 'Failed to list tools', + 500 + ) + } + } +) + +/** + * POST - Add a workflow as a tool to an MCP server + */ +export const POST = withMcpAuth('write')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = await params + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, { + workflowId: body.workflowId, + }) + + if (!body.workflowId) { + return createMcpErrorResponse( + new Error('Missing required field: workflowId'), + 'Missing required field', + 400 + ) + } + + // Verify server exists and belongs to workspace + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + // Verify workflow exists and is deployed + const [workflowRecord] = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + isDeployed: workflow.isDeployed, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, body.workflowId)) + .limit(1) + + if (!workflowRecord) { + return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404) + } + + // Verify workflow belongs to the same workspace + if (workflowRecord.workspaceId !== workspaceId) { + return createMcpErrorResponse( + new Error('Workflow does not belong to this workspace'), + 'Access denied', + 403 + ) + } + + if (!workflowRecord.isDeployed) { + return createMcpErrorResponse( + new Error('Workflow must be deployed before adding as a tool'), + 'Workflow not deployed', + 400 + ) + } + + // Verify workflow has a valid start block + const hasStartBlock = await hasValidStartBlock(body.workflowId) + if (!hasStartBlock) { + return createMcpErrorResponse( + new Error('Workflow must have a Start block to be used as an MCP tool'), + 'No start block found', + 400 + ) + } + + // Check if tool already exists for this workflow + const [existingTool] = await db + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, serverId), + eq(workflowMcpTool.workflowId, body.workflowId) + ) + ) + .limit(1) + + if (existingTool) { + return createMcpErrorResponse( + new Error('This workflow is already added as a tool to this server'), + 'Tool already exists', + 409 + ) + } + + const toolName = sanitizeToolName(body.toolName?.trim() || workflowRecord.name) + const toolDescription = + body.toolDescription?.trim() || + workflowRecord.description || + `Execute ${workflowRecord.name} workflow` + + // Create the tool + const toolId = crypto.randomUUID() + const [tool] = await db + .insert(workflowMcpTool) + .values({ + id: toolId, + serverId, + workflowId: body.workflowId, + toolName, + toolDescription, + parameterSchema: body.parameterSchema || {}, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + logger.info( + `[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}` + ) + + return createMcpSuccessResponse({ tool }, 201) + } catch (error) { + logger.error(`[${requestId}] Error adding tool:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to add tool'), + 'Failed to add tool', + 500 + ) + } + } +) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts new file mode 100644 index 0000000000..25258e0b21 --- /dev/null +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -0,0 +1,132 @@ +import { db } from '@sim/db' +import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq, inArray, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' + +const logger = createLogger('WorkflowMcpServersAPI') + +export const dynamic = 'force-dynamic' + +/** + * GET - List all workflow MCP servers for the workspace + */ +export const GET = withMcpAuth('read')( + async (request: NextRequest, { userId, workspaceId, requestId }) => { + try { + logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`) + + const servers = await db + .select({ + id: workflowMcpServer.id, + workspaceId: workflowMcpServer.workspaceId, + createdBy: workflowMcpServer.createdBy, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + createdAt: workflowMcpServer.createdAt, + updatedAt: workflowMcpServer.updatedAt, + toolCount: sql`( + SELECT COUNT(*)::int + FROM "workflow_mcp_tool" + WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id" + )`.as('tool_count'), + }) + .from(workflowMcpServer) + .where(eq(workflowMcpServer.workspaceId, workspaceId)) + + // Fetch all tools for these servers + const serverIds = servers.map((s) => s.id) + const tools = + serverIds.length > 0 + ? await db + .select({ + serverId: workflowMcpTool.serverId, + toolName: workflowMcpTool.toolName, + }) + .from(workflowMcpTool) + .where(inArray(workflowMcpTool.serverId, serverIds)) + : [] + + // Group tool names by server + const toolNamesByServer: Record = {} + for (const tool of tools) { + if (!toolNamesByServer[tool.serverId]) { + toolNamesByServer[tool.serverId] = [] + } + toolNamesByServer[tool.serverId].push(tool.toolName) + } + + // Attach tool names to servers + const serversWithToolNames = servers.map((server) => ({ + ...server, + toolNames: toolNamesByServer[server.id] || [], + })) + + logger.info( + `[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}` + ) + return createMcpSuccessResponse({ servers: serversWithToolNames }) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow MCP servers:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to list workflow MCP servers'), + 'Failed to list workflow MCP servers', + 500 + ) + } + } +) + +/** + * POST - Create a new workflow MCP server + */ +export const POST = withMcpAuth('write')( + async (request: NextRequest, { userId, workspaceId, requestId }) => { + try { + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Creating workflow MCP server:`, { + name: body.name, + workspaceId, + }) + + if (!body.name) { + return createMcpErrorResponse( + new Error('Missing required field: name'), + 'Missing required field', + 400 + ) + } + + const serverId = crypto.randomUUID() + + const [server] = await db + .insert(workflowMcpServer) + .values({ + id: serverId, + workspaceId, + createdBy: userId, + name: body.name.trim(), + description: body.description?.trim() || null, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + logger.info( + `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` + ) + + return createMcpSuccessResponse({ server }, 201) + } catch (error) { + logger.error(`[${requestId}] Error creating workflow MCP server:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to create workflow MCP server'), + 'Failed to create workflow MCP server', + 500 + ) + } + } +) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index c54124f47d..acf8f21205 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,22 +1,126 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' +import { + extractInputFormatFromBlocks, + generateToolInputSchema, +} from '@/lib/mcp/workflow-tool-schema' import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { createSchedulesForDeploy, deleteSchedulesForWorkflow, validateWorkflowSchedules, } from '@/lib/workflows/schedules' +import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeployAPI') +/** + * Check if a workflow has a valid start block by loading from database + */ +async function hasValidStartBlock(workflowId: string): Promise { + try { + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + return hasValidStartBlockInState(normalizedData) + } catch (error) { + logger.warn('Error checking for start block:', error) + return false + } +} + export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +/** + * Extract input format from workflow blocks and generate MCP tool parameter schema + */ +async function generateMcpToolSchema(workflowId: string): Promise> { + try { + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalizedData?.blocks) { + return { type: 'object', properties: {} } + } + + const inputFormat = extractInputFormatFromBlocks(normalizedData.blocks) + if (!inputFormat || inputFormat.length === 0) { + return { type: 'object', properties: {} } + } + + return generateToolInputSchema(inputFormat) as unknown as Record + } catch (error) { + logger.warn('Error generating MCP tool schema:', error) + return { type: 'object', properties: {} } + } +} + +/** + * Update all MCP tools that reference this workflow with the latest parameter schema. + * If the workflow no longer has a start block, remove all MCP tools. + */ +async function syncMcpToolsOnDeploy(workflowId: string, requestId: string): Promise { + try { + // Get all MCP tools that use this workflow + const tools = await db + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + if (tools.length === 0) { + logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`) + return + } + + // Check if workflow still has a valid start block + const hasStart = await hasValidStartBlock(workflowId) + if (!hasStart) { + // No start block - remove all MCP tools for this workflow + await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId)) + + logger.info( + `[${requestId}] Removed ${tools.length} MCP tool(s) - workflow no longer has a start block: ${workflowId}` + ) + return + } + + // Generate the latest parameter schema + const parameterSchema = await generateMcpToolSchema(workflowId) + + // Update all tools with the new schema + await db + .update(workflowMcpTool) + .set({ + parameterSchema, + updatedAt: new Date(), + }) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + logger.info(`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow: ${workflowId}`) + } catch (error) { + logger.error(`[${requestId}] Error syncing MCP tools:`, error) + // Don't throw - this is a non-critical operation + } +} + +/** + * Remove all MCP tools that reference this workflow when undeploying + */ +async function removeMcpToolsOnUndeploy(workflowId: string, requestId: string): Promise { + try { + const result = await db + .delete(workflowMcpTool) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + logger.info(`[${requestId}] Removed MCP tools for undeployed workflow: ${workflowId}`) + } catch (error) { + logger.error(`[${requestId}] Error removing MCP tools:`, error) + // Don't throw - this is a non-critical operation + } +} + export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() const { id } = await params @@ -160,6 +264,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) + // Sync MCP tools with the latest parameter schema + await syncMcpToolsOnDeploy(id, requestId) + const responseApiKeyInfo = workflowData!.workspaceId ? 'Workspace API keys' : 'Personal API keys' @@ -217,6 +324,9 @@ export async function DELETE( .where(eq(workflow.id, id)) }) + // Remove all MCP tools that reference this workflow + await removeMcpToolsOnUndeploy(id, requestId) + logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`) try { diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts index 1ef4761e68..9449fc4be4 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts @@ -1,8 +1,13 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' +import { + extractInputFormatFromBlocks, + generateToolInputSchema, +} from '@/lib/mcp/workflow-tool-schema' +import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -11,6 +16,80 @@ const logger = createLogger('WorkflowActivateDeploymentAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +/** + * Extract input format from a deployment version state and generate MCP tool parameter schema + */ +function generateMcpToolSchemaFromState(state: any): Record { + try { + if (!state?.blocks) { + return { type: 'object', properties: {} } + } + + const inputFormat = extractInputFormatFromBlocks(state.blocks) + if (!inputFormat || inputFormat.length === 0) { + return { type: 'object', properties: {} } + } + + return generateToolInputSchema(inputFormat) as unknown as Record + } catch (error) { + logger.warn('Error generating MCP tool schema from state:', error) + return { type: 'object', properties: {} } + } +} + +/** + * Sync MCP tools when activating a deployment version. + * If the version has no start block, remove all MCP tools. + */ +async function syncMcpToolsOnVersionActivate( + workflowId: string, + versionState: any, + requestId: string +): Promise { + try { + // Get all MCP tools that use this workflow + const tools = await db + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + if (tools.length === 0) { + logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`) + return + } + + // Check if the activated version has a valid start block + if (!hasValidStartBlockInState(versionState)) { + // No start block - remove all MCP tools for this workflow + await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId)) + + logger.info( + `[${requestId}] Removed ${tools.length} MCP tool(s) - activated version has no start block: ${workflowId}` + ) + return + } + + // Generate the parameter schema from the activated version's state + const parameterSchema = generateMcpToolSchemaFromState(versionState) + + // Update all tools with the new schema + await db + .update(workflowMcpTool) + .set({ + parameterSchema, + updatedAt: new Date(), + }) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + logger.info( + `[${requestId}] Synced ${tools.length} MCP tool(s) for workflow version activation: ${workflowId}` + ) + } catch (error) { + logger.error(`[${requestId}] Error syncing MCP tools on version activate:`, error) + // Don't throw - this is a non-critical operation + } +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string; version: string }> } @@ -31,6 +110,18 @@ export async function POST( const now = new Date() + // Get the state of the version being activated for MCP tool sync + const [versionData] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.version, versionNum) + ) + ) + .limit(1) + await db.transaction(async (tx) => { await tx .update(workflowDeploymentVersion) @@ -65,6 +156,11 @@ export async function POST( await tx.update(workflow).set(updateData).where(eq(workflow.id, id)) }) + // Sync MCP tools with the activated version's parameter schema + if (versionData?.state) { + await syncMcpToolsOnVersionActivate(id, versionData.state, requestId) + } + return createSuccessResponse({ success: true, deployedAt: now }) } catch (error: any) { logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 5b33e6c146..b84ac4e1aa 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,10 +1,15 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' +import { + extractInputFormatFromBlocks, + generateToolInputSchema, +} from '@/lib/mcp/workflow-tool-schema' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -13,6 +18,80 @@ const logger = createLogger('RevertToDeploymentVersionAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +/** + * Extract input format from a deployment version state and generate MCP tool parameter schema + */ +function generateMcpToolSchemaFromState(state: any): Record { + try { + if (!state?.blocks) { + return { type: 'object', properties: {} } + } + + const inputFormat = extractInputFormatFromBlocks(state.blocks) + if (!inputFormat || inputFormat.length === 0) { + return { type: 'object', properties: {} } + } + + return generateToolInputSchema(inputFormat) as unknown as Record + } catch (error) { + logger.warn('Error generating MCP tool schema from state:', error) + return { type: 'object', properties: {} } + } +} + +/** + * Sync MCP tools when reverting to a deployment version. + * If the version has no start block, remove all MCP tools. + */ +async function syncMcpToolsOnRevert( + workflowId: string, + versionState: any, + requestId: string +): Promise { + try { + // Get all MCP tools that use this workflow + const tools = await db + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + if (tools.length === 0) { + logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`) + return + } + + // Check if the reverted version has a valid start block + if (!hasValidStartBlockInState(versionState)) { + // No start block - remove all MCP tools for this workflow + await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId)) + + logger.info( + `[${requestId}] Removed ${tools.length} MCP tool(s) - reverted version has no start block: ${workflowId}` + ) + return + } + + // Generate the parameter schema from the reverted version's state + const parameterSchema = generateMcpToolSchemaFromState(versionState) + + // Update all tools with the new schema + await db + .update(workflowMcpTool) + .set({ + parameterSchema, + updatedAt: new Date(), + }) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + logger.info( + `[${requestId}] Synced ${tools.length} MCP tool(s) for workflow revert: ${workflowId}` + ) + } catch (error) { + logger.error(`[${requestId}] Error syncing MCP tools on revert:`, error) + // Don't throw - this is a non-critical operation + } +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string; version: string }> } @@ -87,6 +166,9 @@ export async function POST( .set({ lastSynced: new Date(), updatedAt: new Date() }) .where(eq(workflow.id, id)) + // Sync MCP tools with the reverted version's parameter schema + await syncMcpToolsOnRevert(id, deployedState, requestId) + try { const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' await fetch(`${socketServerUrl}/api/workflow-reverted`, { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 5d1a7d7a02..7368394a6a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -109,7 +109,7 @@ type AsyncExecutionParams = { workflowId: string userId: string input: any - triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' + triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp' } /** @@ -252,14 +252,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: }) const executionId = uuidv4() - type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' + type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp' let loggingTriggerType: LoggingTriggerType = 'manual' if ( triggerType === 'api' || triggerType === 'chat' || triggerType === 'webhook' || triggerType === 'schedule' || - triggerType === 'manual' + triggerType === 'manual' || + triggerType === 'mcp' ) { loggingTriggerType = triggerType as LoggingTriggerType } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index a84c69ac44..19ed1684bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -22,7 +22,7 @@ import { useFilterStore } from '@/stores/logs/filters/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { AutocompleteSearch } from './components/search' -const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const +const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const const TIME_RANGE_OPTIONS: ComboboxOption[] = [ { value: 'All time', label: 'All time' }, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index e17af8b90a..44125a8ed6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -4,7 +4,7 @@ import { Badge } from '@/components/emcn' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' -const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const +const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const const RUNNING_COLOR = '#22c55e' as const const PENDING_COLOR = '#f59e0b' as const export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp-tool/mcp-tool.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp-tool/mcp-tool.tsx new file mode 100644 index 0000000000..95f469d9d2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp-tool/mcp-tool.tsx @@ -0,0 +1,561 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { Server } from 'lucide-react' +import { useParams } from 'next/navigation' +import { Badge, Combobox, type ComboboxOption, Input, Label, Textarea } from '@/components/emcn' +import { Skeleton } from '@/components/ui' +import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' +import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils' +import type { InputFormatField } from '@/lib/workflows/types' +import { + useAddWorkflowMcpTool, + useDeleteWorkflowMcpTool, + useUpdateWorkflowMcpTool, + useWorkflowMcpServers, + useWorkflowMcpTools, + type WorkflowMcpServer, + type WorkflowMcpTool, +} from '@/hooks/queries/workflow-mcp-servers' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const logger = createLogger('McpToolDeploy') + +/** InputFormatField with guaranteed name (after normalization) */ +type NormalizedField = InputFormatField & { name: string } + +interface McpToolDeployProps { + workflowId: string + workflowName: string + workflowDescription?: string | null + isDeployed: boolean + onAddedToServer?: () => void + onSubmittingChange?: (submitting: boolean) => void + onCanSaveChange?: (canSave: boolean) => void +} + +/** + * Generate JSON Schema from input format with optional descriptions + */ +function generateParameterSchema( + inputFormat: NormalizedField[], + descriptions: Record +): Record { + const fieldsWithDescriptions = inputFormat.map((field) => ({ + ...field, + description: descriptions[field.name]?.trim() || undefined, + })) + return generateToolInputSchema(fieldsWithDescriptions) as unknown as Record +} + +/** + * Component to query tools for a single server and report back via callback. + */ +function ServerToolsQuery({ + workspaceId, + server, + workflowId, + onData, +}: { + workspaceId: string + server: WorkflowMcpServer + workflowId: string + onData: (serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => void +}) { + const { data: tools, isLoading } = useWorkflowMcpTools(workspaceId, server.id) + + useEffect(() => { + const tool = tools?.find((t) => t.workflowId === workflowId) || null + onData(server.id, tool, isLoading) + }, [tools, isLoading, workflowId, server.id, onData]) + + return null +} + +export function McpToolDeploy({ + workflowId, + workflowName, + workflowDescription, + isDeployed, + onAddedToServer, + onSubmittingChange, + onCanSaveChange, +}: McpToolDeployProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + + const { + data: servers = [], + isLoading: isLoadingServers, + refetch: refetchServers, + } = useWorkflowMcpServers(workspaceId) + const addToolMutation = useAddWorkflowMcpTool() + const deleteToolMutation = useDeleteWorkflowMcpTool() + const updateToolMutation = useUpdateWorkflowMcpTool() + + const blocks = useWorkflowStore((state) => state.blocks) + + const starterBlockId = useMemo(() => { + for (const [blockId, block] of Object.entries(blocks)) { + if (!block || typeof block !== 'object') continue + const blockType = (block as { type?: string }).type + if (blockType && isValidStartBlockType(blockType)) { + return blockId + } + } + return null + }, [blocks]) + + const subBlockValues = useSubBlockStore((state) => + workflowId ? (state.workflowValues[workflowId] ?? {}) : {} + ) + + const inputFormat = useMemo((): NormalizedField[] => { + if (!starterBlockId) return [] + + // Try SubBlockStore first (runtime state) + const storeValue = subBlockValues[starterBlockId]?.inputFormat + const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[] + if (normalized.length > 0) return normalized + + // Fallback to block definition + const startBlock = blocks[starterBlockId] + const blockValue = startBlock?.subBlocks?.inputFormat?.value + return normalizeInputFormatValue(blockValue) as NormalizedField[] + }, [starterBlockId, subBlockValues, blocks]) + + const [toolName, setToolName] = useState(() => sanitizeToolName(workflowName)) + const [toolDescription, setToolDescription] = useState( + () => workflowDescription || `Execute ${workflowName} workflow` + ) + const [parameterDescriptions, setParameterDescriptions] = useState>({}) + const [pendingServerChanges, setPendingServerChanges] = useState>(new Set()) + + const parameterSchema = useMemo( + () => generateParameterSchema(inputFormat, parameterDescriptions), + [inputFormat, parameterDescriptions] + ) + + const [serverToolsMap, setServerToolsMap] = useState< + Record + >({}) + + const handleServerToolData = useCallback( + (serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => { + setServerToolsMap((prev) => { + const existing = prev[serverId] + if (existing?.tool?.id === tool?.id && existing?.isLoading === isLoading) { + return prev + } + return { + ...prev, + [serverId]: { tool, isLoading }, + } + }) + }, + [] + ) + + const selectedServerIds = useMemo(() => { + const ids: string[] = [] + for (const server of servers) { + const toolInfo = serverToolsMap[server.id] + if (toolInfo?.tool) { + ids.push(server.id) + } + } + return ids + }, [servers, serverToolsMap]) + + const hasLoadedInitialData = useRef(false) + + useEffect(() => { + for (const server of servers) { + const toolInfo = serverToolsMap[server.id] + if (toolInfo?.tool) { + setToolName(toolInfo.tool.toolName) + setToolDescription(toolInfo.tool.toolDescription || '') + + const schema = toolInfo.tool.parameterSchema as Record | undefined + const properties = schema?.properties as + | Record + | undefined + if (properties) { + const descriptions: Record = {} + for (const [name, prop] of Object.entries(properties)) { + if ( + prop.description && + prop.description !== name && + prop.description !== 'Array of file objects' + ) { + descriptions[name] = prop.description + } + } + if (Object.keys(descriptions).length > 0) { + setParameterDescriptions(descriptions) + } + } + hasLoadedInitialData.current = true + break + } + } + }, [servers, serverToolsMap]) + + // Track saved values to detect changes (use state so updates trigger re-render) + const [savedValues, setSavedValues] = useState<{ + toolName: string + toolDescription: string + parameterDescriptions: Record + } | null>(null) + + // Store saved values once loaded + useEffect(() => { + if (hasLoadedInitialData.current && !savedValues) { + setSavedValues({ + toolName, + toolDescription, + parameterDescriptions: { ...parameterDescriptions }, + }) + } + }, [toolName, toolDescription, parameterDescriptions, savedValues]) + + // Determine if there are unsaved changes + const hasDeployedTools = selectedServerIds.length > 0 + const hasChanges = useMemo(() => { + if (!savedValues || !hasDeployedTools) return false + if (toolName !== savedValues.toolName) return true + if (toolDescription !== savedValues.toolDescription) return true + if ( + JSON.stringify(parameterDescriptions) !== JSON.stringify(savedValues.parameterDescriptions) + ) { + return true + } + return false + }, [toolName, toolDescription, parameterDescriptions, hasDeployedTools, savedValues]) + + // Notify parent about save availability + useEffect(() => { + onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim()) + }, [hasChanges, hasDeployedTools, toolName, onCanSaveChange]) + + /** + * Save tool configuration to all deployed servers + */ + const handleSave = useCallback(async () => { + if (!toolName.trim()) return + + const toolsToUpdate: Array<{ serverId: string; toolId: string }> = [] + for (const server of servers) { + const toolInfo = serverToolsMap[server.id] + if (toolInfo?.tool) { + toolsToUpdate.push({ serverId: server.id, toolId: toolInfo.tool.id }) + } + } + + if (toolsToUpdate.length === 0) return + + onSubmittingChange?.(true) + try { + for (const { serverId, toolId } of toolsToUpdate) { + await updateToolMutation.mutateAsync({ + workspaceId, + serverId, + toolId, + toolName: toolName.trim(), + toolDescription: toolDescription.trim() || undefined, + parameterSchema, + }) + } + // Update saved values after successful save (triggers re-render → hasChanges becomes false) + setSavedValues({ + toolName, + toolDescription, + parameterDescriptions: { ...parameterDescriptions }, + }) + onCanSaveChange?.(false) + onSubmittingChange?.(false) + } catch (error) { + logger.error('Failed to save tool configuration:', error) + onSubmittingChange?.(false) + } + }, [ + toolName, + toolDescription, + parameterDescriptions, + parameterSchema, + servers, + serverToolsMap, + workspaceId, + updateToolMutation, + onSubmittingChange, + onCanSaveChange, + ]) + + const serverOptions: ComboboxOption[] = useMemo(() => { + return servers.map((server) => ({ + label: server.name, + value: server.id, + icon: Server, + })) + }, [servers]) + + const handleServerSelectionChange = useCallback( + async (newSelectedIds: string[]) => { + if (!toolName.trim()) return + + const currentIds = new Set(selectedServerIds) + const newIds = new Set(newSelectedIds) + + const toAdd = newSelectedIds.filter((id) => !currentIds.has(id)) + const toRemove = selectedServerIds.filter((id) => !newIds.has(id)) + + for (const serverId of toAdd) { + setPendingServerChanges((prev) => new Set(prev).add(serverId)) + try { + await addToolMutation.mutateAsync({ + workspaceId, + serverId, + workflowId, + toolName: toolName.trim(), + toolDescription: toolDescription.trim() || undefined, + parameterSchema, + }) + refetchServers() + onAddedToServer?.() + logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`) + } catch (error) { + logger.error('Failed to add tool:', error) + } finally { + setPendingServerChanges((prev) => { + const next = new Set(prev) + next.delete(serverId) + return next + }) + } + } + + for (const serverId of toRemove) { + const toolInfo = serverToolsMap[serverId] + if (toolInfo?.tool) { + setPendingServerChanges((prev) => new Set(prev).add(serverId)) + try { + await deleteToolMutation.mutateAsync({ + workspaceId, + serverId, + toolId: toolInfo.tool.id, + }) + setServerToolsMap((prev) => { + const next = { ...prev } + delete next[serverId] + return next + }) + refetchServers() + } catch (error) { + logger.error('Failed to remove tool:', error) + } finally { + setPendingServerChanges((prev) => { + const next = new Set(prev) + next.delete(serverId) + return next + }) + } + } + } + }, + [ + selectedServerIds, + serverToolsMap, + toolName, + toolDescription, + workspaceId, + workflowId, + parameterSchema, + addToolMutation, + deleteToolMutation, + refetchServers, + onAddedToServer, + ] + ) + + const selectedServersLabel = useMemo(() => { + const count = selectedServerIds.length + if (count === 0) return 'Select servers...' + if (count === 1) { + const server = servers.find((s) => s.id === selectedServerIds[0]) + return server?.name || '1 server' + } + return `${count} servers selected` + }, [selectedServerIds, servers]) + + const isPending = pendingServerChanges.size > 0 + + if (!isDeployed) { + return ( +
+ +
+

Deploy workflow first

+

+ You need to deploy your workflow before adding it as an MCP tool. +

+
+
+ ) + } + + if (isLoadingServers) { + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ ) + } + + if (servers.length === 0) { + return ( +
+ +
+

No MCP servers yet

+

+ Create an MCP Server in Settings → MCP Servers first. +

+
+
+ ) + } + + return ( +
{ + e.preventDefault() + handleSave() + }} + > + {/* Hidden submit button for parent modal to trigger */} +