mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
feat(editor): Allow MCP access only for webhook triggered workflows (no-changelog) (#20414)
This commit is contained in:
parent
c22f86224a
commit
278ca8b9e8
|
|
@ -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> = {},
|
||||
): 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<User> = {}) =>
|
||||
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<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.json.mockReturnThis();
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('McpSettingsController', () => {
|
||||
const logger = mock<Logger>();
|
||||
const moduleRegistry = mockDeep<ModuleRegistry>();
|
||||
const mcpSettingsService = mock<McpSettingsService>();
|
||||
const mcpServerApiKeyService = mockDeep<McpServerApiKeyService>();
|
||||
const workflowFinderService = mock<WorkflowFinderService>();
|
||||
const workflowService = mock<WorkflowService>();
|
||||
|
||||
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> = {}): INode => ({
|
||||
id: 'node-1',
|
||||
name: 'Webhook',
|
||||
type: WEBHOOK_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createWorkflow = (overrides: Partial<WorkflowEntity> = {}) => {
|
||||
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<Response>(), 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<Response>(), 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<Response>(), 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<Response>(), 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<Response>(), workflowId, {
|
||||
availableInMCP: false,
|
||||
}),
|
||||
).rejects.toThrow(new BadRequestError('MCP access can only be set for active workflows'));
|
||||
|
||||
expect(workflowService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<WorkflowEntity> = {}) => ({
|
||||
id: 'wf-1',
|
||||
|
|
@ -7,7 +8,7 @@ export const createWorkflow = (overrides: Partial<WorkflowEntity> = {}) => ({
|
|||
{
|
||||
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<WorkflowEntity> = {}) => ({
|
|||
{
|
||||
id: 'node-2',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start',
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
position: [100, 0],
|
||||
disabled: false,
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class UpdateWorkflowAvailabilityDto extends Z.class({
|
||||
availableInMCP: z.boolean(),
|
||||
}) {}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Promise<PathItem[]>>(new Promise(() => {}));
|
||||
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
|
||||
|
|
@ -150,7 +152,7 @@ const cardBreadcrumbs = computed<PathItem[]>(() => {
|
|||
});
|
||||
|
||||
const actions = computed(() => {
|
||||
const items = [
|
||||
const items: Array<UserAction<IUser>> = [
|
||||
{
|
||||
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'));
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
<N8nTooltip placement="top">
|
||||
<template #content>
|
||||
{{ i18n.baseText('workflowSettings.availableInMCP.tooltip') }}
|
||||
{{
|
||||
isEligibleForMCPAccess
|
||||
? i18n.baseText('workflowSettings.availableInMCP.tooltip')
|
||||
: i18n.baseText('mcp.workflowNotEligable.description')
|
||||
}}
|
||||
</template>
|
||||
<N8nIcon icon="circle-help" />
|
||||
</N8nTooltip>
|
||||
|
|
@ -854,13 +869,18 @@ onBeforeUnmount(() => {
|
|||
</ElCol>
|
||||
<ElCol :span="14">
|
||||
<div>
|
||||
<ElSwitch
|
||||
ref="inputField"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:model-value="workflowSettings.availableInMCP ?? false"
|
||||
data-test-id="workflow-settings-available-in-mcp"
|
||||
@update:model-value="toggleAvailableInMCP"
|
||||
></ElSwitch>
|
||||
<N8nTooltip placement="top" :disabled="isEligibleForMCPAccess">
|
||||
<template #content>
|
||||
{{ i18n.baseText('mcp.workflowNotEligable.description') }}
|
||||
</template>
|
||||
<ElSwitch
|
||||
ref="inputField"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update || !isEligibleForMCPAccess"
|
||||
:model-value="workflowSettings.availableInMCP ?? false"
|
||||
data-test-id="workflow-settings-available-in-mcp"
|
||||
@update:model-value="toggleAvailableInMCP"
|
||||
></ElSwitch>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
|
|
|||
|
|
@ -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<ApiKey> {
|
|||
export async function rotateApiKey(context: IRestApiContext): Promise<ApiKey> {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ApiKey> {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user