Compare commits

...

1 Commits

Author SHA1 Message Date
waleed
7c9dc7568a feat(mcp): added ability to connect an mcp server and allow agents to do discovery 2026-02-02 14:39:03 -08:00
4 changed files with 434 additions and 81 deletions

View File

@@ -1,7 +1,7 @@
import type React from 'react' import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' 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 { useParams } from 'next/navigation'
import { import {
Badge, Badge,
@@ -111,18 +111,33 @@ interface ToolInputProps {
* Represents a tool selected and configured in the workflow * Represents a tool selected and configured in the workflow
* *
* @remarks * @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. * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
* Everything else (title, schema, code) is loaded dynamically from the database. * Everything else (title, schema, code) is loaded dynamically from the database.
* Legacy custom tools with inline schema/code are still supported for backwards compatibility. * Legacy custom tools with inline schema/code are still supported for backwards compatibility.
*/ */
interface StoredTool { 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 type: string
/** Display title for the tool (optional for new custom tool format) */ /** Display title for the tool (optional for new custom tool format) */
title?: string title?: string
/** Direct tool ID for execution (optional for new custom tool format) */ /** Direct tool ID for execution (optional for new custom tool format) */
toolId?: string 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<string, string> params?: Record<string, string>
/** Whether the tool details are expanded in UI */ /** Whether the tool details are expanded in UI */
isExpanded?: boolean isExpanded?: boolean
@@ -1007,6 +1022,7 @@ export const ToolInput = memo(function ToolInput({
const [draggedIndex, setDraggedIndex] = useState<number | null>(null) const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null) const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
const [expandedMcpServers, setExpandedMcpServers] = useState<Set<string>>(new Set())
const value = isPreview ? previewValue : storeValue 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) 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. * 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<string, typeof availableMcpTools>()
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. * Checks if a block supports multiple operations.
* *
@@ -1805,41 +1864,125 @@ export const ToolInput = memo(function ToolInput({
}) })
} }
// MCP Tools section // MCP Servers section - grouped by server with expandable folders
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) { if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) {
groups.push({ // Create items for each server (as expandable folders)
section: 'MCP Tools', const serverItems: ComboboxOption[] = []
items: availableMcpTools.map((mcpTool) => {
const server = mcpServers.find((s) => s.id === mcpTool.serverId) for (const [serverId, tools] of mcpToolsByServer) {
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) const server = mcpServers.find((s) => s.id === serverId)
return { const serverName = tools[0]?.serverName || server?.name || 'Unknown Server'
label: mcpTool.name, const isExpanded = expandedMcpServers.has(serverId)
value: `mcp-${mcpTool.id}`, const serverAlreadySelected = isMcpServerAlreadySelected(serverId)
iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), const toolCount = tools.length
// Server folder header (clickable to expand/collapse)
serverItems.push({
label: serverName,
value: `mcp-server-folder-${serverId}`,
iconElement: (
<div className='flex items-center gap-[4px]'>
<ChevronRight
className={cn(
'h-[12px] w-[12px] text-[var(--text-tertiary)] transition-transform',
isExpanded && 'rotate-90'
)}
/>
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: '#6366F1' }}
>
<ServerIcon className='h-[10px] w-[10px] text-white' />
</div>
</div>
),
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: (
<div className='ml-[20px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
<McpIcon className='h-[10px] w-[10px] text-white' />
</div>
),
onSelect: () => { 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 = { const newTool: StoredTool = {
type: 'mcp', type: 'mcp-server',
title: mcpTool.name, title: `${serverName} (all tools)`,
toolId: mcpTool.id, toolId: `mcp-server-${serverId}`,
params: { params: {
serverId: mcpTool.serverId, serverId,
...(server?.url && { serverUrl: server.url }), ...(server?.url && { serverUrl: server.url }),
toolName: mcpTool.name, serverName,
serverName: mcpTool.serverName, toolCount: String(toolCount),
}, },
isExpanded: true, isExpanded: false,
usageControl: 'auto', 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: (
<div className='ml-[20px]'>
{createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon)}
</div>
),
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, customTools,
availableMcpTools, availableMcpTools,
mcpServers, mcpServers,
mcpToolsByServer,
expandedMcpServers,
toolBlocks, toolBlocks,
isPreview, isPreview,
disabled, disabled,
@@ -1935,8 +2080,10 @@ export const ToolInput = memo(function ToolInput({
getToolIdForOperation, getToolIdForOperation,
isToolAlreadySelected, isToolAlreadySelected,
isMcpToolAlreadySelected, isMcpToolAlreadySelected,
isMcpServerAlreadySelected,
isCustomToolAlreadySelected, isCustomToolAlreadySelected,
isWorkflowAlreadySelected, isWorkflowAlreadySelected,
toggleMcpServerExpanded,
]) ])
const toolRequiresOAuth = (toolId: string): boolean => { const toolRequiresOAuth = (toolId: string): boolean => {
@@ -2363,24 +2510,25 @@ export const ToolInput = memo(function ToolInput({
{/* Selected Tools List */} {/* Selected Tools List */}
{selectedTools.length > 0 && {selectedTools.length > 0 &&
selectedTools.map((tool, toolIndex) => { 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 isCustomTool = tool.type === 'custom-tool'
const isMcpTool = tool.type === 'mcp' const isMcpTool = tool.type === 'mcp'
const isMcpServer = tool.type === 'mcp-server'
const isWorkflowTool = tool.type === 'workflow' const isWorkflowTool = tool.type === 'workflow'
const toolBlock = const toolBlock =
!isCustomTool && !isMcpTool !isCustomTool && !isMcpTool && !isMcpServer
? toolBlocks.find((block) => block.type === tool.type) ? toolBlocks.find((block) => block.type === tool.type)
: null : null
// Get the current tool ID (may change based on operation) // Get the current tool ID (may change based on operation)
const currentToolId = const currentToolId =
!isCustomTool && !isMcpTool !isCustomTool && !isMcpTool && !isMcpServer
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || '' ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || ''
: tool.toolId || '' : tool.toolId || ''
// Get tool parameters using the new utility with block type for UI components // Get tool parameters using the new utility with block type for UI components
const toolParams = const toolParams =
!isCustomTool && !isMcpTool && currentToolId !isCustomTool && !isMcpTool && !isMcpServer && currentToolId
? getToolParametersConfig(currentToolId, tool.type, { ? getToolParametersConfig(currentToolId, tool.type, {
operation: tool.operation, operation: tool.operation,
...tool.params, ...tool.params,
@@ -2449,21 +2597,32 @@ export const ToolInput = memo(function ToolInput({
? customToolParams ? customToolParams
: isMcpTool : isMcpTool
? mcpToolParams ? mcpToolParams
: toolParams?.userInputParameters || [] : isMcpServer
? [] // MCP servers have no user-configurable params
: toolParams?.userInputParameters || []
// Check if tool requires OAuth // Check if tool requires OAuth
const requiresOAuth = const requiresOAuth =
!isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId) !isCustomTool &&
!isMcpTool &&
!isMcpServer &&
currentToolId &&
toolRequiresOAuth(currentToolId)
const oauthConfig = const oauthConfig =
!isCustomTool && !isMcpTool && currentToolId ? getToolOAuthConfig(currentToolId) : null !isCustomTool && !isMcpTool && !isMcpServer && currentToolId
? getToolOAuthConfig(currentToolId)
: null
// Determine if tool has expandable body content // 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) => const filteredDisplayParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool) evaluateParameterCondition(param, tool)
) )
const hasToolBody = // MCP servers are expandable to show tool list
hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0 const hasToolBody = isMcpServer
? true
: hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0
// Only show expansion if tool has body content // Only show expansion if tool has body content
const isExpandedForDisplay = hasToolBody const isExpandedForDisplay = hasToolBody
@@ -2472,6 +2631,11 @@ export const ToolInput = memo(function ToolInput({
: !!tool.isExpanded : !!tool.isExpanded
: false : false
// For MCP servers, get the list of tools for display
const mcpServerTools = isMcpServer
? availableMcpTools.filter((t) => t.serverId === tool.params?.serverId)
: []
return ( return (
<div <div
key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`} key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`}
@@ -2508,7 +2672,7 @@ export const ToolInput = memo(function ToolInput({
style={{ style={{
backgroundColor: isCustomTool backgroundColor: isCustomTool
? '#3B82F6' ? '#3B82F6'
: isMcpTool : isMcpTool || isMcpServer
? mcpTool?.bgColor || '#6366F1' ? mcpTool?.bgColor || '#6366F1'
: isWorkflowTool : isWorkflowTool
? '#6366F1' ? '#6366F1'
@@ -2519,6 +2683,8 @@ export const ToolInput = memo(function ToolInput({
<WrenchIcon className='h-[10px] w-[10px] text-white' /> <WrenchIcon className='h-[10px] w-[10px] text-white' />
) : isMcpTool ? ( ) : isMcpTool ? (
<IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' /> <IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' />
) : isMcpServer ? (
<ServerIcon className='h-[10px] w-[10px] text-white' />
) : isWorkflowTool ? ( ) : isWorkflowTool ? (
<IconComponent icon={WorkflowIcon} className='h-[10px] w-[10px] text-white' /> <IconComponent icon={WorkflowIcon} className='h-[10px] w-[10px] text-white' />
) : ( ) : (
@@ -2531,6 +2697,11 @@ export const ToolInput = memo(function ToolInput({
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'> <span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{isCustomTool ? customToolTitle : tool.title} {isCustomTool ? customToolTitle : tool.title}
</span> </span>
{isMcpServer && (
<Badge variant='default' size='sm'>
{tool.params?.toolCount || mcpServerTools.length} tools
</Badge>
)}
{isMcpTool && {isMcpTool &&
!mcpDataLoading && !mcpDataLoading &&
(() => { (() => {
@@ -2636,31 +2807,53 @@ export const ToolInput = memo(function ToolInput({
{!isCustomTool && isExpandedForDisplay && ( {!isCustomTool && isExpandedForDisplay && (
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'> <div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'>
{/* Operation dropdown for tools with multiple operations */} {/* MCP Server tool list (read-only) */}
{(() => { {isMcpServer && mcpServerTools.length > 0 && (
const hasOperations = hasMultipleOperations(tool.type) <div className='flex flex-col gap-[4px]'>
const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] <div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Available tools:
return hasOperations && operationOptions.length > 0 ? (
<div className='relative space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
Operation
</div>
<Combobox
options={operationOptions
.filter((option) => 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}
/>
</div> </div>
) : null <div className='flex flex-wrap gap-[4px]'>
})()} {mcpServerTools.map((serverTool) => (
<Badge
key={serverTool.id}
variant='outline'
size='sm'
className='text-[11px]'
>
{serverTool.name}
</Badge>
))}
</div>
</div>
)}
{/* Operation dropdown for tools with multiple operations */}
{!isMcpServer &&
(() => {
const hasOperations = hasMultipleOperations(tool.type)
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
return hasOperations && operationOptions.length > 0 ? (
<div className='relative space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
Operation
</div>
<Combobox
options={operationOptions
.filter((option) => 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}
/>
</div>
) : null
})()}
{/* OAuth credential selector if required */} {/* OAuth credential selector if required */}
{requiresOAuth && oauthConfig && ( {requiresOAuth && oauthConfig && (

View File

@@ -52,6 +52,8 @@ export type ComboboxOption = {
onSelect?: () => void onSelect?: () => void
/** Whether this option is disabled */ /** Whether this option is disabled */
disabled?: boolean 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 * Handles selection of an option
*/ */
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedValue: string, customOnSelect?: () => void) => { (selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => {
// If option has custom onSelect, use it instead // If option has custom onSelect, use it instead
if (customOnSelect) { if (customOnSelect) {
customOnSelect() customOnSelect()
setOpen(false) if (!keepOpen) {
setHighlightedIndex(-1) setOpen(false)
setSearchQuery('') setHighlightedIndex(-1)
setSearchQuery('')
}
return return
} }
@@ -270,11 +274,13 @@ const Combobox = memo(
onMultiSelectChange(newValues) onMultiSelectChange(newValues)
} else { } else {
onChange?.(selectedValue) onChange?.(selectedValue)
setOpen(false) if (!keepOpen) {
setHighlightedIndex(-1) setOpen(false)
setSearchQuery('') setHighlightedIndex(-1)
if (editable && inputRef.current) { setSearchQuery('')
inputRef.current.blur() if (editable && inputRef.current) {
inputRef.current.blur()
}
} }
} }
}, },
@@ -343,7 +349,7 @@ const Combobox = memo(
e.preventDefault() e.preventDefault()
const selectedOption = filteredOptions[highlightedIndex] const selectedOption = filteredOptions[highlightedIndex]
if (selectedOption && !selectedOption.disabled) { if (selectedOption && !selectedOption.disabled) {
handleSelect(selectedOption.value, selectedOption.onSelect) handleSelect(selectedOption.value, selectedOption.onSelect, selectedOption.keepOpen)
} }
} else if (!editable) { } else if (!editable) {
e.preventDefault() e.preventDefault()
@@ -668,7 +674,7 @@ const Combobox = memo(
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (!option.disabled) { if (!option.disabled) {
handleSelect(option.value, option.onSelect) handleSelect(option.value, option.onSelect, option.keepOpen)
} }
}} }}
onMouseEnter={() => onMouseEnter={() =>
@@ -743,7 +749,7 @@ const Combobox = memo(
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (!option.disabled) { if (!option.disabled) {
handleSelect(option.value, option.onSelect) handleSelect(option.value, option.onSelect, option.keepOpen)
} }
}} }}
onMouseEnter={() => !option.disabled && setHighlightedIndex(index)} onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}

View File

@@ -143,7 +143,7 @@ export class AgentBlockHandler implements BlockHandler {
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> { private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
if (!Array.isArray(tools) || tools.length === 0) return 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') const hasCustomTools = tools.some((t) => t.type === 'custom-tool')
if (hasMcpTools) { if (hasMcpTools) {
@@ -161,7 +161,7 @@ export class AgentBlockHandler implements BlockHandler {
): Promise<ToolInput[]> { ): Promise<ToolInput[]> {
if (!Array.isArray(tools) || tools.length === 0) return tools 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 if (mcpTools.length === 0) return tools
const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))] 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) => { 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 const serverId = tool.params?.serverId
if (!serverId) return false if (!serverId) return false
return availableServerIds.has(serverId) return availableServerIds.has(serverId)
@@ -211,11 +211,14 @@ export class AgentBlockHandler implements BlockHandler {
}) })
const mcpTools: ToolInput[] = [] const mcpTools: ToolInput[] = []
const mcpServers: ToolInput[] = []
const otherTools: ToolInput[] = [] const otherTools: ToolInput[] = []
for (const tool of filtered) { for (const tool of filtered) {
if (tool.type === 'mcp') { if (tool.type === 'mcp') {
mcpTools.push(tool) mcpTools.push(tool)
} else if (tool.type === 'mcp-server') {
mcpServers.push(tool)
} else { } else {
otherTools.push(tool) otherTools.push(tool)
} }
@@ -224,7 +227,12 @@ export class AgentBlockHandler implements BlockHandler {
const otherResults = await Promise.all( const otherResults = await Promise.all(
otherTools.map(async (tool) => { otherTools.map(async (tool) => {
try { 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) await validateBlockType(ctx.userId, tool.type, ctx)
} }
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) { 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 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( return allTools.filter(
(tool): tool is NonNullable<typeof tool> => tool !== null && tool !== undefined (tool): tool is NonNullable<typeof tool> => 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<any[]> {
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<any> {
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<string, any>) => {
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<any> { private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
const userProvidedParams = tool.params || {} const userProvidedParams = tool.params || {}

View File

@@ -29,11 +29,36 @@ export interface AgentInputs {
verbosity?: string 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 { 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 type?: string
schema?: any schema?: any
title?: string title?: string
code?: 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<string, any> params?: Record<string, any>
timeout?: number timeout?: number
usageControl?: 'auto' | 'force' | 'none' usageControl?: 'auto' | 'force' | 'none'