diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index f7992eea70f..74a3e20bdd1 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/mcp.schema.ts b/packages/@n8n/api-types/src/schemas/mcp.schema.ts index 8f6fad7af6c..51adb5d6b07 100644 --- a/packages/@n8n/api-types/src/schemas/mcp.schema.ts +++ b/packages/@n8n/api-types/src/schemas/mcp.schema.ts @@ -34,6 +34,7 @@ export const MCP_INSTANCE_SCOPES = [ 'dataTable:read', 'dataTable:write', 'project:read', + 'project:write', 'tag:read', ] as const; diff --git a/packages/cli/src/modules/mcp/__tests__/create-folder.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/create-folder.tool.test.ts new file mode 100644 index 00000000000..f00785fdbcf --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/create-folder.tool.test.ts @@ -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) => + createCreateFolderTool(user, mocks.folderService, mocks.projectService, mocks.telemetry); + + const callHandler = async ( + tool: ReturnType, + 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.', + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts index 26d625b60c8..868bc9f4d56 100644 --- a/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts @@ -169,7 +169,6 @@ describe('create-workflow-from-code MCP tool', () => { const workflowFinderService = mockInstance(WorkflowFinderService, { findWorkflowForUser: vi.fn().mockResolvedValue(null), }); - const aiGatewayService = mock(); 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."), diff --git a/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts index b126d3a8995..54183b8df7b 100644 --- a/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts @@ -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(); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts index a51d25e295f..e73ae05736b 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts @@ -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(); const mcpConfig = mock(); const moduleRegistry = mock(); + const licenseState = mock(); 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); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts index ab376303283..f71c06b11f0 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts @@ -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()); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts index 951826c7470..c5ac7d56982 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts @@ -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), ); }; diff --git a/packages/cli/src/modules/mcp/__tests__/move-workflows-to-folder.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/move-workflows-to-folder.tool.test.ts new file mode 100644 index 00000000000..ab373c4d4ed --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/move-workflows-to-folder.tool.test.ts @@ -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 = {}) => ({ + 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 | 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) => + 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' })], + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/search-folders.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-folders.tool.test.ts index 9e5433903b9..09bae312e33 100644 --- a/packages/cli/src/modules/mcp/__tests__/search-folders.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/search-folders.tool.test.ts @@ -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' }); diff --git a/packages/cli/src/modules/mcp/__tests__/update-folder.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/update-folder.tool.test.ts new file mode 100644 index 00000000000..74be5dce3e0 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/update-folder.tool.test.ts @@ -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) => + createUpdateFolderTool(user, mocks.folderService, mocks.projectService, mocks.telemetry); + + const callHandler = async ( + tool: ReturnType, + 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.', + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/mcp-protected-resource.ts b/packages/cli/src/modules/mcp/mcp-protected-resource.ts index 4e74a530eec..21efa016249 100644 --- a/packages/cli/src/modules/mcp/mcp-protected-resource.ts +++ b/packages/cli/src/modules/mcp/mcp-protected-resource.ts @@ -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 { 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)), ), ]), ); diff --git a/packages/cli/src/modules/mcp/mcp-scopes.ts b/packages/cli/src/modules/mcp/mcp-scopes.ts index 47fe6a5adb9..838bc33150d 100644 --- a/packages/cli/src/modules/mcp/mcp-scopes.ts +++ b/packages/cli/src/modules/mcp/mcp-scopes.ts @@ -32,6 +32,7 @@ export const TOOLS_BY_SCOPE: Record = { '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 = { '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 = 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 = new Set([ 'explore_node_resources', 'search_projects', 'search_folders', + ...FOLDER_FEATURE_TOOLS, ]); export const AGENT_TOOLS: ReadonlySet = new Set([ diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index e80c6fa2a45..500c18e24f2 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -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, diff --git a/packages/cli/src/modules/mcp/tools/create-folder.tool.ts b/packages/cli/src/modules/mcp/tools/create-folder.tool.ts new file mode 100644 index 00000000000..ba84d2bd77b --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/create-folder.tool.ts @@ -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 => ({ + 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)); + } + }, +}); diff --git a/packages/cli/src/modules/mcp/tools/folder-error.utils.ts b/packages/cli/src/modules/mcp/tools/folder-error.utils.ts new file mode 100644 index 00000000000..e6054900811 --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/folder-error.utils.ts @@ -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, + }; + }; diff --git a/packages/cli/src/modules/mcp/tools/move-workflows-to-folder.tool.ts b/packages/cli/src/modules/mcp/tools/move-workflows-to-folder.tool.ts new file mode 100644 index 00000000000..ab91a5d270a --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/move-workflows-to-folder.tool.ts @@ -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 => ({ + 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)); + } + }, +}); diff --git a/packages/cli/src/modules/mcp/tools/schemas.ts b/packages/cli/src/modules/mcp/tools/schemas.ts index 3e538d84816..d8dedce8b82 100644 --- a/packages/cli/src/modules/mcp/tools/schemas.ts +++ b/packages/cli/src/modules/mcp/tools/schemas.ts @@ -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()]), diff --git a/packages/cli/src/modules/mcp/tools/search-folders.tool.ts b/packages/cli/src/modules/mcp/tools/search-folders.tool.ts index f7401f1d557..f8f570a3432 100644 --- a/packages/cli/src/modules/mcp/tools/search-folders.tool.ts +++ b/packages/cli/src/modules/mcp/tools/search-folders.tool.ts @@ -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 => ({ 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 = { diff --git a/packages/cli/src/modules/mcp/tools/update-folder.tool.ts b/packages/cli/src/modules/mcp/tools/update-folder.tool.ts new file mode 100644 index 00000000000..683d3c5618b --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/update-folder.tool.ts @@ -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 => ({ + 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)); + } + }, +}); diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts index 6d1ce1a6d07..5d2cb335938 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts @@ -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 => ({ 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> | 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}`, }; diff --git a/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts b/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts index b10ff54fcd3..908fdb3f054 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts @@ -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 { - // 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, +>(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 { + const workflow = await workflowFinderService.findWorkflowForUser( + workflowId, + user, + scopes, + options ?? {}, + ); + + return validateMcpWorkflow(workflow); +} diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index 6206b1beb88..d9f4b4008a8 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -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(), mock(), + mock(), ); expect(mcpResource.getResourceUrl()).toBe('https://n8n-mcp.example.com/mcp-server/http'); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts index 93891d21b0d..7ea531c77f3 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts @@ -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(), mock(), + mock(), ); const configuredRegistry = new ProtectedResourceRegistry(mock()); diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index 14966450810..7bb69b2ae04 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -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); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e8d35072126..a2201fae663 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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",