From 278ca8b9e8917ffabfca2019ae60dec9af47c18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Tue, 7 Oct 2025 15:20:45 +0200 Subject: [PATCH] feat(editor): Allow MCP access only for webhook triggered workflows (no-changelog) (#20414) --- .../__tests__/mcp.settings.controller.test.ts | 216 +++++++++++++++++- .../src/modules/mcp/__tests__/mock.utils.ts | 5 +- .../__tests__/search-workflows.tool.test.ts | 13 +- .../mcp/__tests__/webhook-utils.test.ts | 3 +- .../dto/update-workflow-availability.dto.ts | 6 + .../modules/mcp/mcp.settings.controller.ts | 72 +++++- .../mcp/tools/get-workflow-details.tool.ts | 4 +- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../editor-ui/src/components/WorkflowCard.vue | 10 +- .../src/components/WorkflowSettings.vue | 36 ++- .../src/features/mcpAccess/mcp.api.ts | 16 ++ .../src/features/mcpAccess/mcp.store.ts | 24 +- 12 files changed, 372 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/modules/mcp/dto/update-workflow-availability.dto.ts diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts index 89d558133a1..bd3572af668 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts @@ -1,21 +1,77 @@ import { Logger, ModuleRegistry } from '@n8n/backend-common'; -import { type ApiKey, type AuthenticatedRequest } from '@n8n/db'; +import { type ApiKey, type AuthenticatedRequest, WorkflowEntity, User, Role } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { Response } from 'express'; import { mock, mockDeep } from 'jest-mock-extended'; +import { HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE, type INode } from 'n8n-workflow'; import { UpdateMcpSettingsDto } from '../dto/update-mcp-settings.dto'; import { McpServerApiKeyService } from '../mcp-api-key.service'; import { McpSettingsController } from '../mcp.settings.controller'; import { McpSettingsService } from '../mcp.settings.service'; -const createReq = (body: unknown): AuthenticatedRequest => - ({ body }) as unknown as AuthenticatedRequest; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import { WorkflowService } from '@/workflows/workflow.service'; + +const createReq = ( + body: unknown, + overrides: Partial = {}, +): AuthenticatedRequest => ({ body, ...overrides }) as unknown as AuthenticatedRequest; + +const createRole = () => + Object.assign(new Role(), { + slug: 'member', + displayName: 'Member', + description: null, + systemRole: false, + roleType: 'global' as const, + projectRelations: [], + scopes: [], + }); + +const createUser = (overrides: Partial = {}) => + Object.assign( + new User(), + { + id: 'user-1', + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + password: null, + personalizationAnswers: null, + settings: null, + role: createRole(), + authIdentities: [], + apiKeys: [], + sharedWorkflows: [], + sharedCredentials: [], + projectRelations: [], + disabled: false, + mfaEnabled: false, + mfaSecret: null, + mfaRecoveryCodes: [], + lastActiveAt: null, + isPending: false, + }, + overrides, + ); + +const createRes = () => { + const res = mock(); + res.status.mockReturnThis(); + res.json.mockReturnThis(); + return res; +}; describe('McpSettingsController', () => { const logger = mock(); const moduleRegistry = mockDeep(); const mcpSettingsService = mock(); const mcpServerApiKeyService = mockDeep(); + const workflowFinderService = mock(); + const workflowService = mock(); let controller: McpSettingsController; @@ -25,9 +81,15 @@ describe('McpSettingsController', () => { Container.set(McpSettingsService, mcpSettingsService); Container.set(ModuleRegistry, moduleRegistry); Container.set(McpServerApiKeyService, mcpServerApiKeyService); + Container.set(WorkflowFinderService, workflowFinderService); + Container.set(WorkflowService, workflowService); controller = Container.get(McpSettingsController); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('updateSettings', () => { test('disables MCP access correctly', async () => { const req = createReq({ mcpAccessEnabled: false }); @@ -35,7 +97,7 @@ describe('McpSettingsController', () => { mcpSettingsService.setEnabled.mockResolvedValue(undefined); moduleRegistry.refreshModuleSettings.mockResolvedValue(null); - const res = new Response(); + const res = createRes(); const result = await controller.updateSettings(req, res, dto); expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(false); @@ -49,7 +111,7 @@ describe('McpSettingsController', () => { mcpSettingsService.setEnabled.mockResolvedValue(undefined); moduleRegistry.refreshModuleSettings.mockResolvedValue(null); - const res = new Response(); + const res = createRes(); const result = await controller.updateSettings(req, res, dto); expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(true); @@ -65,7 +127,7 @@ describe('McpSettingsController', () => { mcpSettingsService.setEnabled.mockResolvedValue(undefined); moduleRegistry.refreshModuleSettings.mockRejectedValue(error); - const res = new Response(); + const res = createRes(); const result = await controller.updateSettings(req, res, dto); expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(true); @@ -83,7 +145,7 @@ describe('McpSettingsController', () => { }); describe('getApiKeyForMcpServer', () => { - const mockUser = { id: 'user123', role: { slug: 'member' } }; + const mockUser = createUser({ id: 'user123', email: 'user123@example.com' }); const mockApiKey = { id: 'api-key-123', key: 'mcp-key-abc123', @@ -92,7 +154,7 @@ describe('McpSettingsController', () => { } as unknown as ApiKey; test('returns API key from getOrCreateApiKey', async () => { - const req = { user: mockUser } as AuthenticatedRequest; + const req = createReq({}, { user: mockUser }); mcpServerApiKeyService.getOrCreateApiKey.mockResolvedValue(mockApiKey); const result = await controller.getApiKeyForMcpServer(req); @@ -103,7 +165,7 @@ describe('McpSettingsController', () => { }); describe('rotateApiKeyForMcpServer', () => { - const mockUser = { id: 'user123', role: { slug: 'member' } }; + const mockUser = createUser({ id: 'user123', email: 'user123@example.com' }); const mockApiKey = { id: 'api-key-123', key: 'mcp-key-abc123', @@ -112,7 +174,7 @@ describe('McpSettingsController', () => { } as unknown as ApiKey; test('successfully rotates API key', async () => { - const req = { user: mockUser } as AuthenticatedRequest; + const req = createReq({}, { user: mockUser }); mcpServerApiKeyService.rotateMcpServerApiKey.mockResolvedValue(mockApiKey); @@ -122,4 +184,138 @@ describe('McpSettingsController', () => { expect(result).toEqual(mockApiKey); }); }); + + describe('toggleWorkflowMCPAccess', () => { + const user = createUser(); + const workflowId = 'workflow-1'; + + const createWebhookNode = (overrides: Partial = {}): INode => ({ + id: 'node-1', + name: 'Webhook', + type: WEBHOOK_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: {}, + ...overrides, + }); + + const createWorkflow = (overrides: Partial = {}) => { + const entity = new WorkflowEntity(); + entity.id = workflowId; + entity.active = true; + entity.nodes = [createWebhookNode()]; + entity.settings = { saveManualExecutions: true }; + entity.versionId = 'current-version-id'; + return Object.assign(entity, overrides); + }; + + test('throws when workflow cannot be accessed', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue(null); + const req = createReq({}, { user }); + + await expect( + controller.toggleWorkflowMCPAccess(req, mock(), workflowId, { + availableInMCP: true, + }), + ).rejects.toThrow( + new NotFoundError( + 'Could not load the workflow - you can only access workflows available to you', + ), + ); + expect(workflowService.update).not.toHaveBeenCalled(); + }); + + test('rejects enabling MCP for inactive workflows', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue( + createWorkflow({ active: false }), + ); + + await expect( + controller.toggleWorkflowMCPAccess(createReq({}, { user }), mock(), workflowId, { + availableInMCP: true, + }), + ).rejects.toThrow(new BadRequestError('MCP access can only be set for active workflows')); + + expect(workflowService.update).not.toHaveBeenCalled(); + }); + + test('rejects enabling MCP without active webhook nodes', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue( + createWorkflow({ + nodes: [ + createWebhookNode({ disabled: true }), + { + id: 'node-2', + name: 'HTTP Request', + type: HTTP_REQUEST_NODE_TYPE, + typeVersion: 1, + position: [10, 10], + parameters: {}, + }, + ], + }), + ); + + await expect( + controller.toggleWorkflowMCPAccess(createReq({}, { user }), mock(), workflowId, { + availableInMCP: true, + }), + ).rejects.toThrow( + new BadRequestError('MCP access can only be set for webhook-triggered workflows'), + ); + + expect(workflowService.update).not.toHaveBeenCalled(); + }); + + test('persists MCP availability when validation passes', async () => { + const workflow = createWorkflow(); + workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow); + workflowService.update.mockResolvedValue({ + id: workflowId, + settings: { saveManualExecutions: true, availableInMCP: true }, + versionId: 'updated-version-id', + } as unknown as WorkflowEntity); + + const req = createReq({}, { user }); + const response = await controller.toggleWorkflowMCPAccess(req, mock(), workflowId, { + availableInMCP: true, + }); + + expect(workflowService.update).toHaveBeenCalledTimes(1); + const updateArgs = workflowService.update.mock.calls[0]; + expect(updateArgs[0]).toEqual(user); + expect(updateArgs[1]).toBeInstanceOf(WorkflowEntity); + expect(updateArgs[1].settings).toEqual({ saveManualExecutions: true, availableInMCP: true }); + expect(updateArgs[1].versionId).toEqual('current-version-id'); + expect(updateArgs[2]).toEqual(workflowId); + expect(updateArgs[5]).toEqual(false); + + expect(response).toEqual({ + id: workflowId, + settings: { saveManualExecutions: true, availableInMCP: true }, + versionId: 'updated-version-id', + }); + }); + + test('rejects disabling MCP for inactive workflows', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue( + createWorkflow({ active: false }), + ); + workflowService.update.mockResolvedValue({ + id: workflowId, + settings: { saveManualExecutions: true, availableInMCP: false }, + versionId: 'client-version', + } as unknown as WorkflowEntity); + + const req = createReq({}, { user }); + + await expect( + controller.toggleWorkflowMCPAccess(req, mock(), workflowId, { + availableInMCP: false, + }), + ).rejects.toThrow(new BadRequestError('MCP access can only be set for active workflows')); + + expect(workflowService.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mock.utils.ts b/packages/cli/src/modules/mcp/__tests__/mock.utils.ts index 4751a86545c..cd2eb05d8c8 100644 --- a/packages/cli/src/modules/mcp/__tests__/mock.utils.ts +++ b/packages/cli/src/modules/mcp/__tests__/mock.utils.ts @@ -1,4 +1,5 @@ import type { WorkflowEntity } from '@n8n/db'; +import { MANUAL_TRIGGER_NODE_TYPE, WEBHOOK_NODE_TYPE } from 'n8n-workflow'; export const createWorkflow = (overrides: Partial = {}) => ({ id: 'wf-1', @@ -7,7 +8,7 @@ export const createWorkflow = (overrides: Partial = {}) => ({ { id: 'node-1', name: 'Webhook', - type: 'n8n-nodes-base.webhook', + type: WEBHOOK_NODE_TYPE, typeVersion: 1, position: [0, 0], disabled: false, @@ -17,7 +18,7 @@ export const createWorkflow = (overrides: Partial = {}) => ({ { id: 'node-2', name: 'Start', - type: 'n8n-nodes-base.start', + type: MANUAL_TRIGGER_NODE_TYPE, typeVersion: 1, position: [100, 0], disabled: false, diff --git a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts index 0c87ef7f7e6..75aaa2cdf54 100644 --- a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts @@ -6,6 +6,7 @@ import { createWorkflow } from './mock.utils'; import { searchWorkflows, createSearchWorkflowsTool } from '../tools/search-workflows.tool'; import { WorkflowService } from '@/workflows/workflow.service'; +import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from 'n8n-workflow'; describe('search-workflows MCP tool', () => { const user = Object.assign(new User(), { id: 'user-1' }); @@ -16,7 +17,7 @@ describe('search-workflows MCP tool', () => { createWorkflow({ id: 'wrap-1', name: 'Wrapper', - nodes: [{ name: 'Start', type: 'n8n-nodes-base.start' } as INode], + nodes: [{ name: 'Start', type: MANUAL_TRIGGER_NODE_TYPE } as INode], }), ]; @@ -40,13 +41,15 @@ describe('search-workflows MCP tool', () => { createWorkflow({ id: 'a', name: 'Alpha', - nodes: [{ name: 'Start', type: 'n8n-nodes-base.start' } as INode], + nodes: [{ name: 'Start', type: MANUAL_TRIGGER_NODE_TYPE } as INode], }), createWorkflow({ id: 'b', name: 'Beta', active: true, - nodes: [{ name: 'Cron', type: 'n8n-nodes-base.cron' } as INode], + nodes: [ + { name: 'Execute subworkflow', type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } as INode, + ], }), ]; @@ -64,7 +67,7 @@ describe('search-workflows MCP tool', () => { createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), triggerCount: 1, - nodes: [{ name: 'Start', type: 'n8n-nodes-base.start' }], + nodes: [{ name: 'Start', type: MANUAL_TRIGGER_NODE_TYPE }], }, { id: 'b', @@ -73,7 +76,7 @@ describe('search-workflows MCP tool', () => { createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), triggerCount: 1, - nodes: [{ name: 'Cron', type: 'n8n-nodes-base.cron' }], + nodes: [{ name: 'Execute subworkflow', type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE }], }, ]); }); diff --git a/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts b/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts index ca2a8860f3e..9d130273ded 100644 --- a/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts @@ -7,6 +7,7 @@ import type { INodeParameters, ICredentialDataDecryptedObject, } from 'n8n-workflow'; +import { WEBHOOK_NODE_TYPE } from 'n8n-workflow'; import { buildWebhookPath, getWebhookDetails } from '../tools/webhook-utils'; @@ -40,7 +41,7 @@ const createWebhookNode = ( const base: INode = { id: '1', name: 'Webhook', - type: 'n8n-nodes-base.webhook', + type: WEBHOOK_NODE_TYPE, typeVersion: 1, position: [0, 0], parameters: {}, diff --git a/packages/cli/src/modules/mcp/dto/update-workflow-availability.dto.ts b/packages/cli/src/modules/mcp/dto/update-workflow-availability.dto.ts new file mode 100644 index 00000000000..980c0feccbc --- /dev/null +++ b/packages/cli/src/modules/mcp/dto/update-workflow-availability.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class UpdateWorkflowAvailabilityDto extends Z.class({ + availableInMCP: z.boolean(), +}) {} diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index b467d184a26..bfa61c212aa 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -1,11 +1,19 @@ import { ModuleRegistry, Logger } from '@n8n/backend-common'; -import { type AuthenticatedRequest } from '@n8n/db'; -import { Body, Post, Get, Patch, RestController, GlobalScope } from '@n8n/decorators'; +import { type AuthenticatedRequest, WorkflowEntity } from '@n8n/db'; +import { Body, Post, Get, Patch, RestController, GlobalScope, Param } from '@n8n/decorators'; +import type { Response } from 'express'; +import { WEBHOOK_NODE_TYPE } from 'n8n-workflow'; import { UpdateMcpSettingsDto } from './dto/update-mcp-settings.dto'; +import { UpdateWorkflowAvailabilityDto } from './dto/update-workflow-availability.dto'; import { McpServerApiKeyService } from './mcp-api-key.service'; import { McpSettingsService } from './mcp.settings.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import { WorkflowService } from '@/workflows/workflow.service'; + @RestController('/mcp') export class McpSettingsController { constructor( @@ -13,6 +21,8 @@ export class McpSettingsController { private readonly logger: Logger, private readonly moduleRegistry: ModuleRegistry, private readonly mcpServerApiKeyService: McpServerApiKeyService, + private readonly workflowFinderService: WorkflowFinderService, + private readonly workflowService: WorkflowService, ) {} @GlobalScope('mcp:manage') @@ -45,4 +55,62 @@ export class McpSettingsController { async rotateApiKeyForMcpServer(req: AuthenticatedRequest) { return await this.mcpServerApiKeyService.rotateMcpServerApiKey(req.user); } + + @GlobalScope('mcp:manage') + @Patch('/workflows/:workflowId/toggle-access') + async toggleWorkflowMCPAccess( + req: AuthenticatedRequest, + _res: Response, + @Param('workflowId') workflowId: string, + @Body dto: UpdateWorkflowAvailabilityDto, + ) { + const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, req.user, [ + 'workflow:update', + ]); + + if (!workflow) { + this.logger.warn('User attempted to update MCP availability without permissions', { + workflowId, + userId: req.user.id, + }); + throw new NotFoundError( + 'Could not load the workflow - you can only access workflows available to you', + ); + } + + if (!workflow.active) { + throw new BadRequestError('MCP access can only be set for active workflows'); + } + + const hasWebhooks = workflow.nodes.some( + (node) => node.type === WEBHOOK_NODE_TYPE && node.disabled !== true, + ); + + if (!hasWebhooks) { + throw new BadRequestError('MCP access can only be set for webhook-triggered workflows'); + } + + const workflowUpdate = new WorkflowEntity(); + const currentSettings = workflow.settings ?? {}; + workflowUpdate.settings = { + ...currentSettings, + availableInMCP: dto.availableInMCP, + }; + workflowUpdate.versionId = workflow.versionId; + + const updatedWorkflow = await this.workflowService.update( + req.user, + workflowUpdate, + workflowId, + undefined, // tags + undefined, // parentFolderId + false, // forceSave + ); + + return { + id: updatedWorkflow.id, + settings: updatedWorkflow.settings, + versionId: updatedWorkflow.versionId, + }; + } } diff --git a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts index ab973c5c218..bdc9fdeeaca 100644 --- a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts +++ b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts @@ -1,5 +1,5 @@ import type { User } from '@n8n/db'; -import { UserError } from 'n8n-workflow'; +import { UserError, WEBHOOK_NODE_TYPE } from 'n8n-workflow'; import z from 'zod'; import type { ToolDefinition, WorkflowDetailsResult } from '../mcp.types'; @@ -68,7 +68,7 @@ export async function getWorkflowDetails( } const webhooks = workflow.nodes.filter( - (node) => node.type === 'n8n-nodes-base.webhook' && node.disabled !== true, + (node) => node.type === WEBHOOK_NODE_TYPE && node.disabled !== true, ); let triggerNotice = await getWebhookDetails( diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 8903b88acdc..cd5a0d1c4a0 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1146,6 +1146,7 @@ "mainSidebar.workersView": "Workers", "mainSidebar.whatsNew": "What’s New", "mainSidebar.whatsNew.fullChangelog": "Full changelog", + "mcp.workflowNotEligable.description": "Only active, webhook-triggered workflows can be accessible through MCP", "menuActions.duplicate": "Duplicate", "menuActions.download": "Download", "menuActions.push": "Push to Git", diff --git a/packages/frontend/editor-ui/src/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/components/WorkflowCard.vue index a7eeccd5c5d..02b54405afe 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowCard.vue @@ -23,7 +23,7 @@ import { useRoute, useRouter } from 'vue-router'; import { useTelemetry } from '@/composables/useTelemetry'; import { ResourceType } from '@/utils/projects.utils'; import type { EventBus } from '@n8n/utils/event-bus'; -import type { WorkflowResource } from '@/Interface'; +import type { UserAction, WorkflowResource } from '@/Interface'; import type { IUser } from 'n8n-workflow'; import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types'; import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; @@ -39,6 +39,7 @@ import { N8nText, N8nTooltip, } from '@n8n/design-system'; +import { useMCPStore } from '@/features/mcpAccess/mcp.store'; const WORKFLOW_LIST_ITEM_ACTIONS = { OPEN: 'open', SHARE: 'share', @@ -101,6 +102,7 @@ const usersStore = useUsersStore(); const workflowsStore = useWorkflowsStore(); const projectsStore = useProjectsStore(); const foldersStore = useFoldersStore(); +const mcpStore = useMCPStore(); const hiddenBreadcrumbsItemsAsync = ref>(new Promise(() => {})); const cachedHiddenBreadcrumbsItems = ref([]); @@ -150,7 +152,7 @@ const cardBreadcrumbs = computed(() => { }); const actions = computed(() => { - const items = [ + const items: Array> = [ { label: locale.baseText('workflows.item.open'), value: WORKFLOW_LIST_ITEM_ACTIONS.OPEN, @@ -210,11 +212,13 @@ const actions = computed(() => { items.push({ label: locale.baseText('workflows.item.disableMCPAccess'), value: WORKFLOW_LIST_ITEM_ACTIONS.REMOVE_MCP_ACCESS, + disabled: !props.data.active, }); } else { items.push({ label: locale.baseText('workflows.item.enableMCPAccess'), value: WORKFLOW_LIST_ITEM_ACTIONS.ENABLE_MCP_ACCESS, + disabled: !props.data.active, }); } } @@ -331,7 +335,7 @@ async function onAction(action: string) { async function toggleMCPAccess(enabled: boolean) { try { - await workflowsStore.updateWorkflowSetting(props.data.id, 'availableInMCP', enabled); + await mcpStore.toggleWorkflowMcpAccess(props.data.id, enabled); mcpToggleStatus.value = enabled; } catch (error) { toast.showError(error, locale.baseText('workflowSettings.toggleMCP.error.title')); diff --git a/packages/frontend/editor-ui/src/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/components/WorkflowSettings.vue index 75f4d5fac16..c475f2db40a 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowSettings.vue @@ -8,6 +8,7 @@ import Modal from '@/components/Modal.vue'; import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID, + WEBHOOK_NODE_TYPE, WORKFLOW_SETTINGS_MODAL_KEY, } from '@/constants'; import type { WorkflowSettings } from 'n8n-workflow'; @@ -97,6 +98,16 @@ const workflowOwnerName = computed(() => { }); const workflowPermissions = computed(() => getResourcePermissions(workflow.value?.scopes).workflow); +const isEligibleForMCPAccess = computed(() => { + if (!workflow.value?.active) { + return false; + } + // If it's active, check if workflow has at least one enabled webhook trigger: + return workflow.value?.nodes.some( + (node) => node.type === WEBHOOK_NODE_TYPE && node.disabled !== true, + ); +}); + const onCallerIdsInput = (str: string) => { workflowSettings.value.callerIds = /^[a-zA-Z0-9,\s]+$/.test(str) ? str @@ -846,7 +857,11 @@ onBeforeUnmount(() => { {{ i18n.baseText('workflowSettings.availableInMCP') }} @@ -854,13 +869,18 @@ onBeforeUnmount(() => {
- + + + +
diff --git a/packages/frontend/editor-ui/src/features/mcpAccess/mcp.api.ts b/packages/frontend/editor-ui/src/features/mcpAccess/mcp.api.ts index 088b2af924e..3826f05fce2 100644 --- a/packages/frontend/editor-ui/src/features/mcpAccess/mcp.api.ts +++ b/packages/frontend/editor-ui/src/features/mcpAccess/mcp.api.ts @@ -1,4 +1,5 @@ import type { ApiKey } from '@n8n/api-types'; +import type { IWorkflowSettings } from '@/Interface'; import type { IRestApiContext } from '@n8n/rest-api-client'; import { makeRestApiRequest } from '@n8n/rest-api-client'; @@ -26,3 +27,18 @@ export async function fetchApiKey(context: IRestApiContext): Promise { export async function rotateApiKey(context: IRestApiContext): Promise { return await makeRestApiRequest(context, 'POST', '/mcp/api-key/rotate'); } + +export async function toggleWorkflowMcpAccessApi( + context: IRestApiContext, + workflowId: string, + availableInMCP: boolean, +): Promise<{ id: string; settings: IWorkflowSettings | undefined; versionId: string }> { + return await makeRestApiRequest( + context, + 'PATCH', + `/mcp/workflows/${encodeURIComponent(workflowId)}/toggle-access`, + { + availableInMCP, + }, + ); +} diff --git a/packages/frontend/editor-ui/src/features/mcpAccess/mcp.store.ts b/packages/frontend/editor-ui/src/features/mcpAccess/mcp.store.ts index 6c8ece12f55..092191d0c02 100644 --- a/packages/frontend/editor-ui/src/features/mcpAccess/mcp.store.ts +++ b/packages/frontend/editor-ui/src/features/mcpAccess/mcp.store.ts @@ -3,7 +3,12 @@ import { MCP_STORE } from './mcp.constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import type { WorkflowListItem } from '@/Interface'; import { useRootStore } from '@n8n/stores/useRootStore'; -import { fetchApiKey, updateMcpSettings, rotateApiKey } from '@/features/mcpAccess/mcp.api'; +import { + updateMcpSettings, + toggleWorkflowMcpAccessApi, + fetchApiKey, + rotateApiKey, +} from '@/features/mcpAccess/mcp.api'; import { computed, ref } from 'vue'; import { useSettingsStore } from '@/stores/settings.store'; import { isWorkflowListItem } from '@/utils/typeGuards'; @@ -46,6 +51,22 @@ export const useMCPStore = defineStore(MCP_STORE, () => { return updated; } + async function toggleWorkflowMcpAccess( + workflowId: string, + availableInMCP: boolean, + ): Promise<{ + id: string; + settings: { availableInMCP?: boolean } | undefined; + versionId: string; + }> { + const response = await toggleWorkflowMcpAccessApi( + rootStore.restApiContext, + workflowId, + availableInMCP, + ); + return response; + } + async function getOrCreateApiKey(): Promise { const apiKey = await fetchApiKey(rootStore.restApiContext); currentUserMCPKey.value = apiKey; @@ -62,6 +83,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => { mcpAccessEnabled, fetchWorkflowsAvailableForMCP, setMcpAccessEnabled, + toggleWorkflowMcpAccess, currentUserMCPKey, getOrCreateApiKey, generateNewApiKey,