feat(core): Add endpoint to toggle mcp access for multiple workflows (#29007)

This commit is contained in:
Milorad FIlipović 2026-04-28 16:25:39 +02:00 committed by GitHub
parent e90397627d
commit 0d907d6794
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1373 additions and 347 deletions

View File

@ -1,7 +1,21 @@
import { testDb } from '@n8n/backend-test-utils';
import { ApiKeyRepository, type User } from '@n8n/db';
import {
createTeamProject,
createWorkflow,
getPersonalProject,
linkUserToProject,
shareWorkflowWithProjects,
testDb,
} from '@n8n/backend-test-utils';
import {
ApiKeyRepository,
FolderRepository,
WorkflowRepository,
type Project,
type User,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { createFolder } from '@test-integration/db/folders';
import { createMember, createOwner, createUser } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
@ -184,3 +198,256 @@ describe('MCP API Key Edge Cases', () => {
expect(storedApiKey.length).toBe(1);
});
});
describe('PATCH /mcp/workflows/toggle-access', () => {
// Re-create users per test so we can truncate workflow/project/folder tables
// without leaving dangling references between cases.
let toggleOwner: User;
let toggleMember: User;
const workflowRepository = () => Container.get(WorkflowRepository);
const readAvailableInMCP = async (workflowId: string): Promise<boolean | undefined> => {
const wf = await workflowRepository().findOneBy({ id: workflowId });
return wf?.settings?.availableInMCP;
};
beforeEach(async () => {
await testDb.truncate([
'WorkflowEntity',
'SharedWorkflow',
'Folder',
'ProjectRelation',
'Project',
'User',
]);
toggleOwner = await createOwner();
toggleMember = await createMember();
});
test('rejects requests without a scope', async () => {
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({ availableInMCP: true });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.statusCode).toBeLessThan(500);
});
test('rejects requests with more than one scope', async () => {
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({ availableInMCP: true, workflowIds: ['wf-1'], projectId: 'project-1' });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.statusCode).toBeLessThan(500);
});
test('enables MCP access across an explicit workflow id list in a single transaction', async () => {
// Act as the member — the member lacks global workflow:update scope, so
// the finder service filters out workflows they cannot access. This is
// the behavior we want to exercise for the bulk path.
const ownedByMember = await createWorkflow(
{ name: 'member-1', settings: { saveManualExecutions: true } },
toggleMember,
);
const ownedByMember2 = await createWorkflow({ name: 'member-2', settings: {} }, toggleMember);
const ownedByOwner = await createWorkflow({ name: 'owner-wf', settings: {} }, toggleOwner);
const response = await testServer
.authAgentFor(toggleMember)
.patch('/mcp/workflows/toggle-access')
.send({
availableInMCP: true,
workflowIds: [ownedByMember.id, ownedByMember2.id, ownedByOwner.id],
});
expect(response.statusCode).toBe(200);
expect(response.body.data.updatedCount).toBe(2);
expect(response.body.data.updatedIds.sort()).toEqual(
[ownedByMember.id, ownedByMember2.id].sort(),
);
expect(response.body.data.skippedCount).toBe(1);
expect(await readAvailableInMCP(ownedByMember.id)).toBe(true);
expect(await readAvailableInMCP(ownedByMember2.id)).toBe(true);
// The unauthorized workflow remains untouched.
expect(await readAvailableInMCP(ownedByOwner.id)).toBeUndefined();
// Pre-existing settings on ownedByMember are preserved.
const refreshed = await workflowRepository().findOneBy({ id: ownedByMember.id });
expect(refreshed?.settings?.saveManualExecutions).toBe(true);
});
test('skips archived workflows', async () => {
const live = await createWorkflow({ name: 'live', settings: {} }, toggleOwner);
const archived = await createWorkflow(
{ name: 'archived', settings: {}, isArchived: true },
toggleOwner,
);
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({
availableInMCP: true,
workflowIds: [live.id, archived.id],
});
expect(response.statusCode).toBe(200);
expect(response.body.data.updatedIds).toEqual([live.id]);
expect(response.body.data.skippedCount).toBe(1);
expect(await readAvailableInMCP(archived.id)).toBeUndefined();
});
test('scopes updates to a project id', async () => {
const project: Project = await createTeamProject('team-project');
await linkUserToProject(toggleOwner, project, 'project:admin');
const projectWf1 = await createWorkflow({ name: 'project-wf-1', settings: {} }, project);
const projectWf2 = await createWorkflow({ name: 'project-wf-2', settings: {} }, project);
const unrelatedWf = await createWorkflow({ name: 'unrelated', settings: {} }, toggleOwner);
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({ availableInMCP: true, projectId: project.id });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({ updatedCount: 2, skippedCount: 0, failedCount: 0 });
expect(await readAvailableInMCP(projectWf1.id)).toBe(true);
expect(await readAvailableInMCP(projectWf2.id)).toBe(true);
expect(await readAvailableInMCP(unrelatedWf.id)).toBeUndefined();
});
test('scopes updates to a folder id, including descendants', async () => {
const project = await getPersonalProject(toggleOwner);
const rootFolder = await createFolder(project, { name: 'root' });
const childFolder = await createFolder(project, {
name: 'child',
parentFolder: await Container.get(FolderRepository).findOneByOrFail({ id: rootFolder.id }),
});
const workflowInRoot = await createWorkflow(
{ name: 'wf-root', parentFolder: rootFolder },
toggleOwner,
);
const workflowInChild = await createWorkflow(
{ name: 'wf-child', parentFolder: childFolder },
toggleOwner,
);
const workflowOutsideFolder = await createWorkflow({ name: 'wf-outside' }, toggleOwner);
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({ availableInMCP: true, folderId: rootFolder.id });
expect(response.statusCode).toBe(200);
expect(response.body.data).toEqual({ updatedCount: 2, skippedCount: 0, failedCount: 0 });
expect(await readAvailableInMCP(workflowInRoot.id)).toBe(true);
expect(await readAvailableInMCP(workflowInChild.id)).toBe(true);
expect(await readAvailableInMCP(workflowOutsideFolder.id)).toBeUndefined();
});
test('counts a shared workflow exactly once regardless of its sharings', async () => {
// SharedWorkflow is keyed by (workflowId, projectId), so a workflow
// shared with multiple projects would previously surface as several
// rows from the finder. That inflated `skippedCount` even though the
// single underlying workflow was updated correctly.
const project = await getPersonalProject(toggleOwner);
const folder = await createFolder(project, { name: 'shared-container' });
const workflow = await createWorkflow(
{ name: 'shared-wf', parentFolder: folder, settings: {} },
toggleOwner,
);
// Extra sharings multiply the rows the finder sees for this workflow.
const extraProject1 = await createTeamProject('extra-1');
const extraProject2 = await createTeamProject('extra-2');
await shareWorkflowWithProjects(workflow, [
{ project: extraProject1 },
{ project: extraProject2 },
]);
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({ availableInMCP: true, folderId: folder.id });
expect(response.statusCode).toBe(200);
// Folder-scoped — `updatedIds` is omitted from the response.
expect(response.body.data).toEqual({
updatedCount: 1,
skippedCount: 0,
failedCount: 0,
});
expect(await readAvailableInMCP(workflow.id)).toBe(true);
});
test('disables MCP access for the provided workflows', async () => {
const wf = await createWorkflow(
{ name: 'mcp-enabled', settings: { availableInMCP: true } },
toggleOwner,
);
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({ availableInMCP: false, workflowIds: [wf.id] });
expect(response.statusCode).toBe(200);
expect(response.body.data.updatedIds).toEqual([wf.id]);
expect(await readAvailableInMCP(wf.id)).toBe(false);
});
test('is idempotent when workflows are already in the requested state', async () => {
const alreadyEnabled = await createWorkflow(
{ name: 'already-enabled', settings: { availableInMCP: true } },
toggleOwner,
);
const freshlyChanged = await createWorkflow(
{ name: 'freshly-changed', settings: {} },
toggleOwner,
);
const response = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({
availableInMCP: true,
workflowIds: [alreadyEnabled.id, freshlyChanged.id],
});
expect(response.statusCode).toBe(200);
expect(response.body.data.updatedCount).toBe(2);
expect(response.body.data.updatedIds.sort()).toEqual(
[alreadyEnabled.id, freshlyChanged.id].sort(),
);
expect(response.body.data.skippedCount).toBe(0);
// Re-submitting the same request is a no-op but still reports both as updated.
const second = await testServer
.authAgentFor(toggleOwner)
.patch('/mcp/workflows/toggle-access')
.send({
availableInMCP: true,
workflowIds: [alreadyEnabled.id, freshlyChanged.id],
});
expect(second.statusCode).toBe(200);
expect(second.body.data.updatedCount).toBe(2);
expect(second.body.data.skippedCount).toBe(0);
});
test('requires authentication', async () => {
const response = await testServer.authlessAgent
.patch('/mcp/workflows/toggle-access')
.send({ availableInMCP: true, workflowIds: ['wf-1'] });
expect(response.statusCode).toBe(401);
});
});

