mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]' />
|
||||
)}
|
||||
|
||||
@@ -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 || {}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user