From 1640a69ea66baa68cfcdb04be71818560a78a48d Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 11 Jun 2026 09:25:52 -0400 Subject: [PATCH] feat(core): Add tags support to MCP server (#31446) --- .../mcp/__tests__/list-tags.tool.test.ts | 159 +++++++++++++ .../modules/mcp/__tests__/mcp.service.test.ts | 7 + .../__tests__/search-workflows.tool.test.ts | 58 +++++ .../__tests__/update-workflow.tool.test.ts | 220 ++++++++++++++++- .../mcp/__tests__/workflow-operations.test.ts | 76 ++++++ packages/cli/src/modules/mcp/mcp.service.ts | 10 + packages/cli/src/modules/mcp/mcp.types.ts | 2 + .../mcp/tools/get-workflow-details.tool.ts | 4 +- .../src/modules/mcp/tools/list-tags.tool.ts | 129 ++++++++++ packages/cli/src/modules/mcp/tools/schemas.ts | 3 + .../mcp/tools/search-workflows.tool.ts | 41 +++- .../workflow-builder/update-workflow.tool.ts | 64 ++++- .../workflow-builder/workflow-operations.ts | 63 ++++- .../mcp/tools/workflow-validation.utils.ts | 2 + .../services/__tests__/tag.service.test.ts | 223 ++++++++++++++++++ packages/cli/src/services/tag.service.ts | 114 ++++++++- .../playwright/services/mcp-api-helper.ts | 1 + 17 files changed, 1145 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/modules/mcp/__tests__/list-tags.tool.test.ts create mode 100644 packages/cli/src/modules/mcp/tools/list-tags.tool.ts create mode 100644 packages/cli/src/services/__tests__/tag.service.test.ts diff --git a/packages/cli/src/modules/mcp/__tests__/list-tags.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/list-tags.tool.test.ts new file mode 100644 index 00000000000..4c71188f83e --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/list-tags.tool.test.ts @@ -0,0 +1,159 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import type { ITagWithCountDb } from '@n8n/db'; +import { User } from '@n8n/db'; + +import { TagService } from '@/services/tag.service'; +import { Telemetry } from '@/telemetry'; + +import { createListTagsTool, listTags } from '../tools/list-tags.tool'; + +const buildTag = (overrides: Partial = {}): ITagWithCountDb => + ({ + id: 'tag-1', + name: 'production', + usageCount: 3, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + ...overrides, + }) as ITagWithCountDb; + +const userWithScopes = (scopeSlugs: string[]) => + Object.assign(new User(), { + id: 'user-1', + role: { slug: 'global:test', scopes: scopeSlugs.map((slug) => ({ slug })) }, + }); + +describe('list-tags MCP tool', () => { + const user = userWithScopes(['tag:list']); + + const createMocks = ( + tagsOrError: ITagWithCountDb[] | Error = [], + opts: { totalCount?: number } = {}, + ) => { + const listWithUsageCount = + tagsOrError instanceof Error + ? jest.fn().mockRejectedValue(tagsOrError) + : jest.fn().mockResolvedValue({ + data: tagsOrError, + totalCount: opts.totalCount ?? tagsOrError.length, + }); + const tagService = mockInstance(TagService, { listWithUsageCount }); + const telemetry = mockInstance(Telemetry, { track: jest.fn() }); + return { tagService, telemetry }; + }; + + describe('smoke tests', () => { + test('creates the tool correctly', () => { + const { tagService, telemetry } = createMocks(); + + const tool = createListTagsTool(user, tagService, telemetry); + + expect(tool.name).toBe('list_tags'); + expect(tool.config.description).toEqual(expect.any(String)); + expect(tool.config.inputSchema).toBeDefined(); + expect(tool.config.outputSchema).toBeDefined(); + expect(tool.config.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); + expect(typeof tool.handler).toBe('function'); + }); + }); + + describe('handler', () => { + test('requests usage counts and returns the formatted payload', async () => { + const tags = [buildTag(), buildTag({ id: 'tag-2', name: 'staging', usageCount: 0 })]; + const { tagService } = createMocks(tags); + + const result = await listTags(tagService); + + expect(tagService.listWithUsageCount).toHaveBeenCalledWith({ limit: 500 }); + expect(result.count).toBe(2); + expect(result.totalCount).toBe(2); + expect(result.data).toEqual([ + { + id: 'tag-1', + name: 'production', + usageCount: 3, + createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), + updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), + }, + { + id: 'tag-2', + name: 'staging', + usageCount: 0, + createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), + updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), + }, + ]); + }); + + test('defaults usageCount to 0 when missing', async () => { + const tags = [buildTag({ usageCount: undefined as unknown as number })]; + const { tagService } = createMocks(tags); + + const result = await listTags(tagService); + + expect(result.data[0].usageCount).toBe(0); + }); + + test('pushes the requested limit into the query and reports totalCount', async () => { + const tags = [buildTag({ id: 'tag-1' }), buildTag({ id: 'tag-2' })]; + const { tagService } = createMocks(tags, { totalCount: 3 }); + + const result = await listTags(tagService, { limit: 2 }); + + expect(tagService.listWithUsageCount).toHaveBeenCalledWith({ limit: 2 }); + expect(result.data.map((t) => t.id)).toEqual(['tag-1', 'tag-2']); + expect(result.count).toBe(2); + expect(result.totalCount).toBe(3); + }); + + test('emits telemetry on success', async () => { + const { tagService, telemetry } = createMocks([buildTag()]); + const tool = createListTagsTool(user, tagService, telemetry); + + await tool.handler({ limit: undefined as unknown as number }, {} as never); + + expect(telemetry.track).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tool_name: 'list_tags', + user_id: 'user-1', + results: { success: true, data: { count: 1 } }, + }), + ); + }); + + test('emits telemetry and rethrows on failure', async () => { + const { tagService, telemetry } = createMocks(new Error('boom')); + const tool = createListTagsTool(user, tagService, telemetry); + + await expect( + tool.handler({ limit: undefined as unknown as number }, {} as never), + ).rejects.toThrow('boom'); + + expect(telemetry.track).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tool_name: 'list_tags', + results: { success: false, error: 'boom' }, + }), + ); + }); + + test('rejects when the user does not have tag:list scope', async () => { + const noScopeUser = userWithScopes([]); + const { tagService, telemetry } = createMocks([buildTag()]); + const tool = createListTagsTool(noScopeUser, tagService, telemetry); + + await expect( + tool.handler({ limit: undefined as unknown as number }, {} as never), + ).rejects.toThrow('permission to list tags'); + + expect(tagService.listWithUsageCount).not.toHaveBeenCalled(); + }); + }); +}); 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 4830e76bd57..c1fce61b7a1 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts @@ -42,6 +42,7 @@ import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; +import { TagService } from '@/services/tag.service'; import { UrlService } from '@/services/url.service'; import { Telemetry } from '@/telemetry'; import { WorkflowRunner } from '@/workflow-runner'; @@ -93,6 +94,7 @@ describe('McpService', () => { mockInstance(ExecutionService), mockInstance(DataTableProxyService), mockInstance(CollaborationService), + mockInstance(TagService), mockInstance(LicenseState), mockInstance(PostHogClient), ); @@ -135,6 +137,7 @@ describe('McpService', () => { mockInstance(ExecutionService), mockInstance(DataTableProxyService), mockInstance(CollaborationService), + mockInstance(TagService), mockInstance(LicenseState), mockInstance(PostHogClient), ); @@ -330,6 +333,7 @@ describe('McpService', () => { mockInstance(ExecutionService), mockInstance(DataTableProxyService), mockInstance(CollaborationService), + mockInstance(TagService), mockInstance(LicenseState), opts.postHogClient, ); @@ -433,6 +437,7 @@ describe('McpService', () => { mockInstance(ExecutionService), mockInstance(DataTableProxyService), mockInstance(CollaborationService), + mockInstance(TagService), mockInstance(LicenseState), mockInstance(PostHogClient), ); @@ -477,6 +482,7 @@ describe('McpService', () => { mockInstance(ExecutionService), mockInstance(DataTableProxyService), mockInstance(CollaborationService), + mockInstance(TagService), mockInstance(LicenseState), mockInstance(PostHogClient), ); @@ -545,6 +551,7 @@ describe('McpService', () => { mockInstance(ExecutionService), mockInstance(DataTableProxyService), mockInstance(CollaborationService), + mockInstance(TagService), mockInstance(LicenseState), postHogClient, ); diff --git a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts index 1582386104e..50f2024c0bc 100644 --- a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts @@ -1,5 +1,6 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { User } from '@n8n/db'; +import type { WorkflowEntity } from '@n8n/db'; import type { INode } from 'n8n-workflow'; import { @@ -109,6 +110,7 @@ describe('search-workflows MCP tool', () => { scopes: ['workflow:read', 'workflow:execute'], canExecute: true, availableInMCP: true, + tags: [], }, { id: 'b', @@ -121,10 +123,66 @@ describe('search-workflows MCP tool', () => { scopes: ['workflow:read'], canExecute: false, availableInMCP: true, + tags: [], }, ]); }); + test('forwards tags filter and surfaces workflow tags in output', async () => { + const tags = [ + { id: 'tag-1', name: 'production' }, + { id: 'tag-2', name: 'critical' }, + ] as unknown as WorkflowEntity['tags']; + const workflows = [ + createWorkflow({ + id: 'tagged', + activeVersionId: uuid(), + tags, + }), + ]; + const workflowService = mockInstance(WorkflowService, { + getMany: jest.fn().mockResolvedValue({ workflows, count: 1 }), + }); + + const result = await searchWorkflows(user, workflowService as unknown as WorkflowService, { + tags: ['production', 'critical'], + }); + + const [, optionsArg] = (workflowService.getMany as jest.Mock).mock.calls[0]; + expect(optionsArg.filter).toMatchObject({ tags: ['production', 'critical'] }); + expect(optionsArg.select).toMatchObject({ tags: true }); + expect(result.data[0].tags).toEqual([ + { id: 'tag-1', name: 'production' }, + { id: 'tag-2', name: 'critical' }, + ]); + }); + + test('drops empty tag entries and omits filter when no tags remain', async () => { + const workflowService = mockInstance(WorkflowService, { + getMany: jest.fn().mockResolvedValue({ workflows: [], count: 0 }), + }); + + await searchWorkflows(user, workflowService as unknown as WorkflowService, { + tags: ['', ''], + }); + + const [, optionsArg] = (workflowService.getMany as jest.Mock).mock.calls[0]; + expect(optionsArg.filter.tags).toBeUndefined(); + }); + + test('deduplicates repeated tag names before forwarding the filter', async () => { + const workflowService = mockInstance(WorkflowService, { + getMany: jest.fn().mockResolvedValue({ workflows: [], count: 0 }), + }); + + await searchWorkflows(user, workflowService as unknown as WorkflowService, { + tags: ['production', 'production', 'critical', 'production'], + }); + + const [, optionsArg] = (workflowService.getMany as jest.Mock).mock.calls[0]; + expect(optionsArg.filter.tags).toEqual(['production', 'critical']); + }); + test('applies provided filters and clamps high limit', async () => { const workflows = [createWorkflow({ id: 'x', activeVersionId: uuid() })]; const workflowService = mockInstance(WorkflowService, { diff --git a/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts index 4650292a18c..6e8b0051158 100644 --- a/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts @@ -1,4 +1,5 @@ import { mockInstance } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import { SharedWorkflowRepository, User, WorkflowEntity } from '@n8n/db'; import { NodeConnectionTypes, type IConnections, type INode } from 'n8n-workflow'; @@ -8,6 +9,7 @@ import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsService } from '@/credentials/credentials.service'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NodeTypes } from '@/node-types'; +import { TagService } from '@/services/tag.service'; import { UrlService } from '@/services/url.service'; import { Telemetry } from '@/telemetry'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; @@ -48,8 +50,14 @@ type DataTableOpsMock = { getManyAndCount: jest.Mock; }; +const userWithScopes = (scopeSlugs: string[]) => + Object.assign(new User(), { + id: 'user-1', + role: { slug: 'global:test', scopes: scopeSlugs.map((slug) => ({ slug })) }, + }); + describe('update-workflow MCP tool', () => { - const user = Object.assign(new User(), { id: 'user-1' }); + const user = userWithScopes(['tag:create']); let workflowFinderService: WorkflowFinderService; let findWorkflowMock: jest.Mock; let workflowService: WorkflowService; @@ -61,6 +69,10 @@ describe('update-workflow MCP tool', () => { let nodeTypes: ReturnType>; let collaborationService: CollaborationService; let dataTableOps: DataTableOpsMock; + let tagService: TagService; + let findOrCreateByNamesMock: jest.Mock; + let findByNamesMock: jest.Mock; + let globalConfig: GlobalConfig; const buildExistingWorkflow = () => Object.assign(new WorkflowEntity(), { @@ -122,6 +134,14 @@ describe('update-workflow MCP tool', () => { dataTableOps = { getManyAndCount: jest.fn().mockResolvedValue({ data: [], count: 0 }), }; + + findOrCreateByNamesMock = jest.fn(); + findByNamesMock = jest.fn(); + tagService = mockInstance(TagService, { + findOrCreateByNames: findOrCreateByNamesMock, + findByNames: findByNamesMock, + }); + globalConfig = mockInstance(GlobalConfig, { tags: { disabled: false } }); }); const createTool = () => @@ -136,6 +156,8 @@ describe('update-workflow MCP tool', () => { sharedWorkflowRepository, collaborationService, dataTableOps as never, + tagService, + globalConfig, ); const callHandler = async ( @@ -945,5 +967,201 @@ describe('update-workflow MCP tool', () => { expect(workflowService.update).toHaveBeenCalled(); }); }); + + describe('tag operations', () => { + const workflowWithTags = (tagNames: string[]) => + Object.assign(buildExistingWorkflow(), { + tags: tagNames.map((name, i) => ({ id: `tag-${i}`, name })), + }); + + test('resolves added tag names and passes tagIds to workflow update', async () => { + findWorkflowMock.mockResolvedValue(workflowWithTags(['production'])); + findOrCreateByNamesMock.mockResolvedValue([ + { id: 'tag-0', name: 'production' }, + { id: 'tag-new', name: 'critical' }, + ]); + + const result = await callHandler({ + workflowId: 'wf-1', + operations: [{ type: 'addTags', names: ['critical'] }], + }); + + expect(result.isError).toBeUndefined(); + expect(findWorkflowMock).toHaveBeenCalledWith( + 'wf-1', + user, + ['workflow:update'], + expect.objectContaining({ includeTags: true }), + ); + expect(findOrCreateByNamesMock).toHaveBeenCalledTimes(1); + const passedNames = findOrCreateByNamesMock.mock.calls[0][0] as string[]; + expect(passedNames.sort()).toEqual(['critical', 'production']); + + const [, , , updateOptions] = updateMock.mock.calls[0]; + expect(updateOptions.tagIds.sort()).toEqual(['tag-0', 'tag-new']); + }); + + test('removeTags drops names from the resolved set', async () => { + findWorkflowMock.mockResolvedValue(workflowWithTags(['production', 'critical'])); + findOrCreateByNamesMock.mockResolvedValue([{ id: 'tag-0', name: 'production' }]); + + await callHandler({ + workflowId: 'wf-1', + operations: [{ type: 'removeTags', names: ['critical'] }], + }); + + expect(findOrCreateByNamesMock).toHaveBeenCalledWith(['production']); + const [, , , updateOptions] = updateMock.mock.calls[0]; + expect(updateOptions.tagIds).toEqual(['tag-0']); + }); + + test('removing the last tag passes an empty tagIds array', async () => { + findWorkflowMock.mockResolvedValue(workflowWithTags(['production'])); + findOrCreateByNamesMock.mockResolvedValue([]); + + await callHandler({ + workflowId: 'wf-1', + operations: [{ type: 'removeTags', names: ['production'] }], + }); + + expect(findOrCreateByNamesMock).toHaveBeenCalledWith([]); + const [, , , updateOptions] = updateMock.mock.calls[0]; + expect(updateOptions.tagIds).toEqual([]); + }); + + test('does not call tagService or pass tagIds when no tag ops are present', async () => { + await callHandler({ + workflowId: 'wf-1', + operations: [{ type: 'setWorkflowMetadata', name: 'renamed' }], + }); + + expect(findOrCreateByNamesMock).not.toHaveBeenCalled(); + const [, , , updateOptions] = updateMock.mock.calls[0]; + expect(updateOptions.tagIds).toBeUndefined(); + // Tags should not be loaded when there are no tag ops + expect(findWorkflowMock).toHaveBeenCalledWith( + 'wf-1', + user, + ['workflow:update'], + expect.objectContaining({ includeTags: false }), + ); + }); + + test('rejects tag operations when tags are disabled instance-wide', async () => { + globalConfig = mockInstance(GlobalConfig, { tags: { disabled: true } }); + + const result = await callHandler({ + workflowId: 'wf-1', + operations: [{ type: 'addTags', names: ['anything'] }], + }); + + expect(result.isError).toBe(true); + expect(findOrCreateByNamesMock).not.toHaveBeenCalled(); + expect(workflowService.update).not.toHaveBeenCalled(); + expect(findWorkflowMock).not.toHaveBeenCalled(); + }); + + test('without tag:create scope, attaches only existing tags', async () => { + const memberUser = userWithScopes([]); + findWorkflowMock.mockResolvedValue(workflowWithTags([])); + findByNamesMock.mockResolvedValue([{ id: 'tag-existing', name: 'production' }]); + + const tool = createUpdateWorkflowTool( + memberUser, + workflowFinderService, + workflowService, + urlService, + telemetry, + nodeTypes, + credentialsService, + sharedWorkflowRepository, + collaborationService, + dataTableOps as never, + tagService, + globalConfig, + ); + + await callHandler( + { + workflowId: 'wf-1', + operations: [{ type: 'addTags', names: ['production'] }], + }, + tool, + ); + + expect(findByNamesMock).toHaveBeenCalledWith(['production']); + expect(findOrCreateByNamesMock).not.toHaveBeenCalled(); + const [, , , updateOptions] = updateMock.mock.calls[0]; + expect(updateOptions.tagIds).toEqual(['tag-existing']); + }); + + test('without tag:create scope, fails when a tag name does not exist', async () => { + const memberUser = userWithScopes([]); + findWorkflowMock.mockResolvedValue(workflowWithTags([])); + findByNamesMock.mockResolvedValue([{ id: 'tag-existing', name: 'production' }]); + + const tool = createUpdateWorkflowTool( + memberUser, + workflowFinderService, + workflowService, + urlService, + telemetry, + nodeTypes, + credentialsService, + sharedWorkflowRepository, + collaborationService, + dataTableOps as never, + tagService, + globalConfig, + ); + + const result = await callHandler( + { + workflowId: 'wf-1', + operations: [{ type: 'addTags', names: ['production', 'novel-tag'] }], + }, + tool, + ); + + expect(result.isError).toBe(true); + expect(findOrCreateByNamesMock).not.toHaveBeenCalled(); + expect(workflowService.update).not.toHaveBeenCalled(); + }); + + test('does not flip aiBuilderAssisted when the batch contains only tag operations', async () => { + findWorkflowMock.mockResolvedValue(workflowWithTags(['existing'])); + findOrCreateByNamesMock.mockResolvedValue([{ id: 'tag-0', name: 'existing' }]); + + await callHandler({ + workflowId: 'wf-1', + operations: [{ type: 'addTags', names: ['existing'] }], + }); + + const [, workflowArg, , updateOptions] = updateMock.mock.calls[0]; + expect(updateOptions.aiBuilderAssisted).toBe(false); + expect(workflowArg.meta).not.toEqual( + expect.objectContaining({ aiBuilderAssisted: true, builderVariant: 'mcp' }), + ); + }); + + test('keeps aiBuilderAssisted=true when tag ops are mixed with node ops', async () => { + findWorkflowMock.mockResolvedValue(workflowWithTags([])); + findOrCreateByNamesMock.mockResolvedValue([{ id: 'tag-0', name: 'foo' }]); + + await callHandler({ + workflowId: 'wf-1', + operations: [ + { type: 'setWorkflowMetadata', name: 'Renamed' }, + { type: 'addTags', names: ['foo'] }, + ], + }); + + const [, workflowArg, , updateOptions] = updateMock.mock.calls[0]; + expect(updateOptions.aiBuilderAssisted).toBe(true); + expect(workflowArg.meta).toEqual( + expect.objectContaining({ aiBuilderAssisted: true, builderVariant: 'mcp' }), + ); + }); + }); }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts b/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts index 78b95a34ab9..17d401db01d 100644 --- a/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/workflow-operations.test.ts @@ -794,4 +794,80 @@ describe('applyOperations', () => { expect(result.success).toBe(false); }); }); + + describe('addTags / removeTags', () => { + test('addTags adds names to the existing set without duplicating', () => { + const wf = { ...baseWorkflow(), tagNames: ['production'] }; + const ops: PartialUpdateOperation[] = [ + { type: 'addTags', names: ['critical', 'production'] }, + ]; + const result = applyOperations(wf, ops); + if (!result.success) throw new Error('expected success'); + expect(result.tagNames?.sort()).toEqual(['critical', 'production']); + expect(result.workflow.tagNames?.sort()).toEqual(['critical', 'production']); + }); + + test('removeTags removes only the matching names', () => { + const wf = { ...baseWorkflow(), tagNames: ['production', 'critical', 'wip'] }; + const ops: PartialUpdateOperation[] = [{ type: 'removeTags', names: ['wip', 'missing'] }]; + const result = applyOperations(wf, ops); + if (!result.success) throw new Error('expected success'); + expect(result.tagNames?.sort()).toEqual(['critical', 'production']); + }); + + test('sequential ops apply in order (add then remove yields empty)', () => { + const wf = { ...baseWorkflow(), tagNames: [] }; + const ops: PartialUpdateOperation[] = [ + { type: 'addTags', names: ['a', 'b'] }, + { type: 'removeTags', names: ['a'] }, + { type: 'addTags', names: ['c'] }, + ]; + const result = applyOperations(wf, ops); + if (!result.success) throw new Error('expected success'); + expect(result.tagNames?.sort()).toEqual(['b', 'c']); + }); + + test('returns undefined tagNames when batch has no tag operations', () => { + const wf = { ...baseWorkflow(), tagNames: ['production'] }; + const ops: PartialUpdateOperation[] = [{ type: 'setWorkflowMetadata', name: 'renamed' }]; + const result = applyOperations(wf, ops); + if (!result.success) throw new Error('expected success'); + expect(result.tagNames).toBeUndefined(); + }); + + test('fails when tag operations are present but existing tags were not loaded', () => { + const wf = baseWorkflow(); + const ops: PartialUpdateOperation[] = [{ type: 'addTags', names: ['production'] }]; + const result = applyOperations(wf, ops); + expect(result.success).toBe(false); + }); + + test('does not mutate input tagNames on success', () => { + const wf = { ...baseWorkflow(), tagNames: ['production'] }; + const before = [...wf.tagNames]; + applyOperations(wf, [{ type: 'addTags', names: ['critical'] }]); + expect(wf.tagNames).toEqual(before); + }); + + test('schema rejects empty names array', () => { + const parsed = partialUpdateOperationSchema.safeParse({ type: 'addTags', names: [] }); + expect(parsed.success).toBe(false); + }); + + test('schema rejects empty string names', () => { + const parsed = partialUpdateOperationSchema.safeParse({ type: 'removeTags', names: [''] }); + expect(parsed.success).toBe(false); + }); + + test('schema trims whitespace from names', () => { + const parsed = partialUpdateOperationSchema.safeParse({ + type: 'addTags', + names: [' spaced '], + }); + expect(parsed.success).toBe(true); + if (parsed.success && parsed.data.type === 'addTags') { + expect(parsed.data.names).toEqual(['spaced']); + } + }); + }); }); diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index e3f79df7233..1dae11afdcc 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -38,6 +38,7 @@ import { createGetExecutionTool } from './tools/get-execution.tool'; import { createSearchExecutionsTool } from './tools/search-executions.tool'; import { createWorkflowDetailsTool } from './tools/get-workflow-details.tool'; import { createListCredentialsTool } from './tools/list-credentials.tool'; +import { createListTagsTool } from './tools/list-tags.tool'; import { createPublishWorkflowTool } from './tools/publish-workflow.tool'; import { createSearchFoldersTool } from './tools/search-folders.tool'; import { createSearchProjectsTool } from './tools/search-projects.tool'; @@ -65,6 +66,7 @@ import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; +import { TagService } from '@/services/tag.service'; import { UrlService } from '@/services/url.service'; import { Telemetry } from '@/telemetry'; import { WorkflowRunner } from '@/workflow-runner'; @@ -128,6 +130,7 @@ export class McpService { private readonly executionService: ExecutionService, private readonly dataTableProxyService: DataTableProxyService, private readonly collaborationService: CollaborationService, + private readonly tagService: TagService, private readonly licenseState: LicenseState, private readonly postHogClient: PostHogClient, ) {} @@ -329,6 +332,11 @@ export class McpService { listCredentialsTool.handler, ); + if (!this.globalConfig.tags.disabled) { + const listTagsTool = createListTagsTool(user, this.tagService, this.telemetry); + server.registerTool(listTagsTool.name, listTagsTool.config, listTagsTool.handler); + } + // Data table tools const dataTableOps = this.dataTableProxyService.makeDataTableOperationsForUser(user); @@ -519,6 +527,8 @@ export class McpService { this.sharedWorkflowRepository, this.collaborationService, dataTableOps, + this.tagService, + this.globalConfig, ); server.registerTool(updateTool.name, updateTool.config, updateTool.handler); diff --git a/packages/cli/src/modules/mcp/mcp.types.ts b/packages/cli/src/modules/mcp/mcp.types.ts index 89adfa826f6..13eac112861 100644 --- a/packages/cli/src/modules/mcp/mcp.types.ts +++ b/packages/cli/src/modules/mcp/mcp.types.ts @@ -40,6 +40,7 @@ export type SearchWorkflowsParams = { limit?: number; query?: string; projectId?: string; + tags?: string[]; sortBy?: SearchWorkflowsSortBy; }; @@ -54,6 +55,7 @@ export type SearchWorkflowsItem = { scopes: string[]; canExecute: boolean; availableInMCP: boolean; + tags: Array<{ id: string; name: string }>; }; export type SearchWorkflowsResult = { diff --git a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts index 4d1839063ba..275933a2e42 100644 --- a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts +++ b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts @@ -7,7 +7,7 @@ import type { WorkflowDetailsResult, UserCalledMCPToolEventPayload, } from '../mcp.types'; -import { workflowDetailsOutputSchema } from './schemas'; +import { toTagSummary, workflowDetailsOutputSchema } from './schemas'; import { getTriggerDetails, type WebhookEndpoints } from './webhook-utils'; import { getMcpWorkflow } from './workflow-validation.utils'; @@ -164,7 +164,7 @@ export async function getWorkflowDetails( connections, nodes: nodes.map(({ credentials: _credentials, ...node }) => node), activeVersion, - tags: (workflow.tags ?? []).map((tag) => ({ id: tag.id, name: tag.name })), + tags: toTagSummary(workflow.tags), meta: workflow.meta ?? null, parentFolderId: workflow.parentFolder?.id ?? null, description: workflow.description ?? undefined, diff --git a/packages/cli/src/modules/mcp/tools/list-tags.tool.ts b/packages/cli/src/modules/mcp/tools/list-tags.tool.ts new file mode 100644 index 00000000000..632b3ee0538 --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/list-tags.tool.ts @@ -0,0 +1,129 @@ +import type { User } from '@n8n/db'; +import { hasGlobalScope } from '@n8n/permissions'; +import z from 'zod'; + +import type { TagService } from '@/services/tag.service'; +import type { Telemetry } from '@/telemetry'; + +import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants'; +import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types'; +import { createLimitSchema } from './schemas'; + +const MAX_RESULTS = 500; + +const inputSchema = { + limit: createLimitSchema(MAX_RESULTS), +} satisfies z.ZodRawShape; + +const outputSchema = { + data: z + .array( + z.object({ + id: z.string().describe('The unique identifier of the tag'), + name: z.string().describe('The display name of the tag'), + usageCount: z + .number() + .int() + .min(0) + .describe('Number of non-archived workflows using this tag'), + createdAt: z.string().describe('The ISO timestamp when the tag was created'), + updatedAt: z.string().describe('The ISO timestamp when the tag was last updated'), + }), + ) + .describe('Workflow tags available in the instance'), + count: z.number().int().min(0).describe('Number of tags returned'), + totalCount: z.number().int().min(0).describe('Total number of tags before applying the limit'), +} satisfies z.ZodRawShape; + +export type ListTagsParams = { + limit?: number; +}; + +export type ListTagsItem = { + id: string; + name: string; + usageCount: number; + createdAt: string; + updatedAt: string; +}; + +export type ListTagsResult = { + data: ListTagsItem[]; + count: number; + totalCount: number; +}; + +/** + * Creates mcp tool definition for listing all workflow tags in the instance. + * Tags are global (not project-scoped) and can be used with search_workflows to filter results. + */ +export const createListTagsTool = ( + user: User, + tagService: TagService, + telemetry: Telemetry, +): ToolDefinition => ({ + name: 'list_tags', + config: { + description: 'List all workflow tags in the instance.', + inputSchema, + outputSchema, + annotations: { + title: 'List Tags', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + handler: async ({ limit = MAX_RESULTS }: ListTagsParams) => { + const telemetryPayload: UserCalledMCPToolEventPayload = { + user_id: user.id, + tool_name: 'list_tags', + parameters: { limit }, + }; + + try { + if (!hasGlobalScope(user, 'tag:list')) { + throw new Error('User does not have permission to list tags'); + } + + const payload = await listTags(tagService, { limit }); + + telemetryPayload.results = { + success: true, + data: { count: payload.count }, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + structuredContent: payload, + }; + } catch (error) { + telemetryPayload.results = { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + throw error; + } + }, +}); + +export async function listTags( + tagService: TagService, + { limit = MAX_RESULTS }: ListTagsParams = {}, +): Promise { + const safeLimit = Math.min(Math.max(1, limit), MAX_RESULTS); + const { data: tags, totalCount } = await tagService.listWithUsageCount({ limit: safeLimit }); + + const data: ListTagsItem[] = tags.map((tag) => ({ + id: tag.id, + name: tag.name, + usageCount: tag.usageCount ?? 0, + createdAt: tag.createdAt.toISOString(), + updatedAt: tag.updatedAt.toISOString(), + })); + + return { data, count: data.length, totalCount }; +} diff --git a/packages/cli/src/modules/mcp/tools/schemas.ts b/packages/cli/src/modules/mcp/tools/schemas.ts index a3ed369a684..75ef8680b1b 100644 --- a/packages/cli/src/modules/mcp/tools/schemas.ts +++ b/packages/cli/src/modules/mcp/tools/schemas.ts @@ -10,6 +10,9 @@ export const nodeSchema = z export const tagSchema = z.object({ id: z.string(), name: z.string() }).passthrough(); +export const toTagSummary = (tags: Array<{ id: string; name: string }> | undefined | null) => + (tags ?? []).map((tag) => ({ id: tag.id, name: tag.name })); + export const workflowSettingsSchema = z .custom((_value): _value is IWorkflowSettings => true) .nullable(); diff --git a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts index 06d1e395527..54e1536b521 100644 --- a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts +++ b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts @@ -15,7 +15,7 @@ import type { import type { ListQuery } from '@/requests'; import type { Telemetry } from '@/telemetry'; import type { WorkflowService } from '@/workflows/workflow.service'; -import { createLimitSchema } from './schemas'; +import { createLimitSchema, tagSchema, toTagSummary } from './schemas'; const MAX_RESULTS = 200; @@ -25,6 +25,10 @@ const inputSchema = { limit: createLimitSchema(MAX_RESULTS), query: z.string().optional().describe('Filter by name or description'), projectId: z.string().optional(), + tags: z + .array(z.string()) + .optional() + .describe('Filter by tag names (AND semantics — workflow must have all).'), sortBy: z .enum(SEARCH_WORKFLOWS_SORT_BY_VALUES) .optional() @@ -60,6 +64,7 @@ const outputSchema = { .boolean() .describe('Whether the user has permission to execute this workflow'), availableInMCP: z.boolean().describe('Whether the workflow is visible to MCP tools'), + tags: z.array(tagSchema).describe('Tags assigned to the workflow'), }), ) .describe('List of workflows matching the query'), @@ -67,8 +72,8 @@ const outputSchema = { } satisfies z.ZodRawShape; /** - * Creates mcp tool definition for searching workflows with optional filters. Workflows can be filtered by name, active status, and project ID. - * Returns a preview of each workflow including id, name, active status, creation and update timestamps, and trigger count. + * Creates mcp tool definition for searching workflows with optional filters. Workflows can be filtered by name, project ID, and tags. + * Returns a preview of each workflow including id, name, active status, creation and update timestamps, trigger count, and tags. */ export const createSearchWorkflowsTool = ( user: User, @@ -94,14 +99,10 @@ export const createSearchWorkflowsTool = ( limit = MAX_RESULTS, query, projectId, + tags, sortBy, - }: { - limit?: number; - query?: string; - projectId?: string; - sortBy?: SearchWorkflowsSortBy; - }) => { - const parameters = { limit, query, projectId, sortBy }; + }: SearchWorkflowsParams) => { + const parameters = { limit, query, projectId, tags, sortBy }; const telemetryPayload: UserCalledMCPToolEventPayload = { user_id: user.id, tool_name: 'search_workflows', @@ -113,6 +114,7 @@ export const createSearchWorkflowsTool = ( limit, query, projectId, + tags, sortBy, }); @@ -151,9 +153,10 @@ export const createSearchWorkflowsTool = ( export async function searchWorkflows( user: User, workflowService: WorkflowService, - { limit = MAX_RESULTS, query, projectId, sortBy = DEFAULT_SORT_BY }: SearchWorkflowsParams, + { limit = MAX_RESULTS, query, projectId, tags, sortBy = DEFAULT_SORT_BY }: SearchWorkflowsParams, ): Promise { const safeLimit = Math.min(Math.max(1, limit), MAX_RESULTS); + const filterTags = tags && Array.from(new Set(tags.filter((tag) => tag.length > 0))); const options: ListQuery.Options = { take: safeLimit, @@ -162,6 +165,7 @@ export async function searchWorkflows( isArchived: false, ...(query ? { query } : {}), ...(projectId ? { projectId } : {}), + ...(filterTags && filterTags.length > 0 ? { tags: filterTags } : {}), }, select: { id: true, @@ -173,6 +177,7 @@ export async function searchWorkflows( triggerCount: true, ownedBy: true, // Required for loading 'shared' relation used in scope computation settings: true, + tags: true, }, }; @@ -185,8 +190,17 @@ export async function searchWorkflows( ); const formattedWorkflows: SearchWorkflowsItem[] = workflows.map((workflow) => { - const { id, name, description, activeVersionId, createdAt, updatedAt, triggerCount, settings } = - workflow as WorkflowEntity; + const { + id, + name, + description, + activeVersionId, + createdAt, + updatedAt, + triggerCount, + settings, + tags: workflowTags, + } = workflow as WorkflowEntity; const scopes = ('scopes' in workflow ? (workflow.scopes as string[]) : undefined) ?? []; return { @@ -200,6 +214,7 @@ export async function searchWorkflows( scopes, canExecute: scopes.includes('workflow:execute'), availableInMCP: settings?.availableInMCP ?? false, + tags: toTagSummary(workflowTags), }; }); diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts index d4f05042523..6b07b3e3b11 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/update-workflow.tool.ts @@ -1,4 +1,6 @@ +import type { GlobalConfig } from '@n8n/config'; import { type User, type SharedWorkflowRepository, WorkflowEntity } from '@n8n/db'; +import { hasGlobalScope } from '@n8n/permissions'; import type { WorkflowJSON } from '@n8n/workflow-sdk'; import z from 'zod'; @@ -21,6 +23,7 @@ import type { CollaborationService } from '@/collaboration/collaboration.service import type { CredentialsService } from '@/credentials/credentials.service'; import type { DataTableUserOperations } from '@/modules/data-table/data-table-proxy.service'; import type { NodeTypes } from '@/node-types'; +import type { TagService } from '@/services/tag.service'; import type { UrlService } from '@/services/url.service'; import type { Telemetry } from '@/telemetry'; import { resolveNodeWebhookIds } from '@/workflow-helpers'; @@ -44,6 +47,8 @@ const operationTypeSchema = z.enum([ 'setNodeDisabled', 'setNodeSettings', 'setWorkflowMetadata', + 'addTags', + 'removeTags', ]); const positionInputSchema = z.array(z.number()).length(2).describe('Canvas [x, y].'); @@ -111,6 +116,7 @@ const operationInputSchema = z settings: nodeSettingsInputSchema.optional().describe('For setNodeSettings.'), name: z.string().max(128).optional().describe('Only used for setWorkflowMetadata.'), description: z.string().max(255).optional().describe('Only used for setWorkflowMetadata.'), + names: z.array(z.string()).optional().describe('For addTags / removeTags.'), }) .describe('Workflow update operation. Provide fields matching type.'); @@ -228,6 +234,8 @@ export const createUpdateWorkflowTool = ( sharedWorkflowRepository: SharedWorkflowRepository, collaborationService: CollaborationService, dataTableOps: DataTableUserOperations, + tagService: TagService, + globalConfig: GlobalConfig, ): ToolDefinition => ({ name: MCP_UPDATE_WORKFLOW_TOOL.toolName, config: { @@ -266,16 +274,31 @@ export const createUpdateWorkflowTool = ( try { const strictOperations = parseStrictOperations(operations); + + const hasTagOperations = strictOperations.some( + (op) => op.type === 'addTags' || op.type === 'removeTags', + ); + + if (hasTagOperations && globalConfig.tags.disabled) { + throw new Error( + 'Tag operations are not supported on this instance because tags are disabled.', + ); + } + const existingWorkflow = await getMcpWorkflow( workflowId, user, ['workflow:update'], workflowFinderService, + { includeTags: hasTagOperations }, ); await collaborationService.ensureWorkflowEditable(existingWorkflow.id); - const result = applyOperations(toWorkflowSlice(existingWorkflow), strictOperations); + const result = applyOperations( + toWorkflowSlice(existingWorkflow, { includeTags: hasTagOperations }), + strictOperations, + ); if (!result.success) { throw new Error(result.error); @@ -316,6 +339,10 @@ export const createUpdateWorkflowTool = ( throw new Error(dataTableCheck.error); } + const hasNonTagOperations = strictOperations.some( + (op) => op.type !== 'addTags' && op.type !== 'removeTags', + ); + const workflowUpdateData = new WorkflowEntity(); Object.assign(workflowUpdateData, { name: result.workflow.name, @@ -324,11 +351,13 @@ export const createUpdateWorkflowTool = ( : {}), nodes: result.workflow.nodes, connections: result.workflow.connections, - meta: { - ...(existingWorkflow.meta ?? {}), - aiBuilderAssisted: true, - builderVariant: 'mcp', - }, + meta: hasNonTagOperations + ? { + ...(existingWorkflow.meta ?? {}), + aiBuilderAssisted: true, + builderVariant: 'mcp', + } + : (existingWorkflow.meta ?? {}), }); resolveNodeWebhookIds(workflowUpdateData, nodeTypes); @@ -363,9 +392,30 @@ export const createUpdateWorkflowTool = ( connections: workflowUpdateData.connections, } as unknown as WorkflowJSON); + let tagIds: string[] | undefined; + if (result.tagNames !== undefined) { + if (hasGlobalScope(user, 'tag:create')) { + const resolvedTags = await tagService.findOrCreateByNames(result.tagNames); + tagIds = resolvedTags.map((t) => t.id); + } else { + const resolvedTags = await tagService.findByNames(result.tagNames); + const resolvedNames = new Set(resolvedTags.map((t) => t.name)); + const missing = result.tagNames + .map((n) => n.trim()) + .filter((name) => name.length > 0 && !resolvedNames.has(name)); + if (missing.length > 0) { + throw new Error( + `Cannot apply the following tags because they don't exist and your account does not have permission to create them: ${missing.join(', ')}`, + ); + } + tagIds = resolvedTags.map((t) => t.id); + } + } + const updatedWorkflow = await workflowService.update(user, workflowUpdateData, workflowId, { - aiBuilderAssisted: true, + aiBuilderAssisted: hasNonTagOperations, source: 'n8n-mcp', + ...(tagIds !== undefined ? { tagIds } : {}), }); void collaborationService.broadcastWorkflowUpdate(workflowId, user.id).catch(() => {}); diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts index d47802cfea6..c0264000367 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts @@ -168,6 +168,22 @@ export const partialUpdateOperationSchema = z.discriminatedUnion('type', [ name: z.string().max(128).optional(), description: z.string().max(255).optional(), }), + z.object({ + type: z.literal('addTags'), + names: z + .array(z.string().trim().min(1).max(24)) + .min(1) + .max(50) + .describe('Tag names to attach. Unknown names are auto-created. Idempotent.'), + }), + z.object({ + type: z.literal('removeTags'), + names: z + .array(z.string().trim().min(1).max(24)) + .min(1) + .max(50) + .describe('Tag names to detach from the workflow. Unknown names are ignored.'), + }), ]); export type PartialUpdateOperation = z.infer; @@ -177,12 +193,16 @@ interface WorkflowSlice { description?: string; nodes: INode[]; connections: IConnections; + /** Existing tag names on the workflow. Undefined when not loaded; tag ops require this. */ + tagNames?: string[]; } export interface ApplyOperationsSuccess { success: true; workflow: WorkflowSlice; addedNodeNames: string[]; + /** Final tag set after applying tag ops. Undefined means "leave unchanged". */ + tagNames?: string[]; } export interface ApplyOperationsFailure { @@ -198,6 +218,7 @@ const cloneWorkflow = (workflow: WorkflowSlice): WorkflowSlice => ({ description: workflow.description, nodes: workflow.nodes.map((node) => structuredClone(node)), connections: structuredClone(workflow.connections), + tagNames: workflow.tagNames ? [...workflow.tagNames] : undefined, }); const isPlainObject = (value: unknown): value is Record => @@ -380,6 +401,9 @@ export function applyOperations( const workflow = cloneWorkflow(input); const nodeByName = new Map(workflow.nodes.map((n) => [n.name, n])); const addedNodeNames = new Set(); + // Tag set is null until the first tag op runs; that keeps "no tag ops" + // distinguishable from "tag ops applied to an empty set" at return time. + let tagSet: Set | null = null; for (let i = 0; i < operations.length; i++) { const op = operations[i]; @@ -566,6 +590,20 @@ export function applyOperations( break; } + case 'addTags': + case 'removeTags': { + if (workflow.tagNames === undefined) { + return fail(i, 'tag operations require existing tags to be loaded'); + } + if (tagSet === null) tagSet = new Set(workflow.tagNames); + if (op.type === 'addTags') { + for (const name of op.names) tagSet.add(name); + } else { + for (const name of op.names) tagSet.delete(name); + } + break; + } + default: { op satisfies never; return fail(i, 'unknown operation type'); @@ -573,18 +611,39 @@ export function applyOperations( } } - return { success: true, workflow, addedNodeNames: [...addedNodeNames] }; + if (tagSet !== null) { + workflow.tagNames = [...tagSet]; + } + + return { + success: true, + workflow, + addedNodeNames: [...addedNodeNames], + tagNames: tagSet !== null ? [...tagSet] : undefined, + }; } /** * Pick only the fields the partial-update path needs from a workflow entity. * Keeps the surface explicit and avoids mutating the loaded entity. */ -export function toWorkflowSlice(workflow: IWorkflowBase): WorkflowSlice { +export function toWorkflowSlice( + workflow: IWorkflowBase, + options: { includeTags?: boolean } = {}, +): WorkflowSlice { + let tagNames: string[] | undefined; + if (options.includeTags) { + const tags = (workflow as { tags?: Array<{ name: string }> }).tags; + if (tags === undefined) { + throw new Error('toWorkflowSlice: includeTags=true requires the tags relation to be loaded.'); + } + tagNames = tags.map((t) => t.name); + } return { name: workflow.name ?? '', description: (workflow as { description?: string }).description, nodes: workflow.nodes, connections: workflow.connections, + tagNames, }; } 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 ce6b4ef795f..e7ff7067435 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts @@ -39,6 +39,7 @@ export type FoundWorkflow = NonNullable< export type GetMcpWorkflowOptions = { includeActiveVersion?: boolean; + includeTags?: boolean; }; /** @@ -56,6 +57,7 @@ export async function getMcpWorkflow( ): Promise { const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, scopes, { includeActiveVersion: options?.includeActiveVersion, + includeTags: options?.includeTags, }); if (!workflow) { diff --git a/packages/cli/src/services/__tests__/tag.service.test.ts b/packages/cli/src/services/__tests__/tag.service.test.ts new file mode 100644 index 00000000000..fd8bca37128 --- /dev/null +++ b/packages/cli/src/services/__tests__/tag.service.test.ts @@ -0,0 +1,223 @@ +import type { TagEntity, TagRepository } from '@n8n/db'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import { FindOperator, QueryFailedError } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; + +import type { ExternalHooks } from '@/external-hooks'; +import { TagService } from '@/services/tag.service'; + +const makeTag = (overrides: Partial = {}): TagEntity => + ({ + id: 'tag-id', + name: 'tag', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, + }) as TagEntity; + +describe('TagService', () => { + const tagRepository = mock(); + const externalHooks = mock(); + const tagService = new TagService(externalHooks, tagRepository); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('listWithUsageCount', () => { + test('builds a limited ordered query and returns data + totalCount in parallel', async () => { + const limitFn = jest.fn().mockReturnThis(); + const orderByFn = jest.fn().mockReturnThis(); + const orderByCallOrder: jest.Mock = orderByFn; + const limitCallOrder: jest.Mock = limitFn; + const getMany = jest.fn().mockResolvedValue([makeTag()]); + const builder = { + select: jest.fn().mockReturnThis(), + loadRelationCountAndMap: jest.fn().mockReturnThis(), + orderBy: orderByFn, + limit: limitFn, + getMany, + }; + tagRepository.createQueryBuilder.mockReturnValue(builder as never); + tagRepository.count.mockResolvedValue(42); + + const result = await tagService.listWithUsageCount({ limit: 10 }); + + expect(orderByFn).toHaveBeenCalledWith('tag.name', 'ASC'); + expect(limitFn).toHaveBeenCalledWith(10); + // orderBy must run before limit, or generated SQL is invalid + expect(orderByCallOrder.mock.invocationCallOrder[0]).toBeLessThan( + limitCallOrder.mock.invocationCallOrder[0], + ); + expect(tagRepository.count).toHaveBeenCalledTimes(1); + expect(result.totalCount).toBe(42); + expect(result.data).toHaveLength(1); + }); + + test('does not order when called via getAll without orderByName', async () => { + const orderByFn = jest.fn().mockReturnThis(); + const builder = { + select: jest.fn().mockReturnThis(), + loadRelationCountAndMap: jest.fn().mockReturnThis(), + orderBy: orderByFn, + limit: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + tagRepository.createQueryBuilder.mockReturnValue(builder as never); + + await tagService.getAll({ withUsageCount: true }); + + expect(orderByFn).not.toHaveBeenCalled(); + }); + }); + + describe('findByNames', () => { + test('returns existing tags by name, preserving input order', async () => { + const tags = [ + makeTag({ id: 'tag-1', name: 'production' }), + makeTag({ id: 'tag-2', name: 'critical' }), + ]; + tagRepository.find.mockResolvedValue(tags); + + const result = await tagService.findByNames(['critical', 'production', 'missing']); + + expect(result.map((t) => t.name)).toEqual(['critical', 'production']); + expect(tagRepository.save).not.toHaveBeenCalled(); + }); + + test('returns empty for whitespace-only inputs without querying', async () => { + const result = await tagService.findByNames(['', ' ']); + + expect(result).toEqual([]); + expect(tagRepository.find).not.toHaveBeenCalled(); + }); + }); + + describe('findOrCreateByNames', () => { + test('returns empty array for empty input', async () => { + const result = await tagService.findOrCreateByNames([]); + + expect(result).toEqual([]); + expect(tagRepository.find).not.toHaveBeenCalled(); + }); + + test('returns empty array when all inputs are whitespace', async () => { + const result = await tagService.findOrCreateByNames(['', ' ']); + + expect(result).toEqual([]); + expect(tagRepository.find).not.toHaveBeenCalled(); + }); + + test('returns existing tags without creating', async () => { + const existing = [ + makeTag({ id: 'tag-1', name: 'production' }), + makeTag({ id: 'tag-2', name: 'critical' }), + ]; + tagRepository.find.mockResolvedValue(existing); + + const result = await tagService.findOrCreateByNames(['production', 'critical']); + + expect(result.map((t) => t.id)).toEqual(['tag-1', 'tag-2']); + expect(tagRepository.save).not.toHaveBeenCalled(); + }); + + test('deduplicates the input case-insensitively before lookup', async () => { + tagRepository.find.mockResolvedValue([]); + const createdTag = makeTag({ id: 'tag-new', name: 'Prod' }); + tagRepository.create.mockReturnValue(createdTag); + tagRepository.save.mockResolvedValue(createdTag); + + const result = await tagService.findOrCreateByNames(['Prod', 'prod', 'PROD']); + + expect(tagRepository.find).toHaveBeenCalledTimes(1); + const findArg = tagRepository.find.mock.calls[0][0] as unknown as { + where: { name: FindOperator }; + }; + expect(findArg.where.name).toBeInstanceOf(FindOperator); + expect(findArg.where.name.value).toEqual(['Prod']); + // One create using the first-seen original case. + expect(tagRepository.save).toHaveBeenCalledTimes(1); + expect(tagRepository.create).toHaveBeenCalledWith({ name: 'Prod' }); + expect(result.map((t) => t.name)).toEqual(['Prod']); + }); + + test('matches the DB case-sensitively (parity with REST tags API)', async () => { + tagRepository.find.mockResolvedValue([makeTag({ id: 'tag-1', name: 'Production' })]); + const createdTag = makeTag({ id: 'tag-new', name: 'production' }); + tagRepository.create.mockReturnValue(createdTag); + tagRepository.save.mockResolvedValue(createdTag); + + // 'Production' exists in DB; user asks for 'production' — the existing + // REST contract lets these coexist, so MCP creates a new tag too. + const result = await tagService.findOrCreateByNames(['production']); + + expect(tagRepository.save).toHaveBeenCalledTimes(1); + expect(tagRepository.create).toHaveBeenCalledWith({ name: 'production' }); + expect(result.map((t) => t.id)).toEqual(['tag-new']); + }); + + const uniqueViolationError = (code: string | number) => { + const driver = Object.assign(new Error('duplicate key'), { code }); + return new QueryFailedError('insert', undefined, driver); + }; + + test('returns the now-existing row when a concurrent caller wins the create race (postgres)', async () => { + tagRepository.find.mockResolvedValue([]); + const racedTag = makeTag({ id: 'tag-raced', name: 'critical' }); + + tagRepository.create.mockReturnValue(racedTag); + tagRepository.save.mockRejectedValueOnce(uniqueViolationError('23505')); + tagRepository.findOneBy.mockResolvedValue(racedTag); + + const result = await tagService.findOrCreateByNames(['critical']); + + expect(result).toEqual([racedTag]); + expect(tagRepository.findOneBy).toHaveBeenCalledWith({ name: 'critical' }); + }); + + test('recognises sqlite unique-constraint code', async () => { + tagRepository.find.mockResolvedValue([]); + const racedTag = makeTag({ id: 'tag-raced', name: 'critical' }); + + tagRepository.create.mockReturnValue(racedTag); + tagRepository.save.mockRejectedValueOnce(uniqueViolationError('SQLITE_CONSTRAINT_UNIQUE')); + tagRepository.findOneBy.mockResolvedValue(racedTag); + + const result = await tagService.findOrCreateByNames(['critical']); + + expect(result).toEqual([racedTag]); + }); + + test('rethrows unrelated QueryFailedError instead of masking it as a race', async () => { + tagRepository.find.mockResolvedValue([]); + tagRepository.create.mockReturnValue(makeTag({ name: 'critical' })); + const unrelated = new QueryFailedError('insert', undefined, new Error('connection lost')); + tagRepository.save.mockRejectedValueOnce(unrelated); + + await expect(tagService.findOrCreateByNames(['critical'])).rejects.toBe(unrelated); + expect(tagRepository.findOneBy).not.toHaveBeenCalled(); + }); + + test('rethrows when the loser of the race cannot find the row afterwards', async () => { + tagRepository.find.mockResolvedValue([]); + tagRepository.create.mockReturnValue(makeTag({ name: 'critical' })); + const err = uniqueViolationError('23505'); + tagRepository.save.mockRejectedValueOnce(err); + tagRepository.findOneBy.mockResolvedValue(null); + + await expect(tagService.findOrCreateByNames(['critical'])).rejects.toBe(err); + }); + + test('creates missing tags and merges with existing', async () => { + tagRepository.find.mockResolvedValue([makeTag({ id: 'tag-1', name: 'production' })]); + const createdTag = makeTag({ id: 'tag-new', name: 'critical' }); + tagRepository.create.mockReturnValue(createdTag); + tagRepository.save.mockResolvedValue(createdTag); + + const result = await tagService.findOrCreateByNames(['production', 'critical']); + + expect(result.map((t) => t.name)).toEqual(['production', 'critical']); + expect(tagRepository.save).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts index 20d66cbf3a0..cdf33db367f 100644 --- a/packages/cli/src/services/tag.service.ts +++ b/packages/cli/src/services/tag.service.ts @@ -1,6 +1,8 @@ import type { TagEntity, ITagWithCountDb } from '@n8n/db'; import { TagRepository } from '@n8n/db'; import { Service } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import { In, QueryFailedError } from '@n8n/typeorm'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; @@ -9,6 +11,34 @@ type GetAllResult = T extends { withUsageCount: true } ? ITagWithCountDb[] : type Action = 'Create' | 'Update'; +// Trim and dedupe input names case-insensitively, keeping the first-seen case. +// Inputs are matched against the DB exactly, so this only collapses obvious +// duplicates like ['Prod','prod','PROD'] within a single batch — it does NOT +// change the case-sensitive contract that the REST tag API exposes. +function dedupeNamesPreservingCase(names: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const raw of names) { + const trimmed = raw.trim(); + if (trimmed.length === 0) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; +} + +// n8n supports postgres (SQLSTATE 23505) and sqlite (SQLITE_CONSTRAINT_UNIQUE, +// or the older SQLITE_CONSTRAINT with a "UNIQUE constraint" message). +function isUniqueConstraintViolation(error: unknown): error is QueryFailedError { + if (!(error instanceof QueryFailedError)) return false; + const driver = (error as { driverError?: { code?: unknown } }).driverError; + const code = driver && typeof driver.code !== 'undefined' ? String(driver.code) : undefined; + if (code === '23505' || code === 'SQLITE_CONSTRAINT_UNIQUE') return true; + return code === 'SQLITE_CONSTRAINT' && /UNIQUE constraint/i.test(error.message); +} + @Service() export class TagService { constructor( @@ -46,23 +76,29 @@ export class TagService { return await deleteResult; } - async getAll(options?: T): Promise> { + async getAll( + options?: T, + ): Promise> { if (options?.withUsageCount) { - const tags = await this.tagRepository + const qb = this.tagRepository .createQueryBuilder('tag') .select(['tag.id', 'tag.name', 'tag.createdAt', 'tag.updatedAt']) - .loadRelationCountAndMap('tag.usageCount', 'tag.workflowMappings', 'wm', (qb) => - qb.leftJoin('wm.workflows', 'workflow').where('workflow.isArchived = :isArchived', { + .loadRelationCountAndMap('tag.usageCount', 'tag.workflowMappings', 'wm', (qb2) => + qb2.leftJoin('wm.workflows', 'workflow').where('workflow.isArchived = :isArchived', { isArchived: false, }), - ) - .getMany(); + ); + if (options.orderByName) qb.orderBy('tag.name', 'ASC'); + if (options.limit !== undefined) qb.limit(options.limit); + const tags = await qb.getMany(); return tags as GetAllResult; } return await (this.tagRepository.find({ select: ['id', 'name', 'createdAt', 'updatedAt'], + ...(options?.orderByName ? { order: { name: 'ASC' as const } } : {}), + ...(options?.limit !== undefined ? { take: options.limit } : {}), }) as Promise>); } @@ -72,6 +108,21 @@ export class TagService { }); } + /** + * Paginated tags with non-archived usage counts plus the total count, both + * via DB-level queries. Runs the data query and `count` in parallel. + */ + async listWithUsageCount({ limit }: { limit: number }): Promise<{ + data: ITagWithCountDb[]; + totalCount: number; + }> { + const [data, totalCount] = await Promise.all([ + this.getAll({ withUsageCount: true, limit, orderByName: true }), + this.tagRepository.count(), + ]); + return { data, totalCount }; + } + /** * Sort tags based on the order of the tag IDs in the request. */ @@ -83,4 +134,55 @@ export class TagService { return requestOrder.map((tagId) => tagMap[tagId]); } + + /** + * Look up tags by name; never creates. Input is deduped case-insensitively + * but matched against the DB exactly (REST tag API contract). + */ + async findByNames(names: string[]): Promise { + const uniqueNames = dedupeNamesPreservingCase(names); + if (uniqueNames.length === 0) return []; + + const existing = await this.tagRepository.find({ where: { name: In(uniqueNames) } }); + const existingByName = new Map(existing.map((t) => [t.name, t])); + + const result: TagEntity[] = []; + for (const name of uniqueNames) { + const hit = existingByName.get(name); + if (hit) result.push(hit); + } + return result; + } + + /** + * Resolve names to tag entities, creating any missing. Input is deduped + * case-insensitively (first-seen case wins) but matched against the DB + * exactly. Race-safe against concurrent same-name creates. + */ + async findOrCreateByNames(names: string[]): Promise { + const uniqueNames = dedupeNamesPreservingCase(names); + if (uniqueNames.length === 0) return []; + + const existing = await this.tagRepository.find({ where: { name: In(uniqueNames) } }); + const existingByName = new Map(existing.map((t) => [t.name, t])); + + const result: TagEntity[] = []; + for (const name of uniqueNames) { + const hit = existingByName.get(name); + if (hit) { + result.push(hit); + continue; + } + try { + const created = await this.save(this.toEntity({ name }), 'create'); + result.push(created); + } catch (error) { + if (!isUniqueConstraintViolation(error)) throw error; + const raced = await this.tagRepository.findOneBy({ name }); + if (!raced) throw error; + result.push(raced); + } + } + return result; + } } diff --git a/packages/testing/playwright/services/mcp-api-helper.ts b/packages/testing/playwright/services/mcp-api-helper.ts index ebf6982719f..1f2e7e51f06 100644 --- a/packages/testing/playwright/services/mcp-api-helper.ts +++ b/packages/testing/playwright/services/mcp-api-helper.ts @@ -91,6 +91,7 @@ export interface SearchWorkflowsResult { scopes: string[]; canExecute: boolean; availableInMCP: boolean; + tags: Array<{ id: string; name: string }>; }>; count: number; }