View File

@ -1,24 +1,19 @@
import { Logger, ModuleRegistry } from '@n8n/backend-common';
import { type ApiKey, type AuthenticatedRequest, WorkflowEntity, User, Role } from '@n8n/db';
import { type ApiKey, type AuthenticatedRequest, 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 { v4 as uuid } from 'uuid';
import type { ListQuery } from '@/requests';
import { WorkflowService } from '@/workflows/workflow.service';
import { UpdateMcpSettingsDto } from '../dto/update-mcp-settings.dto';
import { UpdateWorkflowsAvailabilityDto } from '../dto/update-workflows-availability.dto';
import { McpServerApiKeyService } from '../mcp-api-key.service';
import { McpSettingsController } from '../mcp.settings.controller';
import { McpSettingsService } from '../mcp.settings.service';
import { createWorkflow } from './mock.utils';
import { CollaborationService } from '@/collaboration/collaboration.service';
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> = {},
@ -83,9 +78,7 @@ describe('McpSettingsController', () => {
const moduleRegistry = mockDeep<ModuleRegistry>();
const mcpSettingsService = mock<McpSettingsService>();
const mcpServerApiKeyService = mockDeep<McpServerApiKeyService>();
const workflowFinderService = mock<WorkflowFinderService>();
const workflowService = mock<WorkflowService>();
const collaborationService = mock<CollaborationService>();
let controller: McpSettingsController;
@ -95,14 +88,7 @@ describe('McpSettingsController', () => {
Container.set(McpSettingsService, mcpSettingsService);
Container.set(ModuleRegistry, moduleRegistry);
Container.set(McpServerApiKeyService, mcpServerApiKeyService);
Container.set(WorkflowFinderService, workflowFinderService);
Container.set(WorkflowService, workflowService);
Container.set(CollaborationService, collaborationService);
// Default resolved broadcast — the controller fires this without
// awaiting and attaches a `.catch(...)`, so the mock must return a
// real Promise. Tests that exercise the failure path override this
// with `mockRejectedValueOnce`.
collaborationService.broadcastWorkflowSettingsUpdated.mockResolvedValue(undefined);
controller = Container.get(McpSettingsController);
});
@ -315,222 +301,68 @@ describe('McpSettingsController', () => {
});
});
describe('toggleWorkflowMCPAccess', () => {
describe('toggleWorkflowsMCPAccess', () => {
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,
});
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('allows enabling MCP for inactive workflows', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({
activeVersionId: null,
nodes: [createWebhookNode({ disabled: false })],
}),
);
workflowService.update.mockResolvedValue({
id: workflowId,
settings: { saveManualExecutions: true, availableInMCP: true },
versionId: 'client-version',
} as unknown as WorkflowEntity);
await controller.toggleWorkflowMCPAccess(
createReq({}, { user }),
mock<Response>(),
workflowId,
{
availableInMCP: true,
},
);
expect(workflowService.update).toHaveBeenCalledTimes(1);
});
test('allows disabling MCP for inactive workflows', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({ activeVersionId: null }),
);
workflowService.update.mockResolvedValue({
id: workflowId,
settings: { saveManualExecutions: true, availableInMCP: false },
versionId: 'client-version',
} as unknown as WorkflowEntity);
const req = createReq({}, { user });
await controller.toggleWorkflowMCPAccess(req, mock<Response>(), workflowId, {
availableInMCP: false,
});
expect(workflowService.update).toHaveBeenCalledTimes(1);
});
test('allows enabling MCP regardless of trigger node types', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({
activeVersionId: uuid(),
nodes: [
{
id: 'node-2',
name: 'HTTP Request',
type: HTTP_REQUEST_NODE_TYPE,
typeVersion: 1,
position: [10, 10],
parameters: {},
},
],
}),
);
workflowService.update.mockResolvedValue({
id: workflowId,
settings: { saveManualExecutions: true, availableInMCP: true },
versionId: 'client-version',
} as unknown as WorkflowEntity);
await controller.toggleWorkflowMCPAccess(
createReq({}, { user }),
mock<Response>(),
workflowId,
{
availableInMCP: true,
},
);
expect(workflowService.update).toHaveBeenCalledTimes(1);
});
test('persists MCP availability when validation passes', async () => {
const workflow = createWorkflow({
activeVersionId: uuid(),
settings: { saveManualExecutions: true },
nodes: [
createWebhookNode({ disabled: false }),
{
id: 'node-2',
name: 'HTTP Request',
type: HTTP_REQUEST_NODE_TYPE,
typeVersion: 1,
position: [10, 10],
parameters: {},
},
],
});
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, {
test('delegates to mcpSettingsService.bulkSetAvailableInMCP and returns its result', async () => {
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1', 'wf-2'],
});
const bulkResult = {
updatedCount: 2,
updatedIds: ['wf-1', 'wf-2'],
skippedCount: 0,
failedCount: 0,
};
mcpSettingsService.bulkSetAvailableInMCP.mockResolvedValue(bulkResult);
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('some-version-id');
expect(updateArgs[2]).toEqual(workflowId);
const req = createReq({}, { user });
const result = await controller.toggleWorkflowsMCPAccess(req, mock<Response>(), dto);
expect(response).toEqual({
id: workflowId,
settings: { saveManualExecutions: true, availableInMCP: true },
versionId: 'updated-version-id',
});
expect(mcpSettingsService.bulkSetAvailableInMCP).toHaveBeenCalledTimes(1);
expect(mcpSettingsService.bulkSetAvailableInMCP).toHaveBeenCalledWith(user, dto);
expect(result).toEqual(bulkResult);
});
});
describe('UpdateWorkflowsAvailabilityDto', () => {
test('requires availableInMCP to be a boolean', () => {
expect(() => new UpdateWorkflowsAvailabilityDto({} as never)).toThrow();
expect(
() => new UpdateWorkflowsAvailabilityDto({ availableInMCP: 'yes' } as never),
).toThrow();
});
test('broadcasts a settings update with a post-update checksum to open collaborators', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({ activeVersionId: null }),
);
workflowService.update.mockResolvedValue({
id: workflowId,
name: 'wf',
nodes: [],
connections: {},
settings: { availableInMCP: true },
versionId: 'updated-version-id',
} as unknown as WorkflowEntity);
await controller.toggleWorkflowMCPAccess(
createReq({}, { user }),
mock<Response>(),
workflowId,
{
availableInMCP: true,
},
);
expect(collaborationService.broadcastWorkflowSettingsUpdated).toHaveBeenCalledTimes(1);
const [broadcastWorkflowId, broadcastSettings, broadcastChecksum] =
collaborationService.broadcastWorkflowSettingsUpdated.mock.calls[0];
expect(broadcastWorkflowId).toBe(workflowId);
expect(broadcastSettings).toEqual({ availableInMCP: true });
expect(typeof broadcastChecksum).toBe('string');
expect(broadcastChecksum).toMatch(/^[a-f0-9]{64}$/);
});
test('does not fail the request when the broadcast throws', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(
createWorkflow({ activeVersionId: null }),
);
workflowService.update.mockResolvedValue({
id: workflowId,
settings: { availableInMCP: false },
versionId: 'updated-version-id',
} as unknown as WorkflowEntity);
collaborationService.broadcastWorkflowSettingsUpdated.mockRejectedValueOnce(
new Error('push down'),
);
await expect(
controller.toggleWorkflowMCPAccess(createReq({}, { user }), mock<Response>(), workflowId, {
availableInMCP: false,
}),
).resolves.toEqual({
id: workflowId,
settings: { availableInMCP: false },
versionId: 'updated-version-id',
test('accepts a valid workflowIds scope', () => {
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1'],
});
expect(dto.workflowIds).toEqual(['wf-1']);
expect(dto.projectId).toBeUndefined();
expect(dto.folderId).toBeUndefined();
});
test('does not broadcast when the workflow cannot be accessed', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
test('rejects an empty workflowIds array', () => {
expect(
() =>
new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: [],
}),
).toThrow();
});
await expect(
controller.toggleWorkflowMCPAccess(createReq({}, { user }), mock<Response>(), workflowId, {
availableInMCP: true,
}),
).rejects.toThrow(NotFoundError);
expect(collaborationService.broadcastWorkflowSettingsUpdated).not.toHaveBeenCalled();
test('rejects workflowIds arrays over the cap', () => {
const workflowIds = Array.from({ length: 101 }, (_, i) => `wf-${i}`);
expect(
() =>
new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds,
}),
).toThrow();
});
});
});

