mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
feat(core): Add MCP tool search executions (#29161)
This commit is contained in:
@@ -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<
|
||||
|
||||
@@ -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<ExecutionEntity> {
|
||||
vote,
|
||||
projectId,
|
||||
workflowVersionId,
|
||||
isArchived,
|
||||
workflowBooleanSettings,
|
||||
} = query;
|
||||
|
||||
const fields = Object.keys(this.summaryFields)
|
||||
@@ -1089,6 +1092,16 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WorkflowEntity> {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<object>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Entity extends object>(
|
||||
qb: SelectQueryBuilder<Entity>,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ describe('ExecutionService', () => {
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
executionRedactionServiceProxy,
|
||||
);
|
||||
|
||||
@@ -160,6 +162,8 @@ describe('ExecutionService', () => {
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
localExecutionRedactionProxy,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<ExecutionService>();
|
||||
const workflowSharingService = mock<WorkflowSharingService>();
|
||||
const roleService = mock<RoleService>();
|
||||
|
||||
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<ExecutionRequest.GetMany>({ 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<ExecutionRequest.GetMany>({ 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 = {
|
||||
|
||||
@@ -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<ExecutionSummaries.RangeQuery['sharingOptions']> {
|
||||
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[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {}): 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' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<typeof inputSchema> => ({
|
||||
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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -45,6 +45,8 @@ describe('ExecutionService', () => {
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
);
|
||||
|
||||
owner = await createOwner();
|
||||
|
||||
@@ -30,20 +30,22 @@ export const CredentialContextSchema = z
|
||||
*/
|
||||
export type ICredentialContext = z.output<typeof CredentialContextSchema>;
|
||||
|
||||
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<typeof WorkflowExecuteModeSchema>;
|
||||
const WorkflowExecuteModeSchema = z.enum(WorkflowExecuteModeList);
|
||||
|
||||
export type WorkflowExecuteModeValues = (typeof WorkflowExecuteModeList)[number];
|
||||
|
||||
const RedactionPolicySchema = z.union([
|
||||
z.literal('none'),
|
||||
|
||||
Reference in New Issue
Block a user