diff --git a/apps/docs/content/docs/en/mcp/deploy-workflows.mdx b/apps/docs/content/docs/en/mcp/deploy-workflows.mdx new file mode 100644 index 0000000000..f7ece9e966 --- /dev/null +++ b/apps/docs/content/docs/en/mcp/deploy-workflows.mdx @@ -0,0 +1,108 @@ +--- +title: Deploy Workflows as MCP +description: Expose your workflows as MCP tools for external AI assistants and applications +--- + +import { Video } from '@/components/ui/video' +import { Callout } from 'fumadocs-ui/components/callout' + +Deploy your workflows as MCP tools to make them accessible to external AI assistants like Claude Desktop, Cursor, and other MCP-compatible clients. This turns your workflows into callable tools that can be invoked from anywhere. + +## Creating and Managing MCP Servers + +MCP servers group your workflow tools together. Create and manage them in workspace settings: + +
+
+ +1. Navigate to **Settings → MCP Servers** +2. Click **Create Server** +3. Enter a name and optional description +4. Copy the server URL for use in your MCP clients +5. View and manage all tools added to the server + +## Adding a Workflow as a Tool + +Once your workflow is deployed, you can expose it as an MCP tool: + +
+
+ +1. Open your deployed workflow +2. Click **Deploy** and go to the **MCP** tab +3. Configure the tool name and description +4. Add descriptions for each parameter (helps AI understand inputs) +5. Select which MCP servers to add it to + + +The workflow must be deployed before it can be added as an MCP tool. + + +## Tool Configuration + +### Tool Name +Use lowercase letters, numbers, and underscores. The name should be descriptive and follow MCP naming conventions (e.g., `search_documents`, `send_email`). + +### Description +Write a clear description of what the tool does. This helps AI assistants understand when to use the tool. + +### Parameters +Your workflow's input format fields become tool parameters. Add descriptions to each parameter to help AI assistants provide correct values. + +## Connecting MCP Clients + +Use the server URL from settings to connect external applications: + +### Claude Desktop +Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "my-sim-workflows": { + "command": "npx", + "args": ["-y", "mcp-remote", "YOUR_SERVER_URL"] + } + } +} +``` + +### Cursor +Add the server URL in Cursor's MCP settings using the same mcp-remote pattern. + + +Include your API key header (`X-API-Key`) for authenticated access when using mcp-remote or other HTTP-based MCP transports. + + +## Server Management + +From the server detail view in **Settings → MCP Servers**, you can: + +- **View tools**: See all workflows added to a server +- **Copy URL**: Get the server URL for MCP clients +- **Add workflows**: Add more deployed workflows as tools +- **Remove tools**: Remove workflows from the server +- **Delete server**: Remove the entire server and all its tools + +## How It Works + +When an MCP client calls your tool: + +1. The request is received at your MCP server URL +2. Sim validates the request and maps parameters to workflow inputs +3. The deployed workflow executes with the provided inputs +4. Results are returned to the MCP client + +Workflows execute using the same deployment version as API calls, ensuring consistent behavior. + +## Permission Requirements + +| Action | Required Permission | +|--------|-------------------| +| Create MCP servers | **Admin** | +| Add workflows to servers | **Write** or **Admin** | +| View MCP servers | **Read**, **Write**, or **Admin** | +| Delete MCP servers | **Admin** | + diff --git a/apps/docs/content/docs/en/mcp/index.mdx b/apps/docs/content/docs/en/mcp/index.mdx index a6cb8bd4f2..8975c4f39a 100644 --- a/apps/docs/content/docs/en/mcp/index.mdx +++ b/apps/docs/content/docs/en/mcp/index.mdx @@ -1,8 +1,10 @@ --- -title: MCP (Model Context Protocol) +title: Using MCP Tools +description: Connect external tools and services using the Model Context Protocol --- import { Image } from '@/components/ui/image' +import { Video } from '@/components/ui/video' import { Callout } from 'fumadocs-ui/components/callout' The Model Context Protocol ([MCP](https://modelcontextprotocol.com/)) allows you to connect external tools and services using a standardized protocol, enabling you to integrate APIs and services directly into your workflows. With MCP, you can extend Sim's capabilities by adding custom integrations that work seamlessly with your agents and workflows. @@ -20,14 +22,8 @@ MCP is an open standard that enables AI assistants to securely connect to extern MCP servers provide collections of tools that your agents can use. Configure them in workspace settings: -
- Configuring MCP Server in Settings +
+
1. Navigate to your workspace settings @@ -40,6 +36,10 @@ MCP servers provide collections of tools that your agents can use. Configure the You can also configure MCP servers directly from the toolbar in an Agent block for quick setup. +### Refresh Tools + +Click **Refresh** on a server to fetch the latest tool schemas and automatically update any agent blocks using those tools with the new parameter definitions. + ## Using MCP Tools in Agents Once MCP servers are configured, their tools become available within your agent blocks: diff --git a/apps/docs/content/docs/en/mcp/meta.json b/apps/docs/content/docs/en/mcp/meta.json new file mode 100644 index 0000000000..9ee1817875 --- /dev/null +++ b/apps/docs/content/docs/en/mcp/meta.json @@ -0,0 +1,5 @@ +{ + "title": "MCP", + "pages": ["index", "deploy-workflows"], + "defaultOpen": false +} diff --git a/apps/docs/public/static/blocks/triggers.png b/apps/docs/public/static/blocks/triggers.png index fdcf21d811..43c1b4e3ee 100644 Binary files a/apps/docs/public/static/blocks/triggers.png and b/apps/docs/public/static/blocks/triggers.png differ diff --git a/apps/docs/public/static/environment/environment-1.png b/apps/docs/public/static/environment/environment-1.png index 4e5957f651..6a2eded627 100644 Binary files a/apps/docs/public/static/environment/environment-1.png and b/apps/docs/public/static/environment/environment-1.png differ diff --git a/apps/docs/public/static/environment/environment-2.png b/apps/docs/public/static/environment/environment-2.png index edd28301e5..3f7617cece 100644 Binary files a/apps/docs/public/static/environment/environment-2.png and b/apps/docs/public/static/environment/environment-2.png differ diff --git a/apps/docs/public/static/environment/environment-3.png b/apps/docs/public/static/environment/environment-3.png index f2850e47d6..29ae2e1d1c 100644 Binary files a/apps/docs/public/static/environment/environment-3.png and b/apps/docs/public/static/environment/environment-3.png differ diff --git a/apps/docs/public/static/knowledgebase/knowledgebase.png b/apps/docs/public/static/knowledgebase/knowledgebase.png index 9fd1954156..a938385f57 100644 Binary files a/apps/docs/public/static/knowledgebase/knowledgebase.png and b/apps/docs/public/static/knowledgebase/knowledgebase.png differ diff --git a/apps/sim/app/(landing)/careers/page.tsx b/apps/sim/app/(landing)/careers/page.tsx index 37c5f7a0e1..d756661aa1 100644 --- a/apps/sim/app/(landing)/careers/page.tsx +++ b/apps/sim/app/(landing)/careers/page.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { X } from 'lucide-react' +import { Textarea } from '@/components/emcn' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -13,7 +14,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Textarea } from '@/components/ui/textarea' import { isHosted } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' diff --git a/apps/sim/app/(landing)/studio/[slug]/page.tsx b/apps/sim/app/(landing)/studio/[slug]/page.tsx index 531cebd52f..77362a0554 100644 --- a/apps/sim/app/(landing)/studio/[slug]/page.tsx +++ b/apps/sim/app/(landing)/studio/[slug]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next' import Image from 'next/image' import Link from 'next/link' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn' import { FAQ } from '@/lib/blog/faq' import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry' import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo' diff --git a/apps/sim/app/(landing)/studio/post-grid.tsx b/apps/sim/app/(landing)/studio/post-grid.tsx index 22c49032bc..69b0562621 100644 --- a/apps/sim/app/(landing)/studio/post-grid.tsx +++ b/apps/sim/app/(landing)/studio/post-grid.tsx @@ -2,7 +2,7 @@ import Image from 'next/image' import Link from 'next/link' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn' interface Author { id: string 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..94348a0f73 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -1,31 +1,150 @@ import { db } from '@sim/db' -import { mcpServers } from '@sim/db/schema' +import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' -import type { McpServerStatusConfig } from '@/lib/mcp/types' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +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[] +} + /** - * POST - Refresh an MCP server connection (requires any workspace permission) + * Syncs tool schemas from discovered MCP tools to all workflow blocks using those tools. + * Returns the count and IDs of updated workflows. */ +async function syncToolSchemasToWorkflows( + workspaceId: string, + serverId: string, + tools: McpTool[], + requestId: string +): Promise { + 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(eq(workflowBlocks.type, 'agent')) + + const updatedWorkflowIds = new Set() + + for (const block of agentBlocks) { + if (!workflowIds.includes(block.workflowId)) continue + + const subBlocks = block.subBlocks as Record | 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) + + if (!schemasMatch) { + hasUpdates = true + + const validParamKeys = new Set(Object.keys(newSchema.properties || {})) + + const cleanedParams: Record = {} + for (const [key, value] of Object.entries(tool.params || {})) { + if (MCP_TOOL_CORE_PARAMS.has(key) || validParamKeys.has(key)) { + cleanedParams[key] = value + } + } + + 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 = 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() @@ -50,6 +169,8 @@ export const POST = withMcpAuth<{ id: string }>('read')( 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) ?? { @@ -58,11 +179,16 @@ export const POST = withMcpAuth<{ id: string }>('read')( } try { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId) connectionStatus = 'connected' - toolCount = tools.length - logger.info( - `[${requestId}] Successfully connected to server ${serverId}, discovered ${toolCount} tools` + toolCount = discoveredTools.length + logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`) + + syncResult = await syncToolSchemasToWorkflows( + workspaceId, + serverId, + discoveredTools, + requestId ) } catch (error) { connectionStatus = 'error' @@ -94,14 +220,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({ @@ -109,6 +228,8 @@ export const POST = withMcpAuth<{ id: string }>('read')( toolCount, lastConnected: refreshedServer?.lastConnected?.toISOString() || null, error: lastError, + workflowsUpdated: syncResult.updatedCount, + updatedWorkflowIds: syncResult.updatedWorkflowIds, }) } catch (error) { logger.error(`[${requestId}] Error refreshing MCP server:`, error) 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..5a5519c277 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -4,39 +4,20 @@ 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, StoredMcpTool } from '@/lib/mcp/types' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('McpStoredToolsAPI') export const dynamic = 'force-dynamic' -interface StoredMcpTool { - workflowId: string - workflowName: string - serverId: string - serverUrl?: string - toolName: string - schema?: Record -} - -/** - * 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 +28,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 +58,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..2413eaa2db 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -3,6 +3,7 @@ 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 { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { createSchedulesForDeploy, @@ -160,6 +161,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 syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' }) + const responseApiKeyInfo = workflowData!.workspaceId ? 'Workspace API keys' : 'Personal API keys' @@ -217,6 +221,9 @@ export async function DELETE( .where(eq(workflow.id, id)) }) + // Remove all MCP tools that reference this workflow + await removeMcpToolsForWorkflow(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..4ffc35f9ef 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 @@ -3,6 +3,7 @@ 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 { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -31,6 +32,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 +78,16 @@ 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 syncMcpToolsForWorkflow({ + workflowId: id, + requestId, + state: versionData.state, + context: 'activate', + }) + } + 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..5e8f43560f 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 @@ -4,6 +4,7 @@ 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 { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -87,6 +88,14 @@ 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 syncMcpToolsForWorkflow({ + workflowId: id, + requestId, + state: deployedState, + context: 'revert', + }) + 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/changelog/components/timeline-list.tsx b/apps/sim/app/changelog/components/timeline-list.tsx index 9e2b81a03a..74f65b900d 100644 --- a/apps/sim/app/changelog/components/timeline-list.tsx +++ b/apps/sim/app/changelog/components/timeline-list.tsx @@ -2,7 +2,7 @@ import React from 'react' import ReactMarkdown from 'react-markdown' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn' import { inter } from '@/app/_styles/fonts/inter/inter' import { soehne } from '@/app/_styles/fonts/soehne/soehne' import type { ChangelogEntry } from '@/app/changelog/components/changelog-content' diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index 8e3fb2fa22..c6487636a6 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -1,14 +1,18 @@ 'use client' -import { useState } from 'react' -import { ArrowLeft, Bell, Folder, Key, Settings, User } from 'lucide-react' +import { useEffect, useState } from 'react' +import { ArrowLeft, Bell, Folder, Key, Moon, Settings, Sun, User } from 'lucide-react' import { notFound, useRouter } from 'next/navigation' import { + Avatar, + AvatarFallback, + AvatarImage, Badge, Breadcrumb, BubbleChatPreview, Button, Card as CardIcon, + Checkbox, ChevronDown, Code, Combobox, @@ -50,6 +54,7 @@ import { PopoverTrigger, Redo, Rocket, + Slider, SModal, SModalContent, SModalMain, @@ -62,6 +67,12 @@ import { SModalSidebarSectionTitle, SModalTrigger, Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, Textarea, Tooltip, Trash, @@ -112,7 +123,19 @@ export default function PlaygroundPage() { const router = useRouter() const [comboboxValue, setComboboxValue] = useState('') const [switchValue, setSwitchValue] = useState(false) + const [checkboxValue, setCheckboxValue] = useState(false) + const [sliderValue, setSliderValue] = useState([50]) const [activeTab, setActiveTab] = useState('profile') + const [isDarkMode, setIsDarkMode] = useState(false) + + const toggleDarkMode = () => { + setIsDarkMode(!isDarkMode) + document.documentElement.classList.toggle('dark') + } + + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains('dark')) + }, []) if (!isTruthy(env.NEXT_PUBLIC_ENABLE_PLAYGROUND)) { notFound() @@ -121,18 +144,26 @@ export default function PlaygroundPage() { return (
- - - - - Go back - +
+ + + + + Go back + +
+
+ + + + + {isDarkMode ? 'Light mode' : 'Dark mode'} + +

