feat(core): Add MCP tool search executions (#29161)

This commit is contained in:
Daria
2026-05-04 13:41:47 +00:00
committed by GitHub
parent f69aea3899
commit 1d9548c81f
15 changed files with 687 additions and 62 deletions
@@ -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();
+15 -13
View File
@@ -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'),