feat(editor): Minor UX improvements for MCP (no-changelog) (#22131)

This commit is contained in:
Milorad FIlipović
2025-11-24 10:11:41 +01:00
committed by GitHub
parent 019cd8ed87
commit 2ad3135584
8 changed files with 499 additions and 179 deletions
@@ -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",
@@ -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');
});
});
@@ -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 () => {
@@ -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<typeof import('@n8n/i18n')>();
@@ -132,6 +140,9 @@ describe('WorkflowProductionChecklist', () => {
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let uiStore: ReturnType<typeof useUIStore>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let mcpComposable: ReturnType<typeof useMcp>;
beforeEach(() => {
router = {
@@ -159,6 +170,11 @@ describe('WorkflowProductionChecklist', () => {
track: vi.fn(),
} as unknown as ReturnType<typeof useTelemetry>;
(useTelemetry as ReturnType<typeof vi.fn>).mockReturnValue(telemetry);
mcpComposable = {
isEligibleForMcpAccess: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof useMcp>;
(useMcp as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
@@ -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<WorkflowSettings | null>(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) {
@@ -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];
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ElSwitch } from 'element-plus';
import { N8nText, N8nTooltip } from '@n8n/design-system';
import { N8nLink, N8nText, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants';
type Props = {
modelValue: boolean;
@@ -27,33 +28,50 @@ const onUpdateMCPEnabled = (value: string | number | boolean) => {
</script>
<template>
<div :class="$style['main-toggle-container']">
<div :class="$style['main-toggle-info']">
<N8nText :bold="true">{{ i18n.baseText('settings.mcp.toggle.label') }}</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.mcp.toggle.description') }}
</N8nText>
<div :class="$style['mcp-access-toggle']">
<div :class="$style['main-toggle-container']">
<div :class="$style['main-toggle-info']">
<N8nText :bold="true">{{ i18n.baseText('settings.mcp.toggle.label') }}</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.mcp.toggle.description') }}
</N8nText>
</div>
<div :class="$style['main-toggle']" data-test-id="mcp-toggle-container">
<N8nTooltip
:content="i18n.baseText('settings.mcp.toggle.disabled.tooltip')"
:disabled="!props.disabled"
placement="top"
>
<ElSwitch
size="large"
data-test-id="mcp-access-toggle"
:model-value="props.modelValue"
:disabled="props.disabled"
:loading="props.loading"
@update:model-value="onUpdateMCPEnabled"
/>
</N8nTooltip>
</div>
</div>
<div :class="$style['main-toggle']" data-test-id="mcp-toggle-container">
<N8nTooltip
:content="i18n.baseText('settings.mcp.toggle.disabled.tooltip')"
:disabled="!props.disabled"
placement="top"
>
<ElSwitch
size="large"
data-test-id="mcp-access-toggle"
:model-value="props.modelValue"
:disabled="props.disabled"
:loading="props.loading"
@update:model-value="onUpdateMCPEnabled"
/>
</N8nTooltip>
<div v-if="!props.modelValue" :class="$style['toggle-notice']">
<N8nText color="text-base" data-test-id="mcp-toggle-disabled-notice">
{{ i18n.baseText('settings.mcp.toggle.disabled.notice') }}
</N8nText>
<N8nLink :to="MCP_DOCS_PAGE_URL" :new-window="true">
{{ i18n.baseText('generic.learnMore') }}
</N8nLink>
</div>
</div>
</template>
<style module lang="scss">
.mcp-access-toggle {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
}
.main-toggle-container {
display: flex;
align-items: center;
@@ -78,4 +96,10 @@ const onUpdateMCPEnabled = (value: string | number | boolean) => {
align-items: center;
flex-shrink: 0;
}
.toggle-notice {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
</style>
@@ -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();