mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(editor): Minor UX improvements for MCP (no-changelog) (#22131)
This commit is contained in:
@@ -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",
|
||||
|
||||
+3
-102
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-15
@@ -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];
|
||||
|
||||
|
||||
+46
-22
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user