fix(core): Scope live and test webhook handlers to their route family (#28804)

This commit is contained in:
Bernhard Wittmann
2026-04-24 11:06:02 +00:00
committed by GitHub
parent 089d6bda0e
commit bb25d4cc38
11 changed files with 417 additions and 62 deletions
+18 -9
View File
@@ -232,12 +232,16 @@ export abstract class AbstractServer {
// Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests
if (this.webhooksEnabled) {
const liveWebhooksRequestHandler = createWebhookHandlerFor(Container.get(LiveWebhooks));
const liveWebhooks = Container.get(LiveWebhooks);
// Register a handler for live forms
this.app.all(`/${this.endpointForm}/*path`, liveWebhooksRequestHandler);
this.app.all(`/${this.endpointForm}/*path`, createWebhookHandlerFor(liveWebhooks, 'form'));
// Register a handler for live webhooks
this.app.all(`/${this.endpointWebhook}/*path`, liveWebhooksRequestHandler);
this.app.all(
`/${this.endpointWebhook}/*path`,
createWebhookHandlerFor(liveWebhooks, 'webhook'),
);
// Register a handler for waiting forms
this.app.all(
@@ -252,18 +256,23 @@ export abstract class AbstractServer {
);
// Register a handler for live MCP servers
this.app.all(`/${this.endpointMcp}/*path`, liveWebhooksRequestHandler);
this.app.all(`/${this.endpointMcp}/*path`, createWebhookHandlerFor(liveWebhooks, 'mcp'));
}
if (this.testWebhooksEnabled) {
const testWebhooksRequestHandler = createWebhookHandlerFor(Container.get(TestWebhooks));
const testWebhooks = Container.get(TestWebhooks);
// Register a handler
this.app.all(`/${this.endpointFormTest}/*path`, testWebhooksRequestHandler);
this.app.all(`/${this.endpointWebhookTest}/*path`, testWebhooksRequestHandler);
this.app.all(
`/${this.endpointFormTest}/*path`,
createWebhookHandlerFor(testWebhooks, 'form'),
);
this.app.all(
`/${this.endpointWebhookTest}/*path`,
createWebhookHandlerFor(testWebhooks, 'webhook'),
);
// Register a handler for test MCP servers
this.app.all(`/${this.endpointMcpTest}/*path`, testWebhooksRequestHandler);
this.app.all(`/${this.endpointMcpTest}/*path`, createWebhookHandlerFor(testWebhooks, 'mcp'));
}
// Block bots from scanning the application
@@ -13,6 +13,7 @@ import type {
Workflow,
} from 'n8n-workflow';
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
import type { NodeTypes } from '@/node-types';
import { LiveWebhooks } from '@/webhooks/live-webhooks';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
@@ -105,7 +106,7 @@ describe('LiveWebhooks', () => {
httpMethod,
path: webhookPath,
node: nodeName,
webhookDescription: {},
webhookDescription: { nodeType: undefined } as never,
workflowId,
});
@@ -218,7 +219,7 @@ describe('LiveWebhooks', () => {
httpMethod,
path: webhookPath,
node: nodeName,
webhookDescription: {},
webhookDescription: { nodeType: undefined } as never,
workflowId,
});
@@ -261,4 +262,141 @@ describe('LiveWebhooks', () => {
expect(capturedWorkflowData!.nodes[1].id).not.toBe('set-node-draft');
});
});
describe('route family scoping', () => {
const workflowId = 'workflow-1';
const nodeName = 'Trigger';
const webhookPath = 'my-path';
const httpMethod: IHttpRequestMethods = 'GET';
const setupMocks = (
declaredNodeType: 'form' | 'webhook' | 'mcp' | undefined,
nodeTypeName = 'n8n-nodes-base.webhook',
) => {
const node: INode = {
id: 'trigger-node',
name: nodeName,
type: nodeTypeName,
typeVersion: 1,
position: [0, 0],
parameters: { path: webhookPath, httpMethod },
};
const activeVersion = mock<WorkflowHistory>({
versionId: 'v1',
workflowId,
nodes: [node],
connections: {},
});
const workflowEntity = mock<WorkflowEntity>({
id: workflowId,
name: 'Test Workflow',
active: true,
activeVersionId: 'v1',
nodes: [node],
connections: {},
activeVersion,
shared: [{ role: 'workflow:owner', project: { id: 'project-1', projectRelations: [] } }],
});
const webhookEntity = mock<WebhookEntity>({
workflowId,
node: nodeName,
webhookPath,
method: httpMethod,
isDynamic: false,
});
const webhookNodeType = mock<INodeType>({
description: {
name: nodeTypeName,
properties: [],
webhooks: [{ nodeType: declaredNodeType, name: 'default' } as never],
},
webhook: jest.fn(),
});
const webhookData = mock<IWebhookData>({
httpMethod,
path: webhookPath,
node: nodeName,
webhookDescription: { nodeType: declaredNodeType } as never,
workflowId,
});
webhookService.findWebhook.mockResolvedValue(webhookEntity);
webhookService.getWebhookMethods.mockResolvedValue([httpMethod]);
workflowRepository.findOne.mockResolvedValue(workflowEntity);
nodeTypes.getByNameAndVersion.mockReturnValue(webhookNodeType);
webhookService.getNodeWebhooks.mockReturnValue([webhookData]);
(WebhookHelpers.executeWebhook as jest.Mock).mockImplementation((...args: unknown[]) => {
const webhookCallback = args[args.length - 1] as (
error: Error | null,
data: object,
) => void;
void webhookCallback(null, {});
});
};
const buildRequest = () =>
mock<WebhookRequest>({ method: httpMethod, params: { path: webhookPath } });
it('executes a form trigger on the form route family', async () => {
setupMocks('form', 'n8n-nodes-base.formTrigger');
await expect(
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'form'),
).resolves.toBeDefined();
});
it('returns a not-found error when a form trigger is requested on the webhook route family', async () => {
setupMocks('form', 'n8n-nodes-base.formTrigger');
await expect(
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'webhook'),
).rejects.toBeInstanceOf(WebhookNotFoundError);
});
it('executes a regular webhook on the webhook route family', async () => {
setupMocks(undefined);
await expect(
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'webhook'),
).resolves.toBeDefined();
});
it('returns a not-found error when a regular webhook is requested on the form route family', async () => {
setupMocks(undefined);
await expect(
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'form'),
).rejects.toBeInstanceOf(WebhookNotFoundError);
});
it('executes an mcp trigger on the mcp route family', async () => {
setupMocks('mcp', '@n8n/n8n-nodes-langchain.mcpTrigger');
await expect(
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'mcp'),
).resolves.toBeDefined();
});
it('returns a not-found error when an mcp trigger is requested on the form route family', async () => {
setupMocks('mcp', '@n8n/n8n-nodes-langchain.mcpTrigger');
await expect(
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'form'),
).rejects.toBeInstanceOf(WebhookNotFoundError);
});
it('executes without family scoping when expectedNodeType is not provided', async () => {
setupMocks('form', 'n8n-nodes-base.formTrigger');
await expect(
liveWebhooks.executeWebhook(buildRequest(), mock<Response>()),
).resolves.toBeDefined();
});
});
});
@@ -381,6 +381,46 @@ describe('TestWebhooks', () => {
await expect(promise).rejects.toThrowError(NotFoundError);
});
test('returns a not-found error when a form trigger is requested on the webhook route family', async () => {
const formWebhook = mock<IWebhookData>({
httpMethod,
path,
workflowId: workflowEntity.id,
webhookDescription: { nodeType: 'form' } as never,
});
jest.spyOn(testWebhooks, 'getActiveWebhook').mockResolvedValue(formWebhook);
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
const promise = testWebhooks.executeWebhook(
mock<WebhookRequest>({ params: { path } }),
mock<express.Response>(),
'webhook',
);
await expect(promise).rejects.toThrowError(WebhookNotFoundError);
});
test('returns a not-found error when a regular webhook is requested on the form route family', async () => {
const regularWebhook = mock<IWebhookData>({
httpMethod,
path,
workflowId: workflowEntity.id,
webhookDescription: { nodeType: undefined } as never,
});
jest.spyOn(testWebhooks, 'getActiveWebhook').mockResolvedValue(regularWebhook);
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
const promise = testWebhooks.executeWebhook(
mock<WebhookRequest>({ params: { path } }),
mock<express.Response>(),
'form',
);
await expect(promise).rejects.toThrowError(WebhookNotFoundError);
});
});
describe('deactivateWebhooks()', () => {
@@ -168,7 +168,7 @@ describe('WebhookRequestHandler', () => {
await handler(req, res);
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res);
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.setHeaders).toHaveBeenCalledWith(new Map([['x-custom-header', 'test']]));
expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data);
@@ -190,7 +190,7 @@ describe('WebhookRequestHandler', () => {
await handler(req, res);
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res);
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
code: 100,
@@ -264,7 +264,7 @@ describe('WebhookRequestHandler', () => {
await handler(req, res);
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res);
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data);
},
+23 -9
View File
@@ -5,15 +5,6 @@ import type { Response } from 'express';
import { Workflow, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type { INode, IWebhookData, IHttpRequestMethods, IWorkflowBase } from 'n8n-workflow';
import { authAllowlistedNodes } from './constants';
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
import type {
IWebhookResponseCallbackData,
IWebhookManager,
WebhookAccessControlOptions,
WebhookRequest,
} from './webhook.types';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
import { NodeTypes } from '@/node-types';
@@ -22,6 +13,17 @@ import { WebhookService } from '@/webhooks/webhook.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { authAllowlistedNodes } from './constants';
import { matchesExpectedNodeType } from './node-type-matcher';
import type { ExpectedWebhookNodeType } from './node-type-matcher';
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
import type {
IWebhookResponseCallbackData,
IWebhookManager,
WebhookAccessControlOptions,
WebhookRequest,
} from './webhook.types';
/**
* Service for handling the execution of live webhooks, i.e. webhooks
* that belong to activated workflows and use the production URL
@@ -62,6 +64,7 @@ export class LiveWebhooks implements IWebhookManager {
// we need to use webhookId for matching
isChatWebhookNode(type, webhookId),
);
return webhookNode?.parameters?.options as WebhookAccessControlOptions;
}
@@ -71,6 +74,7 @@ export class LiveWebhooks implements IWebhookManager {
async executeWebhook(
request: WebhookRequest,
response: Response,
expectedNodeType?: ExpectedWebhookNodeType,
): Promise<IWebhookResponseCallbackData> {
const httpMethod = request.method;
const path = request.params.path;
@@ -146,6 +150,16 @@ export class LiveWebhooks implements IWebhookManager {
.getNodeWebhooks(workflow, workflow.getNode(webhook.node) as INode, additionalData)
.find((w) => w.httpMethod === httpMethod && w.path === webhook.webhookPath) as IWebhookData;
if (
expectedNodeType &&
!matchesExpectedNodeType(expectedNodeType, webhookData?.webhookDescription.nodeType)
) {
throw new WebhookNotFoundError(
{ path, httpMethod, webhookMethods: await this.getWebhookMethods(path) },
{ hint: 'production' },
);
}
// Get the node which has the webhook defined to know where to start from and to
// get additional data
const workflowStartNode = workflow.getNode(webhookData.node);
@@ -0,0 +1,10 @@
import type { IWebhookDescription } from 'n8n-workflow';
export type ExpectedWebhookNodeType = NonNullable<IWebhookDescription['nodeType']>;
export function matchesExpectedNodeType(
expected: ExpectedWebhookNodeType,
declared: IWebhookDescription['nodeType'] = 'webhook',
): boolean {
return expected === declared;
}
+25 -11
View File
@@ -1,4 +1,3 @@
import { TEST_WEBHOOK_TIMEOUT } from '@/constants';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import type express from 'express';
@@ -13,16 +12,7 @@ import type {
IDestinationNode,
} from 'n8n-workflow';
import { authAllowlistedNodes } from './constants';
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
import { WebhookService } from './webhook.service';
import type {
IWebhookResponseCallbackData,
IWebhookManager,
WebhookAccessControlOptions,
WebhookRequest,
} from './webhook.types';
import { TEST_WEBHOOK_TIMEOUT } from '@/constants';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
import { SingleWebhookTriggerError } from '@/errors/single-webhook-trigger.error';
@@ -37,6 +27,18 @@ import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowRequest } from '@/workflows/workflow.request';
import { authAllowlistedNodes } from './constants';
import { matchesExpectedNodeType } from './node-type-matcher';
import type { ExpectedWebhookNodeType } from './node-type-matcher';
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
import { WebhookService } from './webhook.service';
import type {
IWebhookResponseCallbackData,
IWebhookManager,
WebhookAccessControlOptions,
WebhookRequest,
} from './webhook.types';
const SINGLE_WEBHOOK_TRIGGERS = [
'n8n-nodes-base.telegramTrigger',
'n8n-nodes-base.slackTrigger',
@@ -67,6 +69,7 @@ export class TestWebhooks implements IWebhookManager {
async executeWebhook(
request: WebhookRequest,
response: express.Response,
expectedNodeType?: ExpectedWebhookNodeType,
): Promise<IWebhookResponseCallbackData> {
const httpMethod = request.method;
@@ -100,6 +103,17 @@ export class TestWebhooks implements IWebhookManager {
});
}
if (
expectedNodeType &&
!matchesExpectedNodeType(expectedNodeType, webhook.webhookDescription.nodeType)
) {
throw new WebhookNotFoundError({
path,
httpMethod,
webhookMethods: await this.getWebhookMethods(path),
});
}
const key = this.registrations.toKey(webhook);
const registration = await this.registrations.get(key);
@@ -8,6 +8,7 @@ import { finished } from 'stream/promises';
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
import * as ResponseHelper from '@/response-helper';
import type { ExpectedWebhookNodeType } from '@/webhooks/node-type-matcher';
import type {
WebhookStaticResponse,
WebhookResponse,
@@ -29,7 +30,10 @@ import type {
const WEBHOOK_METHODS: IHttpRequestMethods[] = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];
class WebhookRequestHandler {
constructor(private readonly webhookManager: IWebhookManager) {}
constructor(
private readonly webhookManager: IWebhookManager,
private readonly expectedNodeType?: ExpectedWebhookNodeType,
) {}
/**
* Handles an incoming webhook request. Handles CORS and delegates the
@@ -58,7 +62,7 @@ class WebhookRequestHandler {
}
try {
const response = await this.webhookManager.executeWebhook(req, res);
const response = await this.webhookManager.executeWebhook(req, res, this.expectedNodeType);
// Modern way of responding to webhooks
if (isWebhookResponse(response)) {
@@ -231,8 +235,11 @@ class WebhookRequestHandler {
}
}
export function createWebhookHandlerFor(webhookManager: IWebhookManager) {
const handler = new WebhookRequestHandler(webhookManager);
export function createWebhookHandlerFor(
webhookManager: IWebhookManager,
expectedNodeType?: ExpectedWebhookNodeType,
) {
const handler = new WebhookRequestHandler(webhookManager, expectedNodeType);
return async (req: WebhookRequest | WebhookOptionsRequest, res: express.Response) => {
const { params } = req;
+7 -1
View File
@@ -1,6 +1,8 @@
import type { Request, Response } from 'express';
import type { IDataObject, IHttpRequestMethods } from 'n8n-workflow';
import type { ExpectedWebhookNodeType } from './node-type-matcher';
export type WebhookOptionsRequest = Request & { method: 'OPTIONS' };
export type WebhookRequest = Request<{ path: string }> & {
@@ -26,7 +28,11 @@ export interface IWebhookManager {
httpMethod: IHttpRequestMethods,
) => Promise<WebhookAccessControlOptions | undefined>;
executeWebhook(req: WebhookRequest, res: Response): Promise<IWebhookResponseCallbackData>;
executeWebhook(
req: WebhookRequest,
res: Response,
expectedNodeType?: ExpectedWebhookNodeType,
): Promise<IWebhookResponseCallbackData>;
}
export interface IWebhookResponseCallbackData {
@@ -0,0 +1,117 @@
import type { IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'form-endpoint-isolation' } } });
test.describe(
'Form Endpoint Isolation',
{
annotation: [{ type: 'owner', description: 'NODES' }],
},
() => {
const cleanupWorkflowIds: string[] = [];
test.afterEach(async ({ api }) => {
while (cleanupWorkflowIds.length > 0) {
try {
// Webhook paths are non-unique in these tests, so a flaky run would otherwise
// leave active webhooks behind and fail the next activation with a conflict.
const workflowId = cleanupWorkflowIds.pop();
await api.workflows.deactivate(workflowId!);
await api.workflows.delete(workflowId!);
} catch {
// ignore potential errors in the cleanup process
}
}
});
test('form triggers are served on /form and return 404 on /webhook', async ({ api }) => {
const webhookId = nanoid();
const workflow: Partial<IWorkflowBase> = {
name: `Form Endpoint Test ${nanoid()}`,
nodes: [
{
parameters: {
formTitle: 'Test Form',
formDescription: 'This is a test form',
formFields: {
values: [
{
fieldLabel: 'Name',
fieldType: 'text',
requiredField: true,
},
],
},
options: {},
},
type: 'n8n-nodes-base.formTrigger',
typeVersion: 2.5,
position: [0, 0],
id: nanoid(),
name: 'Form Trigger',
webhookId,
},
],
connections: {},
};
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
workflow,
{ makeUnique: false },
);
cleanupWorkflowIds.push(workflowId);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const formResponse = await api.webhooks.trigger(`/form/${webhookId}`);
expect(formResponse.ok()).toBe(true);
expect(formResponse.status()).toBe(200);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookId}`);
expect(webhookResponse.ok()).toBe(false);
expect(webhookResponse.status()).toBe(404);
});
test('regular webhooks are served on /webhook and return 404 on /form', async ({ api }) => {
const webhookPath = `webhook-test-${nanoid()}`;
const workflow: Partial<IWorkflowBase> = {
name: `Webhook Endpoint Test ${nanoid()}`,
nodes: [
{
parameters: {
httpMethod: 'GET',
path: webhookPath,
options: {},
},
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: [0, 0],
id: nanoid(),
name: 'Webhook',
webhookId: nanoid(),
},
],
connections: {},
};
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
workflow,
{ makeUnique: false },
);
cleanupWorkflowIds.push(workflowId);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(true);
expect(webhookResponse.status()).toBe(200);
const formResponse = await api.webhooks.trigger(`/form/${webhookPath}`);
expect(formResponse.ok()).toBe(false);
expect(formResponse.status()).toBe(404);
});
},
);
@@ -29,7 +29,7 @@ test.describe(
// Get the MCP path from the workflow
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
@@ -44,7 +44,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const tools = await api.mcp.listTools(session, mcpPath);
@@ -61,7 +61,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const result = await api.mcp.callTool(session, mcpPath, 'echo', {
@@ -80,7 +80,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const deleteResponse = await api.mcp.streamableHttpDelete(session, mcpPath);
@@ -98,7 +98,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.sseSetup(mcpPath);
@@ -118,7 +118,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.sseSetup(mcpPath);
@@ -139,7 +139,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.sseSetup(mcpPath);
@@ -182,7 +182,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Try without auth - should fail
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
@@ -223,7 +223,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Try with valid auth - should succeed
const session = await api.mcp.streamableHttpInitialize(mcpPath, {
@@ -259,7 +259,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Try without auth - should fail
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
@@ -290,7 +290,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const tools = await api.mcp.listTools(session, mcpPath);
@@ -308,7 +308,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
@@ -334,7 +334,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
@@ -360,7 +360,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Create a fake session with an invalid session ID
const fakeSession: McpSession = {
@@ -382,7 +382,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Initialize and then delete session
const session = await api.mcp.streamableHttpInitialize(mcpPath);
@@ -398,7 +398,7 @@ test.describe(
test.describe('Error Handling', () => {
test('should return 404 for non-existent endpoint', async ({ api }) => {
const response = await api.webhooks.trigger('webhook/non-existent-mcp-endpoint-12345', {
const response = await api.webhooks.trigger('mcp/non-existent-mcp-endpoint-12345', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: api.mcp.createMessage('initialize'),
@@ -414,7 +414,7 @@ test.describe(
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// First establish a valid session
const session = await api.mcp.streamableHttpInitialize(mcpPath);
@@ -450,7 +450,7 @@ test.describe('MCP Trigger - Queue Mode', () => {
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// In SSE mode with queue mode enabled, tool calls should return 202 Accepted
// because the execution is queued and response comes via Redis pub/sub
@@ -494,7 +494,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Initialize session on main-1 (direct access, bypassing load balancer)
const main1Api = await createApiForMain(0);
@@ -527,7 +527,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Initialize session on main-1
const main1Api = await createApiForMain(0);
@@ -576,7 +576,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Initialize SSE session on main-1
const main1Api = await createApiForMain(0);
@@ -615,7 +615,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Initialize SSE session on main-1
const main1Api = await createApiForMain(0);
@@ -670,7 +670,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
// Initialize SSE session on main-1
const main1Api = await createApiForMain(0);