feat(core): Add MCP support for folder creation and workflow moves (#35964)

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Svetoslav Dekov
2026-08-14 15:11:55 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 6f50a5a53f
commit d4dcc1cb91
26 changed files with 1286 additions and 130 deletions
+1
View File
@@ -125,6 +125,7 @@ export type { BannerName } from './schemas/banner-name.schema';
export { ViewableMimeTypes } from './schemas/binary-data.schema';
export { passwordSchema, createPasswordSchema } from './schemas/password.schema';
export { n8nIdSchema } from './schemas/id.schema';
export { folderNameSchema, folderIdSchema } from './schemas/folder.schema';
export {
SYSTEM_RESOLVER_ID,
credentialResolverSchema,
@@ -34,6 +34,7 @@ export const MCP_INSTANCE_SCOPES = [
'dataTable:read',
'dataTable:write',
'project:read',
'project:write',
'tag:read',
] as const;
@@ -0,0 +1,171 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { PROJECT_ROOT } from 'n8n-workflow';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
import { FolderService } from '@/services/folder.service';
import { ProjectService } from '@/services/project.service.ee';
import { Telemetry } from '@/telemetry';
import { createCreateFolderTool } from '../tools/create-folder.tool';
describe('create-folder MCP tool', () => {
const user = Object.assign(new User(), { id: 'user-1' });
const createMocks = (overrides?: {
createdFolder?: { id: string; name: string; parentFolder?: { id: string } | null };
projectAccessible?: boolean;
}) => {
const createdFolder = overrides?.createdFolder ?? {
id: 'folder-1',
name: 'Marketing',
parentFolder: null,
};
const projectAccessible = overrides?.projectAccessible ?? true;
const folderService = mockInstance(FolderService, {
createFolder: vi.fn().mockResolvedValue(createdFolder),
});
const projectService = mockInstance(ProjectService, {
getProjectWithScope: vi
.fn()
.mockResolvedValue(projectAccessible ? { id: 'proj-1', type: 'team' } : null),
});
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
return { folderService, projectService, telemetry };
};
const createTool = (mocks: ReturnType<typeof createMocks>) =>
createCreateFolderTool(user, mocks.folderService, mocks.projectService, mocks.telemetry);
const callHandler = async (
tool: ReturnType<typeof createCreateFolderTool>,
args: { projectId: string; name: string; parentFolderId?: string },
) =>
await tool.handler(
{
projectId: args.projectId,
name: args.name,
parentFolderId: args.parentFolderId as string,
},
{} as never,
);
test('creates tool correctly', () => {
const tool = createTool(createMocks());
expect(tool.name).toBe('create_folder');
expect(tool.config).toBeDefined();
expect(typeof tool.config.description).toBe('string');
expect(tool.config.inputSchema).toBeDefined();
expect(typeof tool.handler).toBe('function');
});
test('creates a folder at the project root', async () => {
const mocks = createMocks();
const tool = createTool(mocks);
const result = await callHandler(tool, { projectId: 'proj-1', name: 'Marketing' });
expect(mocks.projectService.getProjectWithScope).toHaveBeenCalledWith(user, 'proj-1', [
'folder:create',
]);
expect(mocks.folderService.createFolder).toHaveBeenCalledWith(
{ name: 'Marketing', parentFolderId: undefined },
'proj-1',
);
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toEqual({
id: 'folder-1',
name: 'Marketing',
parentFolderId: null,
});
});
test('treats the "0" root sentinel as no parent', async () => {
const mocks = createMocks();
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
name: 'Marketing',
parentFolderId: PROJECT_ROOT,
});
expect(mocks.folderService.createFolder).toHaveBeenCalledWith(
{ name: 'Marketing', parentFolderId: undefined },
'proj-1',
);
expect(result.isError).toBeUndefined();
});
test('creates a nested folder', async () => {
const mocks = createMocks({
createdFolder: { id: 'folder-2', name: 'Campaigns', parentFolder: { id: 'folder-1' } },
});
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
name: 'Campaigns',
parentFolderId: 'folder-1',
});
expect(mocks.folderService.createFolder).toHaveBeenCalledWith(
{ name: 'Campaigns', parentFolderId: 'folder-1' },
'proj-1',
);
expect(result.structuredContent).toEqual({
id: 'folder-2',
name: 'Campaigns',
parentFolderId: 'folder-1',
});
});
test('returns error when user lacks access to project', async () => {
const mocks = createMocks({ projectAccessible: false });
const tool = createTool(mocks);
const result = await callHandler(tool, { projectId: 'proj-no-access', name: 'Marketing' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({ error: 'Project not found or access denied' });
expect(mocks.folderService.createFolder).not.toHaveBeenCalled();
});
test('returns error when the folder service fails', async () => {
const mocks = createMocks();
mocks.folderService.createFolder.mockRejectedValue(new Error('DB error'));
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
name: 'Campaigns',
parentFolderId: 'missing-folder',
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({ error: 'DB error' });
});
test('points unknown parent folder ids at search_folders', async () => {
const mocks = createMocks();
mocks.folderService.createFolder.mockRejectedValue(new FolderNotFoundError('missing-folder'));
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
name: 'Campaigns',
parentFolderId: 'missing-folder',
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error:
'Could not find the folder: missing-folder. Use search_folders to look up a valid folder id.',
});
});
});
@@ -169,7 +169,6 @@ describe('create-workflow-from-code MCP tool', () => {
const workflowFinderService = mockInstance(WorkflowFinderService, {
findWorkflowForUser: vi.fn().mockResolvedValue(null),
});
const aiGatewayService = mock<AiGatewayService>();
aiGatewayService.isAvailable.mockResolvedValue({ available: false });
@@ -244,6 +243,38 @@ describe('create-workflow-from-code MCP tool', () => {
const response = parseResult(result);
expect(response.error).toBe('projectId is required when folderId is provided');
});
test('passes the folder to the creation service and echoes it as targetFolder', async () => {
createWorkflowMock.mockImplementation(async (_user, workflow) =>
Object.assign(new WorkflowEntity(), {
...workflow,
id: 'wf-saved-1',
parentFolder: { id: 'folder-1', name: 'Marketing Campaigns' },
}),
);
const result = await callHandler({
code: 'const wf = ...',
projectId: 'custom-project-id',
folderId: 'folder-1',
});
expect(result.isError).toBeUndefined();
expect(createWorkflowMock).toHaveBeenCalledWith(
user,
expect.anything(),
expect.objectContaining({ projectId: 'custom-project-id', parentFolderId: 'folder-1' }),
);
const response = parseResult(result);
expect(response.targetFolder).toEqual({ id: 'folder-1', name: 'Marketing Campaigns' });
});
test('omits targetFolder when no folderId is provided', async () => {
const result = await callHandler({ code: 'const wf = ...' });
expect(result.isError).toBeUndefined();
expect(parseResult(result).targetFolder).toBeUndefined();
});
});
describe('handler tests', () => {
@@ -449,6 +480,35 @@ describe('create-workflow-from-code MCP tool', () => {
expect(response.note).toContain('post-save operation failed');
});
test('includes targetFolder in recovery output from the persisted parent folder', async () => {
createWorkflowMock.mockImplementation(async (_user, workflow: WorkflowEntity) => {
workflow.id = 'wf-recovery-2';
throw new Error('Post-save hook failed');
});
(workflowFinderService.findWorkflowForUser as Mock).mockResolvedValueOnce({
id: 'wf-recovery-2',
name: 'Recovered',
nodes: mockNodes,
parentFolder: { id: 'folder-1', name: 'Marketing Campaigns' },
});
const result = await callHandler({
code: 'const wf = ...',
projectId: 'custom-project-id',
folderId: 'folder-1',
});
expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith(
'wf-recovery-2',
user,
['workflow:read'],
{ includeParentFolder: true },
);
const response = parseResult(result);
expect(response.targetFolder).toEqual({ id: 'folder-1', name: 'Marketing Campaigns' });
expect(response.note).toContain('post-save operation failed');
});
test('returns error when service throws permission error', async () => {
createWorkflowMock.mockRejectedValue(
new Error("You don't have the permissions to save the workflow in this project."),
@@ -167,6 +167,31 @@ describe('get-workflow-details MCP tool', () => {
});
});
test('reports the parent folder of a workflow inside a folder', async () => {
const workflow = createWorkflow({
parentFolder: { id: 'folder-1' } as WorkflowEntity['parentFolder'],
});
const workflowFinderService = mockInstance(WorkflowFinderService, {
findWorkflowForUser: vi.fn().mockResolvedValue(workflow),
});
const credentialsService = mockInstance(CredentialsService, {});
const endpoints = { webhook: 'webhook', webhookTest: 'webhook-test' };
const payload = await getWorkflowDetails(
user,
baseWebhookUrl,
workflowFinderService,
credentialsService,
nodeTypes,
endpoints,
roleService,
projectService,
{ workflowId: 'wf-1', detailLevel: 'execution' },
);
expect(payload.workflow.parentFolderId).toBe('folder-1');
});
test("omits graph fields when detailLevel is 'execution'", async () => {
const workflow = createWorkflow({ activeVersionId: uuid() });
const findWorkflowForUser = vi.fn().mockResolvedValue(workflow);
@@ -189,12 +214,13 @@ describe('get-workflow-details MCP tool', () => {
);
// The published version stays loaded: its graph is omitted, but its
// triggers still feed activeVersionTriggerInfo
// triggers still feed activeVersionTriggerInfo. The parent folder must
// be requested too — without it parentFolderId is silently always null.
expect(findWorkflowForUser).toHaveBeenCalledWith(
'wf-1',
user,
['workflow:read'],
expect.objectContaining({ includeActiveVersion: true }),
expect.objectContaining({ includeActiveVersion: true, includeParentFolder: true }),
);
expect(payload.workflow.nodes).toBeUndefined();
@@ -1,4 +1,4 @@
import type { ModuleRegistry } from '@n8n/backend-common';
import type { LicenseState, ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
@@ -19,18 +19,21 @@ describe('McpProtectedResource', () => {
const mcpSettingsService = mock<McpSettingsService>();
const mcpConfig = mock<McpConfig>();
const moduleRegistry = mock<ModuleRegistry>();
const licenseState = mock<LicenseState>();
const resource = new McpProtectedResource(
urlService,
mcpSettingsService,
mcpConfig,
makeGlobalConfig(),
moduleRegistry,
licenseState,
);
beforeEach(() => {
vi.clearAllMocks();
mcpConfig.baseUrl = '';
moduleRegistry.isActive.mockReturnValue(true);
licenseState.isFoldersLicensed.mockReturnValue(true);
});
describe('getScopeTools', () => {
@@ -52,6 +55,7 @@ describe('McpProtectedResource', () => {
mcpConfig,
makeGlobalConfig({ builderEnabled: false, tagsDisabled: true }),
moduleRegistry,
licenseState,
);
const scopeTools = limitedResource.getScopeTools();
@@ -69,6 +73,19 @@ describe('McpProtectedResource', () => {
expect(scopeTools['tag:read']).toEqual([]);
});
it('should drop folder tools when folders are not licensed', () => {
licenseState.isFoldersLicensed.mockReturnValue(false);
const scopeTools = resource.getScopeTools();
expect(scopeTools['project:write']).not.toContain('create_folder');
expect(scopeTools['project:write']).not.toContain('update_folder');
expect(scopeTools['workflow:write']).not.toContain('move_workflows_to_folder');
expect(scopeTools['project:write']).not.toContain('search_folders');
expect(scopeTools['project:read']).not.toContain('search_folders');
expect(scopeTools['project:read']).toContain('search_projects');
});
it('should drop agent scopes and tools when the agents module is inactive', () => {
moduleRegistry.isActive.mockReturnValue(false);
@@ -1,13 +1,7 @@
import { LicenseState, ModuleRegistry } from '@n8n/backend-common';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config';
import {
ExecutionRepository,
FolderRepository,
ProjectRepository,
SharedWorkflowRepository,
User,
} from '@n8n/db';
import { ExecutionRepository, ProjectRepository, SharedWorkflowRepository, User } from '@n8n/db';
import { InstanceSettings } from 'n8n-core';
import { ActiveExecutions } from '@/active-executions';
@@ -21,6 +15,8 @@ import { NodeCatalogService } from '@/node-catalog';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { FolderFinderService } from '@/services/folder-finder.service';
import { FolderService } from '@/services/folder.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@@ -91,7 +87,7 @@ describe('getAllowedToolNames', () => {
describe('McpService scope enforcement', () => {
const user = Object.assign(new User(), { id: 'user-1' });
const buildService = ({ builderEnabled = true } = {}) =>
const buildService = ({ builderEnabled = true, foldersLicensed = true } = {}) =>
new McpService(
mockLogger(),
mockInstance(ExecutionsConfig, { mode: 'regular' }),
@@ -119,7 +115,7 @@ describe('McpService scope enforcement', () => {
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(FolderFinderService),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
@@ -127,7 +123,9 @@ describe('McpService scope enforcement', () => {
mockInstance(CollaborationService),
mockInstance(NodeResourceExplorerService),
mockInstance(TagService),
mockInstance(LicenseState),
mockInstance(LicenseState, {
isFoldersLicensed: vi.fn().mockReturnValue(foldersLicensed),
}),
mockInstance(PostHogClient),
mockInstance(WorkflowHistoryService),
mockInstance(WorkflowsConfig),
@@ -138,6 +136,7 @@ describe('McpService scope enforcement', () => {
}),
mockInstance(ModuleRegistry),
mockInstance(EventService),
mockInstance(FolderService),
);
beforeEach(() => {
@@ -176,6 +175,20 @@ describe('McpService scope enforcement', () => {
expect(gated).toEqual([...BUILDER_TOOLS].sort());
});
it('does not register folder tools when folders are not licensed', async () => {
const server = await buildService({ foldersLicensed: false }).getServer(
user,
mcpFeatureFlags(),
);
const registered = getRegisteredToolNames(server);
expect(registered).not.toContain('search_folders');
expect(registered).not.toContain('create_folder');
expect(registered).not.toContain('update_folder');
expect(registered).not.toContain('move_workflows_to_folder');
expect(registered).toContain('search_projects');
});
it('registers all tools when no scopes are provided (API keys, legacy tokens)', async () => {
const service = buildService();
const unscoped = await service.getServer(user, mcpFeatureFlags());
@@ -2,13 +2,7 @@ import { createMcpHandler, type McpServer } from '@modelcontextprotocol/server';
import { LicenseState, ModuleRegistry, type Logger } from '@n8n/backend-common';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config';
import {
ExecutionRepository,
FolderRepository,
ProjectRepository,
SharedWorkflowRepository,
User,
} from '@n8n/db';
import { ExecutionRepository, ProjectRepository, SharedWorkflowRepository, User } from '@n8n/db';
import { InstanceSettings } from 'n8n-core';
import type { IRun } from 'n8n-workflow';
import { createEmptyRunExecutionData, ManualExecutionCancelledError } from 'n8n-workflow';
@@ -31,6 +25,8 @@ import { NodeCatalogService } from '@/node-catalog';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { FolderFinderService } from '@/services/folder-finder.service';
import { FolderService } from '@/services/folder.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@@ -108,7 +104,7 @@ describe('McpService', () => {
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(FolderFinderService),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
@@ -125,6 +121,7 @@ describe('McpService', () => {
mockAiGatewayService(),
mockInstance(ModuleRegistry),
eventService,
mockInstance(FolderService),
);
});
@@ -159,7 +156,7 @@ describe('McpService', () => {
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(FolderFinderService),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
@@ -176,6 +173,7 @@ describe('McpService', () => {
mockAiGatewayService(),
mockInstance(ModuleRegistry),
mockInstance(EventService),
mockInstance(FolderService),
);
expect(queueMcpService.isQueueMode).toBe(true);
@@ -365,7 +363,7 @@ describe('McpService', () => {
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(FolderFinderService),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
@@ -382,6 +380,7 @@ describe('McpService', () => {
mockAiGatewayService(),
mockInstance(ModuleRegistry),
mockInstance(EventService),
mockInstance(FolderService),
);
const user = Object.assign(new User(), { id: 'user-1' });
@@ -854,7 +853,7 @@ describe('McpService', () => {
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(FolderFinderService),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
@@ -871,6 +870,7 @@ describe('McpService', () => {
mockAiGatewayService(),
mockInstance(ModuleRegistry),
mockInstance(EventService),
mockInstance(FolderService),
);
const server = await service.getServer(user, mcpFeatureFlags());
@@ -907,7 +907,7 @@ describe('McpService', () => {
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(FolderFinderService),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
@@ -924,6 +924,7 @@ describe('McpService', () => {
mockAiGatewayService(),
mockInstance(ModuleRegistry),
mockInstance(EventService),
mockInstance(FolderService),
);
const server = await service.getServer(user, mcpFeatureFlags());
@@ -985,7 +986,7 @@ describe('McpService', () => {
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(FolderFinderService),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
@@ -1002,6 +1003,7 @@ describe('McpService', () => {
mockAiGatewayService(),
mockInstance(ModuleRegistry),
mockInstance(EventService),
mockInstance(FolderService),
);
};
@@ -0,0 +1,195 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { PROJECT_ROOT } from 'n8n-workflow';
import { FolderFinderService } from '@/services/folder-finder.service';
import { Telemetry } from '@/telemetry';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { createMoveWorkflowsToFolderTool } from '../tools/move-workflows-to-folder.tool';
const workflowFixture = (id: string, name: string, overrides: Record<string, unknown> = {}) => ({
id,
name,
isArchived: false,
settings: { availableInMCP: true },
...overrides,
});
describe('move-workflows-to-folder MCP tool', () => {
const user = Object.assign(new User(), { id: 'user-1' });
const createMocks = (overrides?: {
workflows?: Record<string, ReturnType<typeof workflowFixture> | null>;
folder?: { id: string; name: string } | null;
}) => {
const workflows = overrides?.workflows ?? {
'wf-1': workflowFixture('wf-1', 'My workflow'),
};
const folder =
overrides?.folder === undefined ? { id: 'folder-1', name: 'Marketing' } : overrides.folder;
const workflowFinderService = mockInstance(WorkflowFinderService, {
findWorkflowsByIdsForUser: vi
.fn()
.mockImplementation(async (workflowIds: string[]) =>
workflowIds.map((workflowId) => workflows[workflowId]).filter(Boolean),
),
});
const workflowService = mockInstance(WorkflowService, {
update: vi.fn().mockResolvedValue({}),
});
const folderFinderService = mockInstance(FolderFinderService, {
findFoldersByIdsForUser: vi.fn().mockResolvedValue(folder ? [folder] : []),
});
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
return { workflowFinderService, workflowService, folderFinderService, telemetry };
};
const createTool = (mocks: ReturnType<typeof createMocks>) =>
createMoveWorkflowsToFolderTool(
user,
mocks.workflowFinderService,
mocks.workflowService,
mocks.folderFinderService,
mocks.telemetry,
);
test('creates tool correctly', () => {
const tool = createTool(createMocks());
expect(tool.name).toBe('move_workflows_to_folder');
expect(tool.config).toBeDefined();
expect(typeof tool.config.description).toBe('string');
expect(tool.config.inputSchema).toBeDefined();
expect(typeof tool.handler).toBe('function');
});
test('moves a workflow into a folder', async () => {
const mocks = createMocks();
const tool = createTool(mocks);
const result = await tool.handler({ workflowIds: ['wf-1'], folderId: 'folder-1' });
expect(mocks.folderFinderService.findFoldersByIdsForUser).toHaveBeenCalledWith(
['folder-1'],
user,
['folder:read'],
);
expect(mocks.workflowFinderService.findWorkflowsByIdsForUser).toHaveBeenCalledWith(
['wf-1'],
user,
['workflow:update'],
);
expect(mocks.workflowService.update).toHaveBeenCalledWith(user, expect.anything(), 'wf-1', {
parentFolderId: 'folder-1',
source: 'n8n-mcp',
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toEqual({
folder: { id: 'folder-1', name: 'Marketing' },
moved: [{ workflowId: 'wf-1', name: 'My workflow' }],
});
});
test('moves multiple workflows and reports partial failures', async () => {
const mocks = createMocks({
workflows: {
'wf-1': workflowFixture('wf-1', 'First'),
'wf-2': null,
'wf-3': workflowFixture('wf-3', 'Third'),
},
});
const tool = createTool(mocks);
const result = await tool.handler({
workflowIds: ['wf-1', 'wf-2', 'wf-3'],
folderId: 'folder-1',
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toEqual({
folder: { id: 'folder-1', name: 'Marketing' },
moved: [
{ workflowId: 'wf-1', name: 'First' },
{ workflowId: 'wf-3', name: 'Third' },
],
failed: [
{
workflowId: 'wf-2',
error: "Workflow not found or you don't have permission to access it.",
},
],
});
});
test('moves workflows to the project root without a folder lookup', async () => {
const mocks = createMocks();
const tool = createTool(mocks);
const result = await tool.handler({ workflowIds: ['wf-1'], folderId: PROJECT_ROOT });
expect(mocks.folderFinderService.findFoldersByIdsForUser).not.toHaveBeenCalled();
expect(mocks.workflowService.update).toHaveBeenCalledWith(user, expect.anything(), 'wf-1', {
parentFolderId: PROJECT_ROOT,
source: 'n8n-mcp',
});
expect(result.structuredContent).toEqual({
moved: [{ workflowId: 'wf-1', name: 'My workflow' }],
});
});
test('returns error when the destination folder does not exist', async () => {
const mocks = createMocks({ folder: null });
const tool = createTool(mocks);
const result = await tool.handler({ workflowIds: ['wf-1'], folderId: 'missing' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: 'Could not find the folder: missing. Use search_folders to look up a valid folder id.',
});
expect(mocks.workflowService.update).not.toHaveBeenCalled();
});
test('returns error when no workflow could be moved', async () => {
const mocks = createMocks({ workflows: { 'wf-1': null } });
const tool = createTool(mocks);
const result = await tool.handler({ workflowIds: ['wf-1'], folderId: 'folder-1' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
folder: { id: 'folder-1', name: 'Marketing' },
failed: [
{
workflowId: 'wf-1',
error: "Workflow not found or you don't have permission to access it.",
},
],
error: 'None of the workflows could be moved',
});
});
test('rejects workflows that are not available in MCP', async () => {
const mocks = createMocks({
workflows: {
'wf-1': workflowFixture('wf-1', 'Hidden', { settings: { availableInMCP: false } }),
},
});
const tool = createTool(mocks);
const result = await tool.handler({ workflowIds: ['wf-1'], folderId: 'folder-1' });
expect(result.isError).toBe(true);
expect(mocks.workflowService.update).not.toHaveBeenCalled();
expect(result.structuredContent).toMatchObject({
failed: [expect.objectContaining({ workflowId: 'wf-1' })],
});
});
});
@@ -1,6 +1,7 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { FolderRepository, User } from '@n8n/db';
import { User } from '@n8n/db';
import { FolderService } from '@/services/folder.service';
import { ProjectService } from '@/services/project.service.ee';
import { Telemetry } from '@/telemetry';
@@ -10,7 +11,12 @@ describe('search-folders MCP tool', () => {
const user = Object.assign(new User(), { id: 'user-1' });
const createMocks = (overrides?: {
folders?: Array<{ id: string; name: string; parentFolderId: string | null }>;
folders?: Array<{
id: string;
name: string;
parentFolder?: { id: string } | null;
path?: string[];
}>;
count?: number;
projectAccessible?: boolean;
}) => {
@@ -18,7 +24,7 @@ describe('search-folders MCP tool', () => {
const count = overrides?.count ?? folders.length;
const projectAccessible = overrides?.projectAccessible ?? true;
const folderRepository = mockInstance(FolderRepository, {
const folderService = mockInstance(FolderService, {
getManyAndCount: vi.fn().mockResolvedValue([folders, count]),
});
@@ -32,7 +38,7 @@ describe('search-folders MCP tool', () => {
track: vi.fn(),
});
return { folderRepository, projectService, telemetry };
return { folderService, projectService, telemetry };
};
const callHandler = async (
@@ -49,14 +55,9 @@ describe('search-folders MCP tool', () => {
);
test('creates tool correctly', () => {
const { folderRepository, projectService, telemetry } = createMocks();
const { folderService, projectService, telemetry } = createMocks();
const tool = createSearchFoldersTool(
user,
folderRepository as unknown as FolderRepository,
projectService as unknown as ProjectService,
telemetry,
);
const tool = createSearchFoldersTool(user, folderService, projectService, telemetry);
expect(tool.name).toBe('search_folders');
expect(tool.config).toBeDefined();
@@ -67,24 +68,24 @@ describe('search-folders MCP tool', () => {
test('returns folders for a project', async () => {
const folders = [
{ id: 'folder-1', name: 'Production', parentFolderId: null },
{ id: 'folder-2', name: 'Dev', parentFolderId: 'folder-1' },
{ id: 'folder-1', name: 'Production', parentFolder: null, path: ['Production'] },
{
id: 'folder-2',
name: 'Dev',
parentFolder: { id: 'folder-1' },
path: ['Production', 'Dev'],
},
];
const { folderRepository, projectService, telemetry } = createMocks({ folders });
const { folderService, projectService, telemetry } = createMocks({ folders });
const tool = createSearchFoldersTool(
user,
folderRepository as unknown as FolderRepository,
projectService as unknown as ProjectService,
telemetry,
);
const tool = createSearchFoldersTool(user, folderService, projectService, telemetry);
const result = await callHandler(tool, { projectId: 'proj-1' });
expect(result.structuredContent).toEqual({
data: [
{ id: 'folder-1', name: 'Production', parentFolderId: null },
{ id: 'folder-2', name: 'Dev', parentFolderId: 'folder-1' },
{ id: 'folder-1', name: 'Production', parentFolderId: null, path: ['Production'] },
{ id: 'folder-2', name: 'Dev', parentFolderId: 'folder-1', path: ['Production', 'Dev'] },
],
count: 2,
});
@@ -93,41 +94,33 @@ describe('search-folders MCP tool', () => {
'folder:list',
]);
expect(folderRepository.getManyAndCount).toHaveBeenCalledWith({
filter: { projectId: 'proj-1' },
expect(folderService.getManyAndCount).toHaveBeenCalledWith('proj-1', {
filter: {},
select: { name: true, parentFolder: true, path: true, updatedAt: true },
take: 100,
});
});
test('filters by query', async () => {
const { folderRepository, projectService, telemetry } = createMocks();
const { folderService, projectService, telemetry } = createMocks();
const tool = createSearchFoldersTool(
user,
folderRepository as unknown as FolderRepository,
projectService as unknown as ProjectService,
telemetry,
);
const tool = createSearchFoldersTool(user, folderService, projectService, telemetry);
await callHandler(tool, { projectId: 'proj-1', query: 'prod' });
expect(folderRepository.getManyAndCount).toHaveBeenCalledWith({
filter: { projectId: 'proj-1', name: 'prod' },
expect(folderService.getManyAndCount).toHaveBeenCalledWith('proj-1', {
filter: { name: 'prod' },
select: { name: true, parentFolder: true, path: true, updatedAt: true },
take: 100,
});
});
test('returns error when user lacks access to project', async () => {
const { folderRepository, projectService, telemetry } = createMocks({
const { folderService, projectService, telemetry } = createMocks({
projectAccessible: false,
});
const tool = createSearchFoldersTool(
user,
folderRepository as unknown as FolderRepository,
projectService as unknown as ProjectService,
telemetry,
);
const tool = createSearchFoldersTool(user, folderService, projectService, telemetry);
const result = await callHandler(tool, { projectId: 'proj-no-access' });
@@ -137,11 +130,11 @@ describe('search-folders MCP tool', () => {
count: 0,
error: 'Project not found or access denied',
});
expect(folderRepository.getManyAndCount).not.toHaveBeenCalled();
expect(folderService.getManyAndCount).not.toHaveBeenCalled();
});
test('handles errors', async () => {
const folderRepository = mockInstance(FolderRepository, {
const folderService = mockInstance(FolderService, {
getManyAndCount: vi.fn().mockRejectedValue(new Error('DB error')),
});
const projectService = mockInstance(ProjectService, {
@@ -149,12 +142,7 @@ describe('search-folders MCP tool', () => {
});
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
const tool = createSearchFoldersTool(
user,
folderRepository as unknown as FolderRepository,
projectService as unknown as ProjectService,
telemetry,
);
const tool = createSearchFoldersTool(user, folderService, projectService, telemetry);
const result = await callHandler(tool, { projectId: 'proj-1' });
@@ -0,0 +1,182 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
import { FolderService } from '@/services/folder.service';
import { ProjectService } from '@/services/project.service.ee';
import { Telemetry } from '@/telemetry';
import { createUpdateFolderTool } from '../tools/update-folder.tool';
describe('update-folder MCP tool', () => {
const user = Object.assign(new User(), { id: 'user-1' });
const createMocks = (overrides?: {
updatedFolder?: { id: string; name: string; parentFolderId?: string | null };
projectAccessible?: boolean;
}) => {
const updatedFolder = overrides?.updatedFolder ?? {
id: 'folder-1',
name: 'Renamed',
parentFolderId: null,
};
const projectAccessible = overrides?.projectAccessible ?? true;
const folderService = mockInstance(FolderService, {
updateFolder: vi.fn().mockResolvedValue(updatedFolder),
});
const projectService = mockInstance(ProjectService, {
getProjectWithScope: vi
.fn()
.mockResolvedValue(projectAccessible ? { id: 'proj-1', type: 'team' } : null),
});
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
return { folderService, projectService, telemetry };
};
const createTool = (mocks: ReturnType<typeof createMocks>) =>
createUpdateFolderTool(user, mocks.folderService, mocks.projectService, mocks.telemetry);
const callHandler = async (
tool: ReturnType<typeof createUpdateFolderTool>,
args: { projectId: string; folderId: string; name?: string; parentFolderId?: string },
) =>
await tool.handler(
{
projectId: args.projectId,
folderId: args.folderId,
name: args.name as string,
parentFolderId: args.parentFolderId as string,
},
{} as never,
);
test('creates tool correctly', () => {
const tool = createTool(createMocks());
expect(tool.name).toBe('update_folder');
expect(tool.config).toBeDefined();
expect(typeof tool.config.description).toBe('string');
expect(tool.config.inputSchema).toBeDefined();
expect(typeof tool.handler).toBe('function');
});
test('renames a folder', async () => {
const mocks = createMocks();
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
folderId: 'folder-1',
name: 'Renamed',
});
expect(mocks.projectService.getProjectWithScope).toHaveBeenCalledWith(user, 'proj-1', [
'folder:update',
]);
expect(mocks.folderService.updateFolder).toHaveBeenCalledWith('folder-1', 'proj-1', {
name: 'Renamed',
parentFolderId: undefined,
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toEqual({
id: 'folder-1',
name: 'Renamed',
parentFolderId: null,
});
});
test('moves a folder under another folder', async () => {
const mocks = createMocks({
updatedFolder: { id: 'folder-1', name: 'Campaigns', parentFolderId: 'folder-2' },
});
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
folderId: 'folder-1',
parentFolderId: 'folder-2',
});
expect(mocks.folderService.updateFolder).toHaveBeenCalledWith('folder-1', 'proj-1', {
name: undefined,
parentFolderId: 'folder-2',
});
expect(result.structuredContent).toEqual({
id: 'folder-1',
name: 'Campaigns',
parentFolderId: 'folder-2',
});
});
test('returns error when neither name nor parentFolderId is provided', async () => {
const mocks = createMocks();
const tool = createTool(mocks);
const result = await callHandler(tool, { projectId: 'proj-1', folderId: 'folder-1' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: 'Provide at least one of name or parentFolderId',
});
expect(mocks.projectService.getProjectWithScope).not.toHaveBeenCalled();
expect(mocks.folderService.updateFolder).not.toHaveBeenCalled();
});
test('returns error when user lacks access to project', async () => {
const mocks = createMocks({ projectAccessible: false });
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-no-access',
folderId: 'folder-1',
name: 'Renamed',
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({ error: 'Project not found or access denied' });
expect(mocks.folderService.updateFolder).not.toHaveBeenCalled();
});
test('surfaces validation errors from the folder service', async () => {
const mocks = createMocks();
mocks.folderService.updateFolder.mockRejectedValue(
new Error(
"Cannot set a folder's parent to a folder that is a descendant of the current folder",
),
);
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
folderId: 'folder-1',
parentFolderId: 'descendant-folder',
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: "Cannot set a folder's parent to a folder that is a descendant of the current folder",
});
});
test('points unknown folder ids at search_folders', async () => {
const mocks = createMocks();
mocks.folderService.updateFolder.mockRejectedValue(new FolderNotFoundError('missing-folder'));
const tool = createTool(mocks);
const result = await callHandler(tool, {
projectId: 'proj-1',
folderId: 'missing-folder',
name: 'Renamed',
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error:
'Could not find the folder: missing-folder. Use search_folders to look up a valid folder id.',
});
});
});
@@ -1,5 +1,5 @@
import { MCP_AGENT_SCOPES, MCP_INSTANCE_SCOPES } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { LicenseState, ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { INSTANCE_MCP_RESOURCE_ID } from '@n8n/constants';
import type { User } from '@n8n/db';
@@ -8,7 +8,7 @@ import { Service } from '@n8n/di';
import type { ProtectedResource } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { BUILDER_TOOLS, TOOLS_BY_SCOPE } from './mcp-scopes';
import { BUILDER_TOOLS, FOLDER_FEATURE_TOOLS, TOOLS_BY_SCOPE } from './mcp-scopes';
import { areAgentToolsAvailable } from './mcp-tool-availability';
import { McpConfig } from './mcp.config';
import { McpSettingsService } from './mcp.settings.service';
@@ -52,6 +52,7 @@ export class McpProtectedResource implements ProtectedResource {
private readonly mcpConfig: McpConfig,
private readonly globalConfig: GlobalConfig,
private readonly moduleRegistry: ModuleRegistry,
private readonly licenseState: LicenseState,
) {}
get scopes(): string[] {
@@ -66,6 +67,7 @@ export class McpProtectedResource implements ProtectedResource {
getScopeTools(): Record<string, string[]> {
const builderEnabled = this.globalConfig.endpoints.mcpBuilderEnabled;
const tagsDisabled = this.globalConfig.tags.disabled;
const foldersLicensed = this.licenseState.isFoldersLicensed();
const supportedScopes = new Set(this.scopes);
return Object.fromEntries(
@@ -76,7 +78,8 @@ export class McpProtectedResource implements ProtectedResource {
tools.filter(
(tool) =>
(builderEnabled || !BUILDER_TOOLS.has(tool)) &&
(!tagsDisabled || tool !== 'list_workflow_tags'),
(!tagsDisabled || tool !== 'list_workflow_tags') &&
(foldersLicensed || !FOLDER_FEATURE_TOOLS.has(tool)),
),
]),
);
@@ -32,6 +32,7 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
'restore_workflow_version',
'publish_workflow',
'unpublish_workflow',
'move_workflows_to_folder',
// Builder support tools, so a write-only grant can still build
'search_nodes',
'get_node_types',
@@ -85,9 +86,25 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
'add_data_table_rows',
],
'project:read': ['search_projects', 'search_folders'],
// Creating or moving folders requires finding projects and folders first,
// so the search tools ride along on a write-only grant.
'project:write': ['create_folder', 'update_folder', 'search_projects', 'search_folders'],
'tag:read': ['list_workflow_tags'],
};
/**
* Tools that operate on folders and therefore require the `feat:folders`
* license, matching the gate on the REST and public API folder endpoints.
* Only registered (and advertised on the consent screen) when the instance
* is licensed for folders.
*/
export const FOLDER_FEATURE_TOOLS: ReadonlySet<string> = new Set([
'search_folders',
'create_folder',
'update_folder',
'move_workflows_to_folder',
]);
/**
* Tools only registered when the workflow builder is enabled
* (`N8N_MCP_BUILDER_ENABLED`). Keep in sync with `registerBuilderTools` in
@@ -107,6 +124,7 @@ export const BUILDER_TOOLS: ReadonlySet<string> = new Set([
'explore_node_resources',
'search_projects',
'search_folders',
...FOLDER_FEATURE_TOOLS,
]);
export const AGENT_TOOLS: ReadonlySet<string> = new Set([
+45 -15
View File
@@ -7,13 +7,7 @@ import {
} from '@n8n/api-types';
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config';
import {
ExecutionRepository,
FolderRepository,
ProjectRepository,
SharedWorkflowRepository,
User,
} from '@n8n/db';
import { ExecutionRepository, ProjectRepository, SharedWorkflowRepository, User } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import {
mcpAppToolMeta,
@@ -38,6 +32,8 @@ import { NodeCatalogService } from '@/node-catalog';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { FolderFinderService } from '@/services/folder-finder.service';
import { FolderService } from '@/services/folder.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@@ -62,6 +58,7 @@ import type {
ToolDefinition,
} from './mcp.types';
import { shapeToStandardSchema } from './tool-schema.util';
import { createCreateFolderTool } from './tools/create-folder.tool';
import {
createAddDataTableColumnTool,
createAddDataTableRowsTool,
@@ -81,6 +78,7 @@ import { createGetWorkflowVersionsDiffTool } from './tools/get-workflow-versions
import { createListCredentialsTool } from './tools/list-credentials.tool';
import { createListN8nConnectServicesTool } from './tools/list-n8n-connect-services.tool';
import { createListTagsTool } from './tools/list-tags.tool';
import { createMoveWorkflowsToFolderTool } from './tools/move-workflows-to-folder.tool';
import { createPrepareTestPinDataTool } from './tools/prepare-workflow-pin-data.tool';
import { createPublishWorkflowTool } from './tools/publish-workflow.tool';
import { createSearchExecutionsTool } from './tools/search-executions.tool';
@@ -89,6 +87,7 @@ import { createSearchProjectsTool } from './tools/search-projects.tool';
import { createSearchWorkflowsTool } from './tools/search-workflows.tool';
import { createTestWorkflowTool } from './tools/test-workflow.tool';
import { createUnpublishWorkflowTool } from './tools/unpublish-workflow.tool';
import { createUpdateFolderTool } from './tools/update-folder.tool';
import { MCP_CREATE_WORKFLOW_FROM_CODE_TOOL } from './tools/workflow-builder/constants';
import { createCreateWorkflowFromCodeTool } from './tools/workflow-builder/create-workflow-from-code.tool';
import { createArchiveWorkflowTool } from './tools/workflow-builder/delete-workflow.tool';
@@ -204,7 +203,7 @@ export class McpService {
private readonly workflowCreationService: WorkflowCreationService,
private readonly nodeTypes: NodeTypes,
private readonly projectRepository: ProjectRepository,
private readonly folderRepository: FolderRepository,
private readonly folderFinderService: FolderFinderService,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly executionRepository: ExecutionRepository,
private readonly executionService: ExecutionService,
@@ -221,6 +220,7 @@ export class McpService {
private readonly aiGatewayService: AiGatewayService,
private readonly moduleRegistry: ModuleRegistry,
private readonly eventService: EventService,
private readonly folderService: FolderService,
) {}
/**
@@ -721,13 +721,43 @@ export class McpService {
);
registerIfAllowed(searchProjectsTool);
const searchFoldersTool = createSearchFoldersTool(
user,
this.folderRepository,
this.projectService,
this.telemetry,
);
registerIfAllowed(searchFoldersTool);
// Folder tools require the folders feature; unlicensed instances never
// see them (the consent screen filters them the same way), matching the
// license gate on the REST and public API folder endpoints.
if (this.licenseState.isFoldersLicensed()) {
const searchFoldersTool = createSearchFoldersTool(
user,
this.folderService,
this.projectService,
this.telemetry,
);
registerIfAllowed(searchFoldersTool);
const createFolderTool = createCreateFolderTool(
user,
this.folderService,
this.projectService,
this.telemetry,
);
registerIfAllowed(createFolderTool);
const updateFolderTool = createUpdateFolderTool(
user,
this.folderService,
this.projectService,
this.telemetry,
);
registerIfAllowed(updateFolderTool);
const moveWorkflowsToFolderTool = createMoveWorkflowsToFolderTool(
user,
this.workflowFinderService,
this.workflowService,
this.folderFinderService,
this.telemetry,
);
registerIfAllowed(moveWorkflowsToFolderTool);
}
const archiveTool = createArchiveWorkflowTool(
user,
@@ -0,0 +1,91 @@
import { folderIdSchema, folderNameSchema } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { PROJECT_ROOT } from 'n8n-workflow';
import z from 'zod';
import type { FolderService } from '@/services/folder.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
import { createFailHandler, describeFolderError } from './folder-error.utils';
import { folderOutputSchema } from './schemas';
const inputSchema = {
projectId: z
.string()
.describe(
'The ID of the project to create the folder in. Use search_projects to resolve a project name to an ID.',
),
name: folderNameSchema.describe('The name of the folder to create'),
parentFolderId: folderIdSchema
.optional()
.describe(
`Optional parent folder ID to nest the new folder under. Must belong to the same project — use search_folders to find it. Omit it or pass "${PROJECT_ROOT}" to create the folder at the project root.`,
),
} satisfies z.ZodRawShape;
export const createCreateFolderTool = (
user: User,
folderService: FolderService,
projectService: ProjectService,
telemetry: Telemetry,
): ToolDefinition<typeof inputSchema> => ({
name: 'create_folder',
config: {
description:
'Create a folder in a project, optionally nested under an existing folder. Requires a projectId — use search_projects first if needed. If the user named a parent folder, resolve it with search_folders; when multiple folders match the name, ask the user which one they meant before creating. After creation, confirm the folder to the user by name, not ID.',
inputSchema,
outputSchema: folderOutputSchema,
annotations: {
title: 'Create Folder',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
handler: async ({
projectId,
name,
parentFolderId,
}: {
projectId: string;
name: string;
parentFolderId?: string;
}) => {
const parent = parentFolderId === PROJECT_ROOT ? undefined : parentFolderId;
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: 'create_folder',
parameters: { projectId, hasParentFolderId: !!parent },
};
const fail = createFailHandler(telemetry, telemetryPayload);
try {
const project = await projectService.getProjectWithScope(user, projectId, ['folder:create']);
if (!project) {
return fail('Project not found or access denied');
}
const folder = await folderService.createFolder({ name, parentFolderId: parent }, projectId);
telemetryPayload.results = { success: true, data: { folderId: folder.id } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
const output = {
id: folder.id,
name: folder.name,
parentFolderId: folder.parentFolder?.id ?? null,
};
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output,
};
} catch (error) {
return fail(describeFolderError(error));
}
},
});
@@ -0,0 +1,34 @@
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
import type { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { UserCalledMCPToolEventPayload } from '../mcp.types';
/**
* Formats a folder-operation failure for MCP output, pointing unknown folder
* ids at search_folders so agents can recover without guessing.
*/
export function describeFolderError(error: unknown): string {
if (error instanceof FolderNotFoundError) {
return `${error.message}. Use search_folders to look up a valid folder id.`;
}
return ensureError(error).message;
}
/**
* Builds the shared failure path for the folder tools: tracks the failed call
* and returns the MCP error envelope.
*/
export const createFailHandler =
(telemetry: Telemetry, telemetryPayload: UserCalledMCPToolEventPayload) => (error: string) => {
telemetryPayload.results = { success: false, error };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
const output = { error };
return {
content: [{ type: 'text' as const, text: JSON.stringify(output) }],
structuredContent: output,
isError: true,
};
};
@@ -0,0 +1,150 @@
import { folderIdSchema } from '@n8n/api-types';
import type { Folder, User } from '@n8n/db';
import { PROJECT_ROOT } from 'n8n-workflow';
import z from 'zod';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
import type { FolderFinderService } from '@/services/folder-finder.service';
import type { Telemetry } from '@/telemetry';
import { createWorkflowEntityFromPayload } from '@/workflows/workflow-entity-mapper';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowService } from '@/workflows/workflow.service';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
import { createFailHandler, describeFolderError } from './folder-error.utils';
import { validateMcpWorkflow } from './workflow-validation.utils';
const MAX_WORKFLOWS = 20;
const inputSchema = {
workflowIds: z
.array(z.string())
.min(1)
.max(MAX_WORKFLOWS)
.describe(`The IDs of the workflows to move (up to ${MAX_WORKFLOWS} at a time)`),
folderId: folderIdSchema.describe(
`The ID of the destination folder. It must belong to the project that owns the workflows — use search_folders to find it by name. Pass "${PROJECT_ROOT}" to move the workflows to the project root.`,
),
} satisfies z.ZodRawShape;
const outputSchema = {
folder: z
.object({
id: z.string().describe('The ID of the destination folder'),
name: z.string().describe('The name of the destination folder'),
})
.optional()
.describe('The destination folder. Omitted when moving to the project root.'),
moved: z
.array(
z.object({
workflowId: z.string().describe('The ID of the moved workflow'),
name: z.string().describe('The name of the moved workflow'),
}),
)
.optional()
.describe('Workflows that were moved successfully'),
failed: z
.array(
z.object({
workflowId: z.string().describe('The ID of the workflow that could not be moved'),
error: z.string().describe('Why the workflow could not be moved'),
}),
)
.optional()
.describe('Workflows that could not be moved, with the reason for each'),
error: z
.string()
.optional()
.describe('Error message explaining why the move failed. Present only on failure.'),
} satisfies z.ZodRawShape;
export const createMoveWorkflowsToFolderTool = (
user: User,
workflowFinderService: WorkflowFinderService,
workflowService: WorkflowService,
folderFinderService: FolderFinderService,
telemetry: Telemetry,
): ToolDefinition<typeof inputSchema> => ({
name: 'move_workflows_to_folder',
config: {
description:
'Move one or more existing workflows into a folder (or to the project root). The destination folder must be in the same project as the workflows. Resolve the folder by name with search_folders first; when multiple folders match the name, ask the user which one they meant before moving. After moving, confirm the destination to the user by folder name, not ID. Moves may partially succeed — report any entries in `failed` to the user.',
inputSchema,
outputSchema,
annotations: {
title: 'Move Workflows to Folder',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
handler: async ({ workflowIds, folderId }: { workflowIds: string[]; folderId: string }) => {
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: 'move_workflows_to_folder',
parameters: { workflowCount: workflowIds.length, folderId },
};
const fail = createFailHandler(telemetry, telemetryPayload);
try {
let folder: Folder | undefined;
if (folderId !== PROJECT_ROOT) {
[folder] = await folderFinderService.findFoldersByIdsForUser([folderId], user, [
'folder:read',
]);
if (!folder) {
return fail(describeFolderError(new FolderNotFoundError(folderId)));
}
}
const accessibleWorkflows = await workflowFinderService.findWorkflowsByIdsForUser(
workflowIds,
user,
['workflow:update'],
);
const workflowsById = new Map(accessibleWorkflows.map((workflow) => [workflow.id, workflow]));
const moved: Array<{ workflowId: string; name: string }> = [];
const failed: Array<{ workflowId: string; error: string }> = [];
for (const workflowId of workflowIds) {
try {
const workflow = validateMcpWorkflow(workflowsById.get(workflowId));
// Folder-only update, mirroring the REST PATCH path: an empty entity
// payload so only the folder placement (validated against the
// workflow's owning project) changes.
await workflowService.update(user, createWorkflowEntityFromPayload({}), workflowId, {
parentFolderId: folderId,
source: 'n8n-mcp',
});
moved.push({ workflowId, name: workflow.name });
} catch (error) {
failed.push({ workflowId, error: describeFolderError(error) });
}
}
const anyMoved = moved.length > 0;
telemetryPayload.results = {
success: anyMoved,
data: { movedCount: moved.length, failedCount: failed.length },
};
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
const output = {
...(folder ? { folder: { id: folder.id, name: folder.name } } : {}),
...(anyMoved ? { moved } : { error: 'None of the workflows could be moved' }),
...(failed.length > 0 ? { failed } : {}),
};
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output,
...(anyMoved ? {} : { isError: true }),
};
} catch (error) {
return fail(describeFolderError(error));
}
},
});
@@ -109,6 +109,20 @@ export const columnNameSchema = z
'Column name. Must start with a letter, contain only letters, numbers, and underscores (max 63 chars)',
);
export const folderOutputSchema = {
id: z.string().optional().describe('The ID of the folder'),
name: z.string().optional().describe('The name of the folder'),
parentFolderId: z
.string()
.nullable()
.optional()
.describe('The ID of the parent folder, or null if the folder is at the project root'),
error: z
.string()
.optional()
.describe('Error message explaining why the operation failed. Present only on failure.'),
} satisfies z.ZodRawShape;
export const dataTableRowSchema = z.record(
z.string(),
z.union([z.string(), z.number(), z.boolean(), z.null()]),
@@ -1,6 +1,7 @@
import type { FolderRepository, User } from '@n8n/db';
import type { User } from '@n8n/db';
import z from 'zod';
import type { FolderService } from '@/services/folder.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { Telemetry } from '@/telemetry';
@@ -26,22 +27,31 @@ const outputSchema = {
.string()
.nullable()
.describe('The ID of the parent folder, or null if at project root'),
path: z
.array(z.string())
.describe(
"The folder's full name path from the project root, ending with the folder's own name. Use it to tell same-named folders apart and to present folders to the user by name.",
),
}),
)
.describe('List of folders matching the query'),
count: z.number().int().min(0).describe('Total number of matching folders'),
error: z
.string()
.optional()
.describe('Error message explaining why the search failed. Present only on failure.'),
} satisfies z.ZodRawShape;
export const createSearchFoldersTool = (
user: User,
folderRepository: FolderRepository,
folderService: FolderService,
projectService: ProjectService,
telemetry: Telemetry,
): ToolDefinition<typeof inputSchema> => ({
name: 'search_folders',
config: {
description:
'Search for folders within a project. Use this to find a folder ID before creating a workflow in a specific folder. Requires a projectId — use search_projects first if needed.',
"Search for folders within a project. Use this to resolve a folder name to an ID before creating a workflow in a folder, creating or updating a folder, or moving workflows into a folder. Each result includes the folder's full name path — when multiple folders match a name, use the paths to ask the user which one they meant. Requires a projectId — use search_projects first if needed.",
inputSchema,
outputSchema,
annotations: {
@@ -82,18 +92,19 @@ export const createSearchFoldersTool = (
const safeLimit = Math.min(Math.max(1, limit), MAX_RESULTS);
const [folders, count] = await folderRepository.getManyAndCount({
filter: {
projectId,
...(query ? { name: query } : {}),
},
const [folders, count] = await folderService.getManyAndCount(projectId, {
filter: query ? { name: query } : {},
// updatedAt backs the repository's default ORDER BY; with `take`,
// TypeORM's distinct wrapper can only sort on selected columns.
select: { name: true, parentFolder: true, path: true, updatedAt: true },
take: safeLimit,
});
const data = folders.map((folder) => ({
id: folder.id,
name: folder.name,
parentFolderId: folder.parentFolderId ?? null,
parentFolderId: folder.parentFolder?.id ?? null,
path: folder.path ?? [folder.name],
}));
telemetryPayload.results = {
@@ -0,0 +1,102 @@
import { folderIdSchema, folderNameSchema } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { PROJECT_ROOT } from 'n8n-workflow';
import z from 'zod';
import type { FolderService } from '@/services/folder.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
import { createFailHandler, describeFolderError } from './folder-error.utils';
import { folderOutputSchema } from './schemas';
const inputSchema = {
projectId: z
.string()
.describe(
'The ID of the project the folder belongs to. Use search_projects to resolve a project name to an ID.',
),
folderId: folderIdSchema.describe(
'The ID of the folder to update. Use search_folders to find it by name.',
),
name: folderNameSchema.optional().describe('New name for the folder (rename)'),
parentFolderId: folderIdSchema
.optional()
.describe(
`New parent folder ID to move the folder under. Must belong to the same project and must not be a descendant of the folder being moved. Pass "${PROJECT_ROOT}" to move the folder to the project root. Omit to leave the folder where it is.`,
),
} satisfies z.ZodRawShape;
export const createUpdateFolderTool = (
user: User,
folderService: FolderService,
projectService: ProjectService,
telemetry: Telemetry,
): ToolDefinition<typeof inputSchema> => ({
name: 'update_folder',
config: {
description:
'Rename a folder and/or move it under another folder in the same project. Resolve folders by name with search_folders first; when multiple folders match a name, ask the user which one they meant before updating. After the update, confirm the result to the user using folder names, not IDs.',
inputSchema,
outputSchema: folderOutputSchema,
annotations: {
title: 'Update Folder',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
handler: async ({
projectId,
folderId,
name,
parentFolderId,
}: {
projectId: string;
folderId: string;
name?: string;
parentFolderId?: string;
}) => {
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: 'update_folder',
parameters: { projectId, folderId, hasName: !!name, hasParentFolderId: !!parentFolderId },
};
const fail = createFailHandler(telemetry, telemetryPayload);
if (!name && !parentFolderId) {
return fail('Provide at least one of name or parentFolderId');
}
try {
const project = await projectService.getProjectWithScope(user, projectId, ['folder:update']);
if (!project) {
return fail('Project not found or access denied');
}
const folder = await folderService.updateFolder(folderId, projectId, {
name,
parentFolderId,
});
telemetryPayload.results = { success: true, data: { folderId } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
const output = {
id: folder.id,
name: folder.name,
parentFolderId: folder.parentFolderId ?? null,
};
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output,
};
} catch (error) {
return fail(describeFolderError(error));
}
},
});
@@ -1,4 +1,10 @@
import { type Project, type ProjectRepository, type User, WorkflowEntity } from '@n8n/db';
import {
type Folder,
type Project,
type ProjectRepository,
type User,
WorkflowEntity,
} from '@n8n/db';
import z from 'zod';
import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check';
@@ -96,7 +102,7 @@ const inputSchema = {
.string()
.optional()
.describe(
'Optional folder ID to create the workflow in. Requires projectId to be set. Use search_folders to find a folder by name within a project.',
'Optional folder ID to create the workflow in. Requires projectId to be set. Use search_folders to find a folder by name within a project; when multiple folders match the name, ask the user which one they meant before creating.',
),
} satisfies z.ZodRawShape;
@@ -138,6 +144,15 @@ const outputSchema = {
})
.optional()
.describe('The project the workflow was actually created in.'),
targetFolder: z
.object({
id: z.string().describe('The ID of the folder the workflow was created in'),
name: z.string().describe('The name of the folder the workflow was created in'),
})
.optional()
.describe(
'The folder the workflow was created in. Absent when the workflow was created at the project root.',
),
note: z
.string()
.optional()
@@ -200,7 +215,7 @@ export const createCreateWorkflowFromCodeTool = (
): ToolDefinition<typeof inputSchema> => ({
name: MCP_CREATE_WORKFLOW_FROM_CODE_TOOL.toolName,
config: {
description: `Create a workflow in n8n from validated SDK code. This tool expects code that already follows the n8n Workflow SDK patterns and has passed ${CODE_BUILDER_VALIDATE_TOOL.toolName}. If code fails to parse, call get_workflow_sdk_reference, rewrite the code using the reference, validate again, then retry creation. If the user named a target project, resolve it via search_projects before calling this tool; when projectId is omitted, the workflow is created in the user's personal project. If you used n8n skills while preparing this workflow, pass their identifiers in skillsUsed. After creation, always tell the user which project the workflow landed in (see the targetProject field in the response).`,
description: `Create a workflow in n8n from validated SDK code. This tool expects code that already follows the n8n Workflow SDK patterns and has passed ${CODE_BUILDER_VALIDATE_TOOL.toolName}. If code fails to parse, call get_workflow_sdk_reference, rewrite the code using the reference, validate again, then retry creation. If the user named a target project, resolve it via search_projects before calling this tool; when projectId is omitted, the workflow is created in the user's personal project. If the user named a target folder, resolve it via search_folders. If you used n8n skills while preparing this workflow, pass their identifiers in skillsUsed. After creation, always tell the user which project — and folder, if any — the workflow landed in (see the targetProject and targetFolder fields in the response).`,
inputSchema,
outputSchema,
annotations: {
@@ -258,6 +273,7 @@ export const createCreateWorkflowFromCodeTool = (
let newWorkflow: WorkflowEntity | undefined;
let landingProject: Project | null = null;
let landingFolder: Folder | null = null;
try {
const { ParseValidateHandler, stripImportStatements } = await import(
@@ -371,6 +387,9 @@ export const createCreateWorkflowFromCodeTool = (
versionName: versionMetadata.name,
versionDescription: versionMetadata.description,
});
// The saved workflow carries the project-validated parent folder, echoed
// back as targetFolder.
landingFolder = savedWorkflow.parentFolder ?? null;
const nodeTypesByName = new Map(savedWorkflow.nodes.map((n) => [n.name, n.type]));
trackAutoassignOutcomes(
@@ -419,6 +438,9 @@ export const createCreateWorkflowFromCodeTool = (
name: landingProject.name,
type: landingProject.type,
},
targetFolder: landingFolder
? { id: landingFolder.id, name: landingFolder.name }
: undefined,
note: notes.length ? notes.join(' ') : undefined,
skippedGroups: skippedGroups.length > 0 ? skippedGroups : undefined,
};
@@ -440,9 +462,15 @@ export const createCreateWorkflowFromCodeTool = (
let persisted: Awaited<ReturnType<WorkflowFinderService['findWorkflowForUser']>> | null =
null;
try {
persisted = await workflowFinderService.findWorkflowForUser(newWorkflow.id, user, [
'workflow:read',
]);
persisted = await workflowFinderService.findWorkflowForUser(
newWorkflow.id,
user,
['workflow:read'],
// landingFolder is only assigned after createWorkflow returns, so a
// post-save failure inside it leaves the variable unset — load the
// relation here so targetFolder still reflects where the row landed.
{ includeParentFolder: true },
);
} catch {
// Verification lookup failed — fall through and report the original error.
}
@@ -472,6 +500,9 @@ export const createCreateWorkflowFromCodeTool = (
name: landingProject.name,
type: landingProject.type,
},
targetFolder: persisted.parentFolder
? { id: persisted.parentFolder.id, name: persisted.parentFolder.name }
: undefined,
note: `Workflow was created successfully, but a post-save operation failed: ${errorMessage}`,
};
@@ -44,27 +44,14 @@ export type GetMcpWorkflowOptions = {
};
/**
* Validates and retrieves a workflow for MCP operations.
* Performs permission, archive, and MCP availability checks.
* Validates an already-fetched workflow for MCP operations: permission
* (present at all), archive, and MCP availability checks.
*
* @throws WorkflowAccessError with appropriate reason if validation fails
*/
export async function getMcpWorkflow(
workflowId: string,
user: User,
scopes: Scope[],
workflowFinderService: WorkflowFinderService,
options?: GetMcpWorkflowOptions,
): Promise<FoundWorkflow> {
// Forwarded whole: hand-copying each flag is what let `includeParentFolder`
// go missing here while the finder already supported it.
const workflow = await workflowFinderService.findWorkflowForUser(
workflowId,
user,
scopes,
options ?? {},
);
export function validateMcpWorkflow<
T extends Pick<FoundWorkflow, 'id' | 'isArchived' | 'settings'>,
>(workflow: T | null | undefined): T {
if (!workflow) {
throw new WorkflowAccessError(
"Workflow not found or you don't have permission to access it.",
@@ -74,7 +61,7 @@ export async function getMcpWorkflow(
if (workflow.isArchived) {
throw new WorkflowAccessError(
`Workflow '${workflowId}' is archived and cannot be accessed.`,
`Workflow '${workflow.id}' is archived and cannot be accessed.`,
'workflow_archived',
);
}
@@ -88,3 +75,26 @@ export async function getMcpWorkflow(
return workflow;
}
/**
* Validates and retrieves a workflow for MCP operations.
* Performs permission, archive, and MCP availability checks.
*
* @throws WorkflowAccessError with appropriate reason if validation fails
*/
export async function getMcpWorkflow(
workflowId: string,
user: User,
scopes: Scope[],
workflowFinderService: WorkflowFinderService,
options?: GetMcpWorkflowOptions,
): Promise<FoundWorkflow> {
const workflow = await workflowFinderService.findWorkflowForUser(
workflowId,
user,
scopes,
options ?? {},
);
return validateMcpWorkflow(workflow);
}
@@ -2,7 +2,7 @@ import {
InvalidGrantError,
InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { Logger, type ModuleRegistry } from '@n8n/backend-common';
import { Logger, type LicenseState, type ModuleRegistry } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
@@ -1500,6 +1500,7 @@ describe('OAuthServerService', () => {
mcpConfig,
mock<GlobalConfig>(),
mock<ModuleRegistry>(),
mock<LicenseState>(),
);
expect(mcpResource.getResourceUrl()).toBe('https://n8n-mcp.example.com/mcp-server/http');
@@ -1,5 +1,5 @@
import type { Mocked } from 'vitest';
import { Logger, type ModuleRegistry } from '@n8n/backend-common';
import { Logger, type LicenseState, type ModuleRegistry } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import type { OperationContext, TransactionRunner, User } from '@n8n/db';
@@ -777,6 +777,7 @@ describe('OAuthTokenService', () => {
mcpConfig,
mock<GlobalConfig>(),
mock<ModuleRegistry>(),
mock<LicenseState>(),
);
const configuredRegistry = new ProtectedResourceRegistry(mock<Logger>());
+4 -1
View File
@@ -315,7 +315,10 @@ export class FolderService {
};
}
async getManyAndCount(projectId: string, options: ListQuery.Options) {
async getManyAndCount(
projectId: string,
options: ListQuery.Options,
): Promise<[FolderWithWorkflowAndSubFolderCountAndPath[], number]> {
options.filter = { ...options.filter, projectId, isArchived: false };
// eslint-disable-next-line prefer-const
let [folders, count] = await this.folderRepository.getManyAndCount(options);
@@ -3363,6 +3363,7 @@
"settings.mcp.oAuthClients.scope.dataTable.read": "List data tables",
"settings.mcp.oAuthClients.scope.dataTable.write": "Create and update data tables",
"settings.mcp.oAuthClients.scope.project.read": "Search projects and folders",
"settings.mcp.oAuthClients.scope.project.write": "Create and update folders",
"settings.mcp.oAuthClients.scope.tag.read": "List tags",
"settings.mcp.oAuthClients.scope.more": "+{count}",
"settings.mcp.oAuthClients.access.full": "Full access",