From 7c9dc7568a751acf1e5ca05465148ce4ee317bcd Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 2 Feb 2026 14:39:03 -0800 Subject: [PATCH] feat(mcp): added ability to connect an mcp server and allow agents to do discovery --- .../components/tool-input/tool-input.tsx | 321 ++++++++++++++---- .../emcn/components/combobox/combobox.tsx | 30 +- .../executor/handlers/agent/agent-handler.ts | 139 +++++++- apps/sim/executor/handlers/agent/types.ts | 25 ++ 4 files changed, 434 insertions(+), 81 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index cd2f342a3..15e3b61b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -1,7 +1,7 @@ import type React from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Loader2, WrenchIcon, XIcon } from 'lucide-react' +import { ChevronRight, Loader2, ServerIcon, WrenchIcon, XIcon } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -111,18 +111,33 @@ interface ToolInputProps { * Represents a tool selected and configured in the workflow * * @remarks + * Valid types include: + * - Standard block types (e.g., 'api', 'search', 'function') + * - 'custom-tool': User-defined tools with custom code + * - 'mcp': Individual MCP tool from a connected server + * - 'mcp-server': All tools from an MCP server (agent discovery mode). + * At execution time, this expands into individual tool definitions for + * all tools available on the server. + * * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded. * Everything else (title, schema, code) is loaded dynamically from the database. * Legacy custom tools with inline schema/code are still supported for backwards compatibility. */ interface StoredTool { - /** Block type identifier */ + /** + * Block type identifier. + * 'mcp-server' enables server-level selection where all tools from + * the server are made available to the LLM at execution time. + */ type: string /** Display title for the tool (optional for new custom tool format) */ title?: string /** Direct tool ID for execution (optional for new custom tool format) */ toolId?: string - /** Parameter values configured by the user (optional for new custom tool format) */ + /** + * Parameter values configured by the user. + * For 'mcp-server' type, includes: serverId, serverUrl, serverName, toolCount + */ params?: Record /** Whether the tool details are expanded in UI */ isExpanded?: boolean @@ -1007,6 +1022,7 @@ export const ToolInput = memo(function ToolInput({ const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState(null) + const [expandedMcpServers, setExpandedMcpServers] = useState>(new Set()) const value = isPreview ? previewValue : storeValue @@ -1236,6 +1252,18 @@ export const ToolInput = memo(function ToolInput({ return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) } + /** + * Checks if an MCP server is already selected (all tools mode). + * + * @param serverId - The MCP server identifier to check + * @returns `true` if the MCP server is already selected + */ + const isMcpServerAlreadySelected = (serverId: string): boolean => { + return selectedTools.some( + (tool) => tool.type === 'mcp-server' && tool.params?.serverId === serverId + ) + } + /** * Checks if a custom tool is already selected. * @@ -1260,6 +1288,37 @@ export const ToolInput = memo(function ToolInput({ ) } + /** + * Groups MCP tools by their parent server. + * + * @returns Map of serverId to array of tools + */ + const mcpToolsByServer = useMemo(() => { + const grouped = new Map() + for (const tool of availableMcpTools) { + if (!grouped.has(tool.serverId)) { + grouped.set(tool.serverId, []) + } + grouped.get(tool.serverId)!.push(tool) + } + return grouped + }, [availableMcpTools]) + + /** + * Toggles the expanded state of an MCP server in the dropdown. + */ + const toggleMcpServerExpanded = useCallback((serverId: string) => { + setExpandedMcpServers((prev) => { + const next = new Set(prev) + if (next.has(serverId)) { + next.delete(serverId) + } else { + next.add(serverId) + } + return next + }) + }, []) + /** * Checks if a block supports multiple operations. * @@ -1805,41 +1864,125 @@ export const ToolInput = memo(function ToolInput({ }) } - // MCP Tools section - if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) { - groups.push({ - section: 'MCP Tools', - items: availableMcpTools.map((mcpTool) => { - const server = mcpServers.find((s) => s.id === mcpTool.serverId) - const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) - return { - label: mcpTool.name, - value: `mcp-${mcpTool.id}`, - iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), + // MCP Servers section - grouped by server with expandable folders + if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) { + // Create items for each server (as expandable folders) + const serverItems: ComboboxOption[] = [] + + for (const [serverId, tools] of mcpToolsByServer) { + const server = mcpServers.find((s) => s.id === serverId) + const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' + const isExpanded = expandedMcpServers.has(serverId) + const serverAlreadySelected = isMcpServerAlreadySelected(serverId) + const toolCount = tools.length + + // Server folder header (clickable to expand/collapse) + serverItems.push({ + label: serverName, + value: `mcp-server-folder-${serverId}`, + iconElement: ( +
+ +
+ +
+
+ ), + onSelect: () => { + toggleMcpServerExpanded(serverId) + }, + disabled: false, + keepOpen: true, // Keep dropdown open when toggling folder expansion + }) + + // If expanded, show "Use all tools" option and individual tools + if (isExpanded) { + // "Use all tools from server" option + serverItems.push({ + label: `Use all ${toolCount} tools`, + value: `mcp-server-all-${serverId}`, + iconElement: ( +
+ +
+ ), onSelect: () => { - if (alreadySelected) return + if (serverAlreadySelected) return + // Remove any individual tools from this server that were previously selected + const filteredTools = selectedTools.filter( + (tool) => !(tool.type === 'mcp' && tool.params?.serverId === serverId) + ) const newTool: StoredTool = { - type: 'mcp', - title: mcpTool.name, - toolId: mcpTool.id, + type: 'mcp-server', + title: `${serverName} (all tools)`, + toolId: `mcp-server-${serverId}`, params: { - serverId: mcpTool.serverId, + serverId, ...(server?.url && { serverUrl: server.url }), - toolName: mcpTool.name, - serverName: mcpTool.serverName, + serverName, + toolCount: String(toolCount), }, - isExpanded: true, + isExpanded: false, usageControl: 'auto', - schema: { - ...mcpTool.inputSchema, - description: mcpTool.description, - }, } - handleMcpToolSelect(newTool, true) + setStoreValue([ + ...filteredTools.map((tool) => ({ ...tool, isExpanded: false })), + newTool, + ]) + setOpen(false) }, - disabled: isPreview || disabled || alreadySelected, + disabled: isPreview || disabled || serverAlreadySelected, + }) + + // Individual tools from this server + for (const mcpTool of tools) { + const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) || serverAlreadySelected + serverItems.push({ + label: mcpTool.name, + value: `mcp-${mcpTool.id}`, + iconElement: ( +
+ {createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon)} +
+ ), + onSelect: () => { + if (alreadySelected) return + const newTool: StoredTool = { + type: 'mcp', + title: mcpTool.name, + toolId: mcpTool.id, + params: { + serverId: mcpTool.serverId, + ...(server?.url && { serverUrl: server.url }), + toolName: mcpTool.name, + serverName: mcpTool.serverName, + }, + isExpanded: true, + usageControl: 'auto', + schema: { + ...mcpTool.inputSchema, + description: mcpTool.description, + }, + } + handleMcpToolSelect(newTool, true) + }, + disabled: isPreview || disabled || alreadySelected, + }) } - }), + } + } + + groups.push({ + section: 'MCP Servers', + items: serverItems, }) } @@ -1922,6 +2065,8 @@ export const ToolInput = memo(function ToolInput({ customTools, availableMcpTools, mcpServers, + mcpToolsByServer, + expandedMcpServers, toolBlocks, isPreview, disabled, @@ -1935,8 +2080,10 @@ export const ToolInput = memo(function ToolInput({ getToolIdForOperation, isToolAlreadySelected, isMcpToolAlreadySelected, + isMcpServerAlreadySelected, isCustomToolAlreadySelected, isWorkflowAlreadySelected, + toggleMcpServerExpanded, ]) const toolRequiresOAuth = (toolId: string): boolean => { @@ -2363,24 +2510,25 @@ export const ToolInput = memo(function ToolInput({ {/* Selected Tools List */} {selectedTools.length > 0 && selectedTools.map((tool, toolIndex) => { - // Handle custom tools, MCP tools, and workflow tools differently + // Handle custom tools, MCP tools, MCP servers, and workflow tools differently const isCustomTool = tool.type === 'custom-tool' const isMcpTool = tool.type === 'mcp' + const isMcpServer = tool.type === 'mcp-server' const isWorkflowTool = tool.type === 'workflow' const toolBlock = - !isCustomTool && !isMcpTool + !isCustomTool && !isMcpTool && !isMcpServer ? toolBlocks.find((block) => block.type === tool.type) : null // Get the current tool ID (may change based on operation) const currentToolId = - !isCustomTool && !isMcpTool + !isCustomTool && !isMcpTool && !isMcpServer ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || '' : tool.toolId || '' // Get tool parameters using the new utility with block type for UI components const toolParams = - !isCustomTool && !isMcpTool && currentToolId + !isCustomTool && !isMcpTool && !isMcpServer && currentToolId ? getToolParametersConfig(currentToolId, tool.type, { operation: tool.operation, ...tool.params, @@ -2449,21 +2597,32 @@ export const ToolInput = memo(function ToolInput({ ? customToolParams : isMcpTool ? mcpToolParams - : toolParams?.userInputParameters || [] + : isMcpServer + ? [] // MCP servers have no user-configurable params + : toolParams?.userInputParameters || [] // Check if tool requires OAuth const requiresOAuth = - !isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId) + !isCustomTool && + !isMcpTool && + !isMcpServer && + currentToolId && + toolRequiresOAuth(currentToolId) const oauthConfig = - !isCustomTool && !isMcpTool && currentToolId ? getToolOAuthConfig(currentToolId) : null + !isCustomTool && !isMcpTool && !isMcpServer && currentToolId + ? getToolOAuthConfig(currentToolId) + : null // Determine if tool has expandable body content - const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) + const hasOperations = + !isCustomTool && !isMcpTool && !isMcpServer && hasMultipleOperations(tool.type) const filteredDisplayParams = displayParams.filter((param) => evaluateParameterCondition(param, tool) ) - const hasToolBody = - hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0 + // MCP servers are expandable to show tool list + const hasToolBody = isMcpServer + ? true + : hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0 // Only show expansion if tool has body content const isExpandedForDisplay = hasToolBody @@ -2472,6 +2631,11 @@ export const ToolInput = memo(function ToolInput({ : !!tool.isExpanded : false + // For MCP servers, get the list of tools for display + const mcpServerTools = isMcpServer + ? availableMcpTools.filter((t) => t.serverId === tool.params?.serverId) + : [] + return (
) : isMcpTool ? ( + ) : isMcpServer ? ( + ) : isWorkflowTool ? ( ) : ( @@ -2531,6 +2697,11 @@ export const ToolInput = memo(function ToolInput({ {isCustomTool ? customToolTitle : tool.title} + {isMcpServer && ( + + {tool.params?.toolCount || mcpServerTools.length} tools + + )} {isMcpTool && !mcpDataLoading && (() => { @@ -2636,31 +2807,53 @@ export const ToolInput = memo(function ToolInput({ {!isCustomTool && isExpandedForDisplay && (
- {/* Operation dropdown for tools with multiple operations */} - {(() => { - const hasOperations = hasMultipleOperations(tool.type) - const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] - - return hasOperations && operationOptions.length > 0 ? ( -
-
- Operation -
- option.id !== '') - .map((option) => ({ - label: option.label, - value: option.id, - }))} - value={tool.operation || operationOptions[0].id} - onChange={(value) => handleOperationChange(toolIndex, value)} - placeholder='Select operation' - disabled={disabled} - /> + {/* MCP Server tool list (read-only) */} + {isMcpServer && mcpServerTools.length > 0 && ( +
+
+ Available tools:
- ) : null - })()} +
+ {mcpServerTools.map((serverTool) => ( + + {serverTool.name} + + ))} +
+
+ )} + + {/* Operation dropdown for tools with multiple operations */} + {!isMcpServer && + (() => { + const hasOperations = hasMultipleOperations(tool.type) + const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] + + return hasOperations && operationOptions.length > 0 ? ( +
+
+ Operation +
+ option.id !== '') + .map((option) => ({ + label: option.label, + value: option.id, + }))} + value={tool.operation || operationOptions[0].id} + onChange={(value) => handleOperationChange(toolIndex, value)} + placeholder='Select operation' + disabled={disabled} + /> +
+ ) : null + })()} {/* OAuth credential selector if required */} {requiresOAuth && oauthConfig && ( diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index 49f464640..ad8dd51de 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -52,6 +52,8 @@ export type ComboboxOption = { onSelect?: () => void /** Whether this option is disabled */ disabled?: boolean + /** When true, keep the dropdown open after selecting this option */ + keepOpen?: boolean } /** @@ -252,13 +254,15 @@ const Combobox = memo( * Handles selection of an option */ const handleSelect = useCallback( - (selectedValue: string, customOnSelect?: () => void) => { + (selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => { // If option has custom onSelect, use it instead if (customOnSelect) { customOnSelect() - setOpen(false) - setHighlightedIndex(-1) - setSearchQuery('') + if (!keepOpen) { + setOpen(false) + setHighlightedIndex(-1) + setSearchQuery('') + } return } @@ -270,11 +274,13 @@ const Combobox = memo( onMultiSelectChange(newValues) } else { onChange?.(selectedValue) - setOpen(false) - setHighlightedIndex(-1) - setSearchQuery('') - if (editable && inputRef.current) { - inputRef.current.blur() + if (!keepOpen) { + setOpen(false) + setHighlightedIndex(-1) + setSearchQuery('') + if (editable && inputRef.current) { + inputRef.current.blur() + } } } }, @@ -343,7 +349,7 @@ const Combobox = memo( e.preventDefault() const selectedOption = filteredOptions[highlightedIndex] if (selectedOption && !selectedOption.disabled) { - handleSelect(selectedOption.value, selectedOption.onSelect) + handleSelect(selectedOption.value, selectedOption.onSelect, selectedOption.keepOpen) } } else if (!editable) { e.preventDefault() @@ -668,7 +674,7 @@ const Combobox = memo( e.preventDefault() e.stopPropagation() if (!option.disabled) { - handleSelect(option.value, option.onSelect) + handleSelect(option.value, option.onSelect, option.keepOpen) } }} onMouseEnter={() => @@ -743,7 +749,7 @@ const Combobox = memo( e.preventDefault() e.stopPropagation() if (!option.disabled) { - handleSelect(option.value, option.onSelect) + handleSelect(option.value, option.onSelect, option.keepOpen) } }} onMouseEnter={() => !option.disabled && setHighlightedIndex(index)} diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 007833d9c..be8f44acb 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -143,7 +143,7 @@ export class AgentBlockHandler implements BlockHandler { private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise { if (!Array.isArray(tools) || tools.length === 0) return - const hasMcpTools = tools.some((t) => t.type === 'mcp') + const hasMcpTools = tools.some((t) => t.type === 'mcp' || t.type === 'mcp-server') const hasCustomTools = tools.some((t) => t.type === 'custom-tool') if (hasMcpTools) { @@ -161,7 +161,7 @@ export class AgentBlockHandler implements BlockHandler { ): Promise { if (!Array.isArray(tools) || tools.length === 0) return tools - const mcpTools = tools.filter((t) => t.type === 'mcp') + const mcpTools = tools.filter((t) => t.type === 'mcp' || t.type === 'mcp-server') if (mcpTools.length === 0) return tools const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))] @@ -195,7 +195,7 @@ export class AgentBlockHandler implements BlockHandler { } return tools.filter((tool) => { - if (tool.type !== 'mcp') return true + if (tool.type !== 'mcp' && tool.type !== 'mcp-server') return true const serverId = tool.params?.serverId if (!serverId) return false return availableServerIds.has(serverId) @@ -211,11 +211,14 @@ export class AgentBlockHandler implements BlockHandler { }) const mcpTools: ToolInput[] = [] + const mcpServers: ToolInput[] = [] const otherTools: ToolInput[] = [] for (const tool of filtered) { if (tool.type === 'mcp') { mcpTools.push(tool) + } else if (tool.type === 'mcp-server') { + mcpServers.push(tool) } else { otherTools.push(tool) } @@ -224,7 +227,12 @@ export class AgentBlockHandler implements BlockHandler { const otherResults = await Promise.all( otherTools.map(async (tool) => { try { - if (tool.type && tool.type !== 'custom-tool' && tool.type !== 'mcp') { + if ( + tool.type && + tool.type !== 'custom-tool' && + tool.type !== 'mcp' && + tool.type !== 'mcp-server' + ) { await validateBlockType(ctx.userId, tool.type, ctx) } if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) { @@ -240,12 +248,133 @@ export class AgentBlockHandler implements BlockHandler { const mcpResults = await this.processMcpToolsBatched(ctx, mcpTools) - const allTools = [...otherResults, ...mcpResults] + // Process MCP servers (all tools from server mode) + const mcpServerResults = await this.processMcpServerSelections(ctx, mcpServers) + + const allTools = [...otherResults, ...mcpResults, ...mcpServerResults] return allTools.filter( (tool): tool is NonNullable => tool !== null && tool !== undefined ) } + /** + * Process MCP server selections by discovering and formatting all tools from each server. + * This enables "agent discovery" mode where the LLM can call any tool from the server. + */ + private async processMcpServerSelections( + ctx: ExecutionContext, + mcpServerSelections: ToolInput[] + ): Promise { + if (mcpServerSelections.length === 0) return [] + + const results: any[] = [] + + for (const serverSelection of mcpServerSelections) { + const serverId = serverSelection.params?.serverId + const serverName = serverSelection.params?.serverName + const usageControl = serverSelection.usageControl || 'auto' + + if (!serverId) { + logger.error('MCP server selection missing serverId:', serverSelection) + continue + } + + try { + // Discover all tools from this server + const discoveredTools = await this.discoverMcpToolsForServer(ctx, serverId) + + // Create tool definitions for each discovered tool + for (const mcpTool of discoveredTools) { + const created = await this.createMcpToolFromDiscoveredServerTool( + ctx, + mcpTool, + serverId, + serverName || serverId, + usageControl + ) + if (created) results.push(created) + } + + logger.info( + `[AgentHandler] Expanded MCP server ${serverName} into ${discoveredTools.length} tools` + ) + } catch (error) { + logger.error(`[AgentHandler] Failed to process MCP server selection:`, { serverId, error }) + } + } + + return results + } + + /** + * Create an MCP tool from server discovery for the "all tools" mode. + */ + private async createMcpToolFromDiscoveredServerTool( + ctx: ExecutionContext, + mcpTool: any, + serverId: string, + serverName: string, + usageControl: string + ): Promise { + const toolName = mcpTool.name + + const { filterSchemaForLLM } = await import('@/tools/params') + const filteredSchema = filterSchemaForLLM( + mcpTool.inputSchema || { type: 'object', properties: {} }, + {} + ) + + const toolId = createMcpToolId(serverId, toolName) + + return { + id: toolId, + name: toolName, + description: mcpTool.description || `MCP tool ${toolName} from ${serverName}`, + parameters: filteredSchema, + params: {}, + usageControl, + executeFunction: async (callParams: Record) => { + const headers = await buildAuthHeaders() + const execUrl = buildAPIUrl('/api/mcp/tools/execute') + + const execResponse = await fetch(execUrl.toString(), { + method: 'POST', + headers, + body: stringifyJSON({ + serverId, + toolName, + arguments: callParams, + workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, + toolSchema: mcpTool.inputSchema, + }), + }) + + if (!execResponse.ok) { + throw new Error( + `MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}` + ) + } + + const result = await execResponse.json() + if (!result.success) { + throw new Error(result.error || 'MCP tool execution failed') + } + + return { + success: true, + output: result.data.output || {}, + metadata: { + source: 'mcp-server', + serverId, + serverName, + toolName, + }, + } + }, + } + } + private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise { const userProvidedParams = tool.params || {} diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 411b02a27..b9ec9915b 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -29,11 +29,36 @@ export interface AgentInputs { verbosity?: string } +/** + * Represents a tool input for the agent block. + * + * @remarks + * Valid types include: + * - Standard block types (e.g., 'api', 'search', 'function') + * - 'custom-tool': User-defined tools with custom code + * - 'mcp': Individual MCP tool from a connected server + * - 'mcp-server': All tools from an MCP server (agent discovery mode). + * At execution time, this is expanded into individual tool definitions + * for all tools available on the server. This enables dynamic capability + * discovery where the LLM can call any tool from the server. + */ export interface ToolInput { + /** + * Tool type identifier. + * 'mcp-server' enables server-level selection where all tools from + * the server are made available to the LLM at execution time. + */ type?: string schema?: any title?: string code?: string + /** + * Tool parameters. For 'mcp-server' type, includes: + * - serverId: The MCP server ID + * - serverUrl: The server URL (optional) + * - serverName: Human-readable server name + * - toolCount: Number of tools available (for display) + */ params?: Record timeout?: number usageControl?: 'auto' | 'force' | 'none'