View File

@ -1,8 +1,15 @@
import type { Settings, SettingsRepository } from '@n8n/db';
import type { Logger } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import type { Settings, SettingsRepository, User, WorkflowRepository } from '@n8n/db';
import { WorkflowEntity } from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { CacheService } from '@/services/cache/cache.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { UpdateWorkflowsAvailabilityDto } from '../dto/update-workflows-availability.dto';
import { McpSettingsService } from '../mcp.settings.service';
describe('McpSettingsService', () => {
@ -11,14 +18,29 @@ describe('McpSettingsService', () => {
let upsert: jest.Mock;
let settingsRepository: SettingsRepository;
const cacheService = mock<CacheService>();
const workflowRepository = mock<WorkflowRepository>();
const workflowFinderService = mock<WorkflowFinderService>();
const logger = mock<Logger>();
const globalConfig = {
executions: { timeout: -1 },
} as unknown as GlobalConfig;
beforeEach(() => {
jest.clearAllMocks();
findByKey = jest.fn<Promise<Settings | null>, [string]>();
upsert = jest.fn();
settingsRepository = { findByKey, upsert } as unknown as SettingsRepository;
workflowFinderService.hasProjectScopeForUser.mockResolvedValue(true);
workflowFinderService.findProjectIdForFolder.mockResolvedValue('project-1');
service = new McpSettingsService(settingsRepository, cacheService);
service = new McpSettingsService(
settingsRepository,
cacheService,
workflowRepository,
workflowFinderService,
globalConfig,
logger,
);
});
describe('getEnabled', () => {
@ -65,4 +87,500 @@ describe('McpSettingsService', () => {
);
});
});
describe('bulkSetAvailableInMCP', () => {
const user = mock<User>({ id: 'user-1' });
// Minimal `find`/`update` stub that behaves like an `EntityManager`
// scoped to WorkflowEntity rows the test sets up. Mirrors the
// production `select: ['id', 'settings']` — nothing more is needed
// since we no longer compute checksums or emit events here.
const createTransactionStubs = (seeded: Array<Partial<WorkflowEntity> & { id: string }>) => {
const storage = new Map(
seeded.map((w) => [w.id, { ...w, isArchived: w.isArchived ?? false }]),
);
const find = jest.fn(
async (
_entity: unknown,
options: { where: { id: { _value: string[] }; isArchived: boolean } },
) => {
const ids = options.where.id._value;
return ids
.map((id) => storage.get(id))
.filter(
(row): row is Partial<WorkflowEntity> & { id: string; isArchived: boolean } =>
!!row && row.isArchived === options.where.isArchived,
)
.map((row) => ({ id: row.id, settings: row.settings }));
},
);
const update = jest.fn(
async (_entity: unknown, where: { id: string }, patch: Partial<WorkflowEntity>) => {
const existing = storage.get(where.id);
if (!existing) return;
storage.set(where.id, { ...existing, ...patch });
},
);
const trx = { find, update } as unknown as EntityManager;
const manager = {
transaction: jest.fn(async (run: (trx: EntityManager) => Promise<unknown>) => {
return await run(trx);
}),
};
return { trx, manager, find, update, storage };
};
const setupRepository = (seeded: Array<Partial<WorkflowEntity> & { id: string }>) => {
const stubs = createTransactionStubs(seeded);
Object.assign(workflowRepository, { manager: stubs.manager });
return stubs;
};
test('throws BadRequestError when no scope is provided', async () => {
const dto = new UpdateWorkflowsAvailabilityDto({ availableInMCP: true });
await expect(service.bulkSetAvailableInMCP(user, dto)).rejects.toThrow(BadRequestError);
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).not.toHaveBeenCalled();
expect(workflowFinderService.findAllWorkflowIdsForUser).not.toHaveBeenCalled();
});
test('throws BadRequestError when multiple scopes are provided', async () => {
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1'],
projectId: 'project-1',
});
await expect(service.bulkSetAvailableInMCP(user, dto)).rejects.toThrow(BadRequestError);
});
test('filters unauthorized ids and applies updates inside a transaction', async () => {
const stubs = setupRepository([
{ id: 'wf-1', settings: { saveManualExecutions: true } },
{ id: 'wf-2', settings: { availableInMCP: false } },
]);
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(
new Set(['wf-1', 'wf-2']),
);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1', 'wf-2', 'wf-unauthorized'],
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).toHaveBeenCalledWith(
['wf-1', 'wf-2', 'wf-unauthorized'],
user,
['workflow:update'],
);
expect(stubs.manager.transaction).toHaveBeenCalledTimes(1);
expect(stubs.update).toHaveBeenCalledTimes(2);
expect(result).toEqual({
updatedCount: 2,
updatedIds: ['wf-1', 'wf-2'],
// wf-unauthorized was in the request but filtered out — counts as skipped.
skippedCount: 1,
failedCount: 0,
});
});
test('skips archived workflows and reports them as skipped', async () => {
const stubs = setupRepository([
{ id: 'wf-1', settings: {}, isArchived: false },
{ id: 'wf-2', settings: {}, isArchived: true },
]);
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(
new Set(['wf-1', 'wf-2']),
);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1', 'wf-2'],
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(stubs.update).toHaveBeenCalledTimes(1);
expect(stubs.update).toHaveBeenCalledWith(
WorkflowEntity,
{ id: 'wf-1' },
expect.objectContaining({ settings: expect.objectContaining({ availableInMCP: true }) }),
);
expect(result).toEqual({
updatedCount: 1,
updatedIds: ['wf-1'],
skippedCount: 1,
failedCount: 0,
});
});
test('treats no-op updates as already-in-state for idempotency', async () => {
const stubs = setupRepository([
// Will be written (value differs).
{ id: 'wf-1', settings: { availableInMCP: false } },
// No-op — already in the requested state.
{ id: 'wf-2', settings: { availableInMCP: true } },
]);
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(
new Set(['wf-1', 'wf-2']),
);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1', 'wf-2'],
});
const result = await service.bulkSetAvailableInMCP(user, dto);
// Only wf-1 needed a real write. wf-2 was already in the target state.
expect(stubs.update).toHaveBeenCalledTimes(1);
// Both ids are reported as updated (the DB is in the requested state
// for both). No-ops go last in the list.
expect(result).toEqual({
updatedCount: 2,
updatedIds: ['wf-1', 'wf-2'],
skippedCount: 0,
failedCount: 0,
});
});
test('idempotent when all workflows are already in requested state', async () => {
const stubs = setupRepository([
{ id: 'wf-1', settings: { availableInMCP: true } },
{ id: 'wf-2', settings: { availableInMCP: true } },
]);
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(
new Set(['wf-1', 'wf-2']),
);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1', 'wf-2'],
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(stubs.update).not.toHaveBeenCalled();
expect(result).toEqual({
updatedCount: 2,
updatedIds: ['wf-1', 'wf-2'],
skippedCount: 0,
failedCount: 0,
});
});
test('resolves candidates via findAllWorkflowIdsForUser when scoped by projectId', async () => {
const stubs = setupRepository([{ id: 'wf-1', settings: {} }]);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(['wf-1']);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
projectId: 'project-1',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).not.toHaveBeenCalled();
expect(workflowFinderService.hasProjectScopeForUser).toHaveBeenCalledWith(
user,
['workflow:update'],
'project-1',
);
expect(workflowFinderService.findAllWorkflowIdsForUser).toHaveBeenCalledWith(
user,
['workflow:update'],
undefined,
'project-1',
);
expect(stubs.update).toHaveBeenCalledTimes(1);
expect(result.updatedCount).toBe(1);
});
test('does not resolve project-scoped workflows when user lacks project scope', async () => {
const stubs = setupRepository([]);
workflowFinderService.hasProjectScopeForUser.mockResolvedValueOnce(false);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
projectId: 'project-1',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(workflowFinderService.findAllWorkflowIdsForUser).not.toHaveBeenCalled();
expect(stubs.manager.transaction).not.toHaveBeenCalled();
expect(result).toEqual({
updatedCount: 0,
skippedCount: 0,
failedCount: 0,
});
});
test('omits updatedIds from the response when scoped by projectId', async () => {
setupRepository([
{ id: 'wf-1', settings: {} },
{ id: 'wf-2', settings: {} },
]);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(['wf-1', 'wf-2']);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
projectId: 'project-1',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(result).toEqual({ updatedCount: 2, skippedCount: 0, failedCount: 0 });
expect(result).not.toHaveProperty('updatedIds');
});
test('omits updatedIds from the response when scoped by folderId', async () => {
setupRepository([{ id: 'wf-1', settings: {} }]);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(['wf-1']);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
folderId: 'folder-1',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(result).toEqual({ updatedCount: 1, skippedCount: 0, failedCount: 0 });
expect(result).not.toHaveProperty('updatedIds');
});
test('resolves candidates via findAllWorkflowIdsForUser when scoped by folderId', async () => {
setupRepository([{ id: 'wf-1', settings: {} }]);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(['wf-1']);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
folderId: 'folder-1',
});
await service.bulkSetAvailableInMCP(user, dto);
expect(workflowFinderService.findProjectIdForFolder).toHaveBeenCalledWith('folder-1');
expect(workflowFinderService.hasProjectScopeForUser).toHaveBeenCalledWith(
user,
['workflow:update'],
'project-1',
);
expect(workflowFinderService.findAllWorkflowIdsForUser).toHaveBeenCalledWith(
user,
['workflow:update'],
'folder-1',
'project-1',
);
});
test('does not resolve folder-scoped workflows when folder project cannot be scoped', async () => {
const stubs = setupRepository([]);
workflowFinderService.findProjectIdForFolder.mockResolvedValueOnce(null);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
folderId: 'folder-1',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(workflowFinderService.hasProjectScopeForUser).not.toHaveBeenCalled();
expect(workflowFinderService.findAllWorkflowIdsForUser).not.toHaveBeenCalled();
expect(stubs.manager.transaction).not.toHaveBeenCalled();
expect(result).toEqual({
updatedCount: 0,
skippedCount: 0,
failedCount: 0,
});
});
test('returns zeroed result and does not open a transaction when no candidates are found', async () => {
const stubs = setupRepository([]);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue([]);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
projectId: 'empty-project',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(stubs.manager.transaction).not.toHaveBeenCalled();
expect(result).toEqual({
updatedCount: 0,
skippedCount: 0,
failedCount: 0,
});
expect(result).not.toHaveProperty('updatedIds');
});
test('returns an empty updatedIds array when scoped by workflowIds and none are accessible', async () => {
setupRepository([]);
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set());
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1', 'wf-2'],
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(result).toEqual({
updatedCount: 0,
skippedCount: 2,
failedCount: 0,
updatedIds: [],
});
});
test('chunks large candidate sets into one transaction per chunk', async () => {
// 600 workflows -> chunked into 500 + 100 with BULK_CHUNK_SIZE = 500.
const seeded = Array.from({ length: 600 }, (_, i) => ({
id: `wf-${i}`,
settings: {} as Record<string, unknown>,
}));
const stubs = setupRepository(seeded);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(seeded.map((w) => w.id));
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
projectId: 'project-big',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
// Two transactions (one per chunk), each with its own find call.
expect(stubs.manager.transaction).toHaveBeenCalledTimes(2);
expect(stubs.find).toHaveBeenCalledTimes(2);
expect(result.updatedCount).toBe(600);
expect(result.failedCount).toBe(0);
});
test('continues processing when a chunk transaction fails, and reports failedCount', async () => {
// 600 workflows -> 2 chunks
const seeded = Array.from({ length: 600 }, (_, i) => ({
id: `wf-${i}`,
settings: {} as Record<string, unknown>,
}));
const stubs = setupRepository(seeded);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(seeded.map((w) => w.id));
// Force the first chunk's transaction to fail; the second runs normally.
const originalTransaction = stubs.manager.transaction;
stubs.manager.transaction
.mockImplementationOnce(async () => {
throw new Error('chunk transaction failed');
})
.mockImplementationOnce(originalTransaction.getMockImplementation()!);
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
projectId: 'project-big',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(stubs.manager.transaction).toHaveBeenCalledTimes(2);
// Second chunk (100 workflows) committed successfully.
expect(result.updatedCount).toBe(100);
// First chunk (500 workflows) is counted as failed — the
// transaction rolled back so no rows were written.
expect(result.failedCount).toBe(500);
expect(result.skippedCount).toBe(0);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to bulk-update workflow MCP availability'),
expect.objectContaining({
error: expect.any(Error),
chunkSize: 500,
chunkStart: 0,
availableInMCP: true,
}),
);
});
test('surfaces chunk failures in updatedIds when scoped by workflowIds', async () => {
const stubs = setupRepository([{ id: 'wf-1', settings: {} }]);
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set(['wf-1']));
stubs.manager.transaction.mockImplementationOnce(async () => {
throw new Error('chunk transaction failed');
});
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1'],
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(result).toEqual({
updatedCount: 0,
skippedCount: 0,
failedCount: 1,
updatedIds: [],
});
});
test('commits successful chunks independently when a later chunk fails', async () => {
const seeded = Array.from({ length: 600 }, (_, i) => ({
id: `wf-${i}`,
settings: { availableInMCP: false } as Record<string, unknown>,
}));
const stubs = setupRepository(seeded);
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(seeded.map((w) => w.id));
const originalTransaction = stubs.manager.transaction.getMockImplementation()!;
stubs.manager.transaction
.mockImplementationOnce(originalTransaction)
.mockImplementationOnce(async () => {
throw new Error('second chunk failed');
});
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
projectId: 'project-big',
});
const result = await service.bulkSetAvailableInMCP(user, dto);
expect(result.updatedCount).toBe(500);
expect(result.failedCount).toBe(100);
expect(stubs.storage.get('wf-0')?.settings?.availableInMCP).toBe(true);
expect(stubs.storage.get('wf-499')?.settings?.availableInMCP).toBe(true);
expect(stubs.storage.get('wf-500')?.settings?.availableInMCP).toBe(false);
expect(stubs.storage.get('wf-599')?.settings?.availableInMCP).toBe(false);
});
test('deduplicates workflowIds before looking up access', async () => {
setupRepository([{ id: 'wf-1', settings: {} }]);
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set(['wf-1']));
const dto = new UpdateWorkflowsAvailabilityDto({
availableInMCP: true,
workflowIds: ['wf-1', 'wf-1', 'wf-1'],
});
await service.bulkSetAvailableInMCP(user, dto);
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).toHaveBeenCalledWith(
['wf-1'],
user,
['workflow:update'],
);
});
});
});

