feat: UI for mcp servers in agents (no-changelog) (#31195)

This commit is contained in:
yehorkardash 2026-05-28 16:54:13 +03:00 committed by GitHub
parent 4722c4d582
commit 5099287c5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1247 additions and 203 deletions

View File

@ -100,13 +100,21 @@ const AgentJsonSkillConfigSchema = z.object({
.regex(/^[A-Za-z0-9_-]+$/),
});
export const McpAuthenticationSchemaTypes = z.enum([
'none',
'bearerAuth',
'headerAuth',
'multipleHeadersAuth',
'mcpOAuth2Api',
]);
/**
* Configuration for a single MCP (Model Context Protocol) server attached to
* an agent. Tool entries from MCP servers are sourced separately from the
* `tools[]` array the SDK's `McpClient` prefixes each tool name with the
* server name to avoid collisions.
*/
const McpServerConfigSchema = z
export const McpServerConfigSchema = z
.object({
/**
* Unique-per-agent server name. Also used as the SDK tool-name prefix
@ -127,10 +135,7 @@ const McpServerConfigSchema = z
* credential types (e.g. `notionMcpOAuth2Api`, `githubMcpOAuth2Api`).
*/
authentication: z
.union([
z.enum(['none', 'bearerAuth', 'headerAuth', 'multipleHeadersAuth', 'mcpOAuth2Api']),
z.string().endsWith('McpOAuth2Api'),
])
.union([McpAuthenticationSchemaTypes, z.string().endsWith('McpOAuth2Api')])
.default('none'),
/** Credential id required when `authentication` is anything other than `none`. */
credential: z.string().optional(),
@ -287,6 +292,7 @@ export type AgentJsonSkillConfig = z.infer<typeof AgentJsonSkillConfigSchema>;
export type AgentJsonMemoryConfig = z.infer<typeof MemoryConfigSchema>;
export type NodeToolConfig = z.infer<typeof NodeConfigSchema>;
export type AgentJsonMcpServerConfig = z.infer<typeof McpServerConfigSchema>;
export type McpAuthenticationSchemaType = z.infer<typeof McpAuthenticationSchemaTypes>;
export interface ConfigValidationError {
path: string;

View File

@ -6141,6 +6141,8 @@
"agents.tools.workflow.incompatible.message": "Workflow \"{name}\" contains nodes that can't be used as an agent tool: {nodes}.",
"agents.tools.workflow.fetchFailed.title": "Couldn't check workflow compatibility",
"agents.tools.workflow.fetchFailed.message": "Try connecting the workflow again.",
"agents.tools.availableMcpServers": "MCP servers ({count})",
"agents.tools.mcp.added": "MCP server added",
"agents.toolConfig.workflow.description": "Description",
"agents.toolConfig.workflow.description.placeholder": "Explain to the agent when to use this workflow and what it does",
"agents.toolConfig.workflow.description.hint": "The LLM reads this to decide when to call the workflow.",

View File

@ -180,6 +180,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAgentModuleActive('node-tools-searcher'),
);
// Opt-in flag: the `mcp` token must be listed in the backend
// `N8N_AGENTS_MODULES` env var for this to evaluate true.
const isAgentsMcpFeatureEnabled = computed(() => isAgentModuleActive('mcp'));
const isPublicChatTriggerDisabled = computed(
() => settings.value.chatTrigger?.disablePublicChat ?? false,
);
@ -475,6 +479,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isChatFeatureEnabled,
isOtelEnabled,
isAgentsNodeToolsFeatureEnabled,
isAgentsMcpFeatureEnabled,
isPublicChatTriggerDisabled,
};
});

View File

