diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index be85989495a..545e9e2575c 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -208,6 +208,8 @@ export namespace ExecutionSummaries { vote: AnnotationVote; projectId: string; workflowVersionId: string; + isArchived: boolean; + workflowBooleanSettings: Array<{ key: string; value: boolean }>; }>; export type StopExecutionFilterQuery = { workflowId: string } & Pick< diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 53faf7d3d29..bd81c3d3128 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -57,6 +57,7 @@ import type { IExecutionFlattedDb, IExecutionResponse, } from '../entities/types-db'; +import { applyWorkflowBooleanSettingFilter } from '../utils/apply-workflow-boolean-setting-filter'; import { separate } from '../utils/separate'; class PostgresLiveRowsRetrievalError extends UnexpectedError { @@ -986,6 +987,8 @@ export class ExecutionRepository extends Repository { vote, projectId, workflowVersionId, + isArchived, + workflowBooleanSettings, } = query; const fields = Object.keys(this.summaryFields) @@ -1089,6 +1092,16 @@ export class ExecutionRepository extends Repository { .andWhere('sw.projectId = :projectId', { projectId }); } + if (isArchived !== undefined) { + qb.andWhere('workflow.isArchived = :isArchived', { isArchived }); + } + + if (workflowBooleanSettings?.length) { + for (const { key, value } of workflowBooleanSettings) { + applyWorkflowBooleanSettingFilter(qb, this.globalConfig, key, value); + } + } + return qb; } diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 8791570ab77..d97eae5c0ed 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -29,6 +29,7 @@ import type { FolderWithWorkflowAndSubFolderCount, ListQuery, } from '../entities/types-db'; +import { applyWorkflowBooleanSettingFilter } from '../utils/apply-workflow-boolean-setting-filter'; import { buildWorkflowsByNodesQuery } from '../utils/build-workflows-by-nodes-query'; import { isStringArray } from '../utils/is-string-array'; import { TimedQuery } from '../utils/timed-query'; @@ -889,33 +890,15 @@ export class WorkflowRepository extends Repository { filter: ListQuery.Options['filter'], ): void { if (typeof filter?.availableInMCP === 'boolean') { - const dbType = this.globalConfig.database.type; - - if (filter.availableInMCP) { - // When filtering for true, only match explicit true values - if (dbType === 'postgresdb') { - qb.andWhere("workflow.settings ->> 'availableInMCP' = :availableInMCP", { - availableInMCP: 'true', - }); - } else if (dbType === 'sqlite') { - qb.andWhere("JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP", { - availableInMCP: 1, // SQLite stores booleans as 0/1 - }); - } - } else { - // When filtering for false, match explicit false OR null/undefined (field not set) - if (dbType === 'postgresdb') { - qb.andWhere( - "(workflow.settings ->> 'availableInMCP' = :availableInMCP OR workflow.settings ->> 'availableInMCP' IS NULL)", - { availableInMCP: 'false' }, - ); - } else if (dbType === 'sqlite') { - qb.andWhere( - "(JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP OR JSON_EXTRACT(workflow.settings, '$.availableInMCP') IS NULL)", - { availableInMCP: 0 }, // SQLite stores booleans as 0/1 - ); - } - } + applyWorkflowBooleanSettingFilter( + qb, + this.globalConfig, + 'availableInMCP', + filter.availableInMCP, + { + includeNullOnFalse: true, + }, + ); } } diff --git a/packages/@n8n/db/src/utils/__tests__/apply-workflow-boolean-setting-filter.test.ts b/packages/@n8n/db/src/utils/__tests__/apply-workflow-boolean-setting-filter.test.ts new file mode 100644 index 00000000000..e53c3db90a8 --- /dev/null +++ b/packages/@n8n/db/src/utils/__tests__/apply-workflow-boolean-setting-filter.test.ts @@ -0,0 +1,136 @@ +import type { GlobalConfig } from '@n8n/config'; +import type { SelectQueryBuilder } from '@n8n/typeorm'; + +import { applyWorkflowBooleanSettingFilter } from '../apply-workflow-boolean-setting-filter'; + +function createMockQb() { + const qb = { + andWhere: jest.fn(), + where: jest.fn(), + orWhere: jest.fn(), + } as unknown as SelectQueryBuilder; + return qb; +} + +function createGlobalConfig(dbType: 'postgresdb' | 'sqlite') { + return { database: { type: dbType } } as GlobalConfig; +} + +describe('applyWorkflowBooleanSettingFilter', () => { + describe('key validation', () => { + it('should reject keys with special characters', () => { + const qb = createMockQb(); + expect(() => + applyWorkflowBooleanSettingFilter(qb, createGlobalConfig('sqlite'), "'; DROP TABLE", true), + ).toThrow('Invalid settings key'); + }); + + it('should reject keys starting with a number', () => { + const qb = createMockQb(); + expect(() => + applyWorkflowBooleanSettingFilter(qb, createGlobalConfig('sqlite'), '1abc', true), + ).toThrow('Invalid settings key'); + }); + + it('should accept valid alphanumeric keys', () => { + const qb = createMockQb(); + expect(() => + applyWorkflowBooleanSettingFilter(qb, createGlobalConfig('sqlite'), 'availableInMCP', true), + ).not.toThrow(); + }); + }); + + describe('postgres', () => { + const config = createGlobalConfig('postgresdb'); + + it('should filter for true values', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', true); + + expect(qb.andWhere).toHaveBeenCalledWith( + "workflow.settings ->> 'availableInMCP' = :availableInMCP", + { availableInMCP: 'true' }, + ); + }); + + it('should filter for false values', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', false); + + expect(qb.andWhere).toHaveBeenCalledWith( + "(workflow.settings ->> 'availableInMCP' = :availableInMCP)", + { availableInMCP: 'false' }, + ); + }); + + it('should include null clause when includeNullOnFalse is true', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', false, { + includeNullOnFalse: true, + }); + + expect(qb.andWhere).toHaveBeenCalledWith( + "(workflow.settings ->> 'availableInMCP' = :availableInMCP OR workflow.settings ->> 'availableInMCP' IS NULL)", + { availableInMCP: 'false' }, + ); + }); + }); + + describe('sqlite', () => { + const config = createGlobalConfig('sqlite'); + + it('should filter for true values', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', true); + + expect(qb.andWhere).toHaveBeenCalledWith( + "JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP", + { availableInMCP: 1 }, + ); + }); + + it('should filter for false values', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', false); + + expect(qb.andWhere).toHaveBeenCalledWith( + "(JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP)", + { availableInMCP: 0 }, + ); + }); + + it('should include null clause when includeNullOnFalse is true', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', false, { + includeNullOnFalse: true, + }); + + expect(qb.andWhere).toHaveBeenCalledWith( + "(JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP OR JSON_EXTRACT(workflow.settings, '$.availableInMCP') IS NULL)", + { availableInMCP: 0 }, + ); + }); + }); + + describe('options', () => { + const config = createGlobalConfig('sqlite'); + + it('should use custom alias', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', true, { alias: 'wf' }); + + expect(qb.andWhere).toHaveBeenCalledWith( + "JSON_EXTRACT(wf.settings, '$.availableInMCP') = :availableInMCP", + { availableInMCP: 1 }, + ); + }); + + it('should use custom method', () => { + const qb = createMockQb(); + applyWorkflowBooleanSettingFilter(qb, config, 'availableInMCP', true, { method: 'where' }); + + expect(qb.where).toHaveBeenCalled(); + expect(qb.andWhere).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/@n8n/db/src/utils/apply-workflow-boolean-setting-filter.ts b/packages/@n8n/db/src/utils/apply-workflow-boolean-setting-filter.ts new file mode 100644 index 00000000000..7138c00e280 --- /dev/null +++ b/packages/@n8n/db/src/utils/apply-workflow-boolean-setting-filter.ts @@ -0,0 +1,53 @@ +import type { GlobalConfig } from '@n8n/config'; +import type { SelectQueryBuilder } from '@n8n/typeorm'; + +type BooleanSettingFilterOptions = { + alias?: string; + method?: 'where' | 'andWhere' | 'orWhere'; + includeNullOnFalse?: boolean; +}; + +const VALID_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/; + +export function applyWorkflowBooleanSettingFilter( + qb: SelectQueryBuilder, + globalConfig: GlobalConfig, + key: string, + value: boolean, + options: BooleanSettingFilterOptions = {}, +): void { + if (!VALID_KEY_PATTERN.test(key)) { + throw new Error(`Invalid settings key: ${key}`); + } + + const { alias = 'workflow', method = 'andWhere', includeNullOnFalse = false } = options; + const dbType = globalConfig.database.type; + const settingsColumn = `${alias}.settings`; + const parameterName = key; + + if (value) { + // When filtering for true, only match explicit true values. + if (dbType === 'postgresdb') { + qb[method](`${settingsColumn} ->> '${key}' = :${parameterName}`, { + [parameterName]: 'true', + }); + } else if (dbType === 'sqlite') { + qb[method](`JSON_EXTRACT(${settingsColumn}, '$.${key}') = :${parameterName}`, { + [parameterName]: 1, + }); + } + } else if (dbType === 'postgresdb') { + // Optionally treat null/undefined the same as false for settings that default to off. + const nullClause = includeNullOnFalse ? ` OR ${settingsColumn} ->> '${key}' IS NULL` : ''; + qb[method](`(${settingsColumn} ->> '${key}' = :${parameterName}${nullClause})`, { + [parameterName]: 'false', + }); + } else if (dbType === 'sqlite') { + // SQLite stores booleans as 0/1 inside JSON_EXTRACT results. + const extracted = `JSON_EXTRACT(${settingsColumn}, '$.${key}')`; + const nullClause = includeNullOnFalse ? ` OR ${extracted} IS NULL` : ''; + qb[method](`(${extracted} = :${parameterName}${nullClause})`, { + [parameterName]: 0, + }); + } +} diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index f6f8caf2c84..bac84a97f44 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -52,6 +52,8 @@ describe('ExecutionService', () => { mock(), mock(), mock(), + mock(), + mock(), executionRedactionServiceProxy, ); @@ -160,6 +162,8 @@ describe('ExecutionService', () => { mock(), mock(), mock(), + mock(), + mock(), localExecutionRedactionProxy, ); diff --git a/packages/cli/src/executions/__tests__/executions.controller.test.ts b/packages/cli/src/executions/__tests__/executions.controller.test.ts index 96a6e92bbac..d4b76793bab 100644 --- a/packages/cli/src/executions/__tests__/executions.controller.test.ts +++ b/packages/cli/src/executions/__tests__/executions.controller.test.ts @@ -6,20 +6,17 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { ExecutionService } from '@/executions/execution.service'; import type { ExecutionRequest } from '@/executions/execution.types'; import { ExecutionsController } from '@/executions/executions.controller'; -import type { RoleService } from '@/services/role.service'; import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; describe('ExecutionsController', () => { const executionService = mock(); const workflowSharingService = mock(); - const roleService = mock(); const executionsController = new ExecutionsController( executionService, mock(), workflowSharingService, mock(), - roleService, ); beforeEach(() => { @@ -90,7 +87,10 @@ describe('ExecutionsController', () => { test.each(QUERIES_WITH_EITHER_STATUS_OR_RANGE)( 'should fetch executions per query', async (rangeQuery) => { - roleService.rolesWithScope.mockResolvedValue([]); + executionService.buildSharingOptions.mockResolvedValue({ + workflowRoles: [], + projectRoles: [], + }); executionService.findLatestCurrentAndCompleted.mockResolvedValue(NO_EXECUTIONS); const req = mock({ rangeQuery }); @@ -108,7 +108,10 @@ describe('ExecutionsController', () => { test.each(QUERIES_NEITHER_STATUS_NOR_RANGE_PROVIDED)( 'should fetch executions per query', async (rangeQuery) => { - roleService.rolesWithScope.mockResolvedValue([]); + executionService.buildSharingOptions.mockResolvedValue({ + workflowRoles: [], + projectRoles: [], + }); executionService.findLatestCurrentAndCompleted.mockResolvedValue(NO_EXECUTIONS); const req = mock({ rangeQuery }); @@ -124,7 +127,10 @@ describe('ExecutionsController', () => { describe('if both status and range provided', () => { it('should fetch executions per query', async () => { - roleService.rolesWithScope.mockResolvedValue([]); + executionService.buildSharingOptions.mockResolvedValue({ + workflowRoles: [], + projectRoles: [], + }); executionService.findLatestCurrentAndCompleted.mockResolvedValue(NO_EXECUTIONS); const rangeQuery: ExecutionSummaries.RangeQuery = { diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index ebe6602adbc..7623b49e471 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -1,5 +1,5 @@ import { ExecutionRedactionQueryDtoSchema } from '@n8n/api-types'; -import { Logger } from '@n8n/backend-common'; +import { LicenseState, Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import type { CreateExecutionPayload, @@ -17,6 +17,7 @@ import { WorkflowRepository, } from '@n8n/db'; import { Service } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions'; import { stringify } from 'flatted'; import { validate as jsonSchemaValidate } from 'jsonschema'; import type { @@ -50,6 +51,7 @@ import { EventService } from '@/events/event.service'; import type { IExecutionFlattedResponse } from '@/interfaces'; import { License } from '@/license'; import { NodeTypes } from '@/node-types'; +import { RoleService } from '@/services/role.service'; import { WaitTracker } from '@/wait-tracker'; import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @@ -120,11 +122,31 @@ export class ExecutionService { private readonly workflowRunner: WorkflowRunner, private readonly concurrencyControl: ConcurrencyControlService, private readonly license: License, + private readonly licenseState: LicenseState, + private readonly roleService: RoleService, private readonly workflowSharingService: WorkflowSharingService, private readonly eventService: EventService, private readonly executionRedactionServiceProxy: ExecutionRedactionServiceProxy, ) {} + /** + * Build sharing options for execution queries based on whether sharing is licensed. + */ + async buildSharingOptions( + scope: Scope, + ): Promise { + if (this.licenseState.isSharingLicensed()) { + const projectRoles = await this.roleService.rolesWithScope('project', [scope]); + const workflowRoles = await this.roleService.rolesWithScope('workflow', [scope]); + return { scopes: [scope], projectRoles, workflowRoles }; + } + + return { + workflowRoles: ['workflow:owner'], + projectRoles: [PROJECT_OWNER_ROLE_SLUG], + }; + } + async findOne( req: ExecutionRequest.GetOne | ExecutionRequest.Update, sharedWorkflowIds: string[], diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index 4ca72404342..5d4f2ff332c 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -11,7 +11,6 @@ import { validateExecutionUpdatePayload } from './validation'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { License } from '@/license'; -import { RoleService } from '@/services/role.service'; import { isPositiveInteger } from '@/utils'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @@ -22,7 +21,6 @@ export class ExecutionsController { private readonly enterpriseExecutionService: EnterpriseExecutionsService, private readonly workflowSharingService: WorkflowSharingService, private readonly license: License, - private readonly roleService: RoleService, ) {} private async getAccessibleWorkflowIds(user: User, scope: Scope) { @@ -40,19 +38,8 @@ export class ExecutionsController { async getMany(req: ExecutionRequest.GetMany) { const { rangeQuery: query } = req; - // Build sharing options for the subquery instead of fetching IDs upfront - const scope: Scope = 'workflow:read'; query.user = req.user; - if (this.license.isSharingEnabled()) { - const projectRoles = await this.roleService.rolesWithScope('project', [scope]); - const workflowRoles = await this.roleService.rolesWithScope('workflow', [scope]); - query.sharingOptions = { scopes: [scope], projectRoles, workflowRoles }; - } else { - query.sharingOptions = { - workflowRoles: ['workflow:owner'], - projectRoles: [PROJECT_OWNER_ROLE_SLUG], - }; - } + query.sharingOptions = await this.executionService.buildSharingOptions('workflow:read'); if (!this.license.isAdvancedExecutionFiltersEnabled()) { delete query.metadata; 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 04291130888..2809f1b7788 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts @@ -18,6 +18,7 @@ import { WorkflowBuilderToolsService } from '../tools/workflow-builder/workflow- import { ActiveExecutions } from '@/active-executions'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsService } from '@/credentials/credentials.service'; +import { ExecutionService } from '@/executions/execution.service'; import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service'; import { NodeTypes } from '@/node-types'; import { ProjectService } from '@/services/project.service.ee'; @@ -28,7 +29,6 @@ import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowService } from '@/workflows/workflow.service'; -import { ExecutionService } from '@/executions/execution.service'; describe('McpService', () => { let mcpService: McpService; diff --git a/packages/cli/src/modules/mcp/__tests__/search-executions.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-executions.tool.test.ts new file mode 100644 index 00000000000..9ceee112d59 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/search-executions.tool.test.ts @@ -0,0 +1,227 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import { User } from '@n8n/db'; +import type { ExecutionSummary } from 'n8n-workflow'; + +import { ExecutionService } from '@/executions/execution.service'; +import { Telemetry } from '@/telemetry'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import { createSearchExecutionsTool } from '../tools/search-executions.tool'; + +const createExecution = (overrides: Partial = {}): ExecutionSummary => + ({ + id: 'exec-1', + workflowId: 'wf-1', + status: 'success', + mode: 'manual', + startedAt: '2024-06-01T10:00:00.000Z', + stoppedAt: '2024-06-01T10:01:00.000Z', + waitTill: undefined, + finished: true, + createdAt: '2024-06-01T10:00:00.000Z', + ...overrides, + }) as ExecutionSummary; + +describe('search-executions MCP tool', () => { + const user = Object.assign(new User(), { id: 'user-1' }); + let executionService: ExecutionService; + let workflowFinderService: WorkflowFinderService; + let telemetry: Telemetry; + + beforeEach(() => { + executionService = mockInstance(ExecutionService, { + findRangeWithCount: jest.fn().mockResolvedValue({ + results: [], + count: 0, + estimated: false, + }), + buildSharingOptions: jest.fn().mockResolvedValue({ + scopes: ['workflow:read'], + projectRoles: ['project:editor'], + workflowRoles: ['workflow:editor'], + }), + }); + workflowFinderService = mockInstance(WorkflowFinderService, { + findWorkflowForUser: jest.fn().mockResolvedValue({ + id: 'wf-1', + isArchived: false, + settings: { availableInMCP: true }, + }), + }); + telemetry = mockInstance(Telemetry, { + track: jest.fn(), + }); + }); + + const createTool = () => + createSearchExecutionsTool(user, executionService, workflowFinderService, telemetry); + + test('creates tool with correct metadata', () => { + const tool = createTool(); + + expect(tool.name).toBe('search_executions'); + expect(tool.config.annotations?.readOnlyHint).toBe(true); + expect(typeof tool.handler).toBe('function'); + }); + + test('returns executions with correct format', async () => { + const executions = [ + createExecution({ id: 'exec-1', workflowId: 'wf-1', status: 'success' }), + createExecution({ + id: 'exec-2', + workflowId: 'wf-1', + status: 'error', + // ExecutionSummary dates are typed incorrectly + // @ts-expect-error toSummary() returns ISO strings, not Dates + stoppedAt: '2024-06-01T10:02:00.000Z', + }), + ]; + (executionService.findRangeWithCount as jest.Mock).mockResolvedValue({ + results: executions, + count: 2, + estimated: false, + }); + + const result = await createTool().handler({} as never, {} as never); + + expect(result.structuredContent).toEqual({ + data: [ + { + id: 'exec-1', + workflowId: 'wf-1', + status: 'success', + mode: 'manual', + startedAt: '2024-06-01T10:00:00.000Z', + stoppedAt: '2024-06-01T10:01:00.000Z', + waitTill: null, + }, + { + id: 'exec-2', + workflowId: 'wf-1', + status: 'error', + mode: 'manual', + startedAt: '2024-06-01T10:00:00.000Z', + stoppedAt: '2024-06-01T10:02:00.000Z', + waitTill: null, + }, + ], + count: 2, + estimated: false, + }); + }); + + test('filters by workflowId and validates MCP access', async () => { + await createTool().handler({ workflowId: 'wf-1' } as never, {} as never); + + expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith( + 'wf-1', + user, + ['workflow:read'], + { includeActiveVersion: undefined }, + ); + + const query = (executionService.findRangeWithCount as jest.Mock).mock.calls[0][0]; + expect(query.workflowId).toBe('wf-1'); + }); + + test('filters by status', async () => { + await createTool().handler({ status: ['error', 'crashed'] } as never, {} as never); + + const query = (executionService.findRangeWithCount as jest.Mock).mock.calls[0][0]; + expect(query.status).toEqual(['error', 'crashed']); + }); + + test('filters by time range', async () => { + await createTool().handler( + { + startedAfter: '2024-06-01T00:00:00.000Z', + startedBefore: '2024-06-07T23:59:59.999Z', + } as never, + {} as never, + ); + + const query = (executionService.findRangeWithCount as jest.Mock).mock.calls[0][0]; + expect(query.startedAfter).toBe('2024-06-01T00:00:00.000Z'); + expect(query.startedBefore).toBe('2024-06-07T23:59:59.999Z'); + }); + + test('respects limit parameter and clamps to max', async () => { + await createTool().handler({ limit: 500 } as never, {} as never); + + const query = (executionService.findRangeWithCount as jest.Mock).mock.calls[0][0]; + expect(query.range.limit).toBe(200); + }); + + test('uses default limit when not provided', async () => { + await createTool().handler({} as never, {} as never); + + const query = (executionService.findRangeWithCount as jest.Mock).mock.calls[0][0]; + expect(query.range.limit).toBe(200); + }); + + test('handles pagination with lastId', async () => { + await createTool().handler({ lastId: 'exec-50' } as never, {} as never); + + const query = (executionService.findRangeWithCount as jest.Mock).mock.calls[0][0]; + expect(query.range.lastId).toBe('exec-50'); + }); + + test('returns empty results with correct structure', async () => { + const result = await createTool().handler({} as never, {} as never); + + expect(result.structuredContent).toEqual({ + data: [], + count: 0, + estimated: false, + }); + }); + + test('delegates sharing options to executionService.buildSharingOptions', async () => { + await createTool().handler({} as never, {} as never); + + expect(executionService.buildSharingOptions).toHaveBeenCalledWith('workflow:read'); + }); + + test('tracks telemetry on success', async () => { + (executionService.findRangeWithCount as jest.Mock).mockResolvedValue({ + results: [createExecution()], + count: 1, + estimated: false, + }); + + await createTool().handler({ workflowId: 'wf-1' } as never, {} as never); + + expect(telemetry.track).toHaveBeenCalledWith( + 'User called mcp tool', + expect.objectContaining({ + user_id: 'user-1', + tool_name: 'search_executions', + results: { success: true, data: { count: 1, estimated: false } }, + }), + ); + }); + + test('tracks telemetry on failure and returns error response', async () => { + (executionService.findRangeWithCount as jest.Mock).mockRejectedValue( + new Error('DB connection lost'), + ); + + const result = await createTool().handler({} as never, {} as never); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ + data: [], + count: 0, + estimated: false, + error: 'DB connection lost', + }); + + expect(telemetry.track).toHaveBeenCalledWith( + 'User called mcp tool', + expect.objectContaining({ + tool_name: 'search_executions', + results: { success: false, error: 'DB connection lost' }, + }), + ); + }); +}); diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index 80942bfab2f..7713293458b 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -28,6 +28,7 @@ import { } from './tools/data-table'; import { createExecuteWorkflowTool } from './tools/execute-workflow.tool'; import { createGetExecutionTool } from './tools/get-execution.tool'; +import { createSearchExecutionsTool } from './tools/search-executions.tool'; import { createWorkflowDetailsTool } from './tools/get-workflow-details.tool'; import { createPublishWorkflowTool } from './tools/publish-workflow.tool'; import { createSearchFoldersTool } from './tools/search-folders.tool'; @@ -152,6 +153,18 @@ export class McpService { ); server.registerTool(getExecutionTool.name, getExecutionTool.config, getExecutionTool.handler); + const searchExecutionsTool = createSearchExecutionsTool( + user, + this.executionService, + this.workflowFinderService, + this.telemetry, + ); + server.registerTool( + searchExecutionsTool.name, + searchExecutionsTool.config, + searchExecutionsTool.handler, + ); + const workflowDetailsTool = createWorkflowDetailsTool( user, this.urlService.getWebhookBaseUrl(), diff --git a/packages/cli/src/modules/mcp/tools/search-executions.tool.ts b/packages/cli/src/modules/mcp/tools/search-executions.tool.ts new file mode 100644 index 00000000000..f0af42cd740 --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/search-executions.tool.ts @@ -0,0 +1,175 @@ +import type { User } from '@n8n/db'; +import { ExecutionStatusList, WorkflowExecuteModeList, type ExecutionStatus } from 'n8n-workflow'; +import z from 'zod'; + +import type { ExecutionService } from '@/executions/execution.service'; +import type { Telemetry } from '@/telemetry'; +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants'; +import { WorkflowAccessError } from '../mcp.errors'; +import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types'; +import { createLimitSchema } from './schemas'; +import { getMcpWorkflow } from './workflow-validation.utils'; + +const MAX_RESULTS = 200; + +const inputSchema = { + workflowId: z.string().optional().describe('Filter executions by workflow ID'), + status: z + .array(z.enum(ExecutionStatusList)) + .optional() + .describe('Filter by execution status(es)'), + startedAfter: z + .string() + .datetime({ offset: true }) + .optional() + .describe('ISO 8601 timestamp — only return executions that started after this time'), + startedBefore: z + .string() + .datetime({ offset: true }) + .optional() + .describe('ISO 8601 timestamp — only return executions that started before this time'), + limit: createLimitSchema(MAX_RESULTS), + lastId: z + .string() + .optional() + .describe('Cursor for pagination — pass the last execution ID from the previous page'), +} satisfies z.ZodRawShape; + +const outputSchema = { + data: z + .array( + z.object({ + id: z.string().describe('The unique identifier of the execution'), + workflowId: z.string().describe('The workflow this execution belongs to'), + status: z.enum(ExecutionStatusList).describe('The execution status'), + mode: z.enum(WorkflowExecuteModeList).describe('How the execution was triggered'), + startedAt: z.string().nullable().describe('ISO timestamp when the execution started'), + stoppedAt: z.string().nullable().describe('ISO timestamp when the execution stopped'), + waitTill: z + .string() + .nullable() + .describe('ISO timestamp until when the execution is waiting'), + }), + ) + .describe('List of executions matching the query'), + count: z + .union([z.literal(-1), z.number().int().min(0)]) + .describe('Total matching executions, or -1 if the count is unavailable'), + estimated: z.boolean().describe('Whether the count is an estimate (for large datasets)'), + error: z.string().optional().describe('Error message if the query failed'), +} satisfies z.ZodRawShape; + +export const createSearchExecutionsTool = ( + user: User, + executionService: ExecutionService, + workflowFinderService: WorkflowFinderService, + telemetry: Telemetry, +): ToolDefinition => ({ + name: 'search_executions', + config: { + description: + 'Search for workflow executions with optional filters. Returns execution metadata including status, timing, and workflow ID.', + inputSchema, + outputSchema, + annotations: { + title: 'Search Executions', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + handler: async ({ + workflowId, + status, + startedAfter, + startedBefore, + limit = MAX_RESULTS, + lastId, + }: { + workflowId?: string; + status?: ExecutionStatus[]; + startedAfter?: string; + startedBefore?: string; + limit?: number; + lastId?: string; + }) => { + const parameters = { workflowId, status, startedAfter, startedBefore, limit, lastId }; + const telemetryPayload: UserCalledMCPToolEventPayload = { + user_id: user.id, + tool_name: 'search_executions', + parameters, + }; + + try { + // Validate workflow access if workflowId is provided + if (workflowId) { + await getMcpWorkflow(workflowId, user, ['workflow:read'], workflowFinderService); + } + + const safeLimit = Math.min(Math.max(1, limit), MAX_RESULTS); + const sharingOptions = await executionService.buildSharingOptions('workflow:read'); + + const query = { + kind: 'range' as const, + user, + sharingOptions, + range: { + limit: safeLimit, + ...(lastId ? { lastId } : {}), + }, + order: { startedAt: 'DESC' as const }, + ...(workflowId ? { workflowId } : {}), + ...(status?.length ? { status } : {}), + ...(startedAfter ? { startedAfter } : {}), + ...(startedBefore ? { startedBefore } : {}), + isArchived: false, + workflowBooleanSettings: [{ key: 'availableInMCP', value: true }], + }; + + const { results, count, estimated } = await executionService.findRangeWithCount(query); + + const data = results.map((execution) => ({ + id: execution.id, + workflowId: execution.workflowId, + status: execution.status, + mode: execution.mode, + startedAt: execution.startedAt ?? null, + stoppedAt: execution.stoppedAt ?? null, + waitTill: execution.waitTill ?? null, + })); + + const payload = { data, count, estimated }; + + telemetryPayload.results = { + success: true, + data: { count, estimated }, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + + return { + structuredContent: payload, + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; + } catch (er) { + const error = er instanceof Error ? er : new Error(String(er)); + const isAccessError = error instanceof WorkflowAccessError; + + telemetryPayload.results = { + success: false, + error: error.message, + error_reason: isAccessError ? error.reason : undefined, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + + const output = { data: [], count: 0, estimated: false, error: error.message }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output, + isError: true, + }; + } + }, +}); diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index 8b0853ae60b..63be1be76df 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -45,6 +45,8 @@ describe('ExecutionService', () => { mock(), mock(), mock(), + mock(), + mock(), ); owner = await createOwner(); diff --git a/packages/workflow/src/execution-context.ts b/packages/workflow/src/execution-context.ts index 41de66f041c..6c9a60f922b 100644 --- a/packages/workflow/src/execution-context.ts +++ b/packages/workflow/src/execution-context.ts @@ -30,20 +30,22 @@ export const CredentialContextSchema = z */ export type ICredentialContext = z.output; -const WorkflowExecuteModeSchema = z.union([ - z.literal('cli'), - z.literal('error'), - z.literal('integrated'), - z.literal('internal'), - z.literal('manual'), - z.literal('retry'), - z.literal('trigger'), - z.literal('webhook'), - z.literal('evaluation'), - z.literal('chat'), -]); +export const WorkflowExecuteModeList = [ + 'cli', + 'error', + 'integrated', + 'internal', + 'manual', + 'retry', + 'trigger', + 'webhook', + 'evaluation', + 'chat', +] as const; -export type WorkflowExecuteModeValues = z.infer; +const WorkflowExecuteModeSchema = z.enum(WorkflowExecuteModeList); + +export type WorkflowExecuteModeValues = (typeof WorkflowExecuteModeList)[number]; const RedactionPolicySchema = z.union([ z.literal('none'),