@@ -185,6 +216,50 @@ export default function PlaygroundPage() { Outline + + Green + + With Dot + + + + Red + + With Dot + + + + Blue + + With Dot + + + + Blue Secondary + + + Purple + + + Orange + + + Amber + + + Teal + + + Gray + + + Gray Secondary + + + Small + Medium + Large + {/* Input */} @@ -220,6 +295,143 @@ export default function PlaygroundPage() { + {/* Checkbox */} +
+ + setCheckboxValue(!!c)} /> + + {checkboxValue ? 'Checked' : 'Unchecked'} + + + + + Small (14px) + + + + Medium (16px) + + + + Large (20px) + + + + + +
+ + {/* Slider */} +
+ +
+ +
+ {sliderValue[0]} +
+ +
+ +
+
+
+ + {/* Avatar */} +
+ + + XS + + + SM + + + MD + + + LG + + + XL + + + + + + CN + + + + + JD + + + + + JD + + + + + JD + + + + + JD + + + + + XS + + + SM + + + MD + + + LG + + + XL + + +
+ + {/* Table */} +
+ + + + + Name + Status + Role + + + + + Alice + Active + Admin + + + Bob + Pending + User + + + Charlie + Active + User + + +
+
+
+ {/* Combobox */}
diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx index 15e741c278..48090a5d5f 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' -import { Badge, Button, Input, Label, Textarea } from '@/components/emcn' +import { Badge, Button, Textarea } from '@/components/emcn' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { Select, SelectContent, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 69d726c025..76f18034c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -17,26 +17,24 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation' import { Breadcrumb, Button, + Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, - Tooltip, - Trash, -} from '@/components/emcn' -import { Checkbox } from '@/components/ui/checkbox' -import { Input } from '@/components/ui/input' -import { SearchHighlight } from '@/components/ui/search-highlight' -import { Skeleton } from '@/components/ui/skeleton' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' + Tooltip, + Trash, +} from '@/components/emcn' +import { Input } from '@/components/ui/input' +import { SearchHighlight } from '@/components/ui/search-highlight' +import { Skeleton } from '@/components/ui/skeleton' import { CreateChunkModal, DeleteChunkModal, @@ -920,6 +918,7 @@ export function Document({ >
@@ -999,13 +997,13 @@ export function Document({ >
handleSelectChunk(chunk.id, checked as boolean) } disabled={!userPermissions.canEdit} aria-label={`Select chunk ${chunk.chunkIndex}`} - className='h-[14px] w-[14px] border-[var(--border-2)] focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-[12px] [&>*]:w-[12px]' onClick={(e) => e.stopPropagation()} />
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 0d1cf5d1e2..4252c3ce01 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -21,26 +21,24 @@ import { Badge, Breadcrumb, Button, + Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, - Tooltip, - Trash, -} from '@/components/emcn' -import { Checkbox } from '@/components/ui/checkbox' -import { Input } from '@/components/ui/input' -import { SearchHighlight } from '@/components/ui/search-highlight' -import { Skeleton } from '@/components/ui/skeleton' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table' + Tooltip, + Trash, +} from '@/components/emcn' +import { Input } from '@/components/ui/input' +import { SearchHighlight } from '@/components/ui/search-highlight' +import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/core/utils/cn' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { @@ -591,6 +589,11 @@ export function KnowledgeBase({ const document = documents.find((doc) => doc.id === docId) if (!document) return + const newEnabled = !document.enabled + + // Optimistic update - immediately update the UI + updateDocument(docId, { enabled: newEnabled }) + try { const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, { method: 'PUT', @@ -598,7 +601,7 @@ export function KnowledgeBase({ 'Content-Type': 'application/json', }, body: JSON.stringify({ - enabled: !document.enabled, + enabled: newEnabled, }), }) @@ -608,10 +611,13 @@ export function KnowledgeBase({ const result = await response.json() - if (result.success) { - updateDocument(docId, { enabled: !document.enabled }) + if (!result.success) { + // Revert on failure + updateDocument(docId, { enabled: !newEnabled }) } } catch (err) { + // Revert on error + updateDocument(docId, { enabled: !newEnabled }) logger.error('Error updating document:', err) } } @@ -1125,11 +1131,11 @@ export function KnowledgeBase({
@@ -1170,10 +1176,10 @@ export function KnowledgeBase({ onCheckedChange={(checked) => handleSelectDocument(doc.id, checked as boolean) } + size='sm' disabled={!userPermissions.canEdit} onClick={(e) => e.stopPropagation()} aria-label={`Select ${doc.filename}`} - className='h-[14px] w-[14px] border-[var(--border-2)] focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-[12px] [&>*]:w-[12px]' />

diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx index 6045ea7260..b48bf8b63c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx @@ -16,8 +16,7 @@ import { X, Zap, } from 'lucide-react' -import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn' -import { Badge } from '@/components/ui/badge' +import { Badge, Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { redactApiKeys } from '@/lib/core/security/redaction' import { cn } from '@/lib/core/utils/cn' @@ -200,7 +199,7 @@ function PinnedLogs({
- {formatted.blockType} + {formatted.blockType} not executed
@@ -254,7 +253,7 @@ function PinnedLogs({
- + {formatted.blockType} {formatted.status} 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 0f9f25bb6a..39ddc92e57 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 @@ -24,7 +24,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 77d9dd2162..0435309991 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 /** Possible execution status values for workflow logs */ export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx index e718a0f99f..cadc5f1e89 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx @@ -26,9 +26,8 @@ import * as React from 'react' import { Check, GripHorizontal, Pencil, X } from 'lucide-react' -import { Button } from '@/components/emcn' +import { Button, Textarea } from '@/components/emcn' import { Trash } from '@/components/emcn/icons/trash' -import { Textarea } from '@/components/ui' import { cn } from '@/lib/core/utils/cn' import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index f41263c392..c88aed1414 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -12,8 +12,7 @@ import { createLogger } from '@sim/logger' import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react' import { useParams } from 'next/navigation' import { createPortal } from 'react-dom' -import { Badge, Button } from '@/components/emcn' -import { Textarea } from '@/components/ui' +import { Badge, Button, Textarea } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx new file mode 100644 index 0000000000..a458f7c00d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx @@ -0,0 +1,557 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +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 { useSettingsModalStore } from '@/stores/settings-modal/store' +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 McpDeployProps { + 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 McpDeploy({ + workflowId, + workflowName, + workflowDescription, + isDeployed, + onAddedToServer, + onSubmittingChange, + onCanSaveChange, +}: McpDeployProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + const openSettingsModal = useSettingsModalStore((state) => state.openModal) + + 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 [] + + const storeValue = subBlockValues[starterBlockId]?.inputFormat + const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[] + if (normalized.length > 0) return normalized + + 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(() => { + const isDefaultDescription = + !workflowDescription || + workflowDescription === workflowName || + workflowDescription.toLowerCase() === 'new workflow' + + return isDefaultDescription ? '' : workflowDescription + }) + 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) + + const loadedDescription = toolInfo.tool.toolDescription || '' + const isDefaultDescription = + !loadedDescription || + loadedDescription === workflowName || + loadedDescription.toLowerCase() === 'new workflow' + setToolDescription(isDefaultDescription ? '' : loadedDescription) + + 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, workflowName]) + + const [savedValues, setSavedValues] = useState<{ + toolName: string + toolDescription: string + parameterDescriptions: Record + } | null>(null) + + useEffect(() => { + if (hasLoadedInitialData.current && !savedValues) { + setSavedValues({ + toolName, + toolDescription, + parameterDescriptions: { ...parameterDescriptions }, + }) + } + }, [toolName, toolDescription, parameterDescriptions, savedValues]) + + 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]) + + 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, + })) + }, [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 your workflow first to add it as an MCP tool. +
+ ) + } + + if (isLoadingServers) { + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ ) + } + + if (servers.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
{ + e.preventDefault() + handleSave() + }} + > + {/* Hidden submit button for parent modal to trigger */} +