From 64ed86431516fc88f39fd82ee90ce60e412bf8df Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Thu, 13 Aug 2026 14:24:28 +0000 Subject: [PATCH] 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> --- .../nodes/mcp/McpTrigger/McpTrigger.node.ts | 63 +++++- .../__tests__/McpTrigger.node.test.ts | 195 ++++++++++++++++-- .../nodes-langchain/utils/N8nTool.test.ts | 19 ++ .../@n8n/nodes-langchain/utils/N8nTool.ts | 4 +- .../@n8n/nodes-langchain/utils/helpers.ts | 11 +- .../cli/src/__tests__/workflow-runner.test.ts | 14 +- .../__tests__/job-processor.service.test.ts | 146 +++++++++++++ packages/cli/src/scaling/job-processor.ts | 60 +++--- packages/cli/src/scaling/scaling.types.ts | 3 + .../__tests__/webhook-helpers.test.ts | 93 +++++---- packages/cli/src/webhooks/webhook-helpers.ts | 7 +- packages/cli/src/workflow-runner.ts | 1 + .../__tests__/webhook-context.test.ts | 54 +++++ .../node-execution-context/webhook-context.ts | 8 +- .../nodes/Webhook/test/utils.test.ts | 135 ++++++++++++ packages/nodes-base/nodes/Webhook/utils.ts | 16 +- packages/workflow/src/auth-redaction.ts | 33 +++ packages/workflow/src/index.ts | 1 + packages/workflow/src/interfaces.ts | 7 +- packages/workflow/src/n8n-oauth2-auth.ts | 10 +- packages/workflow/test/auth-redaction.test.ts | 66 ++++++ .../workflow/test/n8n-oauth2-auth.test.ts | 67 +++++- 22 files changed, 889 insertions(+), 124 deletions(-) create mode 100644 packages/workflow/src/auth-redaction.ts create mode 100644 packages/workflow/test/auth-redaction.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpTrigger.node.ts index 71b6daba027..d8090331cc7 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpTrigger.node.ts @@ -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); } } diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpTrigger.node.test.ts index 8b7508e8986..a7f61231e0c 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpTrigger.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpTrigger.node.test.ts @@ -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({ - 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({ 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 }) { + function setupContext(opts: { + typeVersion: number; + headers?: Record; + includeUserInOutput?: boolean; + }) { const req = createMockRequest({ path: '/mcp/abc', headers: opts.headers ?? {} }); const resp = createMockResponse(); const node = mock({ 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[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({ 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', () => { diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts index b5943ad807f..b31f8eefa69 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts @@ -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({}, 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(); diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts index 2eaa6152657..40d2fbbc35b 100644 --- a/packages/@n8n/nodes-langchain/utils/N8nTool.ts +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -105,10 +105,12 @@ export class N8nTool extends DynamicStructuredTool { } }; - return new DynamicTool({ + const dynamicTool = new DynamicTool({ name, description: prepareFallbackToolDescription(description, schema), func: wrappedFunc, }); + dynamicTool.metadata = this.metadata; + return dynamicTool; } } diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index d7777f22cab..eb84500f2c2 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -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 => { - 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 diff --git a/packages/cli/src/__tests__/workflow-runner.test.ts b/packages/cli/src/__tests__/workflow-runner.test.ts index 6edc39615a8..71fe9b737aa 100644 --- a/packages/cli/src/__tests__/workflow-runner.test.ts +++ b/packages/cli/src/__tests__/workflow-runner.test.ts @@ -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({ 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)); }); }); diff --git a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts index 932fe4c599a..3c4b311fe87 100644 --- a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts +++ b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts @@ -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.findSingleExecution.mockResolvedValueOnce( + mock({ + mode: 'trigger', + workflowData, + data: mock({ executionData: undefined }), + }), + ); + executionPersistence.findSingleExecution.mockResolvedValueOnce( + mock({ 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()] } }, + }), + }; + + let toolNodeInput: INodeExecutionData[] | undefined; + const nodeTypes = mock(); + 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.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 diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index b99fb10688b..4857a1da276 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -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, - additionalData: ReturnType extends Promise< - infer T - > - ? T - : never, - runExecutionData: IRunExecutionData, - executionContext?: IExecutionContext, - ): Promise { + private async invokeTool({ + workflow, + sourceNodeName, + toolArgs, + toolInput, + additionalData, + runExecutionData, + executionContext, + }: { + workflow: Workflow; + sourceNodeName: string; + toolArgs: Record; + toolInput?: IDataObject; + additionalData: Awaited>; + runExecutionData: IRunExecutionData; + executionContext?: IExecutionContext; + }): Promise { 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, diff --git a/packages/cli/src/scaling/scaling.types.ts b/packages/cli/src/scaling/scaling.types.ts index a945c99d484..c9b1a93beae 100644 --- a/packages/cli/src/scaling/scaling.types.ts +++ b/packages/cli/src/scaling/scaling.types.ts @@ -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 = { diff --git a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts index 2f3bd596bb5..87136373c6e 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts @@ -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({ - 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({ name: 'Seeded Trigger', type }); - const existingNodeExecutionStack: IExecuteData[] = [ - { - node: mock({ name: 'ExistingNode' }), - data: { - main: [[{ json: { existing: 'data' } }]], + const existingNodeExecutionStack: IExecuteData[] = [ + { + node: mock({ 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({ diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 9a40fe1dbfd..5b891a69a45 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -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; diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 9b8a2822c57..ebf75ed654d 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -537,6 +537,7 @@ export class WorkflowRunner { mcpSessionId: data.mcpSessionId, mcpMessageId: data.mcpMessageId, mcpToolCall: data.mcpToolCall, + mcpToolInput: data.mcpToolInput, }; if (!this.scalingService) { diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts index b7e8fc9ca7b..140cf5adbbb 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts @@ -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({ @@ -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({ 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); diff --git a/packages/core/src/execution-engine/node-execution-context/webhook-context.ts b/packages/core/src/execution-engine/node-execution-context/webhook-context.ts index 074df17f2da..c643083452c 100644 --- a/packages/core/src/execution-engine/node-execution-context/webhook-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/webhook-context.ts @@ -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 { + 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 = { diff --git a/packages/nodes-base/nodes/Webhook/test/utils.test.ts b/packages/nodes-base/nodes/Webhook/test/utils.test.ts index 75ad9f3227d..e829e69f6f9 100644 --- a/packages/nodes-base/nodes/Webhook/test/utils.test.ts +++ b/packages/nodes-base/nodes/Webhook/test/utils.test.ts @@ -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, + credentials: IDataObject, + node?: INode, + ) => { + const request = { headers }; + const ctx: Partial = { + 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', () => { diff --git a/packages/nodes-base/nodes/Webhook/utils.ts b/packages/nodes-base/nodes/Webhook/utils.ts index d8091917907..b9a238cc4c4 100644 --- a/packages/nodes-base/nodes/Webhook/utils.ts +++ b/packages/nodes-base/nodes/Webhook/utils.ts @@ -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); } diff --git a/packages/workflow/src/auth-redaction.ts b/packages/workflow/src/auth-redaction.ts new file mode 100644 index 00000000000..06fa0113809 --- /dev/null +++ b/packages/workflow/src/auth-redaction.ts @@ -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; +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index c33f38c70d8..a6d976346a7 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -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'; diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index e26d97ab5fe..ef692741e94 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -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; 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; 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. */ diff --git a/packages/workflow/src/n8n-oauth2-auth.ts b/packages/workflow/src/n8n-oauth2-auth.ts index 8245229699e..78c499947bb 100644 --- a/packages/workflow/src/n8n-oauth2-auth.ts +++ b/packages/workflow/src/n8n-oauth2-auth.ts @@ -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, }; }; diff --git a/packages/workflow/test/auth-redaction.test.ts b/packages/workflow/test/auth-redaction.test.ts new file mode 100644 index 00000000000..3135414cdb2 --- /dev/null +++ b/packages/workflow/test/auth-redaction.test.ts @@ -0,0 +1,66 @@ +import { recordConsumedAuth, redactedHeaders, REDACTED } from '../src/auth-redaction'; + +const request = (headers: Record) => ({ 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' } }); + }); +}); diff --git a/packages/workflow/test/n8n-oauth2-auth.test.ts b/packages/workflow/test/n8n-oauth2-auth.test.ts index 2652e3796bb..c357eeac91e 100644 --- a/packages/workflow/test/n8n-oauth2-auth.test.ts +++ b/packages/workflow/test/n8n-oauth2-auth.test.ts @@ -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; 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, + }; + 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 });