first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,318 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import type { McpAuthenticationOption, McpServerTransport } from '../types';
import { connectMcpClient, getAuthHeaders, tryRefreshOAuth2Token } from '../utils';
jest.mock('@modelcontextprotocol/sdk/client/index.js');
jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
jest.mock('@modelcontextprotocol/sdk/client/sse.js');
const MockedClient = Client as jest.MockedClass<typeof Client>;
const MockedHTTPTransport = StreamableHTTPClientTransport as jest.MockedClass<
typeof StreamableHTTPClientTransport
>;
const MockedSSETransport = SSEClientTransport as jest.MockedClass<typeof SSEClientTransport>;
describe('utils', () => {
describe('tryRefreshOAuth2Token', () => {
it('should refresh an OAuth2 token without headers', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
access_token: 'new-access-token',
});
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');
expect(headers).toEqual({ Authorization: 'Bearer new-access-token' });
});
it('should refresh an OAuth2 token with headers', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
access_token: 'new-access-token',
});
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api', {
Foo: 'bar',
Authorization: 'Bearer old-access-token',
});
expect(headers).toEqual({
Foo: 'bar',
Authorization: 'Bearer new-access-token',
});
});
it('should return null if the authentication method is not oAuth2Api', async () => {
const ctx = mockDeep<IExecuteFunctions>();
const headers = await tryRefreshOAuth2Token(ctx, 'headerAuth');
expect(headers).toBeNull();
});
it('should return null if the refreshOAuth2Token returns no access_token', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
access_token: null,
});
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');
expect(headers).toBeNull();
});
it('should return null if the refreshOAuth2Token throws an error', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.helpers.refreshOAuth2Token.mockRejectedValue(new Error('Failed to refresh OAuth2 token'));
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');
expect(headers).toBeNull();
});
});
describe('getAuthHeaders', () => {
it('should return the headers for mcpOAuth2Api', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'access-token',
},
});
const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');
expect(result).toEqual({ headers: { Authorization: 'Bearer access-token' } });
});
it('should return the headers for headerAuth', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.getCredentials.mockResolvedValue({
name: 'Foo',
value: 'bar',
});
const result = await getAuthHeaders(ctx, 'headerAuth');
expect(result).toEqual({ headers: { Foo: 'bar' } });
});
it('should return the headers for bearerAuth', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.getCredentials.mockResolvedValue({
token: 'access-token',
});
const result = await getAuthHeaders(ctx, 'bearerAuth');
expect(result).toEqual({ headers: { Authorization: 'Bearer access-token' } });
});
it('should return the headers for multipleHeadersAuth', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.getCredentials.mockResolvedValue({
headers: {
values: [
{ name: 'Foo', value: 'bar' },
{ name: 'Test', value: '123' },
],
},
});
const result = await getAuthHeaders(ctx, 'multipleHeadersAuth');
expect(result).toEqual({ headers: { Foo: 'bar', Test: '123' } });
});
it('should return an empty object for none', async () => {
const ctx = mockDeep<IExecuteFunctions>();
const result = await getAuthHeaders(ctx, 'none');
expect(result).toEqual({});
});
it('should return an empty object for an unknown authentication method', async () => {
const ctx = mockDeep<IExecuteFunctions>();
const result = await getAuthHeaders(ctx, 'unknown' as McpAuthenticationOption);
expect(result).toEqual({});
});
it.each([
'headerAuth',
'bearerAuth',
'mcpOAuth2Api',
'multipleHeadersAuth',
] as McpAuthenticationOption[])(
'should return an empty object for %s when it fails',
async (authentication) => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.getCredentials.mockRejectedValue(new Error('Failed to get credentials'));
const result = await getAuthHeaders(ctx, authentication);
expect(result).toEqual({});
},
);
});
describe('connectMcpClient', () => {
const mockClient = {
connect: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
const mockHttpTransport = {} as unknown as StreamableHTTPClientTransport;
const mockSSETransport = {} as unknown as SSEClientTransport;
MockedClient.mockImplementation(() => mockClient as unknown as Client);
MockedHTTPTransport.mockImplementation(() => mockHttpTransport);
MockedSSETransport.mockImplementation(() => mockSSETransport);
});
describe.each([
['httpStreamable', StreamableHTTPClientTransport],
['sse', SSEClientTransport],
] as Array<
[McpServerTransport, typeof StreamableHTTPClientTransport | typeof SSEClientTransport]
>)('%s transport', (transport, Transport) => {
it('should retry on 401 and succeed', async () => {
const unauthorizedError = new Error('Request failed with status 401');
const onUnauthorized = jest.fn().mockResolvedValue({ Authorization: 'Bearer new-token' });
mockClient.connect
.mockRejectedValueOnce(unauthorizedError)
.mockResolvedValueOnce(undefined);
const result = await connectMcpClient({
serverTransport: transport,
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
version: 1,
onUnauthorized,
});
expect(result.ok).toBe(true);
expect(mockClient.connect).toHaveBeenCalledTimes(2);
expect(onUnauthorized).toHaveBeenCalledWith({ Authorization: 'Bearer old-token' });
expect(Transport).toHaveBeenCalledTimes(2);
expect(Transport).toHaveBeenNthCalledWith(
1,
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer old-token',
}),
}),
}),
);
expect(Transport).toHaveBeenNthCalledWith(
2,
expect.any(URL),
expect.objectContaining({
requestInit: expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer new-token',
}),
}),
}),
);
});
it('should not retry on not 401', async () => {
const error = new Error('Internal Server Error');
mockClient.connect.mockRejectedValueOnce(error);
const result = await connectMcpClient({
serverTransport: transport,
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
version: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe('connection');
}
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(Transport).toHaveBeenCalledTimes(1);
});
it('should not retry when onUnauthorized is not provided', async () => {
const error = new Error('Request failed with status 401');
mockClient.connect.mockRejectedValueOnce(error);
const result = await connectMcpClient({
serverTransport: transport,
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
version: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe('auth');
}
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(Transport).toHaveBeenCalledTimes(1);
});
it('should not retry when onUnauthorized returns null', async () => {
const error = new Error('Request failed with status 401');
const onUnauthorized = jest.fn().mockResolvedValue(null);
mockClient.connect.mockRejectedValueOnce(error);
const result = await connectMcpClient({
serverTransport: transport,
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
version: 1,
onUnauthorized,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe('auth');
}
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(Transport).toHaveBeenCalledTimes(1);
expect(onUnauthorized).toHaveBeenCalledWith({ Authorization: 'Bearer old-token' });
});
it('should not retry more than once', async () => {
const error = new Error('Request failed with status 401');
mockClient.connect.mockRejectedValue(error);
const onUnauthorized = jest.fn().mockResolvedValue({ Authorization: 'Bearer new-token' });
const result = await connectMcpClient({
serverTransport: transport,
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
version: 1,
onUnauthorized,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe('auth');
}
expect(mockClient.connect).toHaveBeenCalledTimes(2);
expect(Transport).toHaveBeenCalledTimes(2);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
expect(onUnauthorized).toHaveBeenCalledWith({ Authorization: 'Bearer old-token' });
});
});
});
});
@@ -0,0 +1,65 @@
import type { IDisplayOptions, INodeCredentialDescription, INodeProperties } from 'n8n-workflow';
export const transportSelect = ({
defaultOption,
displayOptions,
}: {
defaultOption: 'sse' | 'httpStreamable';
displayOptions?: IDisplayOptions;
}): INodeProperties => ({
displayName: 'Server Transport',
name: 'serverTransport',
type: 'options',
options: [
{
name: 'HTTP Streamable',
value: 'httpStreamable',
},
{
name: 'Server Sent Events (Deprecated)',
value: 'sse',
},
],
default: defaultOption,
description: 'The transport used by your endpoint',
displayOptions,
});
export const credentials: INodeCredentialDescription[] = [
{
name: 'httpBearerAuth',
required: true,
displayOptions: {
show: {
authentication: ['bearerAuth'],
},
},
},
{
name: 'httpHeaderAuth',
required: true,
displayOptions: {
show: {
authentication: ['headerAuth'],
},
},
},
{
name: 'mcpOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['mcpOAuth2Api'],
},
},
},
{
name: 'httpMultipleHeadersAuth',
required: true,
displayOptions: {
show: {
authentication: ['multipleHeadersAuth'],
},
},
},
];
@@ -0,0 +1,12 @@
import type { JSONSchema7 } from 'json-schema';
export type McpTool = { name: string; description?: string; inputSchema: JSONSchema7 };
export type McpServerTransport = 'sse' | 'httpStreamable';
export type McpAuthenticationOption =
| 'none'
| 'headerAuth'
| 'bearerAuth'
| 'mcpOAuth2Api'
| 'multipleHeadersAuth';
@@ -0,0 +1,291 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { ClientOAuth2TokenData } from '@n8n/client-oauth2';
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
INode,
ISupplyDataFunctions,
Result,
} from 'n8n-workflow';
import { createResultError, createResultOk, NodeOperationError } from 'n8n-workflow';
import { proxyFetch } from '@n8n/ai-utilities';
import type { McpAuthenticationOption, McpServerTransport, McpTool } from './types';
export async function getAllTools(client: Client, cursor?: string): Promise<McpTool[]> {
const { tools, nextCursor } = await client.listTools({ cursor });
if (nextCursor) {
return (tools as McpTool[]).concat(await getAllTools(client, nextCursor));
}
return tools as McpTool[];
}
function safeCreateUrl(url: string, baseUrl?: string | URL): Result<URL, Error> {
try {
return createResultOk(new URL(url, baseUrl));
} catch (error) {
return createResultError(error);
}
}
function normalizeAndValidateUrl(input: string): Result<URL, Error> {
const withProtocol = !/^https?:\/\//i.test(input) ? `https://${input}` : input;
const parsedUrl = safeCreateUrl(withProtocol);
if (!parsedUrl.ok) {
return createResultError(parsedUrl.error);
}
return parsedUrl;
}
function errorHasCode(error: unknown, code: number): boolean {
return (
!!error &&
typeof error === 'object' &&
(('code' in error && Number(error.code) === code) ||
('message' in error &&
typeof error.message === 'string' &&
error.message.includes(code.toString())))
);
}
function isUnauthorizedError(error: unknown): boolean {
return errorHasCode(error, 401);
}
function isForbiddenError(error: unknown): boolean {
return errorHasCode(error, 403);
}
type OnUnauthorizedHandler = (
headers?: Record<string, string>,
) => Promise<Record<string, string> | null>;
type ConnectMcpClientError =
| { type: 'invalid_url'; error: Error }
| { type: 'connection'; error: Error }
| { type: 'auth'; error: Error };
export function mapToNodeOperationError(
node: INode,
error: ConnectMcpClientError,
): NodeOperationError {
switch (error.type) {
case 'invalid_url':
return new NodeOperationError(node, error.error, {
message: 'Could not connect to your MCP server. The provided URL is invalid.',
});
case 'auth':
return new NodeOperationError(node, error.error, {
message: 'Could not connect to your MCP server. Authentication failed.',
description: error.error.message,
});
case 'connection':
default:
return new NodeOperationError(node, error.error, {
message: 'Could not connect to your MCP server',
description: error.error.message,
});
}
}
export async function connectMcpClient({
headers,
serverTransport,
endpointUrl,
name,
version,
onUnauthorized,
}: {
serverTransport: McpServerTransport;
endpointUrl: string;
headers?: Record<string, string>;
name: string;
version: number;
onUnauthorized?: OnUnauthorizedHandler;
}): Promise<Result<Client, ConnectMcpClientError>> {
const endpoint = normalizeAndValidateUrl(endpointUrl);
if (!endpoint.ok) {
return createResultError({ type: 'invalid_url', error: endpoint.error });
}
const client = new Client({ name, version: version.toString() }, { capabilities: {} });
if (serverTransport === 'httpStreamable') {
try {
const transport = new StreamableHTTPClientTransport(endpoint.result, {
requestInit: { headers },
fetch: proxyFetch,
});
await client.connect(transport);
return createResultOk(client);
} catch (error) {
if (onUnauthorized && isUnauthorizedError(error)) {
const newHeaders = await onUnauthorized(headers);
if (newHeaders) {
// Don't pass `onUnauthorized` to avoid possible infinite recursion
return await connectMcpClient({
headers: newHeaders,
serverTransport,
endpointUrl,
name,
version,
});
}
}
if (isUnauthorizedError(error) || isForbiddenError(error)) {
return createResultError({ type: 'auth', error: error as Error });
} else {
return createResultError({ type: 'connection', error: error as Error });
}
}
}
try {
const sseTransport = new SSEClientTransport(endpoint.result, {
eventSourceInit: {
fetch: async (url, init) =>
await proxyFetch(url, {
...init,
headers: {
...headers,
Accept: 'text/event-stream',
},
}),
},
fetch: proxyFetch,
requestInit: { headers },
});
await client.connect(sseTransport);
return createResultOk(client);
} catch (error) {
if (onUnauthorized && isUnauthorizedError(error)) {
const newHeaders = await onUnauthorized(headers);
if (newHeaders) {
// Don't pass `onUnauthorized` to avoid possible infinite recursion
return await connectMcpClient({
headers: newHeaders,
serverTransport,
endpointUrl,
name,
version,
});
}
}
if (isUnauthorizedError(error) || isForbiddenError(error)) {
return createResultError({ type: 'auth', error: error as Error });
} else {
return createResultError({ type: 'connection', error: error as Error });
}
}
}
export async function getAuthHeaders(
ctx: Pick<IExecuteFunctions, 'getCredentials'>,
authentication: McpAuthenticationOption,
): Promise<{ headers?: Record<string, string> }> {
switch (authentication) {
case 'headerAuth': {
const header = await ctx
.getCredentials<{ name: string; value: string }>('httpHeaderAuth')
.catch(() => null);
if (!header) return {};
return { headers: { [header.name]: header.value } };
}
case 'bearerAuth': {
const result = await ctx
.getCredentials<{ token: string }>('httpBearerAuth')
.catch(() => null);
if (!result) return {};
return { headers: { Authorization: `Bearer ${result.token}` } };
}
case 'mcpOAuth2Api': {
const result = await ctx
.getCredentials<{ oauthTokenData: { access_token: string } }>('mcpOAuth2Api')
.catch(() => null);
if (!result) return {};
return { headers: { Authorization: `Bearer ${result.oauthTokenData.access_token}` } };
}
case 'multipleHeadersAuth': {
const result = await ctx
.getCredentials<{ headers: { values: Array<{ name: string; value: string }> } }>(
'httpMultipleHeadersAuth',
)
.catch(() => null);
if (!result) return {};
return {
headers: result.headers.values.reduce(
(acc, cur) => {
acc[cur.name] = cur.value;
return acc;
},
{} as Record<string, string>,
),
};
}
case 'none':
default: {
return {};
}
}
}
/**
* Tries to refresh the OAuth2 token, storing them in the database if successful
* @param ctx - The execution context
* @param authentication - The authentication method
* @param headers - The headers to refresh
* @returns The refreshed headers or null if the authentication method is not oAuth2Api or has failed
*/
export async function tryRefreshOAuth2Token(
ctx: IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions,
authentication: McpAuthenticationOption,
headers?: Record<string, string>,
) {
if (authentication !== 'mcpOAuth2Api') {
return null;
}
let access_token: string | null = null;
try {
const result = (await ctx.helpers.refreshOAuth2Token.call(
ctx,
'mcpOAuth2Api',
)) as ClientOAuth2TokenData;
access_token = result?.access_token;
} catch (error) {
return null;
}
if (!access_token) {
return null;
}
if (!headers) {
return {
Authorization: `Bearer ${access_token}`,
};
}
return {
...headers,
Authorization: `Bearer ${access_token}`,
};
}