mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
feat: UI for mcp servers in agents (no-changelog) (#31195)
This commit is contained in:
parent
4722c4d582
commit
5099287c5e
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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': [];
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import AgentCustomToolViewer from './AgentCustomToolViewer.vue';
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentCustomToolViewer :code="code" />
|
||||
</template>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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: () => {},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -127,5 +127,6 @@ export type {
|
|||
AgentJsonToolConfig as AgentJsonToolRef,
|
||||
AgentJsonSkillConfig as AgentJsonSkillRef,
|
||||
AgentJsonConfig as AgentJsonConfigRef,
|
||||
AgentJsonMcpServerConfig,
|
||||
AgentJsonConfig,
|
||||
} from '@n8n/api-types';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user