feat(agent): add MCP server discovery mode for agent tool input (#3353)

* feat(agent): add MCP server discovery mode for agent tool input

* fix(tool-input): use type variant for MCP server tool count badge

* fix(mcp-dynamic-args): align label styling with standard subblock labels

* standardized inp format UI

* feat(tool-input): replace MCP server inline expand with drill-down navigation

* feat(tool-input): add chevron affordance and keyboard nav for MCP server drill-down

* fix(tool-input): handle mcp-server type in refresh, validation, badges, and usage control

* refactor(tool-validation): extract getMcpServerIssue, remove fake tool hack

* lint

* reorder dropdown

* perf(agent): parallelize MCP server tool creation with Promise.all

* fix(combobox): preserve cursor movement in search input, reset query on drilldown

* fix(combobox): route ArrowRight through handleSelect, remove redundant type guards

* fix(agent): rename mcpServers to mcpServerSelections to avoid shadowing DB import, route ArrowRight through handleSelect

* docs: update google integration docs

* fix(tool-input): reset drilldown state on tool selection to prevent stale view

* perf(agent): parallelize MCP server discovery across multiple servers
This commit is contained in:
Waleed
2026-02-26 15:17:23 -08:00
committed by GitHub
parent 345a95f48d
commit c6e147e56a
21 changed files with 563 additions and 190 deletions

View File

@@ -10,6 +10,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Translate](https://translate.google.com/) is Google's powerful translation service, supporting over 100 languages for text, documents, and websites. Backed by advanced neural machine translation, Google Translate delivers fast and accurate translations for a wide range of use cases, from casual communication to professional workflows.
In Sim, the Google Translate integration allows your agents to translate text and detect languages as part of automated workflows. Agents can translate content between languages, auto-detect source languages, and process multilingual data—all without manual intervention. By connecting Sim with Google Cloud Translation, you can build intelligent workflows that handle localization, multilingual support, content translation, and language detection at scale.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language.

View File

@@ -268,7 +268,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input

View File

@@ -547,7 +547,7 @@ export function McpDeploy({
</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input

View File

@@ -367,7 +367,7 @@ export function DocumentTagEntry({
}))
return (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[8px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Tag</Label>
<Combobox

View File

@@ -165,7 +165,7 @@ export function FilterRuleRow({
)
const renderContent = () => (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[8px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
{index > 0 && (
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Logic</Label>

View File

@@ -239,7 +239,7 @@ function InputMappingField({
</div>
{!collapsed && (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[8px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
<div className='relative'>

View File

@@ -358,7 +358,7 @@ export function KnowledgeTagFilters({
const isBetween = filter.operator === 'between'
return (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[8px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Tag</Label>
<Combobox

View File

@@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
@@ -147,7 +146,7 @@ export function McpDynamicArgs({
/>
<Label
htmlFor={`${paramName}-switch`}
className='cursor-pointer font-normal text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
className='cursor-pointer font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
>
{formatParameterLabel(paramName)}
</Label>
@@ -351,15 +350,14 @@ export function McpDynamicArgs({
<div key={paramName} className='subblock-row'>
<div className='subblock-content flex flex-col gap-[10px]'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-baseline gap-[6px] whitespace-nowrap'>
{formatParameterLabel(paramName)}
{toolSchema.required?.includes(paramName) && (
<span className='ml-0.5'>*</span>
)}
</Label>
</div>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>

View File

@@ -70,7 +70,7 @@ export function SortRuleRow({
)
const renderContent = () => (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[8px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Column</Label>
<Combobox

View File

@@ -552,7 +552,7 @@ export function FieldFormat({
{renderFieldHeader(field, index)}
{!field.collapsed && (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[8px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Name</Label>
<div className='relative'>{renderNameInput(field)}</div>

View File

@@ -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 { ArrowLeft, ChevronRight, Loader2, ServerIcon, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -21,6 +21,7 @@ import {
getIssueBadgeLabel,
getIssueBadgeVariant,
isToolUnavailable,
getMcpServerIssue as validateMcpServer,
getMcpToolIssue as validateMcpTool,
} from '@/lib/mcp/tool-validation'
import type { McpToolSchema } from '@/lib/mcp/types'
@@ -41,6 +42,7 @@ import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowI
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
import {
isCustomToolAlreadySelected,
isMcpServerAlreadySelected,
isMcpToolAlreadySelected,
isWorkflowAlreadySelected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils'
@@ -481,6 +483,7 @@ export const ToolInput = memo(function ToolInput({
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
const [mcpServerDrilldown, setMcpServerDrilldown] = useState<string | null>(null)
const canonicalModeOverrides = useWorkflowStore(
useCallback(
@@ -522,7 +525,9 @@ export const ToolInput = memo(function ToolInput({
)
const hasRefreshedRef = useRef(false)
const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp')
const hasMcpTools = selectedTools.some(
(tool) => tool.type === 'mcp' || tool.type === 'mcp-server'
)
useEffect(() => {
if (isPreview) return
@@ -539,10 +544,30 @@ export const ToolInput = memo(function ToolInput({
*/
const getMcpToolIssue = useCallback(
(tool: StoredTool) => {
if (tool.type !== 'mcp') return null
if (tool.type !== 'mcp' && tool.type !== 'mcp-server') return null
const serverId = tool.params?.serverId as string
const serverStates = mcpServers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError ?? undefined,
}))
if (tool.type === 'mcp-server') {
return validateMcpServer(
serverId,
tool.params?.serverUrl as string | undefined,
serverStates
)
}
const toolName = tool.params?.toolName as string
const discoveredTools = mcpTools.map((t) => ({
serverId: t.serverId,
name: t.name,
inputSchema: t.inputSchema,
}))
// Try to get fresh schema from DB (enables real-time updates after MCP refresh)
const storedTool =
@@ -561,17 +586,8 @@ export const ToolInput = memo(function ToolInput({
toolName,
schema,
},
mcpServers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError ?? undefined,
})),
mcpTools.map((t) => ({
serverId: t.serverId,
name: t.name,
inputSchema: t.inputSchema,
}))
serverStates,
discoveredTools
)
},
[mcpTools, mcpServers, storedMcpTools, workflowId]
@@ -702,6 +718,30 @@ export const ToolInput = memo(function ToolInput({
return selectedTools.some((tool) => tool.toolId === toolId)
}
/**
* Groups MCP tools by their parent server.
*/
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])
/**
* Resets the MCP server drilldown when the combobox closes.
*/
const handleComboboxOpenChange = useCallback((isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) {
setMcpServerDrilldown(null)
}
}, [])
const handleSelectTool = useCallback(
(toolBlock: (typeof toolBlocks)[0]) => {
if (isPreview || disabled) return
@@ -1012,6 +1052,7 @@ export const ToolInput = memo(function ToolInput({
])
if (closePopover) {
setMcpServerDrilldown(null)
setOpen(false)
}
},
@@ -1225,6 +1266,102 @@ export const ToolInput = memo(function ToolInput({
const toolGroups = useMemo((): ComboboxOptionGroup[] => {
const groups: ComboboxOptionGroup[] = []
// MCP Server drill-down: when navigated into a server, show only its tools
if (mcpServerDrilldown && !permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) {
const tools = mcpToolsByServer.get(mcpServerDrilldown)
if (tools && tools.length > 0) {
const server = mcpServers.find((s) => s.id === mcpServerDrilldown)
const serverName = tools[0]?.serverName || server?.name || 'Unknown Server'
const serverAlreadySelected = isMcpServerAlreadySelected(selectedTools, mcpServerDrilldown)
const toolCount = tools.length
const serverToolItems: ComboboxOption[] = []
// Back navigation
serverToolItems.push({
label: 'Back',
value: `mcp-server-back`,
iconElement: <ArrowLeft className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />,
onSelect: () => {
setMcpServerDrilldown(null)
},
keepOpen: true,
})
// "Use all tools" option
serverToolItems.push({
label: `Use all ${toolCount} tools`,
value: `mcp-server-all-${mcpServerDrilldown}`,
iconElement: createToolIcon('#6366F1', ServerIcon),
onSelect: () => {
if (serverAlreadySelected) return
const filteredTools = selectedTools.filter(
(tool) => !(tool.type === 'mcp' && tool.params?.serverId === mcpServerDrilldown)
)
const newTool: StoredTool = {
type: 'mcp-server',
title: `${serverName} (all tools)`,
toolId: `mcp-server-${mcpServerDrilldown}`,
params: {
serverId: mcpServerDrilldown,
...(server?.url && { serverUrl: server.url }),
serverName,
toolCount: String(toolCount),
},
isExpanded: false,
usageControl: 'auto',
}
setStoreValue([
...filteredTools.map((tool) => ({ ...tool, isExpanded: false })),
newTool,
])
setMcpServerDrilldown(null)
setOpen(false)
},
disabled: isPreview || disabled || serverAlreadySelected,
})
// Individual tools
for (const mcpTool of tools) {
const alreadySelected =
isMcpToolAlreadySelected(selectedTools, mcpTool.id) || serverAlreadySelected
serverToolItems.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: serverName,
items: serverToolItems,
})
}
return groups
}
// Root view: show all tool categories
const actionItems: ComboboxOption[] = []
if (!permissionConfig.disableCustomTools) {
actionItems.push({
@@ -1283,40 +1420,30 @@ export const ToolInput = memo(function ToolInput({
})
}
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) {
// MCP Servers — root folder view
if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) {
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 toolCount = tools.length
serverItems.push({
label: `${serverName} (${toolCount} tools)`,
value: `mcp-server-folder-${serverId}`,
iconElement: createToolIcon('#6366F1', ServerIcon),
suffixElement: <ChevronRight className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />,
onSelect: () => {
setMcpServerDrilldown(serverId)
},
keepOpen: true,
})
}
groups.push({
section: 'MCP Tools',
items: availableMcpTools.map((mcpTool) => {
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
const alreadySelected = isMcpToolAlreadySelected(selectedTools, mcpTool.id)
return {
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,
}
}),
section: 'MCP Servers',
items: serverItems,
})
}
@@ -1393,9 +1520,11 @@ export const ToolInput = memo(function ToolInput({
return groups
}, [
mcpServerDrilldown,
customTools,
availableMcpTools,
mcpServers,
mcpToolsByServer,
toolBlocks,
isPreview,
disabled,
@@ -1420,26 +1549,28 @@ export const ToolInput = memo(function ToolInput({
searchPlaceholder='Search tools...'
maxHeight={240}
emptyMessage='No tools found'
onOpenChange={setOpen}
onOpenChange={handleComboboxOpenChange}
onArrowLeft={mcpServerDrilldown ? () => setMcpServerDrilldown(null) : undefined}
/>
{selectedTools.length > 0 &&
selectedTools.map((tool, toolIndex) => {
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
const currentToolId =
!isCustomTool && !isMcpTool
!isCustomTool && !isMcpTool && !isMcpServer
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || ''
: tool.toolId || ''
const toolParams =
!isCustomTool && !isMcpTool && currentToolId
!isCustomTool && !isMcpTool && !isMcpServer && currentToolId
? getToolParametersConfig(currentToolId, tool.type, {
operation: tool.operation,
...tool.params,
@@ -1449,7 +1580,7 @@ export const ToolInput = memo(function ToolInput({
const toolScopedOverrides = scopeCanonicalOverrides(canonicalModeOverrides, tool.type)
const subBlocksResult: SubBlocksForToolInput | null =
!isCustomTool && !isMcpTool && currentToolId
!isCustomTool && !isMcpTool && !isMcpServer && currentToolId
? getSubBlocksForToolInput(
currentToolId,
tool.type,
@@ -1512,21 +1643,26 @@ export const ToolInput = memo(function ToolInput({
)
: []
const useSubBlocks = !isCustomTool && !isMcpTool && subBlocksResult?.subBlocks?.length
const useSubBlocks =
!isCustomTool && !isMcpTool && !isMcpServer && subBlocksResult?.subBlocks?.length
const displayParams: ToolParameterConfig[] = isCustomTool
? customToolParams
: isMcpTool
? mcpToolParams
: toolParams?.userInputParameters || []
: isMcpServer
? [] // MCP servers have no user-configurable params
: toolParams?.userInputParameters || []
const displaySubBlocks: BlockSubBlockConfig[] = useSubBlocks
? subBlocksResult!.subBlocks
: []
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)
const hasOperations =
!isCustomTool && !isMcpTool && !isMcpServer && hasMultipleOperations(tool.type)
const hasParams = useSubBlocks
? displaySubBlocks.length > 0
: displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0
const hasToolBody = hasOperations || hasParams
// MCP servers are expandable to show tool list
const hasToolBody = isMcpServer ? true : hasOperations || hasParams
const isExpandedForDisplay = hasToolBody
? isPreview
@@ -1534,6 +1670,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 (
<div
key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`}
@@ -1572,15 +1713,19 @@ export const ToolInput = memo(function ToolInput({
? '#3B82F6'
: isMcpTool
? mcpTool?.bgColor || '#6366F1'
: isWorkflowTool
: isMcpServer
? '#6366F1'
: toolBlock?.bgColor,
: isWorkflowTool
? '#6366F1'
: toolBlock?.bgColor,
}}
>
{isCustomTool ? (
<WrenchIcon className='h-[10px] w-[10px] text-white' />
) : isMcpTool ? (
<IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' />
) : isMcpServer ? (
<ServerIcon className='h-[10px] w-[10px] text-white' />
) : isWorkflowTool ? (
<IconComponent icon={WorkflowIcon} className='h-[10px] w-[10px] text-white' />
) : (
@@ -1593,7 +1738,12 @@ export const ToolInput = memo(function ToolInput({
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{isCustomTool ? customToolTitle : tool.title}
</span>
{isMcpTool &&
{isMcpServer && (
<Badge variant='type' size='sm'>
{tool.params?.toolCount || mcpServerTools.length} tools
</Badge>
)}
{(isMcpTool || isMcpServer) &&
!mcpDataLoading &&
(() => {
const issue = getMcpToolIssue(tool)
@@ -1628,61 +1778,65 @@ export const ToolInput = memo(function ToolInput({
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) && (
<Popover
open={usageControlPopoverIndex === toolIndex}
onOpenChange={(open) => setUsageControlPopoverIndex(open ? toolIndex : null)}
>
<PopoverTrigger asChild>
<button
className='flex items-center justify-center font-medium text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
onClick={(e: React.MouseEvent) => e.stopPropagation()}
aria-label='Tool usage control'
>
{tool.usageControl === 'auto' && 'Auto'}
{tool.usageControl === 'force' && 'Force'}
{tool.usageControl === 'none' && 'None'}
{!tool.usageControl && 'Auto'}
</button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={8}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className='gap-[2px]'
border
{supportsToolControl &&
!((isMcpTool || isMcpServer) && isMcpToolUnavailable(tool)) && (
<Popover
open={usageControlPopoverIndex === toolIndex}
onOpenChange={(open) =>
setUsageControlPopoverIndex(open ? toolIndex : null)
}
>
<PopoverItem
active={(tool.usageControl || 'auto') === 'auto'}
onClick={() => {
handleUsageControlChange(toolIndex, 'auto')
setUsageControlPopoverIndex(null)
}}
<PopoverTrigger asChild>
<button
className='flex items-center justify-center font-medium text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
onClick={(e: React.MouseEvent) => e.stopPropagation()}
aria-label='Tool usage control'
>
{tool.usageControl === 'auto' && 'Auto'}
{tool.usageControl === 'force' && 'Force'}
{tool.usageControl === 'none' && 'None'}
{!tool.usageControl && 'Auto'}
</button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={8}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className='gap-[2px]'
border
>
Auto <span className='text-[var(--text-tertiary)]'>(model decides)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'force'}
onClick={() => {
handleUsageControlChange(toolIndex, 'force')
setUsageControlPopoverIndex(null)
}}
>
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'none'}
onClick={() => {
handleUsageControlChange(toolIndex, 'none')
setUsageControlPopoverIndex(null)
}}
>
None
</PopoverItem>
</PopoverContent>
</Popover>
)}
<PopoverItem
active={(tool.usageControl || 'auto') === 'auto'}
onClick={() => {
handleUsageControlChange(toolIndex, 'auto')
setUsageControlPopoverIndex(null)
}}
>
Auto{' '}
<span className='text-[var(--text-tertiary)]'>(model decides)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'force'}
onClick={() => {
handleUsageControlChange(toolIndex, 'force')
setUsageControlPopoverIndex(null)
}}
>
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'none'}
onClick={() => {
handleUsageControlChange(toolIndex, 'none')
setUsageControlPopoverIndex(null)
}}
>
None
</PopoverItem>
</PopoverContent>
</Popover>
)}
<button
onClick={(e) => {
e.stopPropagation()
@@ -1698,30 +1852,53 @@ export const ToolInput = memo(function ToolInput({
{!isCustomTool && isExpandedForDisplay && (
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
{(() => {
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}
/>
{/* MCP Server tool list (read-only) */}
{isMcpServer && mcpServerTools.length > 0 && (
<div className='flex flex-col gap-[4px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Available tools:
</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
})()}
{(() => {
const renderedElements: React.ReactNode[] = []

View File

@@ -2,18 +2,33 @@
* 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.
*/
export 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<string, string>
/** Whether the tool details are expanded in UI */
isExpanded?: boolean

View File

@@ -7,6 +7,15 @@ export function isMcpToolAlreadySelected(selectedTools: StoredTool[], mcpToolId:
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
}
/**
* Checks if an MCP server is already selected (all tools mode).
*/
export function isMcpServerAlreadySelected(selectedTools: StoredTool[], serverId: string): boolean {
return selectedTools.some(
(tool) => tool.type === 'mcp-server' && tool.params?.serverId === serverId
)
}
/**
* Checks if a custom tool is already selected.
*/

View File

@@ -377,7 +377,7 @@ export function VariablesInput({
</div>
{!collapsed && (
<div className='flex flex-col gap-[8px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[8px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Variable</Label>
<Combobox

View File

@@ -423,6 +423,12 @@ export const Panel = memo(function Panel() {
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>
</PopoverItem>
{
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<VariableIcon className='h-3 w-3' />
<span>Variables</span>
</PopoverItem>
}
{userPermissions.canAdmin && !isSnapshotView && (
<PopoverItem onClick={handleToggleWorkflowLock} disabled={!hasBlocks}>
{allBlocksLocked ? (
@@ -433,12 +439,6 @@ export const Panel = memo(function Panel() {
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
{
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<VariableIcon className='h-3 w-3' />
<span>Variables</span>
</PopoverItem>
}
{/* <PopoverItem>
<Bug className='h-3 w-3' />
<span>Debug</span>

View File

@@ -284,7 +284,7 @@ export function Variables() {
const isCollapsed = collapsedById[variable.id] ?? false
return (
<div
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
onClick={() => toggleCollapsed(variable.id)}
onKeyDown={(e) => handleHeaderKeyDown(e, variable.id)}
role='button'
@@ -297,7 +297,7 @@ export function Variables() {
{variable.name || `Variable ${index + 1}`}
</span>
{variable.name && (
<Badge style={{ height: `${BADGE_HEIGHT}px`, fontSize: `${BADGE_TEXT_SIZE}px` }}>
<Badge variant='type' size='sm'>
{variable.type}
</Badge>
)}
@@ -460,7 +460,7 @@ export function Variables() {
{!(collapsedById[variable.id] ?? false) && (
<div
id={`variable-content-${variable.id}`}
className='flex flex-col gap-[6px] border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'
className='flex flex-col gap-[6px] rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'
>
<div className='flex flex-col gap-[4px]'>
<Label className='text-[13px]'>{STRINGS.labels.name}</Label>

View File

@@ -616,7 +616,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<EmcnInput

View File

@@ -53,6 +53,10 @@ export type ComboboxOption = {
onSelect?: () => void
/** Whether this option is disabled */
disabled?: boolean
/** When true, keep the dropdown open after selecting this option */
keepOpen?: boolean
/** Optional element rendered at the trailing end of the option (e.g. chevron for folders) */
suffixElement?: ReactNode
}
/**
@@ -107,6 +111,8 @@ export interface ComboboxProps
error?: string | null
/** Callback when popover open state changes */
onOpenChange?: (open: boolean) => void
/** Callback when ArrowLeft is pressed while dropdown is open (for folder back-navigation) */
onArrowLeft?: () => void
/** Enable search input in dropdown (useful for multiselect) */
searchable?: boolean
/** Placeholder for search input */
@@ -158,6 +164,7 @@ const Combobox = memo(
isLoading = false,
error = null,
onOpenChange,
onArrowLeft,
searchable = false,
searchPlaceholder = 'Search...',
align = 'start',
@@ -254,13 +261,16 @@ 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)
// Always reset search/highlight so stale queries don't filter new options
setSearchQuery('')
setHighlightedIndex(-1)
if (!keepOpen) {
setOpen(false)
}
return
}
@@ -272,11 +282,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()
}
}
}
},
@@ -345,7 +357,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()
@@ -380,8 +392,36 @@ const Combobox = memo(
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredOptions.length - 1))
}
}
if (e.key === 'ArrowRight') {
if (open && highlightedIndex >= 0) {
const highlightedOption = filteredOptions[highlightedIndex]
if (highlightedOption?.keepOpen && highlightedOption?.onSelect) {
e.preventDefault()
handleSelect(highlightedOption.value, highlightedOption.onSelect, true)
}
}
}
if (e.key === 'ArrowLeft') {
if (open && onArrowLeft) {
e.preventDefault()
onArrowLeft()
setSearchQuery('')
setHighlightedIndex(-1)
}
}
},
[disabled, open, highlightedIndex, filteredOptions, handleSelect, editable, inputRef]
[
disabled,
open,
highlightedIndex,
filteredOptions,
handleSelect,
editable,
inputRef,
onArrowLeft,
]
)
/**
@@ -591,9 +631,17 @@ const Combobox = memo(
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
// Forward navigation keys to main handler
// Only forward ArrowLeft/ArrowRight when cursor is at the boundary
// so normal text cursor movement still works in the search input
const input = e.currentTarget
const forwardArrowLeft = e.key === 'ArrowLeft' && input.selectionStart === 0
const forwardArrowRight =
e.key === 'ArrowRight' && input.selectionStart === input.value.length
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
forwardArrowRight ||
forwardArrowLeft ||
e.key === 'Enter' ||
e.key === 'Escape'
) {
@@ -671,7 +719,7 @@ const Combobox = memo(
e.preventDefault()
e.stopPropagation()
if (!option.disabled) {
handleSelect(option.value, option.onSelect)
handleSelect(option.value, option.onSelect, option.keepOpen)
}
}}
onMouseEnter={() =>
@@ -693,6 +741,7 @@ const Combobox = memo(
<span className='flex-1 truncate text-[var(--text-primary)]'>
{option.label}
</span>
{option.suffixElement}
{multiSelect && isSelected && (
<Check className='ml-[8px] h-[12px] w-[12px] flex-shrink-0 text-[var(--text-primary)]' />
)}
@@ -746,7 +795,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)}
@@ -766,6 +815,7 @@ const Combobox = memo(
<span className='flex-1 truncate text-[var(--text-primary)]'>
{option.label}
</span>
{option.suffixElement}
{multiSelect && isSelected && (
<Check className='ml-[8px] h-[12px] w-[12px] flex-shrink-0 text-[var(--text-primary)]' />
)}

View File

@@ -164,7 +164,7 @@ export class AgentBlockHandler implements BlockHandler {
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
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) {
@@ -182,7 +182,7 @@ export class AgentBlockHandler implements BlockHandler {
): Promise<ToolInput[]> {
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))]
@@ -216,7 +216,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)
@@ -236,11 +236,14 @@ export class AgentBlockHandler implements BlockHandler {
})
const mcpTools: ToolInput[] = []
const mcpServerSelections: ToolInput[] = []
const otherTools: ToolInput[] = []
for (const tool of filtered) {
if (tool.type === 'mcp') {
mcpTools.push(tool)
} else if (tool.type === 'mcp-server') {
mcpServerSelections.push(tool)
} else {
otherTools.push(tool)
}
@@ -249,7 +252,7 @@ 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') {
await validateBlockType(ctx.userId, tool.type, ctx)
}
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
@@ -265,12 +268,86 @@ export class AgentBlockHandler implements BlockHandler {
const mcpResults = await this.processMcpToolsBatched(ctx, mcpTools)
const allTools = [...otherResults, ...mcpResults]
// Process MCP server selections (all tools from server mode)
const mcpServerResults = await this.processMcpServerSelections(ctx, mcpServerSelections)
const allTools = [...otherResults, ...mcpResults, ...mcpServerResults]
return allTools.filter(
(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 = await Promise.all(
mcpServerSelections.map(async (serverSelection) => {
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)
return []
}
try {
const discoveredTools = await this.discoverMcpToolsForServer(ctx, serverId)
const createdTools = await Promise.all(
discoveredTools.map((mcpTool) =>
this.createMcpToolFromDiscoveredServerTool(
mcpTool,
serverId,
serverName || serverId,
usageControl
)
)
)
logger.info(
`[AgentHandler] Expanded MCP server ${serverName} into ${discoveredTools.length} tools`
)
return createdTools.filter(Boolean)
} catch (error) {
logger.error(`[AgentHandler] Failed to process MCP server selection:`, {
serverId,
error,
})
return []
}
})
)
return results.flat()
}
/**
* Create an MCP tool from server discovery for the "all tools" mode.
* Delegates to buildMcpTool so server-discovered tools use the same
* execution pipeline as individually-selected MCP tools.
*/
private async createMcpToolFromDiscoveredServerTool(
mcpTool: any,
serverId: string,
serverName: string,
usageControl: string
): Promise<any> {
return this.buildMcpTool({
serverId,
toolName: mcpTool.name,
description: mcpTool.description || `MCP tool ${mcpTool.name} from ${serverName}`,
schema: mcpTool.inputSchema || { type: 'object', properties: {} },
userProvidedParams: {},
usageControl,
})
}
private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
const userProvidedParams = tool.params || {}

View File

@@ -39,11 +39,36 @@ export interface AgentInputs {
thinkingLevel?: 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<string, any>
timeout?: number
usageControl?: 'auto' | 'force' | 'none'

View File

@@ -39,13 +39,15 @@ export function hasSchemaChanged(
return !isEqual(storedWithoutDesc, serverWithoutDesc)
}
export function getMcpToolIssue(
storedTool: StoredMcpToolReference,
servers: ServerState[],
discoveredTools: DiscoveredTool[]
/**
* Validates server-level connectivity for an MCP server.
* Checks: server existence, connection status, URL changes.
*/
export function getMcpServerIssue(
serverId: string,
serverUrl: string | undefined,
servers: ServerState[]
): McpToolIssue | null {
const { serverId, serverUrl, toolName, schema } = storedTool
const server = servers.find((s) => s.id === serverId)
if (!server) {
return { type: 'server_not_found', message: 'Server not found' }
@@ -62,6 +64,19 @@ export function getMcpToolIssue(
return { type: 'url_changed', message: 'Server URL changed' }
}
return null
}
export function getMcpToolIssue(
storedTool: StoredMcpToolReference,
servers: ServerState[],
discoveredTools: DiscoveredTool[]
): McpToolIssue | null {
const { serverId, serverUrl, toolName, schema } = storedTool
const serverIssue = getMcpServerIssue(serverId, serverUrl, servers)
if (serverIssue) return serverIssue
const serverTool = discoveredTools.find((t) => t.serverId === serverId && t.name === toolName)
if (!serverTool) {
return { type: 'tool_not_found', message: 'Tool not found on server' }