@ -714,7 +714,11 @@ describe('AgentBuilderView — three-column shell', () => {
);
const wrapper = await renderView();
wrapper.findComponent({ name: 'AgentCapabilitiesSection' }).vm.$emit('open-tool', 0);
wrapper.findComponent({ name: 'AgentCapabilitiesSection' }).vm.$emit('open-tool', {
kind: 'tool',
toolType: 'custom',
id: 'custom_tool',
});
await nextTick();
expect(openModalWithDataMock).toHaveBeenCalledWith(

View File

@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { SimplifiedNodeType } from '@/Interface';
import AgentCapabilitiesSection from '../components/AgentCapabilitiesSection.vue';
import type { AgentJsonToolRef, CustomToolEntry } from '../types';
import type { AgentJsonConfig, AgentJsonToolRef, CustomToolEntry } from '../types';
const getNodeType = vi.fn<(type: string, version?: number) => SimplifiedNodeType | null>(
() => null,
@ -46,10 +46,11 @@ vi.mock('@n8n/i18n', () => ({
function mountSection(
tools: AgentJsonToolRef[],
customTools: Record<string, CustomToolEntry> = {},
config: AgentJsonConfig | null = null,
) {
return mount(AgentCapabilitiesSection, {
props: {
config: null,
config,
tools,
customTools,
skills: [],
@ -77,6 +78,18 @@ function mountSection(
});
}
function configWithMcpServers(
mcpServers: NonNullable<AgentJsonConfig['mcpServers']>,
): AgentJsonConfig {
return {
name: 'Test Agent',
model: '',
instructions: '',
tools: [],
mcpServers,
};
}
describe('AgentCapabilitiesSection', () => {
it('formats node and custom tool chip labels for display', () => {
getNodeType.mockReturnValue(null);
@ -224,4 +237,30 @@ describe('AgentCapabilitiesSection', () => {
expect(wrapper.text()).not.toContain('Send follow up');
expect(wrapper.text()).not.toContain('Archive message');
});
it('shows MCP servers in the tools row even without regular tools', () => {
getNodeType.mockImplementation((type: string) => {
if (type === '@n8n/n8n-nodes-langchain.mcpClientTool') {
return createNodeType('@n8n/n8n-nodes-langchain.mcpClientTool', 'MCP Client Tool');
}
return null;
});
const wrapper = mountSection(
[],
{},
configWithMcpServers([
{
name: 'github',
url: 'https://mcp.github.com',
transport: 'streamableHttp',
authentication: 'none',
},
]),
);
expect(wrapper.text()).toContain('Github');
expect(wrapper.findAll('[data-testid="agent-capabilities-tool-row"]').length).toBe(1);
});
});

View File

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentToolTelemetry } from '../composables/useAgentToolTelemetry';
import type { AgentJsonToolRef } from '../types';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
const trackMock = vi.fn();
@ -71,6 +71,25 @@ describe('useAgentToolTelemetry', () => {
});
});
it('fires "User added agent tool" with MCP server details', () => {
const t = useAgentToolTelemetry('agent-42');
const server: AgentJsonMcpServerConfig = {
name: 'github',
url: 'https://mcp.github.com',
transport: 'streamableHttp',
authentication: 'none',
};
t.trackAddedMcpServer(server);
expect(trackMock).toHaveBeenCalledWith('User added agent tool', {
tool_type: 'mcpServer',
has_approval: false,
server_name: 'github',
authentication: 'none',
agent_id: 'agent-42',
});
});
it('fires "User edited agent tool" with identity props', () => {
const t = useAgentToolTelemetry('agent-42');
t.trackEdited(nodeRef());

View File

@ -4,7 +4,12 @@ import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { deriveAgentStatus } from '../composables/agentTelemetry.utils';
import type { AgentJsonConfig, AgentJsonToolRef, AgentResource } from '../types';
import type {
AgentJsonConfig,
AgentJsonMcpServerConfig,
AgentJsonToolRef,
AgentResource,
} from '../types';
import AgentBuilderUnconfiguredEmptyState from './AgentBuilderUnconfiguredEmptyState.vue';
import AgentChatPanel from './AgentChatPanel.vue';
import AgentChatQuickActions from './AgentChatQuickActions.vue';
@ -29,6 +34,7 @@ const emit = defineEmits<{
'config-updated': [];
'update:streaming': [streaming: boolean];
'update:tools': [tools: AgentJsonToolRef[]];
'update:mcp-servers': [mcpServers: AgentJsonMcpServerConfig[]];
'update:connected-triggers': [triggers: string[]];
'update:full-width': [fullWidth: boolean];
'trigger-added': [payload: { triggerType: string; triggers: string[] }];
@ -91,12 +97,14 @@ const sharedInputDraft = ref('');
<div :class="$style.quickActionsRow">
<AgentChatQuickActions
:tools="localConfig?.tools ?? []"
:mcp-servers="localConfig?.mcpServers ?? []"
:project-id="projectId"
:agent-id="agentId"
:agent-name="agentName"
:is-published="isPublished"
:connected-triggers="connectedTriggers"
@update:tools="emit('update:tools', $event)"
@update:mcp-servers="emit('update:mcp-servers', $event)"
@update:connected-triggers="emit('update:connected-triggers', $event)"
@trigger-added="emit('trigger-added', $event)"
@agent-published="emit('agent-published', $event)"

View File

@ -5,6 +5,7 @@ import { useI18n } from '@n8n/i18n';
import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs';
import type { AgentJsonConfig, AgentResource, AgentSkill } from '../types';
import type { ToolOpenTarget } from './AgentCapabilitiesSection.types';
import AgentSessionsListView from '../views/AgentSessionsListView.vue';
import AgentAdvancedPanel from './AgentAdvancedPanel.vue';
import AgentCapabilitiesSection from './AgentCapabilitiesSection.vue';
@ -33,7 +34,7 @@ const childrenDisabled = computed(() => props.isBuildChatStreaming || !props.can
const emit = defineEmits<{
'update:activeMainTab': [tab: AgentBuilderMainTab];
'update:config': [updates: Partial<AgentJsonConfig>];
'open-tool': [index: number];
'open-tool': [target: ToolOpenTarget];
'open-skill': [id: string];
'open-trigger': [triggerType: string];
'add-tool': [];

View File

@ -1,23 +1,49 @@
import type { SimplifiedNodeType } from '@/Interface';
import type { DropdownMenuItemProps } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system/components/N8nIcon';
import type { AgentJsonToolRef } from '../types';
export type ToolRowNodeType = SimplifiedNodeType | null;
export type ToolOpenTarget =
| {
kind: 'tool';
toolType: AgentJsonToolRef['type'];
id: string;
}
| {
kind: 'mcpServer';
serverName: string;
};
export type ToolRowItem = {
index: number;
label: string;
nodeType: ToolRowNodeType;
openTarget: ToolOpenTarget;
};
export type ToolRow = {
type ToolRowBase = {
index: number;
label: string;
typeLabel: string;
nodeType: ToolRowNodeType;
fallbackIcon: IconName;
isGrouped: boolean;
items: ToolRowItem[];
};
export type ToolMenuItem = DropdownMenuItemProps<number, { nodeType: ToolRowNodeType }>;
export type GroupedToolRow = ToolRowBase & {
isGrouped: true;
tools: ToolRowItem[];
};
export type SingleToolRow = ToolRowBase & {
isGrouped: false;
tool: ToolRowItem;
};
export type ToolRow = GroupedToolRow | SingleToolRow;
export type ToolMenuItem = DropdownMenuItemProps<
string,
{ nodeType: ToolRowNodeType; openTarget: ToolOpenTarget }
>;

View File

@ -1,7 +1,13 @@
import type { IconName } from '@n8n/design-system/components/N8nIcon';
import type { AgentJsonToolRef } from '../types';
import type { ToolRow, ToolRowItem, ToolRowNodeType } from './AgentCapabilitiesSection.types';
import type {
GroupedToolRow,
ToolOpenTarget,
ToolRow,
ToolRowItem,
ToolRowNodeType,
} from './AgentCapabilitiesSection.types';
export const MIN_GROUPED_TOOLS_PER_TYPE = 2;
@ -11,7 +17,8 @@ type BaseToolRow = {
typeLabel: string;
nodeType: ToolRowNodeType;
fallbackIcon: IconName;
toolType: AgentJsonToolRef['type'];
toolType: AgentJsonToolRef['type'] | 'mcpServer';
openTarget: ToolOpenTarget;
};
function toUngroupedToolRow(row: BaseToolRow): ToolRow {
@ -19,6 +26,7 @@ function toUngroupedToolRow(row: BaseToolRow): ToolRow {
index: row.index,
label: row.label,
nodeType: row.nodeType,
openTarget: row.openTarget,
};
return {
@ -28,11 +36,11 @@ function toUngroupedToolRow(row: BaseToolRow): ToolRow {
nodeType: row.nodeType,
fallbackIcon: row.fallbackIcon,
isGrouped: false,
items: [item],
tool: item,
};
}
function toGroupedToolRow(group: BaseToolRow[]): ToolRow {
function toGroupedToolRow(group: BaseToolRow[]): GroupedToolRow {
const [first] = group;
return {
@ -42,10 +50,11 @@ function toGroupedToolRow(group: BaseToolRow[]): ToolRow {
nodeType: first.nodeType,
fallbackIcon: first.fallbackIcon,
isGrouped: true,
items: group.map((row) => ({
tools: group.map((row) => ({
index: row.index,
label: row.label,
nodeType: row.nodeType,
openTarget: row.openTarget,
})),
};
}

View File

@ -1,17 +1,18 @@
<script setup lang="ts">
import NodeIcon from '@/app/components/NodeIcon.vue';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { AGENT_SCHEDULE_TRIGGER_TYPE } from '@n8n/api-types';
import { N8nButton, N8nDropdownMenu, N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
import { updatedIconSet, type IconName } from '@n8n/design-system/components/N8nIcon';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import type { AgentJsonConfig, AgentJsonToolRef } from '../types';
import type { AgentJsonConfig, AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
import type { AgentSkill, CustomToolEntry } from '../types';
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
import type { ToolMenuItem, ToolRow } from './AgentCapabilitiesSection.types';
import type { ToolMenuItem, ToolOpenTarget, ToolRow } from './AgentCapabilitiesSection.types';
import { buildToolRows } from './AgentCapabilitiesSection.utils';
import AgentChipButton from './AgentChipButton.vue';
@ -31,7 +32,7 @@ const props = withDefaults(
);
const emit = defineEmits<{
'open-tool': [index: number];
'open-tool': [target: ToolOpenTarget];
'open-skill': [id: string];
'open-trigger': [triggerType: string];
'add-tool': [];
@ -74,10 +75,57 @@ const triggerRows = computed<Array<{ type: string; label: string; icon: IconName
);
const hasTriggers = computed(() => triggerRows.value.length > 0);
const hasTools = computed(() => props.tools.length > 0);
const mcpServers = computed(() => props.config?.mcpServers ?? []);
const hasTools = computed(() => props.tools.length + mcpServers.value.length > 0);
const hasSkills = computed(() => props.skills.length > 0);
function toolLabel(tool: AgentJsonToolRef, index: number) {
type CapabilityToolEntry =
| {
kind: 'tool';
index: number;
tool: AgentJsonToolRef;
openTarget: ToolOpenTarget;
}
| {
kind: 'mcpServer';
index: number;
server: AgentJsonMcpServerConfig;
openTarget: ToolOpenTarget;
};
function toToolOpenTarget(tool: AgentJsonToolRef): ToolOpenTarget {
if (tool.type === 'custom') {
return { kind: 'tool', toolType: 'custom', id: tool.id };
}
if (tool.type === 'workflow') {
return { kind: 'tool', toolType: 'workflow', id: tool.workflow };
}
return { kind: 'tool', toolType: 'node', id: tool.name };
}
const capabilityTools = computed<CapabilityToolEntry[]>(() => [
...props.tools.map((tool, index) => ({
kind: 'tool' as const,
index,
tool,
openTarget: toToolOpenTarget(tool),
})),
...mcpServers.value.map((server, index) => ({
kind: 'mcpServer' as const,
index: props.tools.length + index,
server,
openTarget: { kind: 'mcpServer' as const, serverName: server.name },
})),
]);
function toolLabel(entry: CapabilityToolEntry) {
if (entry.kind === 'mcpServer') {
return formatToolNameForDisplay(entry.server.name);
}
const { tool, index } = entry;
if (tool.type === 'custom') {
return formatToolNameForDisplay(
(tool.id ? props.customTools?.[tool.id]?.descriptor.name : undefined) ??
@ -93,51 +141,101 @@ function toolLabel(tool: AgentJsonToolRef, index: number) {
return formatToolNameForDisplay(tool.name ?? `${tool.type}-${index + 1}`);
}
function toolIcon(tool: AgentJsonToolRef): IconName {
function toolIcon(entry: CapabilityToolEntry): IconName {
if (entry.kind === 'mcpServer') return 'globe';
const { tool } = entry;
if (tool.type === 'workflow') return 'workflow';
if (tool.type === 'custom') return 'code';
return 'globe';
}
function toolNodeType(tool: AgentJsonToolRef) {
function toolNodeType(entry: CapabilityToolEntry) {
if (entry.kind === 'mcpServer') {
const preferredTypeName = entry.server.metadata?.nodeTypeName ?? AI_MCP_TOOL_NODE_TYPE;
return (
nodeTypesStore.getNodeType(preferredTypeName) ??
nodeTypesStore.getNodeType(AI_MCP_TOOL_NODE_TYPE) ??
null
);
}
const { tool } = entry;
const node = toolRefToNode(tool);
if (!node) return null;
return nodeTypesStore.getNodeType(node.type, node.typeVersion) ?? null;
}
function toolTypeLabel(tool: AgentJsonToolRef, index: number, nodeType = toolNodeType(tool)) {
function toolTypeLabel(entry: CapabilityToolEntry, nodeType = toolNodeType(entry)) {
if (entry.kind === 'mcpServer') {
return nodeType?.displayName ?? toolLabel(entry);
}
const { tool } = entry;
if (tool.type === 'node') {
return nodeType?.displayName.replace(/ Tool$/, '') ?? toolLabel(tool, index);
return nodeType?.displayName.replace(/ Tool$/, '') ?? toolLabel(entry);
}
if (tool.type === 'workflow') return i18n.baseText('agents.builder.tools.type.workflow');
if (tool.type === 'custom') return i18n.baseText('agents.builder.tools.type.custom');
return toolLabel(tool, index);
return toolLabel(entry);
}
const toolRows = computed<ToolRow[]>(() => {
return buildToolRows(
props.tools.map((tool, index) => {
const nodeType = toolNodeType(tool);
capabilityTools.value.map((entry) => {
const nodeType = toolNodeType(entry);
return {
index,
label: toolLabel(tool, index),
typeLabel: toolTypeLabel(tool, index, nodeType),
index: entry.index,
label: toolLabel(entry),
typeLabel: toolTypeLabel(entry, nodeType),
nodeType,
fallbackIcon: toolIcon(tool),
toolType: tool.type,
fallbackIcon: toolIcon(entry),
toolType: entry.kind === 'tool' ? entry.tool.type : 'mcpServer',
openTarget: entry.openTarget,
};
}),
);
});
function toTargetKey(target: ToolOpenTarget): string {
if (target.kind === 'mcpServer') return `mcpServer:${encodeURIComponent(target.serverName)}`;
return `tool:${target.toolType}:${encodeURIComponent(target.id)}`;
}
function fromTargetKey(key: string): ToolOpenTarget | null {
const [scope, toolType, ...rest] = key.split(':');
if (scope === 'mcpServer') {
const encodedServerName = toolType;
if (!encodedServerName) return null;
return { kind: 'mcpServer', serverName: decodeURIComponent(encodedServerName) };
}
if (scope !== 'tool') return null;
if (toolType !== 'node' && toolType !== 'workflow' && toolType !== 'custom') return null;
const encodedId = rest.join(':');
if (!encodedId) return null;
return {
kind: 'tool',
toolType,
id: decodeURIComponent(encodedId),
};
}
function toolMenuItems(tool: ToolRow): ToolMenuItem[] {
return tool.items.map((item) => ({
id: item.index,
if (!tool.isGrouped) return [];
return tool.tools.map((item) => ({
id: toTargetKey(item.openTarget),
label: item.label,
data: { nodeType: item.nodeType },
data: { nodeType: item.nodeType, openTarget: item.openTarget },
}));
}
function onToolMenuSelect(key: string) {
const target = fromTargetKey(key);
if (!target) return;
emit('open-tool', target);
}
</script>
<template>
@ -196,7 +294,7 @@ function toolMenuItems(tool: ToolRow): ToolMenuItem[] {
:items="toolMenuItems(tool)"
placement="bottom-start"
data-testid="agent-capabilities-tool-group"
@select="emit('open-tool', $event)"
@select="onToolMenuSelect"
>
<template #trigger>
<AgentChipButton data-testid="agent-capabilities-tool-row">
@ -221,7 +319,7 @@ function toolMenuItems(tool: ToolRow): ToolMenuItem[] {
<AgentChipButton
v-else-if="tool.nodeType"
data-testid="agent-capabilities-tool-row"
@click="emit('open-tool', tool.index)"
@click="emit('open-tool', tool.tool.openTarget)"
>
<template #icon>
<NodeIcon :node-type="tool.nodeType" :size="16" />
@ -232,7 +330,7 @@ function toolMenuItems(tool: ToolRow): ToolMenuItem[] {
v-else
:icon="tool.fallbackIcon"
data-testid="agent-capabilities-tool-row"
@click="emit('open-tool', tool.index)"
@click="emit('open-tool', tool.tool.openTarget)"
>
{{ tool.label }}
</AgentChipButton>

View File

@ -9,11 +9,12 @@
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/app/stores/ui.store';
import { AGENT_TOOLS_MODAL_KEY, AGENT_ADD_TRIGGER_MODAL_KEY } from '../constants';
import type { AgentJsonToolRef, AgentResource } from '../types';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef, AgentResource } from '../types';
import AgentChipButton from './AgentChipButton.vue';
const props = defineProps<{
tools: AgentJsonToolRef[];
mcpServers?: AgentJsonMcpServerConfig[];
projectId: string;
agentId: string;
agentName: string;
@ -23,6 +24,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:tools': [tools: AgentJsonToolRef[]];
'update:mcp-servers': [mcpServers: AgentJsonMcpServerConfig[]];
'update:connected-triggers': [triggers: string[]];
'trigger-added': [payload: { triggerType: string; triggers: string[] }];
'agent-published': [agent: AgentResource];
@ -37,9 +39,13 @@ function onAddTool() {
name: AGENT_TOOLS_MODAL_KEY,
data: {
tools: props.tools,
mcpServers: props.mcpServers ?? [],
projectId: props.projectId,
agentId: props.agentId,
onConfirm: (tools: AgentJsonToolRef[]) => emit('update:tools', tools),
onConfirm: (tools: AgentJsonToolRef[], mcpServers: AgentJsonMcpServerConfig[] = []) => {
emit('update:tools', tools);
emit('update:mcp-servers', mcpServers);
},
},
});
}

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import AgentCustomToolViewer from './AgentCustomToolViewer.vue';
defineProps<{
code: string;
}>();
</script>
<template>
<AgentCustomToolViewer :code="code" />
</template>

View File

@ -1,96 +1,153 @@
<script setup lang="ts">
/**
* Configure a single tool (node or workflow) on an agent.
*
* Takes an `AgentJsonToolRef` (the persisted shape from `@n8n/api-types`) and
* renders the appropriate form:
* - `type: 'node'` shared `NodeToolSettingsContent` (NDV-style param form)
* - `type: 'workflow'` small `WorkflowToolConfigContent` (description +
* allOutputs toggle)
* - `type: 'custom'` read-only TypeScript source viewer
*
* On Save, edits are merged back into the ref:
* - Node tools round-trip via `updateToolRefFromNode`.
* - Workflow tools round-trip via `updateWorkflowToolRef`.
* Configure one agent tool entry (node/workflow/custom) or one MCP server.
*/
import { computed, ref } from 'vue';
import Modal from '@/app/components/Modal.vue';
import NodeIcon from '@/app/components/NodeIcon.vue';
import NodeToolSettingsContent from '@/features/shared/toolConfig/NodeToolSettingsContent.vue';
import WorkflowToolConfigContent from './WorkflowToolConfigContent.vue';
import { useUIStore } from '@/app/stores/ui.store';
import {
N8nButton,
N8nIcon,
N8nInlineTextEdit,
N8nRadioButtons,
N8nText,
} from '@n8n/design-system';
import { N8nButton, N8nIcon, N8nRadioButtons } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { INode } from 'n8n-workflow';
import type { AgentJsonToolRef, CustomToolEntry } from '../types';
import type {
AgentJsonMcpServerConfig,
AgentJsonToolRef,
CustomToolEntry,
WorkflowToolRef,
} from '../types';
import {
toolRefToNode,
updateToolRefFromNode,
updateWorkflowToolRef,
} from '../composables/useAgentToolRefAdapter';
import { nodeToMcpServer } from '../composables/useMcpServerAdapter';
import AgentJsonEditor from './AgentJsonEditor.vue';
import AgentCustomToolViewer from './AgentCustomToolViewer.vue';
import AgentToolConfigCustomContent from './AgentToolConfigCustomContent.vue';
import AgentToolConfigModalHeader from './AgentToolConfigModalHeader.vue';
import AgentToolConfigNodeContent from './AgentToolConfigNodeContent.vue';
import AgentToolConfigWorkflowContent from './AgentToolConfigWorkflowContent.vue';
interface ToolModalData {
toolRef: AgentJsonToolRef;
customTool?: CustomToolEntry;
existingToolNames?: string[];
projectId?: string;
agentId?: string;
onConfirm: (updatedRef: AgentJsonToolRef) => void;
onRemove?: () => void;
kind?: 'tool';
}
interface McpServerModalData {
kind: 'mcpServer';
mcpServer: AgentJsonMcpServerConfig;
initialNode: INode;
existingToolNames?: string[];
projectId?: string;
agentId?: string;
onConfirm: (updatedServer: AgentJsonMcpServerConfig) => void;
onRemove?: () => void;
}
type AgentToolConfigModalData = ToolModalData | McpServerModalData;
const props = defineProps<{
modalName: string;
data: {
toolRef: AgentJsonToolRef;
customTool?: CustomToolEntry;
existingToolNames?: string[];
projectId?: string;
agentId?: string;
onConfirm: (updatedRef: AgentJsonToolRef) => void;
onRemove?: () => void;
};
data: AgentToolConfigModalData;
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const isWorkflowTool = computed(() => props.data.toolRef.type === 'workflow');
const isCustomTool = computed(() => props.data.toolRef.type === 'custom');
function isMcpServerModalData(data: AgentToolConfigModalData): data is McpServerModalData {
return data.kind === 'mcpServer';
}
const nodeContentRef = ref<InstanceType<typeof NodeToolSettingsContent> | null>(null);
const workflowContentRef = ref<InstanceType<typeof WorkflowToolConfigContent> | null>(null);
const isMcpTool = computed(() => isMcpServerModalData(props.data));
const mcpModalData = computed(() => (isMcpServerModalData(props.data) ? props.data : null));
const toolModalData = computed(() => (isMcpServerModalData(props.data) ? null : props.data));
const isWorkflowTool = computed(() => toolModalData.value?.toolRef.type === 'workflow');
const isCustomTool = computed(() => toolModalData.value?.toolRef.type === 'custom');
const nodeContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
const mcpContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
const workflowContentRef = ref<InstanceType<typeof AgentToolConfigWorkflowContent> | null>(null);
const isValid = ref(false);
const activeView = ref<'config' | 'raw'>('config');
/** Derive an INode view of a node-type ref once. Null for workflow/custom refs. */
const initialNode = computed<INode | null>(() =>
isWorkflowTool.value || isCustomTool.value ? null : toolRefToNode(props.data.toolRef),
isMcpTool.value
? (mcpModalData.value?.initialNode ?? null)
: isWorkflowTool.value || isCustomTool.value
? null
: toolModalData.value
? toolRefToNode(toolModalData.value.toolRef)
: null,
);
const workflowInitialRef = computed<WorkflowToolRef | null>(() =>
isWorkflowTool.value && toolModalData.value?.toolRef.type === 'workflow'
? toolModalData.value.toolRef
: null,
);
const initialName = computed(() => {
const toolName = props.data.toolRef.type === 'node' ? props.data.toolRef.name : undefined;
if (isMcpTool.value) return mcpModalData.value?.mcpServer.name ?? '';
const toolName =
toolModalData.value?.toolRef.type === 'node' ? toolModalData.value.toolRef.name : undefined;
return toolName ?? initialNode.value?.name ?? '';
});
const nodeName = ref(initialName.value);
const customToolCode = computed(() => props.data.customTool?.code ?? '');
const customToolTitle = computed(
() =>
props.data.customTool?.descriptor.name ??
('name' in props.data.toolRef ? props.data.toolRef.name : undefined) ??
('id' in props.data.toolRef ? props.data.toolRef.id : undefined) ??
i18n.baseText('agents.builder.tree.customBadge'),
const customToolCode = computed(() =>
!isMcpTool.value ? (toolModalData.value?.customTool?.code ?? '') : '',
);
const customToolTitle = computed(() => {
const toolRef = toolModalData.value?.toolRef;
const fallbackName =
toolRef?.type === 'custom'
? toolRef.id
: toolRef?.type === 'workflow' || toolRef?.type === 'node'
? toolRef.name
: undefined;
return (
toolModalData.value?.customTool?.descriptor.name ??
fallbackName ??
i18n.baseText('agents.builder.tree.customBadge')
);
});
const viewOptions = computed(() => [
{ label: 'Config', value: 'config' as const },
{ label: 'Raw', value: 'raw' as const },
]);
/** Gate the modal render for node tools we need a resolvable node; workflow
* and custom tools render from data that is already on the ref/agent. */
const canRender = computed(
() => isCustomTool.value || isWorkflowTool.value || initialNode.value !== null,
);
const headerKind = computed<'node' | 'workflow' | 'custom' | 'mcp'>(() => {
if (isCustomTool.value) return 'custom';
if (isWorkflowTool.value) return 'workflow';
if (isMcpTool.value) return 'mcp';
return 'node';
});
const headerNodeTypeDescription = computed(() => {
if (isMcpTool.value) {
return mcpContentRef.value?.getNodeTypeDescription() ?? null;
}
if (isWorkflowTool.value || isCustomTool.value) {
return null;
}
return nodeContentRef.value?.getNodeTypeDescription() ?? null;
});
const rawEditorValue = computed(() =>
isMcpTool.value ? (mcpModalData.value?.mcpServer ?? {}) : (toolModalData.value?.toolRef ?? {}),
);
function closeDialog() {
uiStore.closeModal(props.modalName);
}
@ -101,23 +158,38 @@ function handleConfirm() {
return;
}
if (isWorkflowTool.value) {
const wc = workflowContentRef.value;
if (!wc) return;
const updatedRef = updateWorkflowToolRef(props.data.toolRef, {
name: wc.name,
description: wc.description,
allOutputs: wc.allOutputs,
});
props.data.onConfirm(updatedRef);
if (isMcpTool.value) {
const currentNode = mcpContentRef.value?.getNode();
const mcpData = mcpModalData.value;
if (!currentNode) return;
if (!mcpData) return;
const updatedServer = nodeToMcpServer(currentNode, mcpData.mcpServer);
mcpData.onConfirm(updatedServer);
closeDialog();
return;
}
const currentNode = nodeContentRef.value?.node;
if (isWorkflowTool.value) {
const wc = workflowContentRef.value;
const toolData = toolModalData.value;
if (!toolData) return;
if (!wc) return;
const updatedRef = updateWorkflowToolRef(toolData.toolRef, {
name: wc.getName(),
description: wc.getDescription(),
allOutputs: wc.getAllOutputs(),
});
toolData.onConfirm(updatedRef);
closeDialog();
return;
}
const currentNode = nodeContentRef.value?.getNode();
const toolData = toolModalData.value;
if (!currentNode) return;
const updatedRef = updateToolRefFromNode(props.data.toolRef, currentNode);
props.data.onConfirm(updatedRef);
if (!toolData) return;
const updatedRef = updateToolRefFromNode(toolData.toolRef, currentNode);
toolData.onConfirm(updatedRef);
closeDialog();
}
@ -133,7 +205,9 @@ function handleRemove() {
function handleChangeName(name: string) {
if (isCustomTool.value) return;
if (isWorkflowTool.value) {
if (isMcpTool.value) {
mcpContentRef.value?.handleChangeName(name);
} else if (isWorkflowTool.value) {
workflowContentRef.value?.handleChangeName(name);
} else {
nodeContentRef.value?.handleChangeName(name);
@ -158,32 +232,12 @@ function handleNodeNameUpdate(name: string) {
data-test-id="agent-tool-config-modal"
>
<template #header>
<div :class="$style.header">
<NodeIcon
v-if="!isWorkflowTool && nodeContentRef?.nodeTypeDescription"
:node-type="nodeContentRef.nodeTypeDescription"
:size="24"
:circle="true"
:class="$style.icon"
/>
<N8nIcon
v-else-if="isWorkflowTool"
icon="workflow"
:size="20"
:class="$style.workflowHeaderIcon"
/>
<N8nIcon v-else-if="isCustomTool" icon="code" :size="20" :class="$style.customHeaderIcon" />
<N8nInlineTextEdit
v-if="!isCustomTool"
:model-value="nodeName"
:max-width="400"
:class="$style.title"
@update:model-value="handleChangeName"
/>
<N8nText v-else :class="$style.title">
{{ customToolTitle }}
</N8nText>
</div>
<AgentToolConfigModalHeader
:kind="headerKind"
:title="isCustomTool ? customToolTitle : nodeName"
:node-type-description="headerNodeTypeDescription"
@update:title="handleChangeName"
/>
</template>
<template #content>
<div
@ -192,7 +246,7 @@ function handleNodeNameUpdate(name: string) {
(isCustomTool || activeView === 'raw') && $style.codeContentWrapper,
]"
>
<AgentCustomToolViewer
<AgentToolConfigCustomContent
v-if="isCustomTool"
:code="customToolCode"
:class="$style.customToolViewer"
@ -206,27 +260,37 @@ function handleNodeNameUpdate(name: string) {
/>
<AgentJsonEditor
v-show="activeView === 'raw'"
:value="data.toolRef"
:value="rawEditorValue"
read-only
:show-read-only-overlay="false"
:class="$style.rawEditor"
copy-button-test-id="agent-tool-json-copy"
/>
<div v-show="activeView === 'config'" :class="$style.configureTab">
<WorkflowToolConfigContent
v-if="data.toolRef.type === 'workflow'"
<AgentToolConfigWorkflowContent
v-if="workflowInitialRef"
ref="workflowContentRef"
:initial-ref="data.toolRef"
:initial-ref="workflowInitialRef"
@update:valid="handleValidUpdate"
@update:node-name="handleNodeNameUpdate"
/>
<NodeToolSettingsContent
<AgentToolConfigNodeContent
v-else-if="isMcpTool && initialNode"
ref="mcpContentRef"
:initial-node="initialNode"
:existing-tool-names="data.existingToolNames"
:project-id="data.projectId"
content-test-id="agent-tool-config-mcp-content"
@update:valid="handleValidUpdate"
@update:node-name="handleNodeNameUpdate"
/>
<AgentToolConfigNodeContent
v-else-if="initialNode"
ref="nodeContentRef"
:initial-node="initialNode"
:existing-tool-names="data.existingToolNames"
:project-id="data.projectId"
:hide-ask-assistant="true"
content-test-id="node-tool-settings-content"
@update:valid="handleValidUpdate"
@update:node-name="handleNodeNameUpdate"
/>
@ -269,39 +333,6 @@ function handleNodeNameUpdate(name: string) {
</template>
<style lang="scss" module>
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
min-width: 0;
}
.icon {
flex-shrink: 0;
flex-grow: 0;
}
.workflowHeaderIcon {
flex-shrink: 0;
flex-grow: 0;
color: var(--color--primary);
}
.customHeaderIcon {
flex-shrink: 0;
flex-grow: 0;
color: var(--color--text--tint-1);
}
.title {
font-size: var(--font-size--md);
font-weight: var(--font-weight--regular);
line-height: var(--line-height--lg);
color: var(--color--text--shade-1);
flex: 1;
min-width: 0;
}
.footer {
display: flex;
justify-content: space-between;

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import NodeIcon from '@/app/components/NodeIcon.vue';
import { N8nIcon, N8nInlineTextEdit, N8nText } from '@n8n/design-system';
import type { INodeTypeDescription } from 'n8n-workflow';
type HeaderKind = 'node' | 'workflow' | 'custom' | 'mcp';
withDefaults(
defineProps<{
kind: HeaderKind;
title: string;
nodeTypeDescription?: INodeTypeDescription | null;
editable?: boolean;
}>(),
{
nodeTypeDescription: null,
editable: true,
},
);
const emit = defineEmits<{
'update:title': [name: string];
}>();
</script>
<template>
<div :class="$style.header">
<NodeIcon
v-if="(kind === 'node' || kind === 'mcp') && nodeTypeDescription"
:node-type="nodeTypeDescription"
:size="24"
:circle="true"
:class="$style.icon"
/>
<N8nIcon
v-else-if="kind === 'workflow'"
icon="workflow"
:size="20"
:class="$style.workflowHeaderIcon"
/>
<N8nIcon
v-else-if="kind === 'custom'"
icon="code"
:size="20"
:class="$style.customHeaderIcon"
/>
<N8nInlineTextEdit
v-if="editable && kind !== 'custom'"
:model-value="title"
:max-width="400"
:class="$style.title"
@update:model-value="emit('update:title', $event)"
/>
<N8nText v-else :class="$style.title">
{{ title }}
</N8nText>
</div>
</template>
<style lang="scss" module>
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
min-width: 0;
}
.icon {
flex-shrink: 0;
flex-grow: 0;
}
.workflowHeaderIcon {
flex-shrink: 0;
flex-grow: 0;
color: var(--color--primary);
}
.customHeaderIcon {
flex-shrink: 0;
flex-grow: 0;
color: var(--color--text--tint-1);
}
.title {
font-size: var(--font-size--md);
font-weight: var(--font-weight--regular);
line-height: var(--line-height--lg);
color: var(--color--text--shade-1);
flex: 1;
min-width: 0;
}
</style>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { INode } from 'n8n-workflow';
import NodeToolSettingsContent from '@/features/shared/toolConfig/NodeToolSettingsContent.vue';
const props = defineProps<{
initialNode: INode;
existingToolNames?: string[];
projectId?: string;
contentTestId?: string;
}>();
const emit = defineEmits<{
'update:valid': [valid: boolean];
'update:node-name': [name: string];
}>();
const contentRef = ref<InstanceType<typeof NodeToolSettingsContent> | null>(null);
function handleChangeName(name: string) {
contentRef.value?.handleChangeName(name);
}
function getNode() {
return contentRef.value?.node ?? null;
}
function getNodeTypeDescription() {
return contentRef.value?.nodeTypeDescription ?? null;
}
defineExpose({
getNode,
getNodeTypeDescription,
handleChangeName,
});
</script>
<template>
<NodeToolSettingsContent
ref="contentRef"
:initial-node="props.initialNode"
:existing-tool-names="props.existingToolNames"
:project-id="props.projectId"
:hide-ask-assistant="true"
:data-test-id="props.contentTestId"
@update:valid="emit('update:valid', $event)"
@update:node-name="emit('update:node-name', $event)"
/>
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import WorkflowToolConfigContent from './WorkflowToolConfigContent.vue';
import type { WorkflowToolRef } from '../types';
const props = defineProps<{
initialRef: WorkflowToolRef;
}>();
const emit = defineEmits<{
'update:valid': [valid: boolean];
'update:node-name': [name: string];
}>();
const contentRef = ref<InstanceType<typeof WorkflowToolConfigContent> | null>(null);
function handleChangeName(value: string) {
contentRef.value?.handleChangeName(value);
}
function getName() {
return contentRef.value?.name ?? '';
}
function getDescription() {
return contentRef.value?.description ?? '';
}
function getAllOutputs() {
return contentRef.value?.allOutputs ?? false;
}
defineExpose({
getName,
getDescription,
getAllOutputs,
handleChangeName,
});
</script>
<template>
<WorkflowToolConfigContent
ref="contentRef"
:initial-ref="props.initialRef"
@update:valid="emit('update:valid', $event)"
@update:node-name="emit('update:node-name', $event)"
/>
</template>

View File

@ -3,8 +3,10 @@ import { computed, onMounted, ref, watch } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import Modal from '@/app/components/Modal.vue';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useToast } from '@/app/composables/useToast';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { getWorkflow } from '@/app/api/workflows';
@ -30,7 +32,7 @@ import AgentToolItem from './AgentToolItem.vue';
import WorkflowToolRow from './WorkflowToolRow.vue';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { AgentJsonToolRef, WorkflowToolRef } from '../types';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef, WorkflowToolRef } from '../types';
import { AGENT_TOOL_CONFIG_MODAL_KEY } from '../constants';
import {
getExistingToolNames,
@ -38,22 +40,29 @@ import {
toolRefToNode,
workflowToNewToolRef,
} from '../composables/useAgentToolRefAdapter';
import {
isMcpRelatedNodeType,
mcpServerToNode,
nodeTypeToNewMcpServer,
} from '../composables/useMcpServerAdapter';
import { useAgentToolTelemetry } from '../composables/useAgentToolTelemetry';
const props = defineProps<{
modalName: string;
data: {
tools: AgentJsonToolRef[];
mcpServers?: AgentJsonMcpServerConfig[];
/** Optional — when present, the Available list will include workflows scoped to this project. */
projectId?: string;
/** Optional — tagged onto telemetry events for correlation with agent analytics. */
agentId?: string;
onConfirm: (tools: AgentJsonToolRef[]) => void;
onConfirm: (tools: AgentJsonToolRef[], mcpServers?: AgentJsonMcpServerConfig[]) => void;
};
}>();
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const settingsStore = useSettingsStore();
const nodeHelpers = useNodeHelpers();
const uiStore = useUIStore();
const workflowsListStore = useWorkflowsListStore();
@ -72,6 +81,11 @@ interface WorkingToolEntry {
ref: AgentJsonToolRef;
}
interface WorkingMcpServerEntry {
localId: string;
server: AgentJsonMcpServerConfig;
}
function toWorkingToolEntries(
tools: AgentJsonToolRef[],
existingEntries: WorkingToolEntry[] = [],
@ -82,6 +96,16 @@ function toWorkingToolEntries(
}));
}
function toWorkingMcpServerEntries(
servers: AgentJsonMcpServerConfig[],
existingEntries: WorkingMcpServerEntry[] = [],
): WorkingMcpServerEntry[] {
return servers.map((server, index) => ({
localId: existingEntries[index]?.localId ?? uuidv4(),
server,
}));
}
// Local working copy all edits go here; saved to config via onConfirm.
const workingToolEntries = ref<WorkingToolEntry[]>(toWorkingToolEntries(props.data.tools));
watch(
@ -91,7 +115,21 @@ watch(
},
);
const workingMcpServerEntries = ref<WorkingMcpServerEntry[]>(
toWorkingMcpServerEntries(props.data.mcpServers ?? []),
);
watch(
() => props.data.mcpServers ?? [],
(servers) => {
workingMcpServerEntries.value = toWorkingMcpServerEntries(
servers,
workingMcpServerEntries.value,
);
},
);
const workingTools = computed(() => workingToolEntries.value.map(({ ref }) => ref));
const workingMcpServers = computed(() => workingMcpServerEntries.value.map(({ server }) => server));
const searchQuery = ref('');
const debouncedSearchQuery = ref('');
@ -125,13 +163,19 @@ function needsSetup(nodeType: INodeTypeDescription): boolean {
);
}
function makeUniqueName(baseName: string, existingNames: string[]): string {
function makeUniqueName(
baseName: string,
existingNames: string[],
format?: (name: string, counter: number) => string,
): string {
const defaultFormat = (name: string, counter: number) => `${name} (${counter})`;
const formatFn = format ?? defaultFormat;
if (!existingNames.includes(baseName)) return baseName;
let counter = 1;
while (existingNames.includes(`${baseName} (${counter})`)) {
while (existingNames.includes(formatFn(baseName, counter))) {
counter++;
}
return `${baseName} (${counter})`;
return formatFn(baseName, counter);
}
const agentProviderNodeTypes = new Set<string>(AI_VENDOR_NODE_TYPES);
@ -167,6 +211,16 @@ const availableToolTypes = computed<INodeTypeDescription[]>(() => {
});
});
const availableMcpToolTypes = computed(() =>
settingsStore.isAgentsMcpFeatureEnabled
? availableToolTypes.value.filter((nodeType) => isMcpRelatedNodeType(nodeType.name))
: [],
);
const availableStandardToolTypes = computed(() =>
availableToolTypes.value.filter((nodeType) => !isMcpRelatedNodeType(nodeType.name)),
);
// --- Workflow catalog -------------------------------------------------------
/**
@ -234,6 +288,14 @@ interface ConfiguredToolView {
missingCredentials: boolean;
}
interface ConfiguredMcpServerView {
localId: string;
server: AgentJsonMcpServerConfig;
node: INode;
nodeType: INodeTypeDescription;
missingCredentials: boolean;
}
const configuredTools = computed<ConfiguredToolView[]>(() => {
const out: ConfiguredToolView[] = [];
for (const { localId, ref } of workingToolEntries.value) {
@ -257,6 +319,32 @@ const configuredTools = computed<ConfiguredToolView[]>(() => {
return out;
});
function resolveMcpNodeType(server: AgentJsonMcpServerConfig): INodeTypeDescription | null {
const preferredTypeName = server.metadata?.nodeTypeName ?? AI_MCP_TOOL_NODE_TYPE;
return (
nodeTypesStore.getNodeType(preferredTypeName) ??
nodeTypesStore.getNodeType(AI_MCP_TOOL_NODE_TYPE)
);
}
const configuredMcpServers = computed<ConfiguredMcpServerView[]>(() => {
const out: ConfiguredMcpServerView[] = [];
for (const { localId, server } of workingMcpServerEntries.value) {
const nodeType = resolveMcpNodeType(server);
if (!nodeType) continue;
const node = mcpServerToNode(server, nodeType);
const issues = nodeHelpers.getNodeCredentialIssues(node as INodeUi, nodeType);
out.push({
localId,
server,
node,
nodeType,
missingCredentials: !!issues?.credentials && Object.keys(issues.credentials).length > 0,
});
}
return out;
});
/** Connected workflow tools — mirrors `configuredTools` for the Connected section. */
interface ConfiguredWorkflowView {
localId: string;
@ -292,6 +380,16 @@ const filteredConfiguredTools = computed(() => {
);
});
const filteredConfiguredMcpServers = computed(() => {
if (!debouncedSearchQuery.value) return configuredMcpServers.value;
const query = debouncedSearchQuery.value.toLowerCase();
return configuredMcpServers.value.filter(
(server) =>
server.server.name.toLowerCase().includes(query) ||
server.nodeType.displayName.toLowerCase().includes(query),
);
});
const filteredConfiguredWorkflows = computed(() => {
if (!debouncedSearchQuery.value) return configuredWorkflows.value;
const query = debouncedSearchQuery.value.toLowerCase();
@ -305,9 +403,18 @@ const filteredAvailableTools = computed(() => {
// Duplicates allowed: already-connected node types stay listed so users can
// add a 2nd Slack / Gmail / etc. with a different name + config. The config
// modal enforces tool-name uniqueness via `existingToolNames`.
if (!debouncedSearchQuery.value) return availableToolTypes.value;
if (!debouncedSearchQuery.value) return availableStandardToolTypes.value;
const query = debouncedSearchQuery.value.toLowerCase();
return availableToolTypes.value.filter(
return availableStandardToolTypes.value.filter(
(nt) =>
nt.displayName.toLowerCase().includes(query) || nt.description?.toLowerCase().includes(query),
);
});
const filteredAvailableMcpTools = computed(() => {
if (!debouncedSearchQuery.value) return availableMcpToolTypes.value;
const query = debouncedSearchQuery.value.toLowerCase();
return availableMcpToolTypes.value.filter(
(nt) =>
nt.displayName.toLowerCase().includes(query) || nt.description?.toLowerCase().includes(query),
);
@ -335,6 +442,20 @@ function addToolRef(savedRef: AgentJsonToolRef) {
});
}
function addMcpServer(savedServer: AgentJsonMcpServerConfig) {
workingMcpServerEntries.value = [
...workingMcpServerEntries.value,
{ localId: uuidv4(), server: savedServer },
];
toolTelemetry.trackAddedMcpServer(savedServer);
commit();
uiStore.closeModal(props.modalName);
toast.showMessage({
title: i18n.baseText('agents.tools.mcp.added'),
type: 'success',
});
}
function openConfigForNewRef(newRef: AgentJsonToolRef) {
// Connect open the config panel first. The ref only enters workingTools
// once the user hits Save, so a cancelled config leaves the list untouched.
@ -352,7 +473,49 @@ function openConfigForNewRef(newRef: AgentJsonToolRef) {
});
}
function getExistingMcpServerNames(
servers: AgentJsonMcpServerConfig[],
exclude?: AgentJsonMcpServerConfig,
): string[] {
return servers.filter((server) => server !== exclude).map((server) => server.name);
}
function openConfigForNewMcpServer(
server: AgentJsonMcpServerConfig,
nodeType: INodeTypeDescription,
) {
uiStore.openModalWithData({
name: AGENT_TOOL_CONFIG_MODAL_KEY,
data: {
kind: 'mcpServer',
mcpServer: server,
initialNode: mcpServerToNode(server, nodeType),
projectId: props.data.projectId,
agentId: props.data.agentId,
existingToolNames: getExistingMcpServerNames(workingMcpServers.value),
onConfirm: (savedServer: AgentJsonMcpServerConfig) => {
addMcpServer(savedServer);
},
},
});
}
function handleAddMcpServer(nodeType: INodeTypeDescription) {
const newServer = nodeTypeToNewMcpServer(nodeType);
newServer.name = makeUniqueName(
newServer.name,
getExistingMcpServerNames(workingMcpServers.value),
(name, counter) => `${name}-${counter}`,
);
openConfigForNewMcpServer(newServer, nodeType);
}
function handleAddTool(nodeType: INodeTypeDescription) {
if (isMcpRelatedNodeType(nodeType.name)) {
handleAddMcpServer(nodeType);
return;
}
toolTelemetry.trackAddStarted('node');
const newRef = nodeTypeToNewToolRef(nodeType);
@ -433,8 +596,31 @@ function handleConfigureTool(tool: ConfiguredToolView | ConfiguredWorkflowView)
});
}
function handleConfigureMcpServer(serverView: ConfiguredMcpServerView) {
const nodeType = resolveMcpNodeType(serverView.server);
if (!nodeType) return;
uiStore.openModalWithData({
name: AGENT_TOOL_CONFIG_MODAL_KEY,
data: {
kind: 'mcpServer',
mcpServer: serverView.server,
initialNode: mcpServerToNode(serverView.server, nodeType),
projectId: props.data.projectId,
agentId: props.data.agentId,
existingToolNames: getExistingMcpServerNames(workingMcpServers.value, serverView.server),
onConfirm: (updatedServer: AgentJsonMcpServerConfig) => {
workingMcpServerEntries.value = workingMcpServerEntries.value.map((entry) =>
entry.localId === serverView.localId ? { ...entry, server: updatedServer } : entry,
);
commit();
},
},
});
}
function commit() {
props.data.onConfirm(workingTools.value);
props.data.onConfirm(workingTools.value, workingMcpServers.value);
}
</script>
@ -466,10 +652,25 @@ function commit() {
<div :class="$style.listWrapper" data-test-id="agent-tools-list">
<div
v-if="filteredConfiguredTools.length + filteredConfiguredWorkflows.length > 0"
v-if="
filteredConfiguredMcpServers.length +
filteredConfiguredTools.length +
filteredConfiguredWorkflows.length >
0
"
:class="$style.section"
>
<div :class="$style.toolsList" data-test-id="agent-tools-connected-list">
<AgentToolItem
v-for="server in filteredConfiguredMcpServers"
:key="server.localId"
:node-type="server.nodeType"
:configured-node="server.node"
:missing-credentials="server.missingCredentials"
mode="configured"
:class="$style.toolsListItem"
@configure="handleConfigureMcpServer(server)"
/>
<AgentToolItem
v-for="tool in filteredConfiguredTools"
:key="tool.localId"
@ -493,6 +694,26 @@ function commit() {
</div>
</div>
<div v-if="filteredAvailableMcpTools.length > 0" :class="$style.section">
<N8nHeading size="small" color="text-light" tag="h3">
{{
i18n.baseText('agents.tools.availableMcpServers', {
interpolate: { count: filteredAvailableMcpTools.length },
})
}}
</N8nHeading>
<div :class="$style.toolsList" data-test-id="agent-tools-available-mcp-list">
<AgentToolItem
v-for="nodeType in filteredAvailableMcpTools"
:key="nodeType.name"
:node-type="nodeType"
mode="available"
@add="handleAddTool(nodeType)"
:class="$style.toolsListItem"
/>
</div>
</div>
<div v-if="filteredAvailableTools.length > 0" :class="$style.section">
<N8nHeading size="small" color="text-light" tag="h3">
{{
@ -536,8 +757,10 @@ function commit() {
<div
v-if="
filteredConfiguredMcpServers.length === 0 &&
filteredConfiguredTools.length === 0 &&
filteredConfiguredWorkflows.length === 0 &&
filteredAvailableMcpTools.length === 0 &&
filteredAvailableTools.length === 0 &&
filteredAvailableWorkflows.length === 0
"

View File

@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
import type { INode, INodeCredentials, INodeTypeDescription } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
import type { AgentJsonToolRef, NodeToolConfig } from '../types';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef, NodeToolConfig } from '../types';
/**
* Two-way adapter between the agent's persisted tool shape (`AgentJsonToolRef`
@ -145,6 +145,13 @@ export function getExistingToolNames(
.map((t) => (t as Extract<AgentJsonToolRef, { type: 'workflow' | 'node' }>).name!);
}
export function getExistingMcpServerNames(
mcpServers: AgentJsonMcpServerConfig[],
exclude?: AgentJsonMcpServerConfig,
): string[] {
return mcpServers.filter((s) => s.name !== exclude?.name && Boolean(s.name)).map((s) => s.name);
}
/** Merge edits from the workflow config form back into the ref. */
export function updateWorkflowToolRef(
original: AgentJsonToolRef,

View File

@ -1,7 +1,7 @@
import { useTelemetry } from '@/app/composables/useTelemetry';
import type { GenericValue } from 'n8n-workflow';
import type { AgentJsonToolRef } from '../types';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
/**
* Small, opinionated wrapper around `useTelemetry()` that centralizes the
@ -54,6 +54,19 @@ export function useAgentToolTelemetry(agentId?: string) {
);
}
/** Fired when a new MCP server is saved for the first time. */
function trackAddedMcpServer(server: AgentJsonMcpServerConfig) {
telemetry.track(
'User added agent tool',
withAgent({
tool_type: 'mcpServer',
has_approval: false,
server_name: server.name,
authentication: server.authentication,
}),
);
}
/** Fired when an existing tool's config is saved. */
function trackEdited(ref: AgentJsonToolRef) {
telemetry.track(
@ -70,5 +83,5 @@ export function useAgentToolTelemetry(agentId?: string) {
);
}
return { trackAddStarted, trackAdded, trackEdited, trackRemoved };
return { trackAddStarted, trackAdded, trackAddedMcpServer, trackEdited, trackRemoved };
}

View File

@ -0,0 +1,265 @@
import { v4 as uuidv4 } from 'uuid';
import type { INode, INodeCredentials, INodeTypeDescription } from 'n8n-workflow';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import type { AgentJsonMcpServerConfig } from '../types';
import type { McpAuthenticationSchemaType } from '@n8n/api-types';
const MCP_REGISTRY_NODE_PREFIX = '@n8n/mcp-registry.';
const HTTP_STREAMABLE_TRANSPORT = 'httpStreamable';
function pickLatestVersion(version: number | number[]): number {
if (Array.isArray(version)) {
return [...version].sort((a, b) => b - a)[0] ?? 1;
}
return version;
}
function toNodeTransport(
transport: AgentJsonMcpServerConfig['transport'] | undefined,
): 'sse' | 'httpStreamable' {
return transport === 'sse' ? 'sse' : HTTP_STREAMABLE_TRANSPORT;
}
function toServerTransport(transport: unknown): AgentJsonMcpServerConfig['transport'] {
return transport === 'sse' ? 'sse' : 'streamableHttp';
}
function toArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function toNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
}
function toStringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function slugify(value: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'mcp-server';
}
function resolveDefaultParameter(nodeType: INodeTypeDescription, name: string): unknown {
const property = nodeType.properties.find((candidate) => candidate.name === name);
if (!property || !('default' in property)) {
return undefined;
}
return property.default;
}
function resolveDefaultTimeout(nodeType: INodeTypeDescription): number | undefined {
const optionsProperty = nodeType.properties.find((property) => property.name === 'options');
if (
!optionsProperty ||
optionsProperty.type !== 'collection' ||
!Array.isArray(optionsProperty.options)
) {
return undefined;
}
const timeoutOption = optionsProperty.options.find((option) => option.name === 'timeout');
return toNumber((timeoutOption as { default?: unknown } | undefined)?.default);
}
/**
* Maps an MCP `authentication` option value to the n8n credential type name
* that the node registers under `node.credentials`. The two do not always
* match: `bearerAuth` uses the `httpBearerAuth` credential type, etc.
* OAuth2 variants use their own name as-is (e.g. `mcpOAuth2Api`).
*/
const AUTHENTICATION_TO_CREDENTIAL_TYPE: Record<string, string | undefined> = {
bearerAuth: 'httpBearerAuth',
headerAuth: 'httpHeaderAuth',
multipleHeadersAuth: 'httpMultipleHeadersAuth',
mcpOAuth2Api: 'mcpOAuth2Api',
none: 'none',
} satisfies Record<McpAuthenticationSchemaType, string | undefined>;
function authenticationToCredentialType(authentication: string): string | undefined {
return AUTHENTICATION_TO_CREDENTIAL_TYPE[authentication] ?? authentication;
}
function resolveCredentialType(credentials: INodeCredentials | undefined): string | undefined {
if (!credentials) return undefined;
return Object.entries(credentials).find(([, value]) => toStringValue(value.id))?.[0];
}
function resolveCredentialId(credentials: INodeCredentials | undefined): string | undefined {
if (!credentials) return undefined;
return Object.entries(credentials)
.map(([, value]) => value.id)
.find((id): id is string => typeof id === 'string' && id.length > 0);
}
function resolveAuthenticationFromNode(node: INode): string {
const authentication = toStringValue(node.parameters.authentication);
if (authentication) return authentication;
const credentialType = resolveCredentialType(node.credentials);
if (credentialType) return credentialType;
return 'none';
}
function isMcpRegistryNodeType(nodeTypeName: string): boolean {
return nodeTypeName.startsWith(MCP_REGISTRY_NODE_PREFIX);
}
function isMcpClientNodeType(nodeTypeName: string): boolean {
return nodeTypeName === AI_MCP_TOOL_NODE_TYPE || nodeTypeName === 'mcpClientTool';
}
function resolveMetadata(
nodeTypeName: string,
original: AgentJsonMcpServerConfig | undefined,
): AgentJsonMcpServerConfig['metadata'] {
const metadata = { ...(original?.metadata ?? {}) };
if (isMcpRegistryNodeType(nodeTypeName)) {
metadata.nodeTypeName = nodeTypeName;
} else {
delete metadata.nodeTypeName;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
function resolveDefaultAuthentication(nodeType: INodeTypeDescription): string {
const authentication = resolveDefaultParameter(nodeType, 'authentication');
if (typeof authentication === 'string' && authentication.length > 0) {
return authentication;
}
const credentialType = nodeType.credentials?.[0]?.name;
if (typeof credentialType === 'string' && credentialType.length > 0) {
return credentialType;
}
return 'none';
}
function resolveNodeToolFilter(
toolFilter: AgentJsonMcpServerConfig['toolFilter'],
): Pick<INode['parameters'], 'include' | 'includeTools' | 'excludeTools'> {
if (!toolFilter) {
return { include: 'all', includeTools: [], excludeTools: [] };
}
if (toolFilter.mode === 'allow') {
return { include: 'selected', includeTools: toolFilter.tools, excludeTools: [] };
}
return { include: 'except', includeTools: [], excludeTools: toolFilter.tools };
}
function resolveServerToolFilter(
parameters: INode['parameters'],
): AgentJsonMcpServerConfig['toolFilter'] {
const includeMode = parameters.include;
const includeTools = toArray(parameters.includeTools);
const excludeTools = toArray(parameters.excludeTools);
if (includeMode === 'selected') {
return { mode: 'allow', tools: includeTools };
}
if (includeMode === 'except') {
return { mode: 'exclude', tools: excludeTools };
}
return undefined;
}
export function isMcpRelatedNodeType(nodeTypeName: string): boolean {
return isMcpClientNodeType(nodeTypeName) || isMcpRegistryNodeType(nodeTypeName);
}
export function nodeTypeToNewMcpServer(nodeType: INodeTypeDescription): AgentJsonMcpServerConfig {
const endpointUrlDefault = resolveDefaultParameter(nodeType, 'endpointUrl');
const sseEndpointDefault = resolveDefaultParameter(nodeType, 'sseEndpoint');
const endpointUrl = toStringValue(endpointUrlDefault) ?? toStringValue(sseEndpointDefault) ?? '';
const authentication = resolveDefaultAuthentication(nodeType);
const serverTransport = resolveDefaultParameter(nodeType, 'serverTransport');
const metadata = isMcpRegistryNodeType(nodeType.name)
? { nodeTypeName: nodeType.name }
: undefined;
return {
name: slugify(nodeType.displayName.replace(/\s+tool$/i, '')),
url: endpointUrl,
transport: toServerTransport(serverTransport),
authentication,
connectionTimeoutMs: resolveDefaultTimeout(nodeType),
metadata,
};
}
export function mcpServerToNode(
server: AgentJsonMcpServerConfig,
nodeTypeDescription: INodeTypeDescription,
): INode {
const credentialType = authenticationToCredentialType(server.authentication);
const credentials =
credentialType && server.credential
? {
[credentialType]: {
id: server.credential,
name: server.credential,
},
}
: undefined;
const toolFilterParams = resolveNodeToolFilter(server.toolFilter);
const options = server.connectionTimeoutMs ? { timeout: server.connectionTimeoutMs } : {};
return {
id: uuidv4(),
name: server.name,
type: nodeTypeDescription.name,
typeVersion: pickLatestVersion(nodeTypeDescription.version),
parameters: {
endpointUrl: server.url,
serverTransport: toNodeTransport(server.transport),
authentication: server.authentication,
...toolFilterParams,
options,
},
credentials,
position: [0, 0],
};
}
export function nodeToMcpServer(
node: INode,
original?: AgentJsonMcpServerConfig,
): AgentJsonMcpServerConfig {
const endpointUrl =
toStringValue(node.parameters.endpointUrl) ??
toStringValue(node.parameters.sseEndpoint) ??
original?.url ??
'';
const credential = resolveCredentialId(node.credentials);
const authentication = resolveAuthenticationFromNode(node);
const timeout = toNumber((node.parameters.options as { timeout?: unknown } | undefined)?.timeout);
return {
name: node.name,
url: endpointUrl,
transport: toServerTransport(node.parameters.serverTransport),
authentication,
credential,
toolFilter: resolveServerToolFilter(node.parameters),
description: original?.description,
approval: original?.approval,
connectionTimeoutMs: timeout,
metadata: resolveMetadata(node.type, original),
};
}

View File

@ -48,6 +48,7 @@ export const AgentsModule: FrontendModuleDescription = {
open: false,
data: {
tools: [],
mcpServers: [],
onConfirm: () => {},
},
},
@ -58,8 +59,8 @@ export const AgentsModule: FrontendModuleDescription = {
initialState: {
open: false,
data: {
kind: 'node',
toolRef: null,
existingToolNames: [],
onConfirm: () => {},
},
},

View File

@ -127,5 +127,6 @@ export type {
AgentJsonToolConfig as AgentJsonToolRef,
AgentJsonSkillConfig as AgentJsonSkillRef,
AgentJsonConfig as AgentJsonConfigRef,
AgentJsonMcpServerConfig,
AgentJsonConfig,
} from '@n8n/api-types';

View File

@ -9,9 +9,11 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { LOCAL_STORAGE_AGENT_BUILDER_CHAT_PANEL_WIDTH, MODAL_CONFIRM } from '@/app/constants';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useResizablePanel } from '@/app/composables/useResizablePanel';
import { deepCopy } from 'n8n-workflow';
import {
@ -21,7 +23,13 @@ import {
createAgentSkill,
} from '../composables/useAgentApi';
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
import type { AgentResource, AgentJsonConfig, AgentJsonToolConfig, AgentSkill } from '../types';
import type {
AgentResource,
AgentJsonConfig,
AgentJsonMcpServerConfig,
AgentJsonToolConfig,
AgentSkill,
} from '../types';
import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetry';
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { useAgentConfig } from '../composables/useAgentConfig';
@ -31,6 +39,7 @@ import { useAgentSessionsStore } from '../agentSessions.store';
import { useAgentBuilderSession } from '../composables/useAgentBuilderSession';
import { useAgentConfigAutosave } from '../composables/useAgentConfigAutosave';
import { useAgentBuilderMainTabs } from '../composables/useAgentBuilderMainTabs';
import { mcpServerToNode } from '../composables/useMcpServerAdapter';
import {
AGENT_BUILDER_VIEW,
AGENT_PREVIEW_VIEW,
@ -42,6 +51,7 @@ import {
DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
} from '../constants';
import { agentsEventBus } from '../agents.eventBus';
import type { ToolOpenTarget } from '../components/AgentCapabilitiesSection.types';
import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
import AgentBuilderChatColumn from '../components/AgentBuilderChatColumn.vue';
import AgentBuilderEditorColumn from '../components/AgentBuilderEditorColumn.vue';
@ -57,6 +67,7 @@ const router = useRouter();
const locale = useI18n();
const rootStore = useRootStore();
const projectsStore = useProjectsStore();
const nodeTypesStore = useNodeTypesStore();
const telemetry = useTelemetry();
const sessionsStore = useAgentSessionsStore();
const uiStore = useUIStore();
@ -635,9 +646,11 @@ function onOpenAddToolModal() {
name: AGENT_TOOLS_MODAL_KEY,
data: {
tools: localConfig.value?.tools ?? [],
mcpServers: localConfig.value?.mcpServers ?? [],
projectId: projectId.value,
agentId: agentId.value,
onConfirm: (tools: AgentJsonToolConfig[]) => onConfigFieldUpdate({ tools }),
onConfirm: (tools: AgentJsonToolConfig[], mcpServers: AgentJsonMcpServerConfig[] = []) =>
onConfigFieldUpdate({ tools, mcpServers }),
},
});
}
@ -663,28 +676,86 @@ function onOpenAddTriggerModal(initialTriggerType?: string) {
});
}
function onOpenToolFromList(index: number) {
function onOpenToolFromList(target: ToolOpenTarget | number) {
const tools = localConfig.value?.tools ?? [];
const tool = tools[index];
if (!tool) return;
builderTelemetry.trackOpenedToolFromList(tool.type);
const customTool = tool.type === 'custom' && tool.id ? agent.value?.tools?.[tool.id] : undefined;
const toolIndex =
typeof target === 'number'
? target
: tools.findIndex((tool) => {
if (target.kind !== 'tool') return false;
if (tool.type !== target.toolType) return false;
if (tool.type === 'node') return tool.name === target.id;
if (tool.type === 'workflow') return tool.workflow === target.id;
return tool.id === target.id;
});
if (toolIndex >= 0) {
const tool = tools[toolIndex];
if (!tool) return;
builderTelemetry.trackOpenedToolFromList(tool.type);
const customTool =
tool.type === 'custom' && tool.id ? agent.value?.tools?.[tool.id] : undefined;
uiStore.openModalWithData({
name: AGENT_TOOL_CONFIG_MODAL_KEY,
data: {
toolRef: tool,
customTool,
projectId: projectId.value,
agentId: agentId.value,
existingToolNames: tools
.map((toolRef, i) => (i === toolIndex || toolRef.type === 'custom' ? null : toolRef.name))
.filter((name): name is string => !!name),
onConfirm: (updatedTool: AgentJsonToolConfig) => {
const nextTools = [...(localConfig.value?.tools ?? [])];
nextTools[toolIndex] = updatedTool;
onConfigFieldUpdate({ tools: nextTools });
},
onRemove: () => onRemoveTool(toolIndex),
},
});
return;
}
const mcpServers = localConfig.value?.mcpServers ?? [];
const mcpServerIndex =
typeof target === 'number'
? target - tools.length
: target.kind === 'mcpServer'
? mcpServers.findIndex((server) => server.name === target.serverName)
: -1;
const mcpServer = mcpServers[mcpServerIndex];
if (!mcpServer) return;
builderTelemetry.trackOpenedToolFromList('mcpServer');
const preferredNodeTypeName = mcpServer.metadata?.nodeTypeName ?? AI_MCP_TOOL_NODE_TYPE;
const nodeType =
nodeTypesStore.getNodeType(preferredNodeTypeName) ??
nodeTypesStore.getNodeType(AI_MCP_TOOL_NODE_TYPE);
if (!nodeType) return;
uiStore.openModalWithData({
name: AGENT_TOOL_CONFIG_MODAL_KEY,
data: {
toolRef: tool,
customTool,
kind: 'mcpServer',
mcpServer,
initialNode: mcpServerToNode(mcpServer, nodeType),
projectId: projectId.value,
agentId: agentId.value,
existingToolNames: tools
.map((toolRef, i) => (i === index || toolRef.type === 'custom' ? null : toolRef.name))
.filter((name): name is string => !!name),
onConfirm: (updatedTool: AgentJsonToolConfig) => {
const nextTools = [...(localConfig.value?.tools ?? [])];
nextTools[index] = updatedTool;
onConfigFieldUpdate({ tools: nextTools });
existingToolNames: mcpServers
.filter((_, i) => i !== mcpServerIndex)
.map((server) => server.name),
onConfirm: (updatedServer: AgentJsonMcpServerConfig) => {
const nextMcpServers = [...(localConfig.value?.mcpServers ?? [])];
nextMcpServers[mcpServerIndex] = updatedServer;
onConfigFieldUpdate({ mcpServers: nextMcpServers });
},
onRemove: () => {
const nextMcpServers = (localConfig.value?.mcpServers ?? []).filter(
(_, i) => i !== mcpServerIndex,
);
onConfigFieldUpdate({ mcpServers: nextMcpServers });
},
onRemove: () => onRemoveTool(index),
},
});
}
@ -808,6 +879,10 @@ function onQuickActionAddTool(tools: AgentJsonToolConfig[]) {
onConfigFieldUpdate({ tools });
}
function onQuickActionAddMcpServers(mcpServers: AgentJsonMcpServerConfig[]) {
onConfigFieldUpdate({ mcpServers });
}
function onConnectedTriggersUpdate(triggers: string[]) {
connectedTriggers.value = triggers;
builderTelemetry.trackTriggerListChanged(triggers);
@ -929,6 +1004,7 @@ function onSwitchAgent(nextAgentId: string) {
@config-updated="onConfigUpdated"
@update:streaming="onBuildChatStreamingChange"
@update:tools="onQuickActionAddTool"
@update:mcp-servers="onQuickActionAddMcpServers"
@update:connected-triggers="onConnectedTriggersUpdate"
@update:full-width="isChatFullWidth = $event"
@trigger-added="onTriggerAdded"