feat(mcp): generic MCP tool source with per-node function filtering (#301)

* feat(mcp): generic MCP tool source with per-node function filtering

Adds a Model Context Protocol tool category: connect a customer MCP
server and expose its tools to the agent, with optional per-node
allow-listing of individual MCP functions.

- ToolCategory.MCP enum + alembic migration
- MCP definition validator and collision-safe function-name namespacing
- McpToolSession wrapper: graceful-degrade, per-call open/close lifecycle
- CustomToolManager MCP branch (schemas + proxy handlers)
- Per-node mcp_tool_filters threaded through DTO/graph/engine
- Best-effort discovered_tools catalog cache + POST /tools/{uuid}/mcp/refresh
- UI: MCP create/edit config, tabbed ToolSelector with per-node toggles

* feat: refactor for code standardisation and documentation

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Paulo Busato Favarato 2026-05-19 07:40:00 -03:00 committed by GitHub
parent 0097974444
commit 75839f9de5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3028 additions and 137 deletions

View file

@ -9,16 +9,25 @@ import {
listRecordingsApiV1WorkflowRecordingsGet,
updateToolApiV1ToolsToolUuidPut,
} from "@/client/sdk.gen";
import type { RecordingResponseSchema, ToolResponse, TransferCallConfig as APITransferCallConfig } from "@/client/types.gen";
import type { EndCallConfig } from "@/client/types.gen";
import type {
EndCallConfig,
HttpApiToolDefinition,
RecordingResponseSchema,
ToolResponse,
TransferCallConfig as APITransferCallConfig,
UpdateToolRequest,
} from "@/client/types.gen";
import {
CredentialSelector,
type HttpMethod,
type KeyValueItem,
type ParameterType,
type PresetToolParameter,
type ToolParameter,
validateUrl,
} from "@/components/http";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -26,35 +35,33 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useAuth } from "@/lib/auth";
import {
createMcpDefinition,
DEFAULT_END_CALL_REASON_DESCRIPTION,
type EndCallMessageType,
getCategoryConfig,
getToolTypeLabel,
MCP_URL_PATTERN,
renderToolIcon,
type ToolCategory,
} from "../config";
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
// Extended HttpApiConfig with parameters (until client types are regenerated)
interface HttpApiConfigWithParams {
method?: string;
url?: string;
headers?: Record<string, string>;
credential_uuid?: string;
parameters?: ToolParameter[];
preset_parameters?: Array<{
name?: string;
type?: PresetToolParameter["type"];
value_template?: string;
required?: boolean;
}>;
timeout_ms?: number;
customMessage?: string;
function normalizeParameterType(value: string | null | undefined): ParameterType {
switch (value) {
case "number":
case "boolean":
return value;
default:
return "string";
}
}
export default function ToolDetailPage() {
@ -108,6 +115,11 @@ export default function ToolDetailPage() {
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
const [customMessageRecordingId, setCustomMessageRecordingId] = useState("");
// MCP form state
const [mcpUrl, setMcpUrl] = useState("");
const [mcpCredentialUuid, setMcpCredentialUuid] = useState("");
const [mcpToolsFilter, setMcpToolsFilter] = useState("");
// Org-level recordings for audio dropdowns
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
@ -155,8 +167,7 @@ export default function ToolDetailPage() {
if (config) {
setEndCallMessageType(config.messageType || "none");
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setAudioRecordingId((config as any).audioRecordingId || "");
setAudioRecordingId(config.audioRecordingId || "");
setEndCallReason(config.endCallReason ?? false);
setEndCallReasonDescription(config.endCallReasonDescription || "");
} else {
@ -173,8 +184,7 @@ export default function ToolDetailPage() {
setTransferDestination(config.destination || "");
setTransferMessageType(config.messageType || "none");
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTransferAudioRecordingId((config as any).audioRecordingId || "");
setTransferAudioRecordingId(config.audioRecordingId || "");
setTransferTimeout(config.timeout ?? 30);
} else {
setTransferDestination("");
@ -183,19 +193,35 @@ export default function ToolDetailPage() {
setTransferAudioRecordingId("");
setTransferTimeout(30);
}
} else if (tool.category === "mcp") {
// Populate MCP specific fields
const config = tool.definition?.config as
| { url?: string; credential_uuid?: string | null; tools_filter?: string[] }
| undefined;
if (config) {
setMcpUrl(config.url || "");
setMcpCredentialUuid(config.credential_uuid || "");
setMcpToolsFilter(
Array.isArray(config.tools_filter)
? config.tools_filter.join(", ")
: ""
);
} else {
setMcpUrl("");
setMcpCredentialUuid("");
setMcpToolsFilter("");
}
} else {
// Populate HTTP API specific fields
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
const config = tool.definition?.config as HttpApiToolDefinition["config"] | undefined;
if (config) {
setHttpMethod((config.method as HttpMethod) || "POST");
setUrl(config.url || "");
setCredentialUuid(config.credential_uuid || "");
setTimeoutMs(config.timeout_ms || 5000);
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setCustomMessageType((config as any).customMessageType || "text");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setCustomMessageRecordingId((config as any).customMessageRecordingId || "");
setCustomMessageType(config.customMessageType || "text");
setCustomMessageRecordingId(config.customMessageRecordingId || "");
// Convert headers object to array
if (config.headers) {
@ -212,9 +238,9 @@ export default function ToolDetailPage() {
// Load parameters
if (config.parameters && Array.isArray(config.parameters)) {
setParameters(
config.parameters.map((p: ToolParameter) => ({
config.parameters.map((p) => ({
name: p.name || "",
type: p.type || "string",
type: normalizeParameterType(p.type),
description: p.description || "",
required: p.required ?? true,
}))
@ -227,7 +253,7 @@ export default function ToolDetailPage() {
setPresetParameters(
config.preset_parameters.map((p) => ({
name: p.name || "",
type: p.type || "string",
type: normalizeParameterType(p.type),
valueTemplate: p.value_template || "",
required: p.required ?? true,
}))
@ -275,6 +301,16 @@ export default function ToolDetailPage() {
setError("Please enter a valid phone number (E.164 format) or SIP endpoint (e.g., PJSIP/1234)");
return;
}
} else if (tool.category === "mcp") {
// Validate MCP server URL (must be http(s))
if (!mcpUrl.trim()) {
setError("Please enter the MCP server URL");
return;
}
if (!MCP_URL_PATTERN.test(mcpUrl.trim())) {
setError("MCP server URL must start with http:// or https://");
return;
}
} else if (tool.category !== "end_call") {
// Validate URL for HTTP API tools
const urlValidation = validateUrl(url);
@ -305,7 +341,7 @@ export default function ToolDetailPage() {
setSaveSuccess(false);
const accessToken = await getAccessToken();
let requestBody;
let requestBody: UpdateToolRequest;
if (tool.category === "calculator") {
// Built-in tool - only name/description, no config
@ -351,6 +387,12 @@ export default function ToolDetailPage() {
},
},
};
} else if (tool.category === "mcp") {
requestBody = {
name,
description: description || undefined,
definition: createMcpDefinition(mcpUrl, mcpCredentialUuid, mcpToolsFilter),
};
} else {
// Build HTTP API request body
const headersObject: Record<string, string> = {};
@ -399,8 +441,7 @@ export default function ToolDetailPage() {
const response = await updateToolApiV1ToolsToolUuidPut({
path: { tool_uuid: toolUuid },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: requestBody as any,
body: requestBody,
headers: {
Authorization: `Bearer ${accessToken}`,
},
@ -510,6 +551,7 @@ const data = await response.json();`;
const isEndCallTool = tool.category === "end_call";
const isTransferCallTool = tool.category === "transfer_call";
const isBuiltinTool = tool.category === "calculator";
const isMcpTool = tool.category === "mcp";
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
@ -545,7 +587,7 @@ const data = await response.json();`;
</div>
</div>
<div className="flex items-center gap-2">
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && (
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && !isMcpTool && (
<Button
variant="outline"
onClick={() => setShowCodeDialog(true)}
@ -613,6 +655,79 @@ const data = await response.json();`;
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
/>
) : isMcpTool ? (
<Card>
<CardHeader>
<CardTitle>MCP Server Configuration</CardTitle>
<CardDescription>
Configure the MCP server endpoint. Its tools become available to the agent.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="mcp-name">Tool Name</Label>
<Input
id="mcp-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Customer MCP Server"
/>
</div>
<div className="space-y-2">
<Label htmlFor="mcp-description">Description</Label>
<p className="text-xs text-muted-foreground">
Provide a description which makes it easy for LLM to understand what this tool does
</p>
<Textarea
id="mcp-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this MCP server provide?"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mcp-url">MCP Server URL</Label>
<Input
id="mcp-url"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder="https://your-mcp-server.example.com/mcp"
/>
</div>
<div className="space-y-2">
<Label>Transport</Label>
<Input
value="Streamable HTTP"
disabled
readOnly
/>
</div>
<CredentialSelector
value={mcpCredentialUuid}
onChange={setMcpCredentialUuid}
label="Credential (Optional)"
description="Select a credential for authenticating with the MCP server, or leave empty for no auth."
/>
<div className="space-y-2">
<Label htmlFor="mcp-tools-filter">Tools Filter (Optional)</Label>
<Input
id="mcp-tools-filter"
value={mcpToolsFilter}
onChange={(e) => setMcpToolsFilter(e.target.value)}
placeholder="e.g., tool_one, tool_two"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of tool names to allow. Leave empty to expose all tools from the server.
</p>
</div>
</CardContent>
</Card>
) : (
<HttpApiToolConfig
name={name}

View file

@ -8,11 +8,12 @@ import type {
EndCallConfig,
EndCallToolDefinition,
HttpApiToolDefinition,
McpToolDefinition,
TransferCallConfig,
TransferCallToolDefinition,
} from "@/client/types.gen";
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration";
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
export type EndCallMessageType = "none" | "custom" | "audio";
@ -75,6 +76,14 @@ export const TOOL_CATEGORIES: ToolCategoryConfig[] = [
description: "Perform arithmetic calculations (supports +, -, *, /, **, %, and parentheses)",
},
},
{
value: "mcp",
label: "MCP Server",
description: "Connect a customer MCP server; its tools become available to the agent",
icon: Puzzle,
iconName: "puzzle",
iconColor: "#8B5CF6",
},
{
value: "native",
label: "Native (Coming Soon)",
@ -128,6 +137,8 @@ export function getToolTypeLabel(category: string): string {
return "Native Tool";
case "integration":
return "Integration Tool";
case "mcp":
return "MCP Server Tool";
default:
return "Tool";
}
@ -149,7 +160,12 @@ export const DEFAULT_TRANSFER_CALL_CONFIG: TransferCallConfig = {
timeout: 30,
};
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition | TransferCallToolDefinition | CalculatorToolDefinition;
export type ToolDefinition =
| HttpApiToolDefinition
| EndCallToolDefinition
| TransferCallToolDefinition
| CalculatorToolDefinition
| McpToolDefinition;
export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefinition {
return {
@ -185,6 +201,28 @@ export function createCalculatorDefinition(): CalculatorToolDefinition {
};
}
export const MCP_URL_PATTERN = /^https?:\/\//i;
export function createMcpDefinition(
url: string,
credentialUuid: string,
toolsFilterCsv: string,
): McpToolDefinition {
return {
schema_version: 1,
type: "mcp" as const,
config: {
transport: "streamable_http" as const,
url: url.trim(),
credential_uuid: credentialUuid || null,
tools_filter: toolsFilterCsv
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
},
};
}
export function createToolDefinition(category: ToolCategory): ToolDefinition {
switch (category) {
case "end_call":

View file

@ -10,7 +10,8 @@ import {
listToolsApiV1ToolsGet,
unarchiveToolApiV1ToolsToolUuidUnarchivePost,
} from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import type { CreateToolRequest, ToolResponse } from "@/client/types.gen";
import { CredentialSelector } from "@/components/http";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -41,8 +42,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/lib/auth";
import {
createMcpDefinition,
createToolDefinition,
getCategoryConfig,
MCP_URL_PATTERN,
renderToolIcon,
TOOL_CATEGORIES,
type ToolCategory,
@ -63,6 +66,11 @@ export default function ToolsPage() {
const [error, setError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
// MCP-specific create dialog state
const [mcpUrl, setMcpUrl] = useState("");
const [mcpCredentialUuid, setMcpCredentialUuid] = useState("");
const [mcpToolsFilter, setMcpToolsFilter] = useState("");
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
@ -108,21 +116,38 @@ export default function ToolsPage() {
return;
}
if (newToolCategory === "mcp" && !mcpUrl.trim()) {
setCreateError("Please enter the MCP server URL");
return;
}
if (newToolCategory === "mcp" && !MCP_URL_PATTERN.test(mcpUrl.trim())) {
setCreateError("MCP server URL must start with http:// or https://");
return;
}
try {
setIsCreating(true);
setCreateError(null);
const accessToken = await getAccessToken();
const categoryConfig = getCategoryConfig(newToolCategory);
const definition = newToolCategory === "mcp"
? createMcpDefinition(mcpUrl, mcpCredentialUuid, mcpToolsFilter)
: createToolDefinition(newToolCategory);
const requestBody: CreateToolRequest = {
name: newToolName,
description: newToolDescription || undefined,
category: newToolCategory,
icon: categoryConfig?.iconName || "globe",
icon_color: categoryConfig?.iconColor || "#3B82F6",
definition,
};
const response = await createToolApiV1ToolsPost({
body: {
name: newToolName,
description: newToolDescription || undefined,
category: newToolCategory,
icon: categoryConfig?.iconName || "globe",
icon_color: categoryConfig?.iconColor || "#3B82F6",
definition: createToolDefinition(newToolCategory),
},
body: requestBody,
headers: {
Authorization: `Bearer ${accessToken}`,
},
@ -139,6 +164,9 @@ export default function ToolsPage() {
setNewToolName("");
setNewToolDescription("");
setNewToolCategory("http_api");
setMcpUrl("");
setMcpCredentialUuid("");
setMcpToolsFilter("");
// Navigate to the new tool's detail page
router.push(`/tools/${response.data.tool_uuid}`);
}
@ -233,6 +261,8 @@ export default function ToolsPage() {
return <Badge variant="secondary">Native</Badge>;
case "integration":
return <Badge variant="outline">Integration</Badge>;
case "mcp":
return <Badge variant="outline">MCP</Badge>;
default:
return <Badge variant="outline">{category}</Badge>;
}
@ -465,7 +495,14 @@ export default function ToolsPage() {
{/* Create Tool Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
setIsCreateDialogOpen(open);
if (open) setCreateError(null);
if (open) {
setCreateError(null);
} else {
// Reset MCP fields when dialog is closed without creating
setMcpUrl("");
setMcpCredentialUuid("");
setMcpToolsFilter("");
}
}}>
<DialogContent>
<DialogHeader>
@ -482,6 +519,7 @@ export default function ToolsPage() {
onValueChange={(v) => {
const category = v as ToolCategory;
setNewToolCategory(category);
setCreateError(null);
const categoryConfig = getCategoryConfig(category);
if (categoryConfig?.autoFill) {
setNewToolName(categoryConfig.autoFill.name);
@ -532,6 +570,46 @@ export default function ToolsPage() {
placeholder="What does this tool do?"
/>
</div>
{newToolCategory === "mcp" && (
<>
<div className="grid gap-2">
<Label htmlFor="mcp-url">MCP Server URL</Label>
<Input
id="mcp-url"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder="https://your-mcp-server.example.com/mcp"
/>
</div>
<div className="grid gap-2">
<Label>Transport</Label>
<Input
value="Streamable HTTP"
disabled
readOnly
/>
</div>
<CredentialSelector
value={mcpCredentialUuid}
onChange={setMcpCredentialUuid}
label="Credential (Optional)"
description="Select a credential for authenticating with the MCP server, or leave empty for no auth."
/>
<div className="grid gap-2">
<Label htmlFor="mcp-tools-filter">Tools Filter (Optional)</Label>
<Input
id="mcp-tools-filter"
value={mcpToolsFilter}
onChange={(e) => setMcpToolsFilter(e.target.value)}
placeholder="e.g., tool_one, tool_two"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of tool names to allow. Leave empty to expose all tools from the server.
</p>
</div>
</>
)}
</div>
{createError && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm">

View file

@ -325,14 +325,33 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
await saveWorkflowConfigurations(workflowConfigurations, newName);
}, [saveWorkflowConfigurations, workflowConfigurations]);
const updateTool = useCallback(
(toolUuid: string, updater: (tool: ToolResponse) => ToolResponse) => {
setTools((prev) =>
prev?.map((tool) =>
tool.tool_uuid === toolUuid ? updater(tool) : tool,
),
);
},
[],
);
// Memoize the context value to prevent unnecessary re-renders
const workflowContextValue = useMemo(() => ({
saveWorkflow: guardedSaveWorkflow,
documents,
tools,
updateTool,
recordings,
readOnly: isViewingHistoricalVersion,
}), [guardedSaveWorkflow, documents, tools, recordings, isViewingHistoricalVersion]);
}), [
guardedSaveWorkflow,
documents,
tools,
updateTool,
recordings,
isViewingHistoricalVersion,
]);
return (
<WorkflowProvider value={workflowContextValue}>

View file

@ -7,6 +7,10 @@ interface WorkflowContextType {
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
documents?: DocumentResponseSchema[];
tools?: ToolResponse[];
updateTool?: (
toolUuid: string,
updater: (tool: ToolResponse) => ToolResponse,
) => void;
recordings?: RecordingResponseSchema[];
readOnly?: boolean;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -949,7 +949,9 @@ export type CreateToolRequest = {
type: 'transfer_call';
} & TransferCallToolDefinition) | ({
type: 'calculator';
} & CalculatorToolDefinition);
} & CalculatorToolDefinition) | ({
type: 'mcp';
} & McpToolDefinition);
};
/**
@ -2097,6 +2099,102 @@ export type MpsCreditsResponse = {
total_quota: number;
};
/**
* McpRefreshResponse
*
* Result of re-discovering an MCP server's tool catalog.
*/
export type McpRefreshResponse = {
/**
* Tool Uuid
*/
tool_uuid: string;
/**
* Discovered Tools
*/
discovered_tools?: Array<unknown>;
/**
* Error
*/
error?: string | null;
};
/**
* McpToolConfig
*
* Configuration for an MCP tool definition.
*/
export type McpToolConfig = {
/**
* Transport
*
* MCP transport protocol
*/
transport?: 'streamable_http';
/**
* Url
*
* MCP server URL (must be http:// or https://)
*/
url: string;
/**
* Credential Uuid
*
* Reference to ExternalCredentialModel for auth
*/
credential_uuid?: string | null;
/**
* Tools Filter
*
* Allowlist of MCP tool names to expose (empty = all tools)
*/
tools_filter?: Array<string>;
/**
* Timeout Secs
*
* Connection timeout in seconds
*/
timeout_secs?: number;
/**
* Sse Read Timeout Secs
*
* SSE read timeout in seconds
*/
sse_read_timeout_secs?: number;
/**
* Discovered Tools
*
* Server-managed cache of the MCP server's tool catalog [{name, description}]. Populated best-effort by the backend.
*/
discovered_tools?: Array<{
[key: string]: unknown;
}>;
};
/**
* McpToolDefinition
*
* Persisted MCP tool definition.
*/
export type McpToolDefinition = {
/**
* Schema Version
*
* Schema version
*/
schema_version?: number;
/**
* Type
*
* Tool type
*/
type: 'mcp';
/**
* MCP server configuration
*/
config: McpToolConfig;
};
/**
* NodeCategory
*
@ -3842,7 +3940,9 @@ export type UpdateToolRequest = {
type: 'transfer_call';
} & TransferCallToolDefinition) | ({
type: 'calculator';
} & CalculatorToolDefinition) | null;
} & CalculatorToolDefinition) | ({
type: 'mcp';
} & McpToolDefinition) | null;
/**
* Status
*/
@ -7652,6 +7752,50 @@ export type UpdateToolApiV1ToolsToolUuidPutResponses = {
export type UpdateToolApiV1ToolsToolUuidPutResponse = UpdateToolApiV1ToolsToolUuidPutResponses[keyof UpdateToolApiV1ToolsToolUuidPutResponses];
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Tool Uuid
*/
tool_uuid: string;
};
query?: never;
url: '/api/v1/tools/{tool_uuid}/mcp/refresh';
};
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostError = RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostErrors[keyof RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostErrors];
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses = {
/**
* Successful Response
*/
200: McpRefreshResponse;
};
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponse = RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses[keyof RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses];
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostData = {
body?: never;
headers?: {

View file

@ -9,9 +9,10 @@ import { Badge } from "@/components/ui/badge";
interface ToolBadgesProps {
toolUuids: string[];
onStaleUuidsDetected?: (staleUuids: string[]) => void;
mcpToolFilters?: Record<string, string[]>;
}
export function ToolBadges({ toolUuids, onStaleUuidsDetected }: ToolBadgesProps) {
export function ToolBadges({ toolUuids, onStaleUuidsDetected, mcpToolFilters }: ToolBadgesProps) {
const { tools } = useWorkflow();
const [selectedTools, setSelectedTools] = useState<ToolResponse[]>([]);
@ -50,15 +51,29 @@ export function ToolBadges({ toolUuids, onStaleUuidsDetected }: ToolBadgesProps)
return (
<div className="flex flex-wrap gap-1">
{selectedTools.map((tool) => (
<Badge
key={tool.tool_uuid}
variant="outline"
className="text-xs"
>
{tool.name}
</Badge>
))}
{selectedTools.map((tool) => {
const isMcp = tool.category === "mcp";
const enabledFns = isMcp ? (mcpToolFilters?.[tool.tool_uuid] ?? []) : [];
if (isMcp && enabledFns.length > 0) {
return enabledFns.map((fn) => (
<Badge
key={`${tool.tool_uuid}-${fn}`}
variant="outline"
className="text-xs flex items-center gap-1.5"
>
<span className="h-1.5 w-1.5 rounded-full bg-green-500 shrink-0" />
{fn}
</Badge>
));
}
return (
<Badge key={tool.tool_uuid} variant="outline" className="text-xs">
{tool.name}
</Badge>
);
})}
</div>
);
}

View file

@ -1,15 +1,20 @@
"use client";
import { ExternalLink } from "lucide-react";
import { ExternalLink, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { renderToolIcon } from "@/app/tools/config";
import { useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import type { ToolResponse } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TOOLS_INTRODUCTION_DOC_URL } from "@/constants/documentation";
import { type McpDiscoveredTool, refreshMcpTools } from "./mcpRefresh";
interface ToolSelectorProps {
value: string[];
onChange: (uuids: string[]) => void;
@ -18,6 +23,46 @@ interface ToolSelectorProps {
label?: string;
description?: string;
showLabel?: boolean;
mcpToolFilters?: Record<string, string[]>;
onMcpToolFiltersChange?: (next: Record<string, string[]>) => void;
}
function isMcp(tool: ToolResponse): boolean {
return tool.category === "mcp";
}
function discoveredOf(tool: ToolResponse): McpDiscoveredTool[] {
const def = (tool.definition ?? {}) as {
config?: { discovered_tools?: McpDiscoveredTool[] };
};
return def.config?.discovered_tools ?? [];
}
function withDiscoveredTools(
tool: ToolResponse,
discoveredTools: McpDiscoveredTool[],
): ToolResponse {
const definition =
tool.definition && typeof tool.definition === "object"
? tool.definition
: {};
const config =
"config" in definition &&
definition.config &&
typeof definition.config === "object"
? definition.config
: {};
return {
...tool,
definition: {
...definition,
config: {
...config,
discovered_tools: discoveredTools,
},
},
};
}
export function ToolSelector({
@ -28,18 +73,64 @@ export function ToolSelector({
label = "Tools",
description = "Select tools that the agent can use during the conversation.",
showLabel = true,
mcpToolFilters = {},
onMcpToolFiltersChange = () => {},
}: ToolSelectorProps) {
// Filter to only show active tools
const activeTools = tools.filter((tool) => tool.status === "active");
const workflow = useWorkflowOptional();
const activeTools = tools.filter((t) => t.status === "active");
const httpTools = activeTools.filter((t) => !isMcp(t));
const mcpTools = activeTools.filter(isMcp);
const handleToggle = (toolUuid: string, checked: boolean) => {
if (checked) {
onChange([...value, toolUuid]);
} else {
onChange(value.filter((id) => id !== toolUuid));
}
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({});
const [refreshError, setRefreshError] = useState<Record<string, string>>({});
const httpHandleToggle = (toolUuid: string, checked: boolean) => {
if (checked) onChange([...value, toolUuid]);
else onChange(value.filter((id) => id !== toolUuid));
};
const mcpFnToggle = (toolUuid: string, fnName: string, checked: boolean) => {
const current = mcpToolFilters[toolUuid] ?? [];
const nextFns = checked
? Array.from(new Set([...current, fnName]))
: current.filter((n) => n !== fnName);
const nextFilters = { ...mcpToolFilters };
if (nextFns.length > 0) nextFilters[toolUuid] = nextFns;
else delete nextFilters[toolUuid];
onMcpToolFiltersChange(nextFilters);
const hasUuid = value.includes(toolUuid);
if (nextFns.length > 0 && !hasUuid) onChange([...value, toolUuid]);
else if (nextFns.length === 0 && hasUuid)
onChange(value.filter((id) => id !== toolUuid));
};
const doRefresh = async (toolUuid: string) => {
setRefreshing((r) => ({ ...r, [toolUuid]: true }));
setRefreshError((e) => {
const n = { ...e };
delete n[toolUuid];
return n;
});
const res = await refreshMcpTools(toolUuid);
setRefreshing((r) => ({ ...r, [toolUuid]: false }));
if (res.error && res.discovered_tools.length === 0) {
setRefreshError((e) => ({ ...e, [toolUuid]: res.error as string }));
return;
}
workflow?.updateTool?.(toolUuid, (tool) =>
withDiscoveredTools(tool, res.discovered_tools),
);
};
const selectedCount =
httpTools.filter((t) => value.includes(t.tool_uuid)).length +
mcpTools.reduce(
(acc, t) => acc + (mcpToolFilters[t.tool_uuid]?.length ?? 0),
0,
);
return (
<div className="grid gap-2">
{showLabel && (
@ -48,7 +139,14 @@ export function ToolSelector({
{description && (
<Label className="text-xs text-muted-foreground">
{description}{" "}
<a href={TOOLS_INTRODUCTION_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">Learn more</a>
<a
href={TOOLS_INTRODUCTION_DOC_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Learn more
</a>
</Label>
)}
</>
@ -67,45 +165,178 @@ export function ToolSelector({
</Button>
</div>
) : (
<div className="border rounded-md divide-y">
{activeTools.map((tool) => {
const isSelected = value.includes(tool.tool_uuid);
return (
<label
key={tool.tool_uuid}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Checkbox
checked={isSelected}
disabled={disabled}
onCheckedChange={(checked) => {
handleToggle(tool.tool_uuid, checked === true);
}}
/>
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: tool.icon_color || "#3B82F6",
}}
>
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
<Tabs defaultValue="http">
<TabsList>
<TabsTrigger value="http">
HTTP &amp; Tools ({httpTools.length})
</TabsTrigger>
<TabsTrigger value="mcp">
MCP ({mcpTools.length})
</TabsTrigger>
</TabsList>
<TabsContent value="http">
<div className="border rounded-md divide-y">
{httpTools.length === 0 && (
<div className="p-3 text-sm text-muted-foreground">
No HTTP/native tools.
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{tool.name}
</span>
{tool.description && (
<span className="text-xs text-muted-foreground break-words">
{tool.description}
</span>
)}
)}
{httpTools.map((tool) => {
const isSelected = value.includes(tool.tool_uuid);
return (
<label
key={tool.tool_uuid}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Checkbox
checked={isSelected}
disabled={disabled}
onCheckedChange={(c) =>
httpHandleToggle(tool.tool_uuid, c === true)
}
/>
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: tool.icon_color || "#3B82F6",
}}
>
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{tool.name}
</span>
{tool.description && (
<span className="text-xs text-muted-foreground break-words">
{tool.description}
</span>
)}
</div>
</label>
);
})}
</div>
</TabsContent>
<TabsContent value="mcp">
<div className="border rounded-md divide-y">
{mcpTools.length === 0 && (
<div className="p-3 text-sm text-muted-foreground">
No MCP tools.
</div>
</label>
);
})}
<div className="p-2 bg-muted/30">
)}
{mcpTools.map((tool) => {
const fns = discoveredOf(tool);
const selected = mcpToolFilters[tool.tool_uuid] ?? [];
const busy = !!refreshing[tool.tool_uuid];
const err = refreshError[tool.tool_uuid];
return (
<details key={tool.tool_uuid} className="p-3">
<summary className="flex items-center gap-3 cursor-pointer list-none">
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: tool.icon_color || "#8B5CF6",
}}
>
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{tool.name}
</span>
{tool.description && (
<span className="text-xs text-muted-foreground break-words">
{tool.description}
</span>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{selected.length}/{fns.length} tools
</span>
</summary>
<div className="mt-3 pl-9 grid gap-2">
<div>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => doRefresh(tool.tool_uuid)}
>
<RefreshCw
className={`h-3 w-3 mr-2 ${busy ? "animate-spin" : ""}`}
/>
Refresh tools
</Button>
</div>
{err && (
<p className="text-xs text-destructive">{err}</p>
)}
{fns.length === 0 && !err && (
<p className="text-xs text-muted-foreground">
No tools discovered Refresh.
</p>
)}
{fns.map((fn) => {
const checked = selected.includes(fn.name);
return (
<label
key={fn.name}
className="flex items-start gap-3 cursor-pointer"
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(c) =>
mcpFnToggle(tool.tool_uuid, fn.name, c === true)
}
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium">
{fn.name}
</span>
{fn.description && (
<span className="text-xs text-muted-foreground break-words">
{fn.description}
</span>
)}
</div>
</label>
);
})}
{selected
.filter((n) => !fns.some((f) => f.name === n))
.map((n) => (
<label
key={`stale-${n}`}
className="flex items-start gap-3 cursor-pointer opacity-60"
>
<Checkbox
checked
disabled={disabled}
onCheckedChange={() =>
mcpFnToggle(tool.tool_uuid, n, false)
}
/>
<span className="text-sm line-through">
{n} (unavailable)
</span>
</label>
))}
</div>
</details>
);
})}
</div>
</TabsContent>
<div className="mt-2 p-2 bg-muted/30 rounded-md">
<Link
href="/tools"
target="_blank"
@ -115,12 +346,12 @@ export function ToolSelector({
Manage Tools
</Link>
</div>
</div>
</Tabs>
)}
{value.length > 0 && (
{selectedCount > 0 && (
<p className="text-xs text-muted-foreground">
{value.length} tool{value.length !== 1 ? "s" : ""} selected
{selectedCount} tool{selectedCount !== 1 ? "s" : ""} selected
</p>
)}
</div>

View file

@ -0,0 +1,68 @@
import { refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost } from "@/client/sdk.gen";
import type { McpRefreshResponse } from "@/client/types.gen";
export interface McpDiscoveredTool {
name: string;
description: string;
}
export interface McpRefreshResult {
tool_uuid: string;
discovered_tools: McpDiscoveredTool[];
error: string | null;
}
function normalizeDiscoveredTools(
discoveredTools: McpRefreshResponse["discovered_tools"],
): McpDiscoveredTool[] {
if (!Array.isArray(discoveredTools)) {
return [];
}
return discoveredTools.flatMap((tool) => {
if (!tool || typeof tool !== "object") {
return [];
}
const name = "name" in tool ? tool.name : undefined;
if (typeof name !== "string" || !name.trim()) {
return [];
}
const description =
"description" in tool && typeof tool.description === "string"
? tool.description
: "";
return [{ name, description }];
});
}
/**
* Re-discover an MCP tool's server catalog.
* Uses the shared generated `client` (auth bearer is injected by interceptor).
*/
export async function refreshMcpTools(
toolUuid: string,
): Promise<McpRefreshResult> {
const { data, error } = await refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost({
path: {
tool_uuid: toolUuid,
},
});
if (error || !data) {
return {
tool_uuid: toolUuid,
discovered_tools: [],
error:
typeof error === "string"
? error
: "Refresh request failed. Check the MCP server and try again.",
};
}
return {
tool_uuid: data.tool_uuid,
discovered_tools: normalizeDiscoveredTools(data.discovered_tools),
error: data.error ?? null,
};
}

View file

@ -205,6 +205,7 @@ function CanvasPreview({
<ToolBadges
toolUuids={data.tool_uuids}
onStaleUuidsDetected={onStaleTools}
mcpToolFilters={data.mcp_tool_filters}
/>
</div>
)}
@ -396,14 +397,22 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
const spec = bySpecName.get(type);
// ── Form state ─────────────────────────────────────────────────────
const [values, setValues] = useState<Record<string, unknown>>(() =>
spec ? seedValues(data, spec) : {},
// mcp_tool_filters is not a spec property, so seedValues won't carry it;
// seed merges it back in alongside the spec-derived values.
const seed = useCallback(
() =>
spec
? { ...seedValues(data, spec), mcp_tool_filters: data.mcp_tool_filters }
: {},
[data, spec],
);
const [values, setValues] = useState<Record<string, unknown>>(seed);
// Re-seed once the spec arrives (initial fetch race).
useEffect(() => {
if (spec && Object.keys(values).length === 0) {
setValues(seedValues(data, spec));
setValues(seed());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spec]);
@ -464,7 +473,11 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
const isDirty = useMemo(() => {
if (!spec) return false;
const baseline = seedValues(data, spec);
return propertyNames.some((n) => values[n] !== baseline[n]);
if (propertyNames.some((n) => values[n] !== baseline[n])) return true;
return (
JSON.stringify(values.mcp_tool_filters ?? {}) !==
JSON.stringify(data.mcp_tool_filters ?? {})
);
}, [values, data, spec, propertyNames]);
const handleSave = async () => {
@ -478,12 +491,12 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
};
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && spec) setValues(seedValues(data, spec));
if (newOpen && spec) setValues(seed());
setOpen(newOpen);
};
useEffect(() => {
if (open && spec) setValues(seedValues(data, spec));
if (open && spec) setValues(seed());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, open]);
@ -562,6 +575,18 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
tools: tools ?? [],
documents: documents ?? [],
recordings: recordings ?? [],
mcpToolFilters:
(values.mcp_tool_filters as
| Record<string, string[]>
| undefined) ?? {},
onMcpToolFiltersChange: (next) =>
setValues((prev) => ({
...prev,
mcp_tool_filters:
Object.keys(next).length > 0
? next
: undefined,
})),
}}
/>
{type === "trigger" && (

View file

@ -23,6 +23,10 @@ export interface RendererContext {
tools: ToolResponse[];
documents: DocumentResponseSchema[];
recordings: RecordingResponseSchema[];
/** Per-node MCP function allowlist (sibling of tool_uuids on node data). */
mcpToolFilters?: Record<string, string[]>;
/** Persist a new mcp_tool_filters object onto the node form values. */
onMcpToolFiltersChange?: (next: Record<string, string[]>) => void;
}
export interface PropertyInputProps {
@ -83,6 +87,10 @@ export function PropertyInput({ spec, value, onChange, context }: PropertyInputP
value={value}
onChange={onChange}
tools={context.tools}
mcpToolFilters={context.mcpToolFilters ?? {}}
onMcpToolFiltersChange={
context.onMcpToolFiltersChange ?? (() => {})
}
/>
);
case "document_refs":
@ -401,7 +409,13 @@ function ToolRefsWidget({
value,
onChange,
tools,
}: WidgetProps & { tools: ToolResponse[] }) {
mcpToolFilters,
onMcpToolFiltersChange,
}: WidgetProps & {
tools: ToolResponse[];
mcpToolFilters: Record<string, string[]>;
onMcpToolFiltersChange: (next: Record<string, string[]>) => void;
}) {
return (
<ToolSelector
value={(value as string[] | undefined) ?? []}
@ -409,6 +423,8 @@ function ToolRefsWidget({
tools={tools}
label={spec.display_name}
description={spec.description}
mcpToolFilters={mcpToolFilters}
onMcpToolFiltersChange={onMcpToolFiltersChange}
/>
);
}

View file

@ -55,6 +55,10 @@ export type FlowNodeData = {
qa_sample_rate?: number;
// Tools - array of tool UUIDs that can be invoked by this node
tool_uuids?: string[];
// Per-node MCP function allowlist: { toolUuid: [raw MCP tool name, ...] }.
// Default-none: a toolUuid absent here (or mapped to []) exposes zero
// functions of that MCP server on this node.
mcp_tool_filters?: Record<string, string[]>;
// Documents - array of knowledge base document UUIDs that can be referenced by this node
document_uuids?: string[];
}