View File

@ -1,6 +0,0 @@
import { Z } from '@n8n/api-types';
import { z } from 'zod';
export class UpdateWorkflowAvailabilityDto extends Z.class({
availableInMCP: z.boolean(),
}) {}

View File

@ -0,0 +1,9 @@
import { Z } from '@n8n/api-types';
import { z } from 'zod';
export class UpdateWorkflowsAvailabilityDto extends Z.class({
availableInMCP: z.boolean(),
workflowIds: z.array(z.string().min(1)).min(1).max(100).optional(),
projectId: z.string().min(1).optional(),
folderId: z.string().min(1).optional(),
}) {}

View File

@ -1,30 +1,17 @@
import { ModuleRegistry, Logger } from '@n8n/backend-common';
import { type AuthenticatedRequest, WorkflowEntity } from '@n8n/db';
import {
Body,
Post,
Get,
Patch,
RestController,
GlobalScope,
Param,
ProjectScope,
} from '@n8n/decorators';
import { type AuthenticatedRequest } from '@n8n/db';
import { Body, Post, Get, Patch, RestController, GlobalScope } from '@n8n/decorators';
import type { Response } from 'express';
import { calculateWorkflowChecksum } 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 { CollaborationService } from '@/collaboration/collaboration.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { listQueryMiddleware } from '@/middlewares';
import type { ListQuery } from '@/requests';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { UpdateMcpSettingsDto } from './dto/update-mcp-settings.dto';
import { UpdateWorkflowsAvailabilityDto } from './dto/update-workflows-availability.dto';
import { McpServerApiKeyService } from './mcp-api-key.service';
import { McpSettingsService } from './mcp.settings.service';
@RestController('/mcp')
export class McpSettingsController {
constructor(
@ -32,9 +19,7 @@ export class McpSettingsController {
private readonly logger: Logger,
private readonly moduleRegistry: ModuleRegistry,
private readonly mcpServerApiKeyService: McpServerApiKeyService,
private readonly workflowFinderService: WorkflowFinderService,
private readonly workflowService: WorkflowService,
private readonly collaborationService: CollaborationService,
) {}
@GlobalScope('mcp:manage')
@ -91,60 +76,13 @@ export class McpSettingsController {
res.json({ count, data: workflows });
}
@ProjectScope('workflow:update')
@Patch('/workflows/:workflowId/toggle-access')
async toggleWorkflowMCPAccess(
// Ideally we would use ProjectScope here but it only works if projectId is a URL parameter
@Patch('/workflows/toggle-access')
async toggleWorkflowsMCPAccess(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId') workflowId: string,
@Body dto: UpdateWorkflowAvailabilityDto,
@Body dto: UpdateWorkflowsAvailabilityDto,
) {
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
req.user,
['workflow:update'],
{ includeActiveVersion: true },
);
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',
);
}
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);
const checksum = await calculateWorkflowChecksum(updatedWorkflow);
void this.collaborationService
.broadcastWorkflowSettingsUpdated(
workflowId,
{ availableInMCP: dto.availableInMCP },
checksum,
)
.catch((error) => {
this.logger.warn('Failed to broadcast workflow settings update', {
workflowId,
cause: error instanceof Error ? error.message : String(error),
});
});
return {
id: updatedWorkflow.id,
settings: updatedWorkflow.settings,
versionId: updatedWorkflow.versionId,
};
return await this.mcpSettingsService.bulkSetAvailableInMCP(req.user, dto);
}
}

