diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 70a00a665ac..760ace9f6df 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -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; export type AgentJsonMemoryConfig = z.infer; export type NodeToolConfig = z.infer; export type AgentJsonMcpServerConfig = z.infer; +export type McpAuthenticationSchemaType = z.infer; export interface ConfigValidationError { path: string; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 968cf723423..7787acde3aa 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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.", diff --git a/packages/frontend/editor-ui/src/app/stores/settings.store.ts b/packages/frontend/editor-ui/src/app/stores/settings.store.ts index e5627018a14..0d480341bf3 100644 --- a/packages/frontend/editor-ui/src/app/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/settings.store.ts @@ -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, }; }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 69d0db8b477..22268285341 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -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( diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts index 5b71f703520..0d570b8c1a0 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts @@ -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 = {}, + 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 { + 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); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts index 80a3fee5ec1..a593d7b6b72 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts @@ -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()); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue index e8edf68e7be..c6c41074400 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue @@ -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('');
props.isBuildChatStreaming || !props.can const emit = defineEmits<{ 'update:activeMainTab': [tab: AgentBuilderMainTab]; 'update:config': [updates: Partial]; - 'open-tool': [index: number]; + 'open-tool': [target: ToolOpenTarget]; 'open-skill': [id: string]; 'open-trigger': [triggerType: string]; 'add-tool': []; diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.types.ts b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.types.ts index 2b6a4b03309..958adba7a98 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.types.ts +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.types.ts @@ -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; +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 } +>; diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.utils.ts b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.utils.ts index f5160661fe6..5a38cd82a61 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.utils.ts +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.utils.ts @@ -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, })), }; } diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue index a485530b060..bca4ad71cca 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue @@ -1,17 +1,18 @@