mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Expose incoming request headers to MCP trigger tools (#35236)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
c567279606
commit
64ed864315
|
|
@ -2,11 +2,19 @@ import { WebhookAuthorizationError } from 'n8n-nodes-base/dist/nodes/Webhook/err
|
|||
import { validateWebhookAuthentication } from 'n8n-nodes-base/dist/nodes/Webhook/utils';
|
||||
import type {
|
||||
CredentialCheckResult,
|
||||
IDataObject,
|
||||
INodeTypeDescription,
|
||||
IUser,
|
||||
IWebhookFunctions,
|
||||
IWebhookResponseData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, Node, nodeNameToToolName, n8nOAuth2Auth } from 'n8n-workflow';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
Node,
|
||||
nodeNameToToolName,
|
||||
n8nOAuth2Auth,
|
||||
redactedHeaders,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
|
||||
|
|
@ -26,7 +34,7 @@ export class McpTrigger extends Node {
|
|||
dark: 'file:../mcp.dark.svg',
|
||||
},
|
||||
group: ['trigger'],
|
||||
version: [1, 1.1, 2],
|
||||
version: [1, 1.1, 2, 2.1],
|
||||
description: 'Expose n8n tools as an MCP Server endpoint',
|
||||
activationMessage:
|
||||
'You can now connect your MCP Clients to the URL, using SSE or Streamable HTTP transports.',
|
||||
|
|
@ -66,6 +74,7 @@ export class McpTrigger extends Node {
|
|||
},
|
||||
],
|
||||
outputs: [],
|
||||
sensitiveOutputFields: ['headers.authorization', 'headers.cookie'],
|
||||
credentials: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
|
||||
|
|
@ -121,6 +130,20 @@ export class McpTrigger extends Node {
|
|||
description:
|
||||
'Whether the triggering user must also have permission to execute the workflow in the project it belongs to',
|
||||
},
|
||||
{
|
||||
displayName: 'Include User in Output',
|
||||
name: 'includeUserInOutput',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['n8nOAuth2'],
|
||||
'@version': [{ _cnd: { gte: 2.1 } }],
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Whether to include the calling user's ID, email and name in the trigger output and in the request the connected tools receive",
|
||||
},
|
||||
{
|
||||
displayName: 'Path',
|
||||
name: 'path',
|
||||
|
|
@ -170,6 +193,8 @@ export class McpTrigger extends Node {
|
|||
const req = context.getRequestObject();
|
||||
const resp = context.getResponseObject() as unknown as CompressionResponse;
|
||||
|
||||
let authedUser: IUser | undefined;
|
||||
|
||||
if (context.getNodeParameter('authentication') === 'n8nOAuth2') {
|
||||
if (context.getNode().typeVersion < 2) {
|
||||
resp.writeHead(401);
|
||||
|
|
@ -181,6 +206,7 @@ export class McpTrigger extends Node {
|
|||
return { noWebhookResponse: true };
|
||||
}
|
||||
await context.establishTriggerIdentity(authResult.token, authResult.resource);
|
||||
authedUser = authResult.user;
|
||||
} else {
|
||||
try {
|
||||
await validateWebhookAuthentication(context, 'authentication');
|
||||
|
|
@ -195,6 +221,25 @@ export class McpTrigger extends Node {
|
|||
}
|
||||
|
||||
const node = context.getNode();
|
||||
|
||||
// n8n's own auth credential must never reach the tools — not here, and not on the
|
||||
// worker, which rebuilds their input from `toolInput`. The caller's identity is
|
||||
// surfaced as `user` instead, so tools never need the token to know who called.
|
||||
const headers = redactedHeaders(req);
|
||||
const user =
|
||||
authedUser && context.getNodeParameter('includeUserInOutput', true) !== false
|
||||
? {
|
||||
id: authedUser.id,
|
||||
email: authedUser.email,
|
||||
firstName: authedUser.firstName,
|
||||
lastName: authedUser.lastName,
|
||||
}
|
||||
: undefined;
|
||||
const exposesRequest = node.typeVersion >= 2.1;
|
||||
const toolInput: IDataObject | undefined = exposesRequest
|
||||
? { body: context.getBodyData(), headers, ...(user && { user }) }
|
||||
: undefined;
|
||||
|
||||
const serverName = node.typeVersion > 1 ? nodeNameToToolName(node) : 'n8n-mcp-server';
|
||||
const mcpServer = McpServer.instance(context.logger);
|
||||
|
||||
|
|
@ -204,7 +249,9 @@ export class McpTrigger extends Node {
|
|||
? req.path.replace(new RegExp(`/${MCP_SSE_SETUP_PATH}$`), `/${MCP_SSE_MESSAGES_PATH}`)
|
||||
: req.path;
|
||||
|
||||
const connectedTools = await getConnectedTools(context, true);
|
||||
const connectedTools = await getConnectedTools(context, true, undefined, undefined, {
|
||||
inputData: toolInput,
|
||||
});
|
||||
await mcpServer.handleSetupRequest(req, resp, serverName, postUrl, connectedTools);
|
||||
|
||||
return { noWebhookResponse: true };
|
||||
|
|
@ -217,7 +264,9 @@ export class McpTrigger extends Node {
|
|||
context.logger.debug('MCP POST request received for existing session');
|
||||
|
||||
if (sessionId) {
|
||||
const connectedTools = await getConnectedTools(context, true);
|
||||
const connectedTools = await getConnectedTools(context, true, undefined, undefined, {
|
||||
inputData: toolInput,
|
||||
});
|
||||
|
||||
// For a tool call, check the triggering user's private-credential status
|
||||
// before executing. Returns undefined (no gate) unless an OAuth2 identity
|
||||
|
|
@ -234,10 +283,12 @@ export class McpTrigger extends Node {
|
|||
const workflowData = {
|
||||
...(toolCallInfo && { mcpToolCall: toolCallInfo }),
|
||||
...(messageId && { mcpMessageId: messageId }),
|
||||
...(exposesRequest && { headers, ...(user && { user }) }),
|
||||
};
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
workflowData: [[{ json: workflowData }]],
|
||||
toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +306,9 @@ export class McpTrigger extends Node {
|
|||
};
|
||||
}
|
||||
} else {
|
||||
const connectedTools = await getConnectedTools(context, true);
|
||||
const connectedTools = await getConnectedTools(context, true, undefined, undefined, {
|
||||
inputData: toolInput,
|
||||
});
|
||||
await mcpServer.handleStreamableHttpSetup(req, resp, serverName, connectedTools);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import type {
|
|||
IWebhookFunctions,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import { recordConsumedAuth, REDACTED } from 'n8n-workflow';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
|
||||
import { createMockLogger, createMockRequest, createMockResponse } from './helpers';
|
||||
import { McpServer } from '../McpServer';
|
||||
import { McpTrigger } from '../McpTrigger.node';
|
||||
|
|
@ -76,6 +79,9 @@ describe('McpTrigger', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const toolInputPassedToSubNodes = () =>
|
||||
vi.mocked(getConnectedTools).mock.calls.at(-1)?.[4]?.inputData;
|
||||
|
||||
describe('description', () => {
|
||||
it('should have the correct node metadata', () => {
|
||||
expect(mcpTrigger.description.name).toBe('mcpTrigger');
|
||||
|
|
@ -84,7 +90,7 @@ describe('McpTrigger', () => {
|
|||
});
|
||||
|
||||
it('should support multiple versions', () => {
|
||||
expect(mcpTrigger.description.version).toEqual([1, 1.1, 2]);
|
||||
expect(mcpTrigger.description.version).toEqual([1, 1.1, 2, 2.1]);
|
||||
});
|
||||
|
||||
it('should have authentication options', () => {
|
||||
|
|
@ -260,13 +266,14 @@ describe('McpTrigger', () => {
|
|||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
it('should return workflow data when tool call is detected', async () => {
|
||||
const req = createMockRequest({ method: 'POST', query: { sessionId: 'test-session' } });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({
|
||||
typeVersion: 2,
|
||||
name: 'MCP Server Trigger',
|
||||
const setupToolCall = (typeVersion: number) => {
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
query: { sessionId: 'test-session' },
|
||||
headers: { 'x-user-id': 'user-1' },
|
||||
});
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({ typeVersion, name: 'MCP Server Trigger' });
|
||||
|
||||
mockMcpServer.getSessionId.mockReturnValue('test-session');
|
||||
mockMcpServer.handlePostMessage.mockResolvedValue({
|
||||
|
|
@ -281,6 +288,13 @@ describe('McpTrigger', () => {
|
|||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getNode.mockReturnValue(node);
|
||||
mockContext.getBodyData.mockReturnValue({ method: 'tools/call' });
|
||||
|
||||
return { req };
|
||||
};
|
||||
|
||||
it('should return workflow data when tool call is detected', async () => {
|
||||
setupToolCall(2);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
|
|
@ -299,6 +313,38 @@ describe('McpTrigger', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should include the request headers from version 2.1 on', async () => {
|
||||
setupToolCall(2.1);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(result.workflowData?.[0][0].json).toEqual({
|
||||
mcpToolCall: { toolName: 'test-tool', arguments: { arg1: 'value1' } },
|
||||
mcpMessageId: 'msg-123',
|
||||
headers: { 'x-user-id': 'user-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should carry the tool input on the response from version 2.1 on', async () => {
|
||||
setupToolCall(2.1);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(result.toolInput).toEqual({
|
||||
body: { method: 'tools/call' },
|
||||
headers: { 'x-user-id': 'user-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not produce a tool input on version 2.0', async () => {
|
||||
setupToolCall(2);
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(result.toolInput).toBeUndefined();
|
||||
expect(toolInputPassedToSubNodes()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle Streamable HTTP setup when no session exists', async () => {
|
||||
const req = createMockRequest({ method: 'POST' });
|
||||
const resp = createMockResponse();
|
||||
|
|
@ -392,13 +438,22 @@ describe('McpTrigger', () => {
|
|||
describe('n8nOAuth2 authentication', () => {
|
||||
const resourceUrl = 'https://n8n.example.com/mcp/abc';
|
||||
const prmUrl = 'https://n8n.example.com/.well-known/oauth-protected-resource/mcp/abc';
|
||||
const OAUTH_USER = { id: 'u1', email: 'u@example.com', firstName: 'U', lastName: 'One' };
|
||||
|
||||
function setupContext(opts: { typeVersion: number; headers?: Record<string, string> }) {
|
||||
function setupContext(opts: {
|
||||
typeVersion: number;
|
||||
headers?: Record<string, string>;
|
||||
includeUserInOutput?: boolean;
|
||||
}) {
|
||||
const req = createMockRequest({ path: '/mcp/abc', headers: opts.headers ?? {} });
|
||||
const resp = createMockResponse();
|
||||
const node = mock<INode>({ typeVersion: opts.typeVersion, name: 'MCP Server Trigger' });
|
||||
|
||||
mockContext.getNodeParameter.mockReturnValue('n8nOAuth2');
|
||||
mockContext.getNodeParameter.mockImplementation((name, fallback) => {
|
||||
if (name === 'authentication') return 'n8nOAuth2';
|
||||
if (name === 'includeUserInOutput') return opts.includeUserInOutput ?? fallback;
|
||||
return fallback;
|
||||
});
|
||||
mockContext.getWebhookResourceUrl.mockReturnValue(resourceUrl);
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
|
|
@ -407,6 +462,23 @@ describe('McpTrigger', () => {
|
|||
return { req, resp, node };
|
||||
}
|
||||
|
||||
const setupToolCall = (opts: Parameters<typeof setupContext>[0]) => {
|
||||
const context = setupContext(opts);
|
||||
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getBodyData.mockReturnValue({ method: 'tools/call' });
|
||||
mockMcpServer.getSessionId.mockReturnValue('test-session');
|
||||
mockMcpServer.handlePostMessage.mockResolvedValue({
|
||||
wasToolCall: true,
|
||||
toolCallInfo: { toolName: 'test-tool', arguments: {} },
|
||||
messageId: 'msg-123',
|
||||
relaySessionId: undefined,
|
||||
needsListToolsRelay: false,
|
||||
});
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
it('fails closed with 401 on node version below 2', async () => {
|
||||
const { resp } = setupContext({ typeVersion: 1.1 });
|
||||
|
||||
|
|
@ -468,16 +540,113 @@ describe('McpTrigger', () => {
|
|||
|
||||
it('proceeds to MCP dispatch when the token is valid', async () => {
|
||||
setupContext({ typeVersion: 2, headers: { authorization: 'Bearer good-token' } });
|
||||
mockContext.validateN8nOAuth2Token.mockResolvedValue({
|
||||
valid: true,
|
||||
user: { id: 'u1', email: 'u@example.com', firstName: 'U', lastName: 'One' },
|
||||
});
|
||||
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalled();
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
it('redacts the bearer token in the input the tools see, exposing the caller as `user`', async () => {
|
||||
const { req } = setupContext({
|
||||
typeVersion: 2.1,
|
||||
headers: { authorization: 'Bearer good-token', 'x-user-id': 'user-1' },
|
||||
});
|
||||
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
|
||||
mockContext.getBodyData.mockReturnValue({ method: 'tools/list' });
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(toolInputPassedToSubNodes()).toEqual({
|
||||
body: { method: 'tools/list' },
|
||||
headers: { authorization: REDACTED, 'x-user-id': 'user-1' },
|
||||
user: OAUTH_USER,
|
||||
});
|
||||
expect(req.headers.authorization).toBe('Bearer good-token');
|
||||
});
|
||||
|
||||
it('omits the caller when the user is excluded from the output', async () => {
|
||||
setupContext({
|
||||
typeVersion: 2.1,
|
||||
headers: { authorization: 'Bearer good-token' },
|
||||
includeUserInOutput: false,
|
||||
});
|
||||
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
|
||||
mockContext.getBodyData.mockReturnValue({ method: 'tools/list' });
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(toolInputPassedToSubNodes()).not.toHaveProperty('user');
|
||||
});
|
||||
|
||||
it('exposes no user on version 2.0', async () => {
|
||||
setupToolCall({ typeVersion: 2, headers: { authorization: 'Bearer good-token' } });
|
||||
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(result.workflowData?.[0][0].json).not.toHaveProperty('user');
|
||||
expect(result.toolInput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes the caller in the trigger output on a tool call', async () => {
|
||||
setupToolCall({ typeVersion: 2.1, headers: { authorization: 'Bearer good-token' } });
|
||||
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
|
||||
|
||||
const result = await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(result.workflowData?.[0][0].json).toEqual({
|
||||
mcpToolCall: { toolName: 'test-tool', arguments: {} },
|
||||
mcpMessageId: 'msg-123',
|
||||
headers: { authorization: REDACTED },
|
||||
user: OAUTH_USER,
|
||||
});
|
||||
expect(result.toolInput).toMatchObject({ user: OAUTH_USER });
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumed auth headers', () => {
|
||||
const setup = (typeVersion: number) => {
|
||||
const req = createMockRequest({
|
||||
path: '/mcp/abc',
|
||||
headers: { 'x-api-key': 'secret-value', 'x-tenant-id': 'acme' },
|
||||
});
|
||||
const resp = createMockResponse();
|
||||
|
||||
validateWebhookAuthenticationMock.mockImplementation(async () => {
|
||||
recordConsumedAuth(req, ['x-api-key']);
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter.mockReturnValue('headerAuth');
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getRequestObject.mockReturnValue(req as never);
|
||||
mockContext.getResponseObject.mockReturnValue(resp as never);
|
||||
mockContext.getBodyData.mockReturnValue({ method: 'tools/list' });
|
||||
mockContext.getNode.mockReturnValue(mock<INode>({ typeVersion, name: 'MCP Server Trigger' }));
|
||||
|
||||
return { req };
|
||||
};
|
||||
|
||||
it('redacts the header the credential named, keeping the rest', async () => {
|
||||
const { req } = setup(2.1);
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(toolInputPassedToSubNodes()).toEqual({
|
||||
body: { method: 'tools/list' },
|
||||
headers: { 'x-api-key': REDACTED, 'x-tenant-id': 'acme' },
|
||||
});
|
||||
expect(req.headers).toEqual({ 'x-api-key': 'secret-value', 'x-tenant-id': 'acme' });
|
||||
});
|
||||
|
||||
it('exposes no headers to the tools on version 2.0', async () => {
|
||||
setup(2);
|
||||
|
||||
await mcpTrigger.webhook(mockContext);
|
||||
|
||||
expect(toolInputPassedToSubNodes()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list tools relay', () => {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,25 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
|
|||
expect(dynamicTool).toBeInstanceOf(DynamicTool);
|
||||
});
|
||||
|
||||
// `getConnectedTools` stamps `sourceNodeName` before converting, and the MCP trigger needs it
|
||||
// on the far side to tell a worker which node to run
|
||||
it('should carry metadata over to the dynamic tool', () => {
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func: vi.fn(),
|
||||
schema: z.object({ foo: z.string() }),
|
||||
});
|
||||
tool.metadata = { sourceNodeName: 'My Tool Node', isFromToolkit: false };
|
||||
|
||||
expect(tool.asDynamicTool().metadata).toEqual({
|
||||
sourceNodeName: 'My Tool Node',
|
||||
isFromToolkit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should format fallback description correctly', () => {
|
||||
const func = vi.fn();
|
||||
|
||||
|
|
|
|||
|
|
@ -105,10 +105,12 @@ export class N8nTool extends DynamicStructuredTool<ZodObjectAny> {
|
|||
}
|
||||
};
|
||||
|
||||
return new DynamicTool({
|
||||
const dynamicTool = new DynamicTool({
|
||||
name,
|
||||
description: prepareFallbackToolDescription(description, schema),
|
||||
func: wrappedFunc,
|
||||
});
|
||||
dynamicTool.metadata = this.metadata;
|
||||
return dynamicTool;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { JSONSchema7 } from 'json-schema';
|
|||
import { StructuredToolkit, type SupplyDataToolResponse } from 'n8n-core';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ISupplyDataFunctions,
|
||||
IWebhookFunctions,
|
||||
|
|
@ -194,11 +195,13 @@ export const getConnectedTools = async (
|
|||
enforceUniqueNames: boolean,
|
||||
convertStructuredTool: boolean = true,
|
||||
escapeCurlyBrackets: boolean = false,
|
||||
options?: { inputData?: IDataObject },
|
||||
): Promise<Tool[]> => {
|
||||
const toolkitConnections = (await ctx.getInputConnectionData(
|
||||
NodeConnectionTypes.AiTool,
|
||||
0,
|
||||
)) as SupplyDataToolResponse[];
|
||||
// `getRequestObject` narrows to IWebhookFunctions, the only context whose
|
||||
// getInputConnectionData signature takes the input override.
|
||||
const toolkitConnections = (await ('getRequestObject' in ctx
|
||||
? ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0, options)
|
||||
: ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0))) as SupplyDataToolResponse[];
|
||||
|
||||
// Get parent nodes to map toolkits to their source nodes.
|
||||
// getInputConnectionData filters out disabled nodes, so parents must be filtered
|
||||
|
|
|
|||
|
|
@ -497,14 +497,17 @@ describe('enqueueExecution', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should carry the manual-execution identity into job data', async () => {
|
||||
it.each([
|
||||
{ encryptedRunnerIdentity: 'encrypted-identity-blob' },
|
||||
{ mcpToolInput: { method: 'tools/call', headers: { 'x-user-id': 'user-1' } } },
|
||||
])('should carry %o into job data', async (processData) => {
|
||||
const activeExecutions = Container.get(ActiveExecutions);
|
||||
vi.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValue();
|
||||
vi.spyOn(runner, 'processError').mockResolvedValue();
|
||||
const data = mock<IWorkflowExecutionDataProcess>({
|
||||
workflowData: { nodes: [], staticData: {} },
|
||||
executionData: undefined,
|
||||
encryptedRunnerIdentity: 'encrypted-identity-blob',
|
||||
...processData,
|
||||
});
|
||||
const error = new Error('stop for test purposes');
|
||||
|
||||
|
|
@ -515,12 +518,7 @@ describe('enqueueExecution', () => {
|
|||
// @ts-expect-error Private method
|
||||
await expect(runner.enqueueExecution('1', 'workflow-xyz', data)).rejects.toThrowError(error);
|
||||
|
||||
expect(addJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
encryptedRunnerIdentity: 'encrypted-identity-blob',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(addJob).toHaveBeenCalledWith(expect.objectContaining(processData), expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type ISupplyDataFunctions,
|
||||
type ITaskData,
|
||||
type IDataObject,
|
||||
type INodeExecutionData,
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
NodeConnectionTypes,
|
||||
|
|
@ -1266,6 +1267,151 @@ describe('JobProcessor', () => {
|
|||
expect(lastResponse.response).toBe('supply data tool result');
|
||||
});
|
||||
|
||||
describe('MCP request context on the worker', () => {
|
||||
const toolNode = {
|
||||
name: 'Calculator',
|
||||
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
position: [0, 0] as [number, number],
|
||||
};
|
||||
|
||||
const mcpToolInput = { method: 'tools/call', headers: { 'x-user-id': 'user-1' } };
|
||||
|
||||
/** `triggerNames` is in connection order; only `triggerThatRan` gets run data. */
|
||||
const runToolCall = async ({
|
||||
triggerNames,
|
||||
triggerThatRan,
|
||||
toolInput,
|
||||
}: {
|
||||
triggerNames: string[];
|
||||
triggerThatRan: string;
|
||||
toolInput?: IDataObject;
|
||||
}) => {
|
||||
const workflowData = {
|
||||
id: 'wf-1',
|
||||
nodes: [
|
||||
...triggerNames.map((name) => ({
|
||||
name,
|
||||
type: '@n8n/n8n-nodes-langchain.mcpTrigger',
|
||||
typeVersion: 2,
|
||||
parameters: {},
|
||||
position: [0, 0] as [number, number],
|
||||
})),
|
||||
toolNode,
|
||||
],
|
||||
connections: {
|
||||
[toolNode.name]: {
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
triggerNames.map((node) => ({ node, type: NodeConnectionTypes.AiTool, index: 0 })),
|
||||
],
|
||||
},
|
||||
},
|
||||
staticData: {},
|
||||
};
|
||||
|
||||
const executionPersistence = mock<ExecutionPersistence>();
|
||||
executionPersistence.findSingleExecution.mockResolvedValueOnce(
|
||||
mock<IExecutionResponse>({
|
||||
mode: 'trigger',
|
||||
workflowData,
|
||||
data: mock<IRunExecutionData>({ executionData: undefined }),
|
||||
}),
|
||||
);
|
||||
executionPersistence.findSingleExecution.mockResolvedValueOnce(
|
||||
mock<IExecutionResponse>({ status: 'success', workflowData }),
|
||||
);
|
||||
|
||||
// Real (mutable) run-execution-data, not a mock, so the tool node's own run
|
||||
// lands in `resultData.runData` next to the trigger's.
|
||||
const run: IRun = {
|
||||
mode: 'trigger',
|
||||
status: 'success',
|
||||
finished: true,
|
||||
startedAt: new Date(),
|
||||
stoppedAt: new Date(),
|
||||
storedAt: 'db',
|
||||
data: createRunExecutionData({
|
||||
resultData: { runData: { [triggerThatRan]: [mock<ITaskData>()] } },
|
||||
}),
|
||||
};
|
||||
|
||||
let toolNodeInput: INodeExecutionData[] | undefined;
|
||||
const nodeTypes = mock<NodeTypes>();
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue({
|
||||
description: {
|
||||
name: 'toolCalculator',
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
properties: [],
|
||||
},
|
||||
execute: vi.fn().mockImplementation(function (this: ISupplyDataFunctions) {
|
||||
toolNodeInput = this.getInputData(0, NodeConnectionTypes.Main);
|
||||
return [[{ json: { response: 42 } }]];
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const jobProcessor = new JobProcessor(
|
||||
logger,
|
||||
mock(), // executionRepository
|
||||
executionPersistence,
|
||||
mock(), // workflowRepository
|
||||
nodeTypes,
|
||||
{ hostId: 'worker-host-123' } as unknown as InstanceSettings,
|
||||
createManualExecutionServiceMock(run),
|
||||
executionsConfig,
|
||||
mock(), // eventService
|
||||
mock(), // webhookResponseRelay
|
||||
);
|
||||
|
||||
const job = mock<Job>();
|
||||
job.data = {
|
||||
workflowId: 'wf-1',
|
||||
executionId: 'exec-mcp-input',
|
||||
loadStaticData: false,
|
||||
isMcpExecution: true,
|
||||
mcpType: 'trigger',
|
||||
mcpSessionId: 'session-input',
|
||||
mcpMessageId: 'msg-1',
|
||||
mcpToolCall: {
|
||||
toolName: toolNode.name,
|
||||
arguments: { input: '2 + 2' },
|
||||
sourceNodeName: toolNode.name,
|
||||
},
|
||||
mcpToolInput: toolInput,
|
||||
};
|
||||
|
||||
await jobProcessor.processJob(job);
|
||||
|
||||
return { toolNodeInput, toolRun: run.data.resultData.runData[toolNode.name]?.[0] };
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ toolInput: mcpToolInput, expected: mcpToolInput },
|
||||
{ toolInput: undefined, expected: {} },
|
||||
])('should give the tool node $toolInput as input data', async ({ toolInput, expected }) => {
|
||||
const { toolNodeInput } = await runToolCall({
|
||||
triggerNames: ['MCP Server Trigger'],
|
||||
triggerThatRan: 'MCP Server Trigger',
|
||||
toolInput,
|
||||
});
|
||||
|
||||
expect(toolNodeInput).toEqual([{ json: expected }]);
|
||||
});
|
||||
|
||||
it.each(['MCP Trigger A', 'MCP Trigger B'])(
|
||||
'should record the tool run against the trigger that ran (%s)',
|
||||
async (triggerThatRan) => {
|
||||
const { toolRun } = await runToolCall({
|
||||
triggerNames: ['MCP Trigger A', 'MCP Trigger B'],
|
||||
triggerThatRan,
|
||||
toolInput: mcpToolInput,
|
||||
});
|
||||
|
||||
expect(toolRun?.source?.[0]?.previousNode).toBe(triggerThatRan);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should expose the established execution context to the tool node', async () => {
|
||||
// Regression: in queue mode the tool runs on the worker, where dynamic
|
||||
// (private) credentials resolve from the node's execution context. The
|
||||
|
|
|
|||
|
|
@ -376,17 +376,18 @@ export class JobProcessor {
|
|||
toolResult = await withExpressionIsolate(
|
||||
workflow,
|
||||
async () =>
|
||||
await this.invokeTool(
|
||||
await this.invokeTool({
|
||||
workflow,
|
||||
sourceNodeName,
|
||||
toolArgs,
|
||||
toolInput: job.data.mcpToolInput,
|
||||
additionalData,
|
||||
run.data,
|
||||
runExecutionData: run.data,
|
||||
// The execution context (e.g. the OAuth identity for private credentials)
|
||||
// is established on the main and loaded with the execution here; pass it
|
||||
// through so the tool node can resolve dynamic credentials on the worker.
|
||||
execution.data?.executionData?.runtimeData,
|
||||
),
|
||||
executionContext: execution.data?.executionData?.runtimeData,
|
||||
}),
|
||||
);
|
||||
|
||||
// A tool result is not a response, so it has no offload path: this limit
|
||||
|
|
@ -516,18 +517,23 @@ export class JobProcessor {
|
|||
* For tool wrapper nodes without supplyData (e.g. httpRequestTool), calls
|
||||
* execute directly — mirroring the fallback in get-input-connection-data.ts.
|
||||
*/
|
||||
private async invokeTool(
|
||||
workflow: Workflow,
|
||||
sourceNodeName: string,
|
||||
toolArgs: Record<string, unknown>,
|
||||
additionalData: ReturnType<typeof WorkflowExecuteAdditionalData.getBase> extends Promise<
|
||||
infer T
|
||||
>
|
||||
? T
|
||||
: never,
|
||||
runExecutionData: IRunExecutionData,
|
||||
executionContext?: IExecutionContext,
|
||||
): Promise<unknown> {
|
||||
private async invokeTool({
|
||||
workflow,
|
||||
sourceNodeName,
|
||||
toolArgs,
|
||||
toolInput,
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
executionContext,
|
||||
}: {
|
||||
workflow: Workflow;
|
||||
sourceNodeName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
toolInput?: IDataObject;
|
||||
additionalData: Awaited<ReturnType<typeof WorkflowExecuteAdditionalData.getBase>>;
|
||||
runExecutionData: IRunExecutionData;
|
||||
executionContext?: IExecutionContext;
|
||||
}): Promise<unknown> {
|
||||
const toolNode = workflow.getNode(sourceNodeName);
|
||||
if (!toolNode) {
|
||||
throw new UnexpectedError(`Tool node "${sourceNodeName}" not found in workflow`);
|
||||
|
|
@ -540,14 +546,15 @@ export class JobProcessor {
|
|||
const validatedToolArgs =
|
||||
typeof toolArgs === 'object' && toolArgs !== null && !Array.isArray(toolArgs) ? toolArgs : {};
|
||||
|
||||
// Create input data for the tool node with the tool arguments
|
||||
const inputData: INodeExecutionData[][] = [
|
||||
[
|
||||
{
|
||||
json: validatedToolArgs as INodeExecutionData['json'],
|
||||
},
|
||||
],
|
||||
];
|
||||
// A tool can feed several MCP triggers, and connection order says nothing about which
|
||||
// one this execution came from — only the trigger that ran has run data. Recording it
|
||||
// as parent points the tool's run data `source` at it, as in direct mode.
|
||||
const { runData } = runExecutionData.resultData;
|
||||
const triggerNames = workflow.getChildNodes(sourceNodeName, NodeConnectionTypes.AiTool, 1);
|
||||
const triggerName = triggerNames.find((name) => runData[name]) ?? triggerNames[0];
|
||||
const parentNode = triggerName ? (workflow.getNode(triggerName) ?? undefined) : undefined;
|
||||
|
||||
const inputData: INodeExecutionData[][] = [[{ json: toolInput ?? {} }]];
|
||||
|
||||
// `executionData` must exist for output recording; init it if the run lacks it.
|
||||
runExecutionData.executionData ??= {
|
||||
|
|
@ -572,11 +579,6 @@ export class JobProcessor {
|
|||
|
||||
const closeFunctions: CloseFunction[] = [];
|
||||
|
||||
// Parent = the node the tool feeds (the MCP trigger), so the recorded run
|
||||
// data's `source` points back at it, as in direct mode.
|
||||
const [parentNodeName] = workflow.getChildNodes(sourceNodeName, NodeConnectionTypes.AiTool, 1);
|
||||
const parentNode = parentNodeName ? (workflow.getNode(parentNodeName) ?? undefined) : undefined;
|
||||
|
||||
// Create SupplyDataContext for the tool node
|
||||
const context = new SupplyDataContext(
|
||||
workflow,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type Bull from 'bull';
|
|||
import type {
|
||||
ExecutionError,
|
||||
ExecutionStatus,
|
||||
IDataObject,
|
||||
IExecuteResponsePromiseData,
|
||||
IRun,
|
||||
StructuredChunk,
|
||||
|
|
@ -44,6 +45,8 @@ export type JobData = {
|
|||
/** The n8n node name that provides this tool. */
|
||||
sourceNodeName?: string;
|
||||
};
|
||||
/** The MCP request as node input, so the worker gives the tool node the same `$json` as direct mode. */
|
||||
mcpToolInput?: IDataObject;
|
||||
};
|
||||
|
||||
export type JobResult = {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
WAIT_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
MCP_TRIGGER_NODE_TYPE,
|
||||
WorkflowConfigurationError,
|
||||
NodeOperationError,
|
||||
MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
|
||||
|
|
@ -755,58 +756,60 @@ describe('prepareExecutionData', () => {
|
|||
expect(runExecutionData.manualData).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('MICROSOFT_AGENT365_TRIGGER_NODE_TYPE merge condition', () => {
|
||||
test('should merge nodeExecutionStack when node type is MICROSOFT_AGENT365_TRIGGER_NODE_TYPE and runExecutionData exists', () => {
|
||||
const microsoftAgentNode = mock<INode>({
|
||||
name: 'Microsoft Agent 365',
|
||||
type: MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
|
||||
});
|
||||
describe('seeded execution stack merge condition', () => {
|
||||
test.each([
|
||||
MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
|
||||
MCP_TRIGGER_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
])(
|
||||
'should merge nodeExecutionStack when node type is %s and runExecutionData exists',
|
||||
(type) => {
|
||||
const seededTriggerNode = mock<INode>({ name: 'Seeded Trigger', type });
|
||||
|
||||
const existingNodeExecutionStack: IExecuteData[] = [
|
||||
{
|
||||
node: mock<INode>({ name: 'ExistingNode' }),
|
||||
data: {
|
||||
main: [[{ json: { existing: 'data' } }]],
|
||||
const existingNodeExecutionStack: IExecuteData[] = [
|
||||
{
|
||||
node: mock<INode>({ name: 'ExistingNode' }),
|
||||
data: {
|
||||
main: [[{ json: { existing: 'data' } }]],
|
||||
},
|
||||
source: null,
|
||||
},
|
||||
source: null,
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
const existingRunExecutionData: IRunExecutionData = {
|
||||
version: 1,
|
||||
startData: {},
|
||||
resultData: { runData: {} },
|
||||
executionData: {
|
||||
contextData: {},
|
||||
metadata: {},
|
||||
nodeExecutionStack: existingNodeExecutionStack,
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
const existingRunExecutionData: IRunExecutionData = {
|
||||
version: 1,
|
||||
startData: {},
|
||||
resultData: { runData: {} },
|
||||
executionData: {
|
||||
contextData: {},
|
||||
metadata: {},
|
||||
nodeExecutionStack: existingNodeExecutionStack,
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
const { runExecutionData } = prepareExecutionData(
|
||||
'trigger',
|
||||
microsoftAgentNode,
|
||||
webhookResultData,
|
||||
existingRunExecutionData,
|
||||
);
|
||||
const { runExecutionData } = prepareExecutionData(
|
||||
'trigger',
|
||||
seededTriggerNode,
|
||||
webhookResultData,
|
||||
existingRunExecutionData,
|
||||
);
|
||||
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack).toHaveLength(1);
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.name).toBe(
|
||||
'Microsoft Agent 365',
|
||||
);
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.type).toBe(
|
||||
MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
|
||||
);
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]).toHaveLength(1);
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]?.[0]?.json).toEqual(
|
||||
{
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack).toHaveLength(1);
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.name).toBe(
|
||||
'Seeded Trigger',
|
||||
);
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.type).toBe(type);
|
||||
expect(runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]).toHaveLength(1);
|
||||
expect(
|
||||
runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]?.[0]?.json,
|
||||
).toEqual({
|
||||
existing: 'data',
|
||||
data: 'test',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('should not merge when node type is MICROSOFT_AGENT365_TRIGGER_NODE_TYPE but runExecutionData is undefined', () => {
|
||||
const microsoftAgentNode = mock<INode>({
|
||||
|
|
|
|||
|
|
@ -417,7 +417,9 @@ function reconcileSeededExecutionStack(
|
|||
if (!executionData?.nodeExecutionStack) return;
|
||||
|
||||
if (
|
||||
[MICROSOFT_AGENT365_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(workflowStartNode.type)
|
||||
[MCP_TRIGGER_NODE_TYPE, MICROSOFT_AGENT365_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(
|
||||
workflowStartNode.type,
|
||||
)
|
||||
) {
|
||||
merge(executionData.nodeExecutionStack, nodeExecutionStack);
|
||||
} else if (shouldEstablishTriggerIdentity(workflowStartNode)) {
|
||||
|
|
@ -892,6 +894,9 @@ export async function executeWebhook(
|
|||
runData.mcpToolCall = mcpToolCallValue;
|
||||
}
|
||||
|
||||
// The worker has no access to the request, so carry the node input the trigger built
|
||||
runData.mcpToolInput = webhookResultData.toolInput;
|
||||
|
||||
// Handle MCP list tools relay - forward to main with SSE transport via pub/sub
|
||||
const mcpListToolsRelayValue =
|
||||
firstItem && 'json' in firstItem ? firstItem.json?.mcpListToolsRelay : null;
|
||||
|
|
|
|||
|
|
@ -537,6 +537,7 @@ export class WorkflowRunner {
|
|||
mcpSessionId: data.mcpSessionId,
|
||||
mcpMessageId: data.mcpMessageId,
|
||||
mcpToolCall: data.mcpToolCall,
|
||||
mcpToolInput: data.mcpToolInput,
|
||||
};
|
||||
|
||||
if (!this.scalingService) {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,14 @@ import type {
|
|||
WorkflowExecuteMode,
|
||||
WorkflowExpression,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { getInputConnectionData } from '../utils/get-input-connection-data';
|
||||
import { WebhookContext } from '../webhook-context';
|
||||
|
||||
vi.mock('../utils/get-input-connection-data');
|
||||
|
||||
describe('WebhookContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
|
|
@ -258,6 +262,56 @@ describe('WebhookContext', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getInputConnectionData', () => {
|
||||
const inputPassedToSubNodes = () => vi.mocked(getInputConnectionData).mock.calls[0][3];
|
||||
|
||||
it('should expose the request body to sub-nodes', async () => {
|
||||
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
||||
|
||||
expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
|
||||
});
|
||||
|
||||
it('should expose the input data the trigger supplied instead', async () => {
|
||||
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, {
|
||||
inputData: { body: { test: 'body' }, headers: { test: 'header' } },
|
||||
});
|
||||
|
||||
expect(inputPassedToSubNodes()).toEqual([
|
||||
{ json: { body: { test: 'body' }, headers: { test: 'header' } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fall back to the request body when the trigger supplies no input data', async () => {
|
||||
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, {});
|
||||
|
||||
expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
|
||||
});
|
||||
|
||||
it('should ignore a legacy numeric third argument', async () => {
|
||||
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, 1);
|
||||
|
||||
expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
|
||||
});
|
||||
|
||||
it('should not fail when there is no HTTP request', async () => {
|
||||
const contextAdditionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
contextAdditionalData.httpRequest = undefined;
|
||||
const context = new WebhookContext(
|
||||
workflow,
|
||||
node,
|
||||
contextAdditionalData,
|
||||
mode,
|
||||
webhookData,
|
||||
[],
|
||||
null,
|
||||
);
|
||||
|
||||
await context.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
||||
|
||||
expect(inputPassedToSubNodes()).toEqual([{ json: {} }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { getInputConnectionData } from './utils/get-input-connection-data';
|
|||
import { getRequestHelperFunctions } from './utils/request-helper-functions';
|
||||
import { returnJsonArray } from './utils/return-json-array';
|
||||
import { getNodeWebhookUrl } from './utils/webhook-helper-functions';
|
||||
|
||||
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
|
||||
readonly helpers: IWebhookFunctions['helpers'];
|
||||
|
||||
|
|
@ -225,13 +226,16 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
|
|||
async getInputConnectionData(
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
options?: number | { inputData?: IDataObject },
|
||||
): Promise<unknown> {
|
||||
const inputData =
|
||||
typeof options === 'object' && options !== null ? options.inputData : undefined;
|
||||
// To be able to use expressions like "$json.sessionId" set the
|
||||
// body data the webhook received to what is normally used for
|
||||
// incoming node data.
|
||||
// incoming node data, unless the trigger supplied its own shape.
|
||||
const connectionInputData: INodeExecutionData[] = [
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
{ json: this.additionalData.httpRequest?.body || {} },
|
||||
{ json: inputData ?? (this.additionalData.httpRequest?.body || {}) },
|
||||
];
|
||||
const runExecutionData = this.runExecutionData ?? createEmptyRunExecutionData();
|
||||
const executeData: IExecuteData = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
REDACTED,
|
||||
redactedHeaders,
|
||||
UnexpectedError,
|
||||
type IWebhookFunctions,
|
||||
type INodeExecutionData,
|
||||
|
|
@ -772,6 +774,139 @@ describe('Webhook Utils', () => {
|
|||
);
|
||||
expect(result).toEqual(decodedPayload);
|
||||
});
|
||||
|
||||
describe('records the headers it authenticated with', () => {
|
||||
const setup = (
|
||||
authentication: string,
|
||||
headers: Record<string, string | undefined>,
|
||||
credentials: IDataObject,
|
||||
node?: INode,
|
||||
) => {
|
||||
const request = { headers };
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNode: vi.fn().mockReturnValue(node ?? ({} as INode)),
|
||||
getNodeParameter: vi.fn().mockReturnValue(authentication),
|
||||
getCredentials: vi.fn().mockResolvedValue(credentials),
|
||||
getRequestObject: vi.fn().mockReturnValue(request),
|
||||
getHeaderData: vi.fn().mockReturnValue(headers),
|
||||
};
|
||||
return { ctx: ctx as IWebhookFunctions, request };
|
||||
};
|
||||
|
||||
it('leaves the request alone', async () => {
|
||||
const authorization = `Basic ${Buffer.from('admin:password').toString('base64')}`;
|
||||
const { ctx, request } = setup(
|
||||
'basicAuth',
|
||||
{ authorization, 'x-tenant-id': 'acme' },
|
||||
{ user: 'admin', password: 'password' },
|
||||
);
|
||||
|
||||
await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(request.headers).toEqual({ authorization, 'x-tenant-id': 'acme' });
|
||||
});
|
||||
|
||||
it('records the basic auth header, keeping the rest', async () => {
|
||||
const { ctx, request } = setup(
|
||||
'basicAuth',
|
||||
{
|
||||
authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
|
||||
'x-tenant-id': 'acme',
|
||||
},
|
||||
{ user: 'admin', password: 'password' },
|
||||
);
|
||||
|
||||
await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({
|
||||
authorization: REDACTED,
|
||||
'x-tenant-id': 'acme',
|
||||
});
|
||||
});
|
||||
|
||||
it('records only the carrier that authenticated', async () => {
|
||||
const { ctx, request } = setup(
|
||||
'basicAuth',
|
||||
{
|
||||
authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
|
||||
'x-auth-token': 'callers-own-token',
|
||||
},
|
||||
{ user: 'admin', password: 'password' },
|
||||
);
|
||||
|
||||
await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({
|
||||
authorization: REDACTED,
|
||||
'x-auth-token': 'callers-own-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('records the form auth token accepted in place of basic auth', async () => {
|
||||
const node = { id: 'node-789', webhookId: 'webhook-456' } as INode;
|
||||
const credentials = { user: 'admin', password: 'password' };
|
||||
const { ctx, request } = setup(
|
||||
'basicAuth',
|
||||
{ 'x-auth-token': generateBasicAuthToken(node, credentials) },
|
||||
credentials,
|
||||
node,
|
||||
);
|
||||
|
||||
await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({ 'x-auth-token': REDACTED });
|
||||
});
|
||||
|
||||
it('records the bearer token header', async () => {
|
||||
const { ctx, request } = setup(
|
||||
'bearerAuth',
|
||||
{ authorization: 'Bearer secret-token', accept: 'application/json' },
|
||||
{ token: 'secret-token' },
|
||||
);
|
||||
|
||||
await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({
|
||||
authorization: REDACTED,
|
||||
accept: 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('records the custom header the credential names', async () => {
|
||||
const { ctx, request } = setup(
|
||||
'headerAuth',
|
||||
{ test: 'secret-value', 'x-tenant-id': 'acme' },
|
||||
{ name: 'test', value: 'secret-value' },
|
||||
);
|
||||
|
||||
await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({ test: REDACTED, 'x-tenant-id': 'acme' });
|
||||
});
|
||||
|
||||
it('records the JWT header while still returning its payload', async () => {
|
||||
const decodedPayload = { sub: '1234567890' };
|
||||
(jwt.verify as Mock).mockReturnValue(decodedPayload);
|
||||
const { ctx, request } = setup(
|
||||
'jwtAuth',
|
||||
{ authorization: 'Bearer some.jwt.token' },
|
||||
{ keyType: 'passphrase', publicKey: '', secret: 'secret', algorithm: 'HS256' },
|
||||
);
|
||||
|
||||
const result = await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(result).toEqual(decodedPayload);
|
||||
expect(redactedHeaders(request)).toEqual({ authorization: REDACTED });
|
||||
});
|
||||
|
||||
it('records nothing when authentication is "none"', async () => {
|
||||
const { ctx, request } = setup('none', { authorization: 'Bearer caller-token' }, {});
|
||||
|
||||
await validateWebhookAuthentication(ctx, 'authentication');
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({ authorization: 'Bearer caller-token' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleFormData', () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { formatPemBlock } from '@n8n/utils/format-pem-block';
|
|||
import basicAuth from 'basic-auth';
|
||||
import { rm } from 'fs/promises';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { WorkflowConfigurationError } from 'n8n-workflow';
|
||||
import { recordConsumedAuth, WorkflowConfigurationError } from 'n8n-workflow';
|
||||
import type {
|
||||
IWebhookFunctions,
|
||||
INodeExecutionData,
|
||||
|
|
@ -294,12 +294,16 @@ export async function validateWebhookAuthentication(
|
|||
) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
|
||||
recordConsumedAuth(req, ['x-auth-token']);
|
||||
} else if (
|
||||
providedAuth.name !== expectedAuth.user ||
|
||||
providedAuth.pass !== expectedAuth.password
|
||||
) {
|
||||
// Provided authentication data is wrong
|
||||
throw new WebhookAuthorizationError(401, 'Authentication data is wrong!');
|
||||
} else {
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
}
|
||||
} else if (authentication === 'bearerAuth') {
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
|
|
@ -315,6 +319,8 @@ export async function validateWebhookAuthentication(
|
|||
if (headers.authorization !== `Bearer ${expectedToken}`) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
} else if (authentication === 'headerAuth') {
|
||||
// Special header with value is needed to call webhook
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
|
|
@ -336,6 +342,8 @@ export async function validateWebhookAuthentication(
|
|||
// Provided authentication data is wrong
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
|
||||
recordConsumedAuth(req, [headerName]);
|
||||
} else if (authentication === 'jwtAuth') {
|
||||
let expectedAuth;
|
||||
|
||||
|
|
@ -369,9 +377,13 @@ export async function validateWebhookAuthentication(
|
|||
}
|
||||
|
||||
try {
|
||||
return jwt.verify(token, secretOrPublicKey, {
|
||||
const payload = jwt.verify(token, secretOrPublicKey, {
|
||||
algorithms: [expectedAuth.algorithm],
|
||||
}) as IDataObject;
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
throw new WebhookAuthorizationError(403, error.message);
|
||||
}
|
||||
|
|
|
|||
33
packages/workflow/src/auth-redaction.ts
Normal file
33
packages/workflow/src/auth-redaction.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { IncomingHttpHeaders } from 'http';
|
||||
|
||||
type RequestWithHeaders = { headers: IncomingHttpHeaders };
|
||||
|
||||
export const REDACTED = '**hidden**';
|
||||
const CONSUMED_AUTH = Symbol.for('n8n.consumedAuth');
|
||||
|
||||
function readRecord(req: RequestWithHeaders): string[] {
|
||||
const record = CONSUMED_AUTH in req ? req[CONSUMED_AUTH] : [];
|
||||
return Array.isArray(record) ? (record as string[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves which headers n8n consumed to authenticate this request, so that they can be redacted later
|
||||
*/
|
||||
export function recordConsumedAuth(req: RequestWithHeaders, names: string[]): void {
|
||||
Object.defineProperty(req, CONSUMED_AUTH, {
|
||||
value: [...readRecord(req), ...names],
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function redactedHeaders(req: RequestWithHeaders): IncomingHttpHeaders {
|
||||
const headers = { ...req.headers };
|
||||
|
||||
for (const name of readRecord(req)) {
|
||||
if (name in headers) headers[name] = REDACTED;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ export * from './tool-helpers';
|
|||
export * from './trigger-credential-gate';
|
||||
export * from './trigger-identity';
|
||||
export * from './n8n-oauth2-auth';
|
||||
export * from './auth-redaction';
|
||||
export * from './node-reference-parser-utils';
|
||||
export * from './metadata-utils';
|
||||
export * from './highlighted-data';
|
||||
|
|
|
|||
|
|
@ -1539,7 +1539,8 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod
|
|||
getInputConnectionData(
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
inputIndex?: number,
|
||||
/** A `number` is the never-implemented `inputIndex`, still accepted and ignored so existing nodes keep compiling. */
|
||||
options?: number | { inputData?: IDataObject },
|
||||
): Promise<unknown>;
|
||||
getNodeParameter(
|
||||
parameterName: string,
|
||||
|
|
@ -3133,6 +3134,8 @@ export interface IWebhookResponseData {
|
|||
workflowData?: INodeExecutionData[][];
|
||||
webhookResponse?: any;
|
||||
noWebhookResponse?: boolean;
|
||||
/** Input the trigger wants connected sub-nodes to receive, also when they run on a worker. */
|
||||
toolInput?: IDataObject;
|
||||
}
|
||||
|
||||
export type WebhookResponseData = 'allEntries' | 'firstEntryJson' | 'firstEntryBinary' | 'noData';
|
||||
|
|
@ -3567,6 +3570,8 @@ export interface IWorkflowExecutionDataProcess {
|
|||
arguments: Record<string, unknown>;
|
||||
sourceNodeName?: string;
|
||||
};
|
||||
/** The MCP request as node input, so the worker gives the tool node the same `$json` as direct mode. */
|
||||
mcpToolInput?: IDataObject;
|
||||
/** Key to uniquely identify this execution, generated by the trigger. */
|
||||
deduplicationKey?: string;
|
||||
/** W3C trace context extracted from inbound webhook headers. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { recordConsumedAuth } from './auth-redaction';
|
||||
import { UnexpectedError } from './errors';
|
||||
import type { IWebhookFunctions } from './interfaces';
|
||||
import type { IUser, IWebhookFunctions } from './interfaces';
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
|
|
@ -35,7 +36,8 @@ function sendUnauthorizedResponse(
|
|||
* and the MCP trigger — both expose it as the `n8nOAuth2` authentication mode.
|
||||
*
|
||||
* On success returns the validated `token` and `resource` URL, which the caller
|
||||
* passes to `context.establishTriggerIdentity` to run the workflow as that user.
|
||||
* passes to `context.establishTriggerIdentity` to run the workflow as that user,
|
||||
* plus the resolved `user` for triggers that surface the caller in their output.
|
||||
* When the token is missing or invalid the response is written here (401/403/503,
|
||||
* advertising the OAuth protected-resource metadata URL in `WWW-Authenticate`) and
|
||||
* `'handled'` is returned, so the request never reaches workflow execution.
|
||||
|
|
@ -53,6 +55,7 @@ export const n8nOAuth2Auth = async (
|
|||
status: 'ok';
|
||||
token: string;
|
||||
resource: string;
|
||||
user: IUser;
|
||||
}
|
||||
| 'handled'
|
||||
> => {
|
||||
|
|
@ -94,9 +97,12 @@ export const n8nOAuth2Auth = async (
|
|||
return 'handled';
|
||||
}
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
token,
|
||||
resource: resourceUrl,
|
||||
user: validationResult.user,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
66
packages/workflow/test/auth-redaction.test.ts
Normal file
66
packages/workflow/test/auth-redaction.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { recordConsumedAuth, redactedHeaders, REDACTED } from '../src/auth-redaction';
|
||||
|
||||
const request = (headers: Record<string, string>) => ({ headers });
|
||||
|
||||
describe('recordConsumedAuth / redactedHeaders', () => {
|
||||
it('should leave the request untouched', () => {
|
||||
const req = request({ authorization: 'Bearer secret', 'x-tenant-id': 'acme' });
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
redactedHeaders(req);
|
||||
|
||||
expect(req.headers).toEqual({ authorization: 'Bearer secret', 'x-tenant-id': 'acme' });
|
||||
});
|
||||
|
||||
it('should redact only the recorded headers', () => {
|
||||
const req = request({ authorization: 'Bearer secret', 'x-tenant-id': 'acme' });
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
|
||||
expect(redactedHeaders(req)).toEqual({ authorization: REDACTED, 'x-tenant-id': 'acme' });
|
||||
});
|
||||
|
||||
it('should redact a credential-named header', () => {
|
||||
const req = request({ test: 'secret-value', 'x-tenant-id': 'acme' });
|
||||
|
||||
recordConsumedAuth(req, ['test']);
|
||||
|
||||
expect(redactedHeaders(req)).toEqual({ test: REDACTED, 'x-tenant-id': 'acme' });
|
||||
});
|
||||
|
||||
it('should accumulate across several records', () => {
|
||||
const req = request({ authorization: 'a', 'x-auth-token': 'b', keep: 'c' });
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
recordConsumedAuth(req, ['x-auth-token']);
|
||||
|
||||
expect(redactedHeaders(req)).toEqual({
|
||||
authorization: REDACTED,
|
||||
'x-auth-token': REDACTED,
|
||||
keep: 'c',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore a recorded header the request never sent', () => {
|
||||
const req = request({ 'x-tenant-id': 'acme' });
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
|
||||
expect(redactedHeaders(req)).toEqual({ 'x-tenant-id': 'acme' });
|
||||
});
|
||||
|
||||
it('should return the headers unchanged when nothing was recorded', () => {
|
||||
const req = request({ authorization: 'Bearer caller-token' });
|
||||
|
||||
expect(redactedHeaders(req)).toEqual({ authorization: 'Bearer caller-token' });
|
||||
});
|
||||
|
||||
it('should not be enumerable on the request', () => {
|
||||
const req = request({ authorization: 'Bearer secret' });
|
||||
|
||||
recordConsumedAuth(req, ['authorization']);
|
||||
|
||||
expect(Object.keys(req)).toEqual(['headers']);
|
||||
expect({ ...req }).toEqual({ headers: { authorization: 'Bearer secret' } });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
import type { Response } from 'express';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { REDACTED, redactedHeaders } from '../src/auth-redaction';
|
||||
import type { IWebhookFunctions, N8nOAuth2ValidationResult } from '../src/interfaces';
|
||||
import { n8nOAuth2Auth } from '../src/n8n-oauth2-auth';
|
||||
|
||||
const WEBHOOK_URL = 'https://n8n.example.com/webhook/protected-path';
|
||||
const USER = { id: 'u1', email: 'u@example.com', firstName: 'U', lastName: 'One' };
|
||||
|
||||
const buildContext = (opts: {
|
||||
authorization?: string;
|
||||
otherHeaders?: Record<string, string>;
|
||||
validation?: N8nOAuth2ValidationResult;
|
||||
webhookUrl?: string | undefined;
|
||||
}) => {
|
||||
|
|
@ -22,27 +25,34 @@ const buildContext = (opts: {
|
|||
'webhookUrl' in opts ? opts.webhookUrl : WEBHOOK_URL,
|
||||
);
|
||||
context.getResponseObject.mockReturnValue(response);
|
||||
context.getRequestObject.mockReturnValue({
|
||||
headers: opts.authorization ? { authorization: opts.authorization } : {},
|
||||
} as never);
|
||||
context.validateN8nOAuth2Token.mockResolvedValue(
|
||||
opts.validation ?? { valid: true, user: { id: 'u1' } as never },
|
||||
);
|
||||
const request = {
|
||||
headers: {
|
||||
...(opts.authorization ? { authorization: opts.authorization } : {}),
|
||||
...opts.otherHeaders,
|
||||
} as Record<string, string>,
|
||||
};
|
||||
context.getRequestObject.mockReturnValue(request as never);
|
||||
context.validateN8nOAuth2Token.mockResolvedValue(opts.validation ?? { valid: true, user: USER });
|
||||
|
||||
return { context, response, validateN8nOAuth2Token: context.validateN8nOAuth2Token };
|
||||
return { context, response, request, validateN8nOAuth2Token: context.validateN8nOAuth2Token };
|
||||
};
|
||||
|
||||
describe('n8nOAuth2Auth', () => {
|
||||
it('returns the token and resource for a valid bearer token', async () => {
|
||||
it('returns the token, resource and resolved user for a valid bearer token', async () => {
|
||||
const { context, validateN8nOAuth2Token } = buildContext({
|
||||
authorization: 'Bearer good-token',
|
||||
validation: { valid: true, user: { id: 'u1' } as never },
|
||||
validation: { valid: true, user: USER },
|
||||
});
|
||||
|
||||
const result = await n8nOAuth2Auth(context, { realm: 'n8n Webhook' });
|
||||
|
||||
expect(validateN8nOAuth2Token).toHaveBeenCalledWith('good-token', WEBHOOK_URL);
|
||||
expect(result).toEqual({ status: 'ok', token: 'good-token', resource: WEBHOOK_URL });
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
token: 'good-token',
|
||||
resource: WEBHOOK_URL,
|
||||
user: USER,
|
||||
});
|
||||
});
|
||||
|
||||
it('encodes the served method into the resource and the protected-resource metadata URL', async () => {
|
||||
|
|
@ -55,7 +65,12 @@ describe('n8nOAuth2Auth', () => {
|
|||
|
||||
const expectedResource = `${WEBHOOK_URL}?method=POST`;
|
||||
expect(validateN8nOAuth2Token).toHaveBeenCalledWith('good-token', expectedResource);
|
||||
expect(result).toEqual({ status: 'ok', token: 'good-token', resource: expectedResource });
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
token: 'good-token',
|
||||
resource: expectedResource,
|
||||
user: USER,
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the method-qualified metadata URL in WWW-Authenticate', async () => {
|
||||
|
|
@ -113,6 +128,36 @@ describe('n8nOAuth2Auth', () => {
|
|||
expect(response.send).toHaveBeenCalledWith('OAuth token validation is not available');
|
||||
});
|
||||
|
||||
it('records the authorization header as consumed once the token is validated', async () => {
|
||||
const { context, request } = buildContext({
|
||||
authorization: 'Bearer good-token',
|
||||
otherHeaders: { 'x-tenant-id': 'acme' },
|
||||
});
|
||||
|
||||
await n8nOAuth2Auth(context, { realm: 'n8n Webhook' });
|
||||
|
||||
expect(request.headers).toEqual({
|
||||
authorization: 'Bearer good-token',
|
||||
'x-tenant-id': 'acme',
|
||||
});
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({
|
||||
authorization: REDACTED,
|
||||
'x-tenant-id': 'acme',
|
||||
});
|
||||
});
|
||||
|
||||
it('records nothing when validation fails', async () => {
|
||||
const { context, request } = buildContext({
|
||||
authorization: 'Bearer bad-token',
|
||||
validation: { valid: false, reason: 'invalid_token' },
|
||||
});
|
||||
|
||||
await n8nOAuth2Auth(context, { realm: 'n8n Webhook' });
|
||||
|
||||
expect(redactedHeaders(request)).toEqual({ authorization: 'Bearer bad-token' });
|
||||
});
|
||||
|
||||
it('throws when the webhook URL is unavailable', async () => {
|
||||
const { context } = buildContext({ webhookUrl: undefined });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user