View File

@ -1,15 +1,37 @@
import { SettingsRepository } from '@n8n/db';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { SettingsRepository, WorkflowEntity, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { In } from '@n8n/typeorm';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { CacheService } from '@/services/cache/cache.service';
import { removeDefaultValues } from '@/workflow-helpers';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { UpdateWorkflowsAvailabilityDto } from './dto/update-workflows-availability.dto';
const KEY = 'mcp.access.enabled';
const BULK_CHUNK_SIZE = 500;
type BulkSetAvailableInMCPResult = {
updatedCount: number;
skippedCount: number;
failedCount: number;
updatedIds?: string[];
};
@Service()
export class McpSettingsService {
constructor(
private readonly settingsRepository: SettingsRepository,
private readonly cacheService: CacheService,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowFinderService: WorkflowFinderService,
private readonly globalConfig: GlobalConfig,
private readonly logger: Logger,
) {}
async getEnabled(): Promise<boolean> {
@ -36,4 +58,140 @@ export class McpSettingsService {
await this.cacheService.set(KEY, enabled.toString());
}
async bulkSetAvailableInMCP(
user: User,
dto: UpdateWorkflowsAvailabilityDto,
): Promise<BulkSetAvailableInMCPResult> {
const { availableInMCP, workflowIds, projectId, folderId } = dto;
const scopeCount = [workflowIds, projectId, folderId].filter(Boolean).length;
if (scopeCount !== 1) {
throw new BadRequestError('Provide exactly one of workflowIds, projectId or folderId');
}
const candidateIds = await this.resolveCandidateIds(user, {
workflowIds,
projectId,
folderId,
});
const isWorkflowIdsScope = Boolean(workflowIds);
const baselineSize = isWorkflowIdsScope ? new Set(workflowIds).size : candidateIds.length;
if (candidateIds.length === 0) {
return {
updatedCount: 0,
skippedCount: baselineSize,
failedCount: 0,
...(isWorkflowIdsScope ? { updatedIds: [] } : {}),
};
}
const writtenIds: string[] = [];
const noOpIds: string[] = [];
let failedCount = 0;
for (let start = 0; start < candidateIds.length; start += BULK_CHUNK_SIZE) {
const chunk = candidateIds.slice(start, start + BULK_CHUNK_SIZE);
try {
const chunkResult = await this.workflowRepository.manager.transaction(async (trx) => {
const chunkWritten: string[] = [];
const chunkNoOp: string[] = [];
const now = new Date();
const rows = await trx.find(WorkflowEntity, {
where: { id: In(chunk), isArchived: false },
select: ['id', 'settings'],
});
for (const row of rows) {
if (row.settings?.availableInMCP === availableInMCP) {
chunkNoOp.push(row.id);
continue;
}
const nextSettings = removeDefaultValues(
{ ...(row.settings ?? {}), availableInMCP },
this.globalConfig.executions.timeout,
);
await trx.update(
WorkflowEntity,
{ id: row.id },
{ settings: nextSettings, updatedAt: now },
);
chunkWritten.push(row.id);
}
return { written: chunkWritten, noOp: chunkNoOp };
});
writtenIds.push(...chunkResult.written);
noOpIds.push(...chunkResult.noOp);
} catch (error) {
failedCount += chunk.length;
this.logger.error('Failed to bulk-update workflow MCP availability for chunk', {
error,
chunkSize: chunk.length,
chunkStart: start,
availableInMCP,
});
}
}
const confirmedIds = [...writtenIds, ...noOpIds];
return {
updatedCount: confirmedIds.length,
skippedCount: Math.max(0, baselineSize - confirmedIds.length - failedCount),
failedCount,
...(isWorkflowIdsScope ? { updatedIds: confirmedIds } : {}),
};
}
private async resolveCandidateIds(
user: User,
scope: {
workflowIds?: string[];
projectId?: string;
folderId?: string;
},
): Promise<string[]> {
if (scope.workflowIds) {
const uniqueIds = [...new Set(scope.workflowIds)];
const accessibleIds = await this.workflowFinderService.findWorkflowIdsWithScopeForUser(
uniqueIds,
user,
['workflow:update'],
);
return uniqueIds.filter((id) => accessibleIds.has(id));
}
const projectId =
scope.projectId ??
(scope.folderId
? await this.workflowFinderService.findProjectIdForFolder(scope.folderId)
: null);
if (
projectId === null ||
!(await this.workflowFinderService.hasProjectScopeForUser(
user,
['workflow:update'],
projectId,
))
) {
return [];
}
return await this.workflowFinderService.findAllWorkflowIdsForUser(
user,
['workflow:update'],
scope.folderId,
projectId,
);
}
}

View File

@ -7,6 +7,7 @@ import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { userHasScopes } from '@/permissions.ee/check-access';
import { RoleService } from '@/services/role.service';
@Service()
@ -122,6 +123,19 @@ export class WorkflowFinderService {
return new Set(sharedWorkflows.map((sw) => sw.workflowId));
}
async hasProjectScopeForUser(user: User, scopes: Scope[], projectId: string) {
return await userHasScopes(user, scopes, false, { projectId });
}
async findProjectIdForFolder(folderId: string): Promise<string | null> {
const folder = await this.folderRepository.findOne({
where: { id: folderId },
relations: { homeProject: true },
});
return folder?.homeProject.id ?? null;
}
async findAllWorkflowIdsForUser(
user: User,
scopes: Scope[],
@ -134,7 +148,7 @@ export class WorkflowFinderService {
where,
});
return sharedWorkflows.map(({ workflowId }) => workflowId);
return Array.from(new Set(sharedWorkflows.map(({ workflowId }) => workflowId)));
}
async findAllWorkflowsForUser(

View File

@ -3777,6 +3777,7 @@
"workflowSettings.availableInMCP.tooltip": "Make this workflow visible to AI Agents through n8n MCP",
"workflowSettings.toggleMCP.error.title": "Error updating MCP settings",
"workflowSettings.toggleMCP.notFoundError": "Workflow not found",
"workflowSettings.toggleMCP.updateSkippedError": "Workflow {workflowId} could not be updated. It may be archived or you may no longer have permission to edit it.",
"workflowHistory.title": "Version History",
"workflowHistory.content.actions": "Actions",
"workflowHistory.item.id": "ID: {id}",

View File

@ -3,7 +3,7 @@ import type {
ListOAuthClientsResponseDto,
DeleteOAuthClientResponseDto,
} from '@n8n/api-types';
import type { IWorkflowSettings, WorkflowListItem } from '@/Interface';
import type { WorkflowListItem } from '@/Interface';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest, getFullApiResponse } from '@n8n/rest-api-client';
@ -11,6 +11,18 @@ export type McpSettingsResponse = {
mcpAccessEnabled: boolean;
};
export type ToggleWorkflowsMcpAccessTarget =
| { workflowIds: string[] }
| { projectId: string }
| { folderId: string };
export type ToggleWorkflowsMcpAccessResponse = {
updatedCount: number;
skippedCount: number;
failedCount: number;
updatedIds?: string[];
};
export async function getMcpSettings(context: IRestApiContext): Promise<McpSettingsResponse> {
return await makeRestApiRequest(context, 'GET', '/mcp/settings');
}
@ -32,19 +44,19 @@ export async function rotateApiKey(context: IRestApiContext): Promise<ApiKey> {
return await makeRestApiRequest(context, 'POST', '/mcp/api-key/rotate');
}
export async function toggleWorkflowMcpAccessApi(
/**
* Bulk-toggles MCP availability for a set of workflows scoped by either an
* explicit id list, a project, or a folder (+ its descendants).
*/
export async function toggleWorkflowsMcpAccessApi(
context: IRestApiContext,
workflowId: string,
target: ToggleWorkflowsMcpAccessTarget,
availableInMCP: boolean,
): Promise<{ id: string; settings: IWorkflowSettings | undefined; versionId: string }> {
return await makeRestApiRequest(
context,
'PATCH',
`/mcp/workflows/${encodeURIComponent(workflowId)}/toggle-access`,
{
availableInMCP,
},
);
): Promise<ToggleWorkflowsMcpAccessResponse> {
return await makeRestApiRequest(context, 'PATCH', '/mcp/workflows/toggle-access', {
availableInMCP,
...target,
});
}
export async function fetchOAuthClients(

View File

@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import * as mcpApi from './mcp.api';
import { useMCPStore } from './mcp.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
mockWorkflowDocumentStore: {
allNodes: [],
name: '',
settings: {},
mergeSettings: vi.fn(),
getPinDataSnapshot: vi.fn().mockReturnValue({}),
getNodeByName: vi.fn().mockReturnValue(null),
},
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('@/app/stores/workflowDocument.store', () => ({
useWorkflowDocumentStore: vi.fn(() => mockWorkflowDocumentStore),
createWorkflowDocumentId: (id: string) => id,
}));
describe('mcp.store', () => {
let store: ReturnType<typeof useMCPStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let workflowsListStore: ReturnType<typeof useWorkflowsListStore>;
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
store = useMCPStore();
workflowsStore = useWorkflowsStore();
workflowsListStore = useWorkflowsListStore();
});
describe('toggleWorkflowMcpAccess', () => {
it('patches the list store entry when the backend confirms the update', async () => {
workflowsListStore.workflowsById = {
'wf-1': {
id: 'wf-1',
name: 'wf',
settings: { availableInMCP: false, executionOrder: 'v1' },
},
} as unknown as typeof workflowsListStore.workflowsById;
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 1,
updatedIds: ['wf-1'],
skippedCount: 0,
failedCount: 0,
});
await store.toggleWorkflowMcpAccess('wf-1', true);
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(true);
});
it('creates a settings object on list entries that have none', async () => {
workflowsListStore.workflowsById = {
'wf-1': {
id: 'wf-1',
name: 'wf',
// No `settings` object on this entry — simulates legacy
// workflows or sparse list responses.
},
} as unknown as typeof workflowsListStore.workflowsById;
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 1,
updatedIds: ['wf-1'],
skippedCount: 0,
failedCount: 0,
});
await store.toggleWorkflowMcpAccess('wf-1', true);
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(true);
});
it('merges settings into the active workflow document when toggling its own id', async () => {
workflowsStore.workflow.id = 'wf-current';
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 1,
updatedIds: ['wf-current'],
skippedCount: 0,
failedCount: 0,
});
await store.toggleWorkflowMcpAccess('wf-current', true);
expect(mockWorkflowDocumentStore.mergeSettings).toHaveBeenCalledWith({
availableInMCP: true,
});
});
it('throws when the backend silently skipped the workflow', async () => {
workflowsListStore.workflowsById = {
'wf-1': {
id: 'wf-1',
name: 'wf',
settings: { availableInMCP: false, executionOrder: 'v1' },
},
} as unknown as typeof workflowsListStore.workflowsById;
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 0,
updatedIds: [],
skippedCount: 1,
failedCount: 0,
});
await expect(store.toggleWorkflowMcpAccess('wf-1', true)).rejects.toThrow(
/could not be updated/i,
);
// Local store must remain untouched when the backend rejected the change.
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(false);
expect(mockWorkflowDocumentStore.mergeSettings).not.toHaveBeenCalled();
});
it('propagates network errors without patching local state', async () => {
workflowsListStore.workflowsById = {
'wf-1': {
id: 'wf-1',
name: 'wf',
settings: { availableInMCP: false, executionOrder: 'v1' },
},
} as unknown as typeof workflowsListStore.workflowsById;
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockRejectedValue(new Error('network'));
await expect(store.toggleWorkflowMcpAccess('wf-1', true)).rejects.toThrow('network');
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(false);
expect(mockWorkflowDocumentStore.mergeSettings).not.toHaveBeenCalled();
});
});
describe('toggleWorkflowsMcpAccess (bulk)', () => {
it('applies the new value only to workflows the backend confirmed were updated', async () => {
workflowsListStore.workflowsById = {
'wf-1': {
id: 'wf-1',
name: 'wf-1',
settings: { availableInMCP: false, executionOrder: 'v1' },
},
'wf-2': {
id: 'wf-2',
name: 'wf-2',
settings: { availableInMCP: false, executionOrder: 'v1' },
},
} as unknown as typeof workflowsListStore.workflowsById;
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 1,
updatedIds: ['wf-1'],
skippedCount: 1,
failedCount: 0,
});
const response = await store.toggleWorkflowsMcpAccess(
{ workflowIds: ['wf-1', 'wf-2'] },
true,
);
expect(response.updatedIds).toEqual(['wf-1']);
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(true);
// Skipped workflow remains untouched.
expect(workflowsListStore.workflowsById['wf-2'].settings?.availableInMCP).toBe(false);
});
it('does not throw when none of the targeted workflows were updated', async () => {
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 0,
updatedIds: [],
skippedCount: 2,
failedCount: 0,
});
await expect(
store.toggleWorkflowsMcpAccess({ workflowIds: ['wf-1', 'wf-2'] }, true),
).resolves.toEqual({
updatedCount: 0,
updatedIds: [],
skippedCount: 2,
failedCount: 0,
});
});
it('forwards projectId scope to the API', async () => {
// Project-scoped responses from the backend omit `updatedIds`.
const spy = vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 0,
skippedCount: 0,
failedCount: 0,
});
await store.toggleWorkflowsMcpAccess({ projectId: 'project-1' }, false);
expect(spy).toHaveBeenCalledWith({}, { projectId: 'project-1' }, false);
});
it('forwards folderId scope to the API', async () => {
// Folder-scoped responses from the backend omit `updatedIds`.
const spy = vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 0,
skippedCount: 0,
failedCount: 0,
});
await store.toggleWorkflowsMcpAccess({ folderId: 'folder-1' }, true);
expect(spy).toHaveBeenCalledWith({}, { folderId: 'folder-1' }, true);
});
it('does not patch local stores when the response omits updatedIds (scope mode)', async () => {
workflowsListStore.workflowsById = {
'wf-1': {
id: 'wf-1',
name: 'wf-1',
settings: { availableInMCP: false, executionOrder: 'v1' },
},
} as unknown as typeof workflowsListStore.workflowsById;
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 5,
skippedCount: 0,
failedCount: 0,
});
await expect(
store.toggleWorkflowsMcpAccess({ projectId: 'project-1' }, true),
).resolves.toEqual({ updatedCount: 5, skippedCount: 0, failedCount: 0 });
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(false);
});
it('surfaces partial failures from the backend via failedCount', async () => {
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
updatedCount: 500,
skippedCount: 0,
failedCount: 100,
});
await expect(
store.toggleWorkflowsMcpAccess({ projectId: 'big-project' }, true),
).resolves.toEqual({ updatedCount: 500, skippedCount: 0, failedCount: 100 });
});
});
});

