From 2ad313558465b7608c456747d716ef1ef8adb56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Mon, 24 Nov 2025 10:11:41 +0100 Subject: [PATCH] feat(editor): Minor UX improvements for MCP (no-changelog) (#22131) --- .../frontend/@n8n/i18n/src/locales/en.json | 9 +- .../WorkflowDescriptionPopover.test.ts | 105 +----- .../MainHeader/WorkflowDescriptionPopover.vue | 16 +- .../WorkflowProductionChecklist.test.ts | 343 ++++++++++++++++++ .../WorkflowProductionChecklist.vue | 127 +++++-- .../src/app/composables/useWorkflowsCache.ts | 8 +- .../mcpAccess/components/McpAccessToggle.vue | 68 ++-- .../53-workflow-production-checklist.spec.ts | 2 - 8 files changed, 499 insertions(+), 179 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 84ed186bab1..e4d6235c95e 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1094,8 +1094,7 @@ "workflow.count": "the {count} workflow | the {count} workflows", "workflow.description.tooltip": "Edit workflow description", "workflow.description.placeholder": "Describe the purpose and functionality of this workflow", - "workflow.description.placeholder.mcp": "This will help MCP clients understand when to use this workflow", - "workflow.description.placeholder.mcp.webhook": "Include details about the expected webhook inputs (payload format and whether it should be sent as request body or URL parameters)", + "workflow.description.placeholder.mcp": "To help MCP clients understand when to use this workflow, add a short workflow description that describes what it does.", "workflow.description.error.title": "Problem updating workflow description", "folder.and.workflow.separator": "and", "folders.delete.action": "Archive all workflows and delete subfolders", @@ -1223,8 +1222,9 @@ "mcp.workflowNotEligable.description": "Only active workflows with one of the following trigger nodes can be accessible via MCP: {triggers}", "mcp.instanceLevelAccessDisabled.description": "Instance-level MCP access is disabled. Enable it to allow workflow-level access.", "mcp.workflowDeactivated.title": "MCP Access Disabled", - "mcp.productionCheklist.title": "Enable MCP access", - "mcp.productionCheklist.description": "Allow MCP clients to access this workflow", + "mcp.productionChecklist.title": "Enable MCP access", + "mcp.productionChecklist.workflow.description": "Allow MCP clients to access this workflow", + "mcp.productionChecklist.instance.description": "Enable instance-level MCP access to allow MCP clients to access workflows.", "mcp.workflowDeactivated.message": "MCP Access has been disabled for this workflow because it is deactivated", "menuActions.duplicate": "Duplicate", "menuActions.download": "Download", @@ -2247,6 +2247,7 @@ "settings.mcp": "MCP Access", "settings.mcp.toggle.label": "Enable MCP", "settings.mcp.toggle.description": "Enable MCP access to this n8n instance", + "settings.mcp.toggle.disabled.notice": "Let MCP clients like Claude, Lovable, and other AI tools discover and execute your n8n workflows.", "settings.mcp.available.workflows.heading": "Available Workflows", "settings.mcp.connection.info.heading": "How to connect", "settings.mcp.workflows.table.action.removeMCPAccess": "Remove MCP Access", diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts index 14039b7e09b..8a690211772 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts @@ -9,7 +9,6 @@ import { useUIStore } from '@/app/stores/ui.store'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useToast } from '@/app/composables/useToast'; import { useTelemetry } from '@/app/composables/useTelemetry'; -import { WEBHOOK_NODE_TYPE } from 'n8n-workflow'; import { STORES } from '@n8n/stores'; vi.mock('@/app/composables/useToast', () => { @@ -540,7 +539,7 @@ describe('WorkflowDescriptionPopover', () => { }); }); - describe('MCP and webhook tooltips', () => { + describe('MCP tooltips', () => { it('should show base tooltip when MCP is disabled', async () => { // Ensure MCP is disabled settingsStore.isModuleActive = vi.fn().mockReturnValue(false); @@ -558,8 +557,8 @@ describe('WorkflowDescriptionPopover', () => { // The tooltip text appears as placeholder in the textarea const textarea = getByTestId('workflow-description-input'); const placeholder = textarea.getAttribute('placeholder'); + expect(placeholder).toContain('Edit workflow description'); - // When MCP is disabled, should not contain MCP-specific text expect(placeholder).not.toContain('MCP clients'); }); @@ -580,106 +579,8 @@ describe('WorkflowDescriptionPopover', () => { const textarea = getByTestId('workflow-description-input'); const placeholder = textarea.getAttribute('placeholder'); - // When MCP is enabled, the placeholder includes both base tooltip and MCP-specific text - expect(placeholder).toContain('Edit workflow description'); expect(placeholder).toContain('MCP clients'); - // Should not include webhook notice when no webhooks - expect(placeholder).not.toContain('webhook'); - }); - - it('should show webhook notice when workflow has webhooks and MCP is enabled', async () => { - // Enable MCP module - settingsStore.isModuleActive = vi.fn().mockReturnValue(true); - settingsStore.moduleSettings.mcp = { mcpAccessEnabled: true }; - - // Set up workflow with an enabled webhook node - workflowsStore.workflow = { - id: 'test-workflow-id', - name: 'Test Workflow', - active: false, - activeVersionId: null, - isArchived: false, - createdAt: Date.now(), - updatedAt: Date.now(), - versionId: '1', - nodes: [ - { - id: 'webhook-1', - name: 'Webhook', - type: WEBHOOK_NODE_TYPE, - disabled: false, - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - ], - connections: {}, - }; - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: '', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - const placeholder = textarea.getAttribute('placeholder'); - - // When MCP is enabled and webhook is present, the placeholder includes all three parts - expect(placeholder).toContain('Edit workflow description'); - expect(placeholder).toContain('MCP clients'); - expect(placeholder).toContain('webhook inputs'); - expect(placeholder).toContain('payload format'); - }); - - it('should not show webhook notice for disabled webhook nodes', async () => { - // Enable MCP module - settingsStore.isModuleActive = vi.fn().mockReturnValue(true); - - // Set up workflow with a disabled webhook node - workflowsStore.workflow = { - id: 'test-workflow-id', - name: 'Test Workflow', - active: false, - activeVersionId: null, - isArchived: false, - createdAt: Date.now(), - updatedAt: Date.now(), - versionId: '1', - nodes: [ - { - id: 'webhook-1', - name: 'Webhook', - type: WEBHOOK_NODE_TYPE, - disabled: true, - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - ], - connections: {}, - }; - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: '', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - const placeholder = textarea.getAttribute('placeholder'); - - // Should show MCP text but not webhook notice for disabled webhooks - expect(placeholder).toContain('Edit workflow description'); - expect(placeholder).toContain('MCP clients'); - expect(placeholder).not.toContain('webhook inputs'); - expect(placeholder).not.toContain('payload format'); + expect(placeholder).not.toContain('Edit workflow description'); }); }); diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue index 5a5e267f961..504f6098642 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue @@ -14,7 +14,6 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { useUIStore } from '@/app/stores/ui.store'; import { useToast } from '@/app/composables/useToast'; import { useTelemetry } from '@/app/composables/useTelemetry'; -import { WEBHOOK_NODE_TYPE } from 'n8n-workflow'; type Props = { workflowId: string; @@ -48,24 +47,11 @@ const isMcpEnabled = computed( () => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled, ); -const hasWebhooks = computed(() => { - const workflow = workflowStore.workflow; - if (!workflow) return false; - return workflow.nodes.some((node) => !node.disabled && node.type === WEBHOOK_NODE_TYPE); -}); - -// Descriptive tip that will be used as textarea placeholder and input label tooltip -// Updated based on MCP and webhook presence const textareaTip = computed(() => { - const baseTooltip = i18n.baseText('workflow.description.tooltip'); if (!isMcpEnabled.value) { return i18n.baseText('workflow.description.tooltip'); } - const mcpTooltip = i18n.baseText('workflow.description.placeholder.mcp'); - const webhookNotice = hasWebhooks.value - ? i18n.baseText('workflow.description.placeholder.mcp.webhook') - : ''; - return `${baseTooltip}. ${mcpTooltip}.\n${webhookNotice}`; + return i18n.baseText('workflow.description.placeholder.mcp'); }); const saveDescription = async () => { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts index f1d41cb3112..2ced645397e 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts @@ -23,6 +23,10 @@ import { } from '@/app/constants'; import type { INodeTypeDescription } from 'n8n-workflow'; import { createTestNode } from '@/__tests__/mocks'; +import { useSettingsStore } from '@/app/stores/settings.store'; +import { useUsersStore } from '@/features/settings/users/users.store'; +import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; +import { MCP_DOCS_PAGE_URL, MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants'; vi.mock('vue-router', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports @@ -45,6 +49,10 @@ vi.mock('@/app/composables/useTelemetry', () => ({ useTelemetry: vi.fn(), })); +vi.mock('@/features/ai/mcpAccess/composables/useMcp', () => ({ + useMcp: vi.fn(), +})); + vi.mock('@n8n/i18n', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports const actual = await importOriginal(); @@ -132,6 +140,9 @@ describe('WorkflowProductionChecklist', () => { let nodeTypesStore: ReturnType; let uiStore: ReturnType; let sourceControlStore: ReturnType; + let settingsStore: ReturnType; + let usersStore: ReturnType; + let mcpComposable: ReturnType; beforeEach(() => { router = { @@ -159,6 +170,11 @@ describe('WorkflowProductionChecklist', () => { track: vi.fn(), } as unknown as ReturnType; (useTelemetry as ReturnType).mockReturnValue(telemetry); + + mcpComposable = { + isEligibleForMcpAccess: vi.fn().mockReturnValue(false), + } as unknown as ReturnType; + (useMcp as ReturnType).mockReturnValue(mcpComposable); }); afterEach(() => { @@ -779,6 +795,333 @@ describe('WorkflowProductionChecklist', () => { }); }); + describe('MCP Actions', () => { + it('should not show MCP actions when MCP module is disabled', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(false); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + expect(mockN8nSuggestedActionsProps.actions).toEqual([ + { + id: 'errorWorkflow', + title: 'workflowProductionChecklist.errorWorkflow.title', + description: 'workflowProductionChecklist.errorWorkflow.description', + moreInfoLink: ERROR_WORKFLOW_DOCS_URL, + completed: false, + }, + { + id: 'timeSaved', + title: 'workflowProductionChecklist.timeSaved.title', + description: 'workflowProductionChecklist.timeSaved.description', + moreInfoLink: TIME_SAVED_DOCS_URL, + completed: false, + }, + ]); + }); + }); + + it('should show instance-level MCP action to admins when MCP is disabled at instance level and workflow is eligible', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + usersStore = useUsersStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: false }, + }); + vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(true); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + expect(mockN8nSuggestedActionsProps.actions).toContainEqual({ + id: 'instance-mcp-access', + title: 'mcp.productionChecklist.title', + description: 'mcp.productionChecklist.instance.description', + moreInfoLink: MCP_DOCS_PAGE_URL, + completed: false, + }); + }); + }); + + it('should not show instance-level MCP action to admins when workflow is not eligible for MCP', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + usersStore = useUsersStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: false }, + }); + vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(false); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + const actions = mockN8nSuggestedActionsProps.actions; + expect(actions).toBeDefined(); + expect(actions.find((a: { id: string }) => a.id === 'instance-mcp-access')).toBeUndefined(); + }); + }); + + it('should not show instance-level MCP action to non-admins', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + usersStore = useUsersStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: false }, + }); + vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(false); + vi.spyOn(usersStore, 'isInstanceOwner', 'get').mockReturnValue(false); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + const actions = mockN8nSuggestedActionsProps.actions; + expect(actions).toBeDefined(); + expect(actions.find((a: { id: string }) => a.id === 'instance-mcp-access')).toBeUndefined(); + }); + }); + + it('should not show instance-level MCP action when ignored', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + usersStore = useUsersStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: false }, + }); + vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(true); + + workflowsCache.getMergedWorkflowSettings = vi.fn().mockResolvedValue({ + suggestedActions: { + 'instance-mcp-access': { ignored: true }, + }, + }); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + const actions = mockN8nSuggestedActionsProps.actions; + expect(actions).toBeDefined(); + expect(actions.find((a: { id: string }) => a.id === 'instance-mcp-access')).toBeUndefined(); + }); + }); + + it('should show workflow-level MCP action when workflow is eligible', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: true }, + }); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(true); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + expect(mockN8nSuggestedActionsProps.actions).toContainEqual({ + id: 'workflow-mcp-access', + title: 'mcp.productionChecklist.title', + description: 'mcp.productionChecklist.workflow.description', + moreInfoLink: MCP_DOCS_PAGE_URL, + completed: false, + }); + }); + }); + + it('should mark workflow-level MCP action as completed when enabled in workflow settings', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: true }, + }); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(true); + + renderComponent({ + props: { + workflow: { + ...mockWorkflow, + settings: { + executionOrder: 'v1', + availableInMCP: true, + }, + }, + }, + pinia, + }); + + await vi.waitFor(() => { + expect(mockN8nSuggestedActionsProps.actions).toContainEqual({ + id: 'workflow-mcp-access', + title: 'mcp.productionChecklist.title', + description: 'mcp.productionChecklist.workflow.description', + moreInfoLink: MCP_DOCS_PAGE_URL, + completed: true, + }); + }); + }); + + it('should not show workflow-level MCP action when workflow is not eligible', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: true }, + }); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(false); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + const actions = mockN8nSuggestedActionsProps.actions; + expect(actions).toBeDefined(); + expect(actions.find((a: { id: string }) => a.id === 'workflow-mcp-access')).toBeUndefined(); + }); + }); + + it('should not show workflow-level MCP action when ignored', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: true }, + }); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(true); + + workflowsCache.getMergedWorkflowSettings = vi.fn().mockResolvedValue({ + suggestedActions: { + 'workflow-mcp-access': { ignored: true }, + }, + }); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + const actions = mockN8nSuggestedActionsProps.actions; + expect(actions).toBeDefined(); + expect(actions.find((a: { id: string }) => a.id === 'workflow-mcp-access')).toBeUndefined(); + }); + }); + + it('should navigate to MCP settings when instance-mcp-access action is clicked', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + usersStore = useUsersStore(pinia); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: false }, + }); + vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true); + // Make the workflow eligible for MCP access + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(true); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + expect(mockN8nSuggestedActionsProps.actions).toBeDefined(); + }); + + // Simulate action click + mockN8nSuggestedActionsEmits['action-click']('instance-mcp-access'); + + await vi.waitFor(() => { + expect(router.push).toHaveBeenCalledWith({ name: MCP_SETTINGS_VIEW }); + }); + }); + + it('should open workflow settings modal when workflow-mcp-access action is clicked', async () => { + const pinia = createTestingPinia(); + settingsStore = useSettingsStore(pinia); + uiStore = useUIStore(pinia); + const openModalSpy = vi.spyOn(uiStore, 'openModal'); + + vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + vi.spyOn(settingsStore, 'moduleSettings', 'get').mockReturnValue({ + mcp: { mcpAccessEnabled: true }, + }); + (mcpComposable.isEligibleForMcpAccess as ReturnType).mockReturnValue(true); + + renderComponent({ + props: { + workflow: mockWorkflow, + }, + pinia, + }); + + await vi.waitFor(() => { + expect(mockN8nSuggestedActionsProps.actions).toBeDefined(); + }); + + // Simulate action click + mockN8nSuggestedActionsEmits['action-click']('workflow-mcp-access'); + + await vi.waitFor(() => { + expect(openModalSpy).toHaveBeenCalledWith(WORKFLOW_SETTINGS_MODAL_KEY); + }); + }); + }); + describe('Notice functionality', () => { it('should pass notice prop when source control branch is read-only', async () => { const pinia = createTestingPinia(); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue index c4674d54e63..090f2820f38 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue @@ -20,11 +20,12 @@ import { import { useMessage } from '@/app/composables/useMessage'; import { useTelemetry } from '@/app/composables/useTelemetry'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; -import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants'; +import { MCP_DOCS_PAGE_URL, MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants'; import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; import { N8nSuggestedActions } from '@n8n/design-system'; import { useSettingsStore } from '@/app/stores/settings.store'; +import { useUsersStore } from '@/features/settings/users/users.store'; const props = defineProps<{ workflow: IWorkflowDb; }>(); @@ -40,6 +41,7 @@ const telemetry = useTelemetry(); const sourceControlStore = useSourceControlStore(); const settingsStore = useSettingsStore(); const { isEligibleForMcpAccess } = useMcp(); +const usersStore = useUsersStore(); const isPopoverOpen = ref(false); const cachedSettings = ref(null); @@ -72,18 +74,23 @@ const isProtectedEnvironment = computed(() => { return sourceControlStore.preferences.branchReadOnly; }); -// Show MCP action if: -// - MCP module is active -// - Instance-level access is enabled -// - Workflow is eligible for MCP access -const isMcpAvailable = computed(() => { - return ( - settingsStore.isModuleActive('mcp') && - settingsStore.moduleSettings.mcp?.mcpAccessEnabled && - isEligibleForMcpAccess(props.workflow) - ); +const isOwner = computed(() => usersStore.isInstanceOwner); +const isAdmin = computed(() => usersStore.isAdmin); + +const isMcpModuleEnabled = computed(() => { + return settingsStore.isModuleActive('mcp'); }); +const isMcpAccessEnabled = computed(() => { + return settingsStore.moduleSettings.mcp?.mcpAccessEnabled ?? false; +}); + +const isWorkflowEligibleForMcpAccess = computed(() => { + return isEligibleForMcpAccess(props.workflow); +}); + +const canToggleInstanceMCPAccess = computed(() => isOwner.value || isAdmin.value); + const availableActions = computed(() => { if (props.workflow.activeVersionId === null || workflowsCache.isCacheLoading.value) { return []; @@ -135,17 +142,58 @@ const availableActions = computed(() => { }); } - if (isMcpAvailable.value && !suggestedActionSettings['mcp-access']?.ignored) { - actions.push({ - id: 'mcp-access', - title: i18n.baseText('mcp.productionCheklist.title'), - description: i18n.baseText('mcp.productionCheklist.description'), - moreInfoLink: MCP_DOCS_PAGE_URL, - completed: props.workflow.settings?.availableInMCP ?? false, - }); + // MCP access action + const mcpAction = getMcpAction(); + if (mcpAction) { + actions.push(mcpAction); } return actions; + + function getMcpAction(): { + id: ActionType; + title: string; + description: string; + moreInfoLink: string; + completed: boolean; + } | null { + if (!isMcpModuleEnabled.value || !isWorkflowEligibleForMcpAccess.value) return null; + + const baseAction = { + title: i18n.baseText('mcp.productionChecklist.title'), + moreInfoLink: MCP_DOCS_PAGE_URL, + }; + + // Instance-level MCP access is disabled - show action to enable it + if (!isMcpAccessEnabled.value) { + // Only show to admins if not ignored + if ( + !canToggleInstanceMCPAccess.value || + suggestedActionSettings['instance-mcp-access']?.ignored + ) { + return null; + } + + return { + ...baseAction, + id: 'instance-mcp-access', + description: i18n.baseText('mcp.productionChecklist.instance.description'), + completed: false, + }; + } + + // Workflow-level MCP access (instance-level is enabled) + if (suggestedActionSettings['workflow-mcp-access']?.ignored) { + return null; + } + + return { + ...baseAction, + id: 'workflow-mcp-access', + description: i18n.baseText('mcp.productionChecklist.workflow.description'), + completed: props.workflow.settings?.availableInMCP ?? false, + }; + } }); async function loadWorkflowSettings() { @@ -156,25 +204,38 @@ async function loadWorkflowSettings() { } async function handleActionClick(actionId: string) { - if (actionId === 'evaluations') { - // Navigate to evaluations - await router.push({ - name: VIEWS.EVALUATION_EDIT, - params: { name: props.workflow.id }, - }); - } else if ( - actionId === 'errorWorkflow' || - actionId === 'timeSaved' || - actionId === 'mcp-access' - ) { - // Open workflow settings modal - uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY); + switch (actionId) { + case 'evaluations': + // Navigate to evaluations + await router.push({ + name: VIEWS.EVALUATION_EDIT, + params: { name: props.workflow.id }, + }); + break; + case 'errorWorkflow': + case 'timeSaved': + case 'workflow-mcp-access': + // Open workflow settings modal + uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY); + break; + case 'instance-mcp-access': + // Open settings page + await router.push({ name: MCP_SETTINGS_VIEW }); + break; + default: + break; } isPopoverOpen.value = false; } function isValidAction(action: string): action is ActionType { - return ['evaluations', 'errorWorkflow', 'timeSaved', 'mcp-access'].includes(action); + return [ + 'evaluations', + 'errorWorkflow', + 'timeSaved', + 'workflow-mcp-access', + 'instance-mcp-access', + ].includes(action); } async function handleIgnoreClick(actionId: string) { diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowsCache.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowsCache.ts index 07f454c801f..7cc538515ba 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowsCache.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowsCache.ts @@ -2,7 +2,13 @@ import { indexedDbCache } from '@/app/plugins/cache'; import { jsonParse } from 'n8n-workflow'; import { ref } from 'vue'; -const actionTypes = ['evaluations', 'errorWorkflow', 'timeSaved', 'mcp-access'] as const; +const actionTypes = [ + 'evaluations', + 'errorWorkflow', + 'timeSaved', + 'workflow-mcp-access', + 'instance-mcp-access', +] as const; export type ActionType = (typeof actionTypes)[number]; diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue index 9ad3d3b15ba..4bd1c99cbea 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/McpAccessToggle.vue @@ -1,7 +1,8 @@ diff --git a/packages/testing/playwright/tests/ui/53-workflow-production-checklist.spec.ts b/packages/testing/playwright/tests/ui/53-workflow-production-checklist.spec.ts index fdcdbe1eb8c..55626547b19 100644 --- a/packages/testing/playwright/tests/ui/53-workflow-production-checklist.spec.ts +++ b/packages/testing/playwright/tests/ui/53-workflow-production-checklist.spec.ts @@ -22,7 +22,6 @@ test.describe('Workflow Production Checklist', () => { await expect(n8n.canvas.getProductionChecklistButton()).toBeVisible(); await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible(); - await expect(n8n.canvas.getProductionChecklistActionItem()).toHaveCount(2); await expect(n8n.canvas.getErrorActionItem()).toBeVisible(); await expect(n8n.canvas.getTimeSavedActionItem()).toBeVisible(); }); @@ -43,7 +42,6 @@ test.describe('Workflow Production Checklist', () => { await n8n.workflowActivationModal.close(); await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible(); - await expect(n8n.canvas.getProductionChecklistActionItem()).toHaveCount(3); await expect(n8n.canvas.getEvaluationsActionItem()).toBeVisible(); await n8n.canvas.getEvaluationsActionItem().click();