View File

@ -6,21 +6,24 @@ import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import type { WorkflowListItem } from '@/Interface';
import type { IWorkflowSettings, WorkflowListItem } from '@/Interface';
import { useRootStore } from '@n8n/stores/useRootStore';
import {
updateMcpSettings,
toggleWorkflowMcpAccessApi,
toggleWorkflowsMcpAccessApi,
fetchApiKey,
rotateApiKey,
fetchOAuthClients,
deleteOAuthClient,
fetchMcpEligibleWorkflows,
type ToggleWorkflowsMcpAccessResponse,
type ToggleWorkflowsMcpAccessTarget,
} from '@/features/ai/mcpAccess/mcp.api';
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { isWorkflowListItem } from '@/app/utils/typeGuards';
import type { ApiKey, OAuthClientResponseDto, DeleteOAuthClientResponseDto } from '@n8n/api-types';
import { i18n } from '@n8n/i18n';
export const useMCPStore = defineStore(MCP_STORE, () => {
const workflowsStore = useWorkflowsStore();
@ -62,40 +65,62 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return updated;
}
function applyAvailableInMCPToLocalStores(workflowId: string, availableInMCP: boolean) {
const existing = workflowsListStore.workflowsById[workflowId];
if (existing) {
if (existing.settings) {
existing.settings.availableInMCP = availableInMCP;
} else {
existing.settings = { availableInMCP } as IWorkflowSettings;
}
}
if (workflowId === workflowsStore.workflowId) {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
workflowDocumentStore.mergeSettings({ availableInMCP });
}
}
// Toggle MCP access for a single workflow
async function toggleWorkflowMcpAccess(
workflowId: string,
availableInMCP: boolean,
): Promise<{
id: string;
settings: { availableInMCP?: boolean } | undefined;
versionId: string;
}> {
const response = await toggleWorkflowMcpAccessApi(
): Promise<ToggleWorkflowsMcpAccessResponse> {
const response = await toggleWorkflowsMcpAccessApi(
rootStore.restApiContext,
workflowId,
{ workflowIds: [workflowId] },
availableInMCP,
);
const { id, settings, versionId } = response;
// Update local version of the workflow
if (id === workflowsStore.workflowId) {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(id));
workflowDocumentStore.setVersionData({
versionId,
name: workflowDocumentStore.versionData?.name ?? null,
description: workflowDocumentStore.versionData?.description ?? null,
});
if (settings) {
workflowDocumentStore.mergeSettings(settings);
}
if (!(response.updatedIds ?? []).includes(workflowId)) {
throw new Error(
i18n.baseText('workflowSettings.toggleMCP.updateSkippedError', {
interpolate: { workflowId },
}),
);
}
if (workflowsListStore.workflowsById[id]) {
workflowsListStore.workflowsById[id] = {
...workflowsListStore.workflowsById[id],
settings,
versionId,
};
applyAvailableInMCPToLocalStores(workflowId, availableInMCP);
return response;
}
/**
* Bulk-toggle MCP availability, scoped by an id list, a project,
* or a folder (+ descendants)
*/
async function toggleWorkflowsMcpAccess(
target: ToggleWorkflowsMcpAccessTarget,
availableInMCP: boolean,
): Promise<ToggleWorkflowsMcpAccessResponse> {
const response = await toggleWorkflowsMcpAccessApi(
rootStore.restApiContext,
target,
availableInMCP,
);
for (const id of response.updatedIds ?? []) {
applyAvailableInMCPToLocalStores(id, availableInMCP);
}
return response;
@ -151,6 +176,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
fetchWorkflowsAvailableForMCP,
setMcpAccessEnabled,
toggleWorkflowMcpAccess,
toggleWorkflowsMcpAccess,
currentUserMCPKey,
getOrCreateApiKey,
generateNewApiKey,