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,327 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type {
IBinaryKeyData,
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
NodeExecutionWithMetadata,
} from 'n8n-workflow';
import { jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { ZodError } from 'zod';
import { prettifyError } from 'zod/v4/core';
import * as listSearch from './listSearch';
import * as resourceMapping from './resourceMapping';
import { credentials, transportSelect } from '../shared/descriptions';
import type { McpAuthenticationOption, McpServerTransport } from '../shared/types';
import {
getAuthHeaders,
tryRefreshOAuth2Token,
connectMcpClient,
mapToNodeOperationError,
} from '../shared/utils';
export class McpClient implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client',
description: 'Standalone MCP Client',
name: 'mcpClient',
icon: {
light: 'file:../mcp.svg',
dark: 'file:../mcp.dark.svg',
},
group: ['transform'],
version: 1,
defaults: {
name: 'MCP Client',
},
credentials,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
transportSelect({
defaultOption: 'httpStreamable',
}),
{
displayName: 'MCP Endpoint URL',
name: 'endpointUrl',
type: 'string',
default: '',
placeholder: 'e.g. https://my-mcp-server.ai/mcp',
required: true,
description: 'The URL of the MCP server to connect to',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Bearer Auth',
value: 'bearerAuth',
},
{
name: 'Header Auth',
value: 'headerAuth',
},
{
name: 'MCP OAuth2',
value: 'mcpOAuth2Api',
},
{
name: 'Multiple Headers Auth',
value: 'multipleHeadersAuth',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
description: 'The way to authenticate with your endpoint',
},
{
displayName: 'Credentials',
name: 'credentials',
type: 'credentials',
default: '',
displayOptions: {
show: {
authentication: ['headerAuth', 'bearerAuth', 'mcpOAuth2Api', 'multipleHeadersAuth'],
},
},
},
{
displayName: 'Tool',
name: 'tool',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'The tool to use',
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'getTools',
searchable: true,
skipCredentialsCheckInRLC: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
},
{
displayName: 'Input Mode',
name: 'inputMode',
type: 'options',
default: 'manual',
noDataExpression: true,
options: [
{
name: 'Manual',
value: 'manual',
description: 'Manually specify the input data for each tool parameter',
},
{
name: 'JSON',
value: 'json',
description: 'Specify the input data as a JSON object',
},
],
},
{
displayName: 'Parameters',
name: 'parameters',
type: 'resourceMapper',
default: {
mappingMode: 'defineBelow',
value: null,
},
noDataExpression: true,
required: true,
typeOptions: {
loadOptionsDependsOn: ['tool.value'],
resourceMapper: {
resourceMapperMethod: 'getToolParameters',
hideNoDataError: true,
addAllFields: false,
supportAutoMap: false,
mode: 'add',
fieldWords: {
singular: 'parameter',
plural: 'parameters',
},
},
},
displayOptions: {
show: {
inputMode: ['manual'],
},
},
},
{
displayName: 'JSON',
name: 'jsonInput',
type: 'json',
typeOptions: {
rows: 5,
},
default: '{\n "my_field_1": "value",\n "my_field_2": 1\n}\n',
validateType: 'object',
displayOptions: {
show: {
inputMode: ['json'],
},
},
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Convert to Binary',
name: 'convertToBinary',
type: 'boolean',
default: true,
description:
'Whether to convert images and audio to binary data. If false, images and audio will be returned as base64 encoded strings.',
},
{
displayName: 'Timeout',
name: 'timeout',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 60000,
description: 'Time in ms to wait for tool calls to finish',
},
],
},
],
};
methods = {
listSearch,
resourceMapping,
};
async execute(
this: IExecuteFunctions,
): Promise<INodeExecutionData[][] | NodeExecutionWithMetadata[][] | null> {
const authentication = this.getNodeParameter('authentication', 0) as McpAuthenticationOption;
const serverTransport = this.getNodeParameter('serverTransport', 0) as McpServerTransport;
const endpointUrl = this.getNodeParameter('endpointUrl', 0) as string;
const node = this.getNode();
const { headers } = await getAuthHeaders(this, authentication);
const client = await connectMcpClient({
serverTransport,
endpointUrl,
headers,
name: node.type,
version: node.typeVersion,
onUnauthorized: async (headers) => await tryRefreshOAuth2Token(this, authentication, headers),
});
if (!client.ok) {
throw mapToNodeOperationError(node, client.error);
}
const inputMode = this.getNodeParameter('inputMode', 0, 'manual') as 'manual' | 'json';
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
const tool = this.getNodeParameter('tool.value', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex);
let parameters: IDataObject = {};
if (inputMode === 'manual') {
parameters = this.getNodeParameter('parameters.value', itemIndex) as IDataObject;
} else {
parameters = this.getNodeParameter('jsonInput', itemIndex) as IDataObject;
}
const result = (await client.result.callTool(
{
name: tool,
arguments: parameters,
},
undefined,
{
timeout: options.timeout ? Number(options.timeout) : undefined,
},
)) as CallToolResult;
let binaryIndex = 0;
const binary: IBinaryKeyData = {};
const content: IDataObject[] = [];
const convertToBinary = options.convertToBinary ?? true;
for (const contentItem of result.content) {
if (contentItem.type === 'text') {
content.push({
...contentItem,
text: jsonParse(contentItem.text, { fallbackValue: contentItem.text }),
});
continue;
}
if (convertToBinary && (contentItem.type === 'image' || contentItem.type === 'audio')) {
binary[`data_${binaryIndex}`] = await this.helpers.prepareBinaryData(
Buffer.from(contentItem.data, 'base64'),
undefined,
contentItem.mimeType,
);
binaryIndex++;
continue;
}
content.push(contentItem as IDataObject);
}
returnData.push({
json: {
content: content.length > 0 ? content : undefined,
},
binary: Object.keys(binary).length > 0 ? binary : undefined,
pairedItem: {
item: itemIndex,
},
});
} catch (e) {
const errorMessage =
e instanceof ZodError ? prettifyError(e) : e instanceof Error ? e.message : String(e);
if (this.continueOnFail()) {
returnData.push({
json: {
error: {
message: errorMessage,
issues: e instanceof ZodError ? e.issues : undefined,
},
},
pairedItem: {
item: itemIndex,
},
});
continue;
}
throw new NodeOperationError(node, errorMessage, {
itemIndex,
});
}
}
return [returnData];
}
}
@@ -0,0 +1,221 @@
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import * as sharedUtils from '../../shared/utils';
import { McpClient } from '../McpClient.node';
describe('McpClient', () => {
const getAuthHeaders = jest.spyOn(sharedUtils, 'getAuthHeaders');
const connectMcpClient = jest.spyOn(sharedUtils, 'connectMcpClient');
const executeFunctions = mockDeep<IExecuteFunctions>();
const client = mockDeep<Client>();
const defaultParams = {
authentication: 'none',
serverTransport: 'httpStreamable',
endpointUrl: 'https://test.com/mcp',
inputMode: 'json',
jsonInput: { location: 'Berlin' },
'tool.value': 'get_weather',
options: { timeout: 10000, convertToBinary: false },
};
beforeEach(() => {
jest.resetAllMocks();
executeFunctions.getNode.mockReturnValue({
id: '123',
name: 'MCP Client',
type: '@n8n/n8n-nodes-langchain.mcpClient',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
getAuthHeaders.mockResolvedValue({ headers: {} });
connectMcpClient.mockResolvedValue({
ok: true,
result: client,
});
});
it('should handle json input mode', async () => {
executeFunctions.getNodeParameter.mockImplementation(
(key, _idx, defaultValue) => defaultParams[key as keyof typeof defaultParams] ?? defaultValue,
);
client.callTool.mockResolvedValue({
content: [{ type: 'text', text: 'Weather in Berlin is sunny' }],
});
const result = await new McpClient().execute.call(executeFunctions);
expect(result).toEqual([
[
{
json: { content: [{ type: 'text', text: 'Weather in Berlin is sunny' }] },
pairedItem: { item: 0 },
},
],
]);
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'get_weather', arguments: { location: 'Berlin' } },
undefined,
{ timeout: 10000 },
);
});
it('should handle manual input mode', async () => {
executeFunctions.getNodeParameter.mockImplementation((key, _idx, defaultValue) => {
const params = {
...defaultParams,
jsonInput: undefined,
inputMode: 'manual',
'parameters.value': { location: 'Berlin' },
};
return params[key as keyof typeof params] ?? defaultValue;
});
client.callTool.mockResolvedValue({
content: [{ type: 'text', text: 'Weather in Berlin is sunny' }],
});
const result = await new McpClient().execute.call(executeFunctions);
expect(result).toEqual([
[
{
json: { content: [{ type: 'text', text: 'Weather in Berlin is sunny' }] },
pairedItem: { item: 0 },
},
],
]);
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'get_weather', arguments: { location: 'Berlin' } },
undefined,
{ timeout: 10000 },
);
});
it('should try to parse text content as json', async () => {
executeFunctions.getNodeParameter.mockImplementation(
(key, _idx, defaultValue) => defaultParams[key as keyof typeof defaultParams] ?? defaultValue,
);
client.callTool.mockResolvedValue({
content: [{ type: 'text', text: '{"answer": "Weather in Berlin is sunny"}' }],
});
const result = await new McpClient().execute.call(executeFunctions);
expect(result).toEqual([
[
{
json: { content: [{ type: 'text', text: { answer: 'Weather in Berlin is sunny' } }] },
pairedItem: { item: 0 },
},
],
]);
});
it('should convert images and audio to binary data when convertToBinary is true', async () => {
executeFunctions.getNodeParameter.mockImplementation(
(key, _idx, defaultValue) =>
({
...defaultParams,
jsonInput: { foo: 'bar' },
options: { ...defaultParams.options, convertToBinary: true },
})[key as keyof typeof defaultParams] ?? defaultValue,
);
client.callTool.mockResolvedValue({
content: [
{ type: 'image', data: 'abcdef', mimeType: 'image/jpeg' },
{ type: 'audio', data: 'ghijkl', mimeType: 'audio/mpeg' },
],
});
executeFunctions.helpers.prepareBinaryData.mockResolvedValueOnce({
data: 'abcdef',
mimeType: 'image/jpeg',
});
executeFunctions.helpers.prepareBinaryData.mockResolvedValueOnce({
data: 'ghijkl',
mimeType: 'audio/mpeg',
});
const result = await new McpClient().execute.call(executeFunctions);
expect(result).toEqual([
[
{
json: {},
binary: {
data_0: {
mimeType: 'image/jpeg',
data: 'abcdef',
},
data_1: {
mimeType: 'audio/mpeg',
data: 'ghijkl',
},
},
pairedItem: { item: 0 },
},
],
]);
});
it('should keep images and audio as json when convertToBinary is false', async () => {
executeFunctions.getNodeParameter.mockImplementation(
(key, _idx, defaultValue) =>
({
...defaultParams,
jsonInput: { foo: 'bar' },
options: { ...defaultParams.options, convertToBinary: false },
})[key as keyof typeof defaultParams] ?? defaultValue,
);
client.callTool.mockResolvedValue({
content: [
{ type: 'image', data: 'abcdef', mimeType: 'image/jpeg' },
{ type: 'audio', data: 'ghijkl', mimeType: 'audio/mpeg' },
],
});
const result = await new McpClient().execute.call(executeFunctions);
expect(result).toEqual([
[
{
json: {
content: [
{ type: 'image', data: 'abcdef', mimeType: 'image/jpeg' },
{ type: 'audio', data: 'ghijkl', mimeType: 'audio/mpeg' },
],
},
pairedItem: { item: 0 },
},
],
]);
});
it('should throw an error if the tool call fails', async () => {
executeFunctions.getNodeParameter.mockImplementation(
(key, _idx, defaultValue) => defaultParams[key as keyof typeof defaultParams] ?? defaultValue,
);
client.callTool.mockRejectedValue(new Error('Tool call failed'));
await expect(new McpClient().execute.call(executeFunctions)).rejects.toThrow(
'Tool call failed',
);
});
it('should return an error as json if the tool call fails and continueOnFail is true', async () => {
executeFunctions.getNodeParameter.mockImplementation(
(key, _idx, defaultValue) => defaultParams[key as keyof typeof defaultParams] ?? defaultValue,
);
client.callTool.mockRejectedValue(new Error('Tool call failed'));
executeFunctions.continueOnFail.mockReturnValue(true);
const result = await new McpClient().execute.call(executeFunctions);
expect(result).toEqual([
[{ json: { error: { message: 'Tool call failed' } }, pairedItem: { item: 0 } }],
]);
});
});
@@ -0,0 +1,302 @@
import type { JSONSchema7Definition, JSONSchema7 } from 'json-schema';
import {
convertJsonSchemaToResourceMapperFields,
jsonSchemaTypeToDefaultValue,
jsonSchemaTypeToFieldType,
} from '../utils';
describe('utils', () => {
describe('jsonSchemaTypeToFieldType', () => {
it.each([
[{ schema: { type: 'string', format: 'date-time' } as JSONSchema7, expected: 'dateTime' }],
[{ schema: { type: 'string' } as JSONSchema7, expected: 'string' }],
[{ schema: { type: 'number' } as JSONSchema7, expected: 'number' }],
[{ schema: { type: 'integer' } as JSONSchema7, expected: 'number' }],
[{ schema: { type: 'boolean' } as JSONSchema7, expected: 'boolean' }],
[{ schema: { type: 'array' } as JSONSchema7, expected: 'array' }],
[{ schema: { type: 'object' } as JSONSchema7, expected: 'object' }],
])('should return the correct field type for the schema', ({ schema, expected }) => {
expect(jsonSchemaTypeToFieldType(schema)).toEqual(expected);
});
});
describe('jsonSchemaTypeToDefaultValue', () => {
it.each([
[{ schema: false as JSONSchema7Definition, expected: null }],
[{ schema: true as JSONSchema7Definition, expected: 'any' }],
[{ schema: { type: 'string' } as JSONSchema7Definition, expected: 'string' }],
[{ schema: { type: 'number' } as JSONSchema7Definition, expected: 0 }],
[{ schema: { type: 'integer' } as JSONSchema7Definition, expected: 0 }],
[{ schema: { type: 'number', minimum: -1 } as JSONSchema7Definition, expected: -1 }],
[{ schema: { type: 'number', maximum: 1 } as JSONSchema7Definition, expected: 1 }],
[{ schema: { type: 'boolean' } as JSONSchema7Definition, expected: false }],
[
{
schema: { type: 'string', format: 'date-time' } as JSONSchema7Definition,
expected: '2025-01-01T00:00:00Z',
},
],
[
{
schema: { type: 'string', format: 'uri' } as JSONSchema7Definition,
expected: 'https://example.com',
},
],
[
{
schema: { type: 'string', format: 'url' } as JSONSchema7Definition,
expected: 'https://example.com',
},
],
[
{
schema: { type: 'string', format: 'date' } as JSONSchema7Definition,
expected: '2025-01-01',
},
],
[
{
schema: { type: 'string', format: 'time' } as JSONSchema7Definition,
expected: '00:00:00',
},
],
[{ schema: { type: 'array' } as JSONSchema7Definition, expected: [] }],
[
{
schema: { type: 'array', items: { type: 'string' } } as JSONSchema7Definition,
expected: ['string'],
},
],
[
{
schema: {
type: 'array',
items: [{ type: 'number' }, { type: 'string' }],
} as JSONSchema7Definition,
expected: [0, 'string'],
},
],
[
{
schema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
additionalProperties: false,
} as JSONSchema7Definition,
expected: { name: 'string', age: 0 },
},
],
[
{
schema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
additionalProperties: { type: 'string' },
} as JSONSchema7Definition,
expected: { name: 'string', age: 0, '<additionalProperty>': 'string' },
},
],
[
{
schema: {
type: 'string',
enum: ['foo', 'bar'],
} as JSONSchema7Definition,
expected: 'foo',
},
],
[
{
schema: {
oneOf: [{ type: 'string' }, { type: 'number' }],
} as JSONSchema7Definition,
expected: 'string',
},
],
[
{
schema: {
anyOf: [{ type: 'string' }, { type: 'number' }],
} as JSONSchema7Definition,
expected: 'string',
},
],
[
{
schema: {
allOf: [
{
type: 'object',
properties: {
age: { type: 'number' },
},
required: ['age'],
additionalProperties: false,
},
{
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
additionalProperties: false,
},
],
} as JSONSchema7Definition,
expected: { age: 0, name: 'string' },
},
],
])('should return the correct default value for the schema', ({ schema, expected }) => {
expect(jsonSchemaTypeToDefaultValue(schema)).toEqual(expected);
});
});
describe('convertJsonSchemaToResourceMapperFields', () => {
it.each([
[
{
schema: { type: 'string' } as JSONSchema7,
expected: [],
},
],
[
{
schema: { type: 'object' } as JSONSchema7,
expected: [],
},
],
[
{
schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'number' } },
required: ['name'],
} as JSONSchema7,
expected: [
{
id: 'name',
displayName: 'name',
defaultMatch: false,
required: true,
display: true,
type: 'string',
},
{
id: 'age',
displayName: 'age',
defaultMatch: false,
required: false,
display: true,
type: 'number',
},
],
},
],
[
{
schema: { type: 'object', properties: { name: true, age: false } } as JSONSchema7,
expected: [
{
id: 'name',
displayName: 'name',
defaultMatch: false,
required: false,
display: true,
type: 'string',
},
],
},
],
])(
'should return the correct resource mapper fields for the schema',
({ schema, expected }) => {
expect(convertJsonSchemaToResourceMapperFields(schema)).toEqual(expected);
},
);
it.each([
[
{
schema: {
type: 'object',
properties: { names: { type: 'array', items: { type: 'string' } } },
required: ['names'],
} as JSONSchema7,
expected: [
{
id: 'names',
displayName: 'names',
defaultMatch: false,
required: true,
display: true,
type: 'array',
defaultValue: JSON.stringify(['string'], null, 2),
},
],
},
],
[
{
schema: {
type: 'object',
properties: {
user: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'number' } },
},
},
required: ['user'],
} as JSONSchema7,
expected: [
{
id: 'user',
displayName: 'user',
defaultMatch: false,
required: true,
display: true,
type: 'object',
defaultValue: JSON.stringify({ name: 'string', age: 0 }, null, 2),
},
],
},
],
])('should add defaultValue for arrays and objects', ({ schema, expected }) => {
expect(convertJsonSchemaToResourceMapperFields(schema)).toEqual(expected);
});
it('should add options for enums', () => {
const schema = {
type: 'object',
properties: { color: { type: 'string', enum: ['red', 'green', 'blue'] } },
} as JSONSchema7;
const expected = [
{
id: 'color',
displayName: 'color',
defaultMatch: false,
required: false,
display: true,
type: 'options',
options: [
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'red', value: 'red' },
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'green', value: 'green' },
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'blue', value: 'blue' },
],
},
];
expect(convertJsonSchemaToResourceMapperFields(schema)).toEqual(expected);
});
});
});
@@ -0,0 +1,48 @@
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
import type { McpAuthenticationOption, McpServerTransport } from '../shared/types';
import {
connectMcpClient,
getAuthHeaders,
mapToNodeOperationError,
tryRefreshOAuth2Token,
} from '../shared/utils';
export async function getTools(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const authentication = this.getNodeParameter('authentication') as McpAuthenticationOption;
const serverTransport = this.getNodeParameter('serverTransport') as McpServerTransport;
const endpointUrl = this.getNodeParameter('endpointUrl') as string;
const node = this.getNode();
const { headers } = await getAuthHeaders(this, authentication);
const client = await connectMcpClient({
serverTransport,
endpointUrl,
headers,
name: node.type,
version: node.typeVersion,
onUnauthorized: async (headers) => await tryRefreshOAuth2Token(this, authentication, headers),
});
if (!client.ok) {
throw mapToNodeOperationError(node, client.error);
}
const result = await client.result.listTools({ cursor: paginationToken });
const tools = filter
? result.tools.filter((tool) => tool.name.toLowerCase().includes(filter.toLowerCase()))
: result.tools;
return {
results: tools.map((tool) => ({
name: tool.name,
value: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
paginationToken: result.nextCursor,
};
}
@@ -0,0 +1,48 @@
import type { ILoadOptionsFunctions, ResourceMapperFields } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { convertJsonSchemaToResourceMapperFields } from './utils';
import type { McpAuthenticationOption, McpServerTransport } from '../shared/types';
import {
getAuthHeaders,
connectMcpClient,
getAllTools,
tryRefreshOAuth2Token,
mapToNodeOperationError,
} from '../shared/utils';
export async function getToolParameters(
this: ILoadOptionsFunctions,
): Promise<ResourceMapperFields> {
const toolId = this.getNodeParameter('tool', 0, {
extractValue: true,
}) as string;
const authentication = this.getNodeParameter('authentication') as McpAuthenticationOption;
const serverTransport = this.getNodeParameter('serverTransport') as McpServerTransport;
const endpointUrl = this.getNodeParameter('endpointUrl') as string;
const node = this.getNode();
const { headers } = await getAuthHeaders(this, authentication);
const client = await connectMcpClient({
serverTransport,
endpointUrl,
headers,
name: node.type,
version: node.typeVersion,
onUnauthorized: async (headers) => await tryRefreshOAuth2Token(this, authentication, headers),
});
if (!client.ok) {
throw mapToNodeOperationError(node, client.error);
}
const result = await getAllTools(client.result);
const tool = result.find((tool) => tool.name === toolId);
if (!tool) {
throw new NodeOperationError(this.getNode(), 'Tool not found');
}
const fields = convertJsonSchemaToResourceMapperFields(tool.inputSchema);
return {
fields,
};
}
@@ -0,0 +1,281 @@
import type { JSONSchema7, JSONSchema7Definition } from 'json-schema';
import type {
ResourceMapperField,
FieldType,
INodePropertyOptions,
IDataObject,
} from 'n8n-workflow';
function pickFirstSchema(schema: JSONSchema7Definition): JSONSchema7Definition {
if (typeof schema === 'object' && (schema?.anyOf || schema?.oneOf)) {
if (Array.isArray(schema.anyOf) && schema.anyOf[0] !== undefined) {
return schema.anyOf[0];
}
if (Array.isArray(schema.oneOf) && schema.oneOf[0] !== undefined) {
return schema.oneOf[0];
}
}
return schema;
}
function mergeTwoSchemas(
a?: JSONSchema7Definition,
b?: JSONSchema7Definition,
): JSONSchema7Definition | undefined {
if (a === undefined) {
return b;
}
if (b === undefined) {
return a;
}
a = pickFirstSchema(a);
b = pickFirstSchema(b);
if (a === false || b === false) {
return false;
}
if (a === true || b === true) {
return true;
}
if (a.type === 'object' && b.type === 'object') {
const properties = { ...(a.properties ?? {}), ...(b.properties ?? {}) };
const required = [...(a.required ?? []), ...(b.required ?? [])];
const additionalProperties = mergeTwoSchemas(a.additionalProperties, b.additionalProperties);
return { ...a, ...b, properties, required, additionalProperties };
}
if (a.type === 'array' && b.type === 'array') {
if (Array.isArray(a.items) && Array.isArray(b.items)) {
// Two tuples -> pick the longer one
return a.items.length > b.items.length ? a : b;
}
if (Array.isArray(a.items) || Array.isArray(b.items)) {
// One tuple -> pick the tuple
return Array.isArray(a.items) ? a : b;
}
const items = mergeTwoSchemas(a.items, b.items);
return { ...a, ...b, items };
}
return undefined;
}
function mergeAllOfSchemas(schemas: JSONSchema7Definition[]): JSONSchema7Definition | undefined {
if (schemas.length === 0) {
return undefined;
}
if (schemas.length === 1) {
return schemas[0];
}
return schemas.reduce(
(acc, schema) => mergeTwoSchemas(acc, schema),
undefined as JSONSchema7Definition | undefined,
);
}
export function jsonSchemaTypeToDefaultValue(
schema: JSONSchema7Definition,
): string | number | boolean | object | null {
if (schema === false) {
return null;
}
if (schema === true) {
return 'any';
}
if (schema.allOf) {
const mergedSchema = mergeAllOfSchemas(schema.allOf);
if (mergedSchema !== undefined) {
return jsonSchemaTypeToDefaultValue(mergedSchema);
}
}
if (schema.anyOf) {
const anyOfSchemas = schema.anyOf;
for (const anyOfSchema of anyOfSchemas) {
const defaultValue = jsonSchemaTypeToDefaultValue(anyOfSchema);
if (defaultValue !== null) {
return defaultValue;
}
}
}
if (schema.oneOf) {
const oneOfSchemas = schema.oneOf;
for (const oneOfSchema of oneOfSchemas) {
const defaultValue = jsonSchemaTypeToDefaultValue(oneOfSchema);
if (defaultValue !== null) {
return defaultValue;
}
}
}
if (schema.enum && Array.isArray(schema.enum)) {
return schema.enum[0];
}
if (Array.isArray(schema.type)) {
const types = schema.type;
for (const type of types) {
const defaultValue = jsonSchemaTypeToDefaultValue({ type });
if (defaultValue !== null) {
return defaultValue;
}
}
}
if (schema.type === 'number' || schema.type === 'integer') {
if (schema.minimum !== undefined) {
return schema.minimum;
}
if (schema.maximum !== undefined) {
return schema.maximum;
}
return 0;
}
if (schema.type === 'boolean') {
return false;
}
if (schema.type === 'string') {
if (schema.format === 'date-time') {
return '2025-01-01T00:00:00Z';
}
if (schema.format === 'uri' || schema.format === 'url') {
return 'https://example.com';
}
if (schema.format === 'date') {
return '2025-01-01';
}
if (schema.format === 'time') {
return '00:00:00';
}
return 'string';
}
if (schema.type === 'array') {
if (!schema.items) {
return [];
}
if (Array.isArray(schema.items)) {
return schema.items.map((item) => jsonSchemaTypeToDefaultValue(item));
}
return [jsonSchemaTypeToDefaultValue(schema.items)];
}
if (schema.type === 'object') {
const properties = schema.properties ?? {};
const exampleObject: IDataObject = {};
for (const [key, propertySchema] of Object.entries(properties)) {
const propertyValue = jsonSchemaTypeToDefaultValue(propertySchema);
if (propertyValue !== null) {
exampleObject[key] = propertyValue;
}
}
if (schema.additionalProperties) {
const additionalProperties = jsonSchemaTypeToDefaultValue(schema.additionalProperties);
if (additionalProperties !== null) {
exampleObject['<additionalProperty>'] = additionalProperties;
}
}
return exampleObject;
}
return null;
}
export function jsonSchemaTypeToFieldType(schema: JSONSchema7): FieldType {
if (schema.type === 'string' && schema.format === 'date-time') {
return 'dateTime';
}
if (schema.type === 'number' || schema.type === 'integer') {
return 'number';
}
if (schema.type === 'boolean' || schema.type === 'array' || schema.type === 'object') {
return schema.type;
}
return 'string';
}
export function convertJsonSchemaToResourceMapperFields(
schema: JSONSchema7,
): ResourceMapperField[] {
const fields: ResourceMapperField[] = [];
if (schema.type !== 'object' || !schema.properties) {
return fields;
}
const required = Array.isArray(schema.required) ? schema.required : [];
for (const [key, propertySchema] of Object.entries(schema.properties)) {
if (propertySchema === false) {
continue;
}
if (propertySchema === true) {
fields.push({
id: key,
displayName: key,
defaultMatch: false,
required: required.includes(key),
display: true,
type: 'string', // use string as a "catch all" for any values
});
continue;
}
const schemaType = jsonSchemaTypeToFieldType(propertySchema);
let defaultValue: string | undefined;
if (schemaType === 'object' || schemaType === 'array') {
const result = jsonSchemaTypeToDefaultValue(propertySchema);
if (result !== null) {
defaultValue = JSON.stringify(result, null, 2);
}
}
const field: ResourceMapperField = {
id: key,
displayName: propertySchema.title ?? key,
defaultMatch: false,
required: required.includes(key),
display: true,
type: schemaType,
defaultValue,
};
if (propertySchema.enum && Array.isArray(propertySchema.enum)) {
field.type = 'options';
field.options = propertySchema.enum.map((value) => ({
name: value,
value,
})) as INodePropertyOptions[];
}
fields.push(field);
}
return fields;
}
@@ -0,0 +1,462 @@
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { StructuredToolkit } from 'n8n-core';
import {
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
NodeConnectionTypes,
NodeOperationError,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
import { getTools } from './loadOptions';
import type { McpToolIncludeMode } from './types';
import { createCallTool, getSelectedTools, mcpToolToDynamicTool } from './utils';
import { credentials, transportSelect } from '../shared/descriptions';
import type { McpAuthenticationOption, McpServerTransport } from '../shared/types';
import {
connectMcpClient,
getAllTools,
getAuthHeaders,
mapToNodeOperationError,
tryRefreshOAuth2Token,
} from '../shared/utils';
import type { JSONSchema7 } from 'json-schema';
import pick from 'lodash/pick';
/**
* Get node parameters for MCP client configuration
*/
function getNodeConfig(
ctx: ISupplyDataFunctions | IExecuteFunctions,
itemIndex: number,
): {
authentication: McpAuthenticationOption;
timeout: number;
serverTransport: McpServerTransport;
endpointUrl: string;
mode: McpToolIncludeMode;
includeTools: string[];
excludeTools: string[];
} {
const node = ctx.getNode();
const authentication = ctx.getNodeParameter(
'authentication',
itemIndex,
) as McpAuthenticationOption;
const timeout = ctx.getNodeParameter('options.timeout', itemIndex, 60000) as number;
let serverTransport: McpServerTransport;
let endpointUrl: string;
if (node.typeVersion === 1) {
serverTransport = 'sse';
endpointUrl = ctx.getNodeParameter('sseEndpoint', itemIndex) as string;
} else {
serverTransport = ctx.getNodeParameter('serverTransport', itemIndex) as McpServerTransport;
endpointUrl = ctx.getNodeParameter('endpointUrl', itemIndex) as string;
}
const mode = ctx.getNodeParameter('include', itemIndex) as McpToolIncludeMode;
const includeTools = ctx.getNodeParameter('includeTools', itemIndex, []) as string[];
const excludeTools = ctx.getNodeParameter('excludeTools', itemIndex, []) as string[];
return {
authentication,
timeout,
serverTransport,
endpointUrl,
mode,
includeTools,
excludeTools,
};
}
/**
* Connect to MCP server and get filtered tools
*/
async function connectAndGetTools(
ctx: ISupplyDataFunctions | IExecuteFunctions,
config: ReturnType<typeof getNodeConfig>,
) {
const node = ctx.getNode();
const { headers } = await getAuthHeaders(ctx, config.authentication);
const client = await connectMcpClient({
serverTransport: config.serverTransport,
endpointUrl: config.endpointUrl,
headers,
name: node.type,
version: node.typeVersion,
onUnauthorized: async (headers) =>
await tryRefreshOAuth2Token(ctx, config.authentication, headers),
});
if (!client.ok) {
return { client, mcpTools: null, error: client.error };
}
const allTools = await getAllTools(client.result);
const mcpTools = getSelectedTools({
tools: allTools,
mode: config.mode,
includeTools: config.includeTools,
excludeTools: config.excludeTools,
});
return { client: client.result, mcpTools, error: null };
}
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: {
light: 'file:../mcp.svg',
dark: 'file:../mcp.dark.svg',
},
group: ['output'],
version: [1, 1.1, 1.2],
description: 'Connect tools from an MCP Server',
defaults: {
name: 'MCP Client',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
Tools: ['Recommended Tools'],
},
alias: ['Model Context Protocol', 'MCP Client'],
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp/',
},
],
},
},
inputs: [],
outputs: [{ type: NodeConnectionTypes.AiTool, displayName: 'Tools' }],
credentials,
properties: [
getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]),
{
displayName: 'SSE Endpoint',
name: 'sseEndpoint',
type: 'string',
description: 'SSE Endpoint of your MCP server',
placeholder: 'e.g. https://my-mcp-server.ai/sse',
default: '',
required: true,
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Endpoint',
name: 'endpointUrl',
type: 'string',
description: 'Endpoint of your MCP server',
placeholder: 'e.g. https://my-mcp-server.ai/mcp',
default: '',
required: true,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
transportSelect({
defaultOption: 'sse',
displayOptions: {
show: {
'@version': [1.1],
},
},
}),
transportSelect({
defaultOption: 'httpStreamable',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
}),
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Bearer Auth',
value: 'bearerAuth',
},
{
name: 'Header Auth',
value: 'headerAuth',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
description: 'The way to authenticate with your endpoint',
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 1.2 } }],
},
},
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Bearer Auth',
value: 'bearerAuth',
},
{
name: 'Header Auth',
value: 'headerAuth',
},
{
name: 'MCP OAuth2',
value: 'mcpOAuth2Api',
},
{
name: 'Multiple Headers Auth',
value: 'multipleHeadersAuth',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
description: 'The way to authenticate with your endpoint',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
},
{
displayName: 'Credentials',
name: 'credentials',
type: 'credentials',
default: '',
displayOptions: {
show: {
authentication: ['headerAuth', 'bearerAuth', 'mcpOAuth2Api', 'multipleHeadersAuth'],
},
},
},
{
displayName: 'Tools to Include',
name: 'include',
type: 'options',
description: 'How to select the tools you want to be exposed to the AI Agent',
default: 'all',
options: [
{
name: 'All',
value: 'all',
description: 'Also include all unchanged fields from the input',
},
{
name: 'Selected',
value: 'selected',
description: 'Also include the tools listed in the parameter "Tools to Include"',
},
{
name: 'All Except',
value: 'except',
description: 'Exclude the tools listed in the parameter "Tools to Exclude"',
},
],
},
{
displayName: 'Tools to Include',
name: 'includeTools',
type: 'multiOptions',
default: [],
description:
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getTools',
loadOptionsDependsOn: ['sseEndpoint'],
},
displayOptions: {
show: {
include: ['selected'],
},
},
},
{
displayName: 'Tools to Exclude',
name: 'excludeTools',
type: 'multiOptions',
default: [],
description:
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getTools',
},
displayOptions: {
show: {
include: ['except'],
},
},
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Timeout',
name: 'timeout',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 60000,
description: 'Time in ms to wait for tool calls to finish',
},
],
},
],
};
methods = {
loadOptions: {
getTools,
},
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const node = this.getNode();
const config = getNodeConfig(this, itemIndex);
const setError = (error: NodeOperationError): SupplyData => {
this.addOutputData(NodeConnectionTypes.AiTool, itemIndex, error);
throw error;
};
const { client, mcpTools, error } = await connectAndGetTools(this, config);
if (error) {
this.logger.error('McpClientTool: Failed to connect to MCP Server', { error });
return setError(mapToNodeOperationError(node, error));
}
this.logger.debug('McpClientTool: Successfully connected to MCP Server');
if (!mcpTools?.length) {
return setError(
new NodeOperationError(node, 'MCP Server returned no tools', {
itemIndex,
description:
'Connected successfully to your MCP server but it returned an empty list of tools.',
}),
);
}
const tools = mcpTools.map((tool) =>
logWrapper(
mcpToolToDynamicTool(
tool,
createCallTool(tool.name, client, config.timeout, (errorMessage) => {
const error = new NodeOperationError(node, errorMessage, { itemIndex });
void this.addOutputData(NodeConnectionTypes.AiTool, itemIndex, error);
this.logger.error(`McpClientTool: Tool "${tool.name}" failed to execute`, { error });
}),
),
this,
),
);
this.logger.debug(`McpClientTool: Connected to MCP Server with ${tools.length} tools`);
const toolkit = new StructuredToolkit(tools);
return { response: toolkit, closeFunction: async () => await client.close() };
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const node = this.getNode();
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const item = items[itemIndex];
const config = getNodeConfig(this, itemIndex);
const { client, mcpTools, error } = await connectAndGetTools(this, config);
if (error) {
throw new NodeOperationError(node, error.error, { itemIndex });
}
if (!mcpTools?.length) {
throw new NodeOperationError(node, 'MCP Server returned no tools', { itemIndex });
}
for (const tool of mcpTools) {
// Check for tool name in item.json.tool (for toolkit execution from agent)
// or item.tool (for direct execution)
if (!item.json.tool || typeof item.json.tool !== 'string') {
throw new NodeOperationError(node, 'Tool name not found in item.json.tool or item.tool', {
itemIndex,
});
}
const toolName = item.json.tool;
if (toolName === tool.name) {
// Extract the tool name from arguments before passing to MCP
const { tool: _, ...toolArguments } = item.json;
const schema: JSONSchema7 = tool.inputSchema;
// When additionalProperties is not explicitly true, filter to schema-defined properties.
// Otherwise, pass all arguments through
const sanitizedToolArguments: IDataObject =
schema.additionalProperties !== true
? pick(toolArguments, Object.keys(schema.properties ?? {}))
: toolArguments;
const params: {
name: string;
arguments: IDataObject;
} = {
name: tool.name,
arguments: sanitizedToolArguments,
};
const result = await client.callTool(params, CallToolResultSchema, {
timeout: config.timeout,
});
returnData.push({
json: {
response: result.content as IDataObject,
},
pairedItem: {
item: itemIndex,
},
});
}
}
}
return [returnData];
}
}
@@ -0,0 +1,856 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { McpError, ErrorCode, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { mock, mockDeep } from 'jest-mock-extended';
import { StructuredToolkit } from 'n8n-core';
import {
type IExecuteFunctions,
NodeConnectionTypes,
NodeOperationError,
type ILoadOptionsFunctions,
type INode,
type ISupplyDataFunctions,
} from 'n8n-workflow';
import { getTools } from '../loadOptions';
import { McpClientTool } from '../McpClientTool.node';
jest.mock('@modelcontextprotocol/sdk/client/sse.js');
jest.mock('@modelcontextprotocol/sdk/client/index.js');
describe('McpClientTool', () => {
describe('loadOptions: getTools', () => {
it('should return a list of tools', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'MyTool',
description: 'MyTool does something',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
],
});
const result = await getTools.call(
mock<ILoadOptionsFunctions>({ getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })) }),
);
expect(result).toEqual([
{
description: 'MyTool does something',
name: 'MyTool',
value: 'MyTool',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
]);
});
it('should handle errors', async () => {
jest.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Fail!'));
const node = mock<INode>({ typeVersion: 1 });
await expect(
getTools.call(mock<ILoadOptionsFunctions>({ getNode: jest.fn(() => node) })),
).rejects.toEqual(new NodeOperationError(node, 'Could not connect to your MCP server'));
});
});
describe('supplyData', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should return a valid toolkit with usable tools (that returns a string)', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest
.spyOn(Client.prototype, 'callTool')
.mockResolvedValue({ content: [{ type: 'text', text: 'result from tool' }] });
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'MyTool1',
description: 'MyTool1 does something',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
{
name: 'MyTool2',
description: 'MyTool2 does something',
inputSchema: { type: 'object', properties: { input2: { type: 'string' } } },
},
],
});
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
}),
0,
);
expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
expect(supplyDataResult.response).toBeInstanceOf(StructuredToolkit);
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
expect(tools).toHaveLength(2);
const toolCallResult = await tools[0].invoke({ input: 'foo' });
expect(toolCallResult).toEqual(JSON.stringify([{ type: 'text', text: 'result from tool' }]));
});
it('should support selecting tools to expose', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'MyTool1',
description: 'MyTool1 does something',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
{
name: 'MyTool2',
description: 'MyTool2 does something',
inputSchema: { type: 'object', properties: { input2: { type: 'string' } } },
},
],
});
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
getNode: jest.fn(() =>
mock<INode>({
typeVersion: 1,
}),
),
getNodeParameter: jest.fn((key, _index) => {
const parameters: Record<string, any> = {
include: 'selected',
includeTools: ['MyTool2'],
};
return parameters[key];
}),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
}),
0,
);
expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
expect(supplyDataResult.response).toBeInstanceOf(StructuredToolkit);
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('MyTool2');
});
it('should support selecting tools to exclude', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'MyTool1',
description: 'MyTool1 does something',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
{
name: 'MyTool2',
description: 'MyTool2 does something',
inputSchema: { type: 'object', properties: { input2: { type: 'string' } } },
},
],
});
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
getNode: jest.fn(() =>
mock<INode>({
typeVersion: 1,
}),
),
getNodeParameter: jest.fn((key, _index) => {
const parameters: Record<string, any> = {
include: 'except',
excludeTools: ['MyTool2'],
};
return parameters[key];
}),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
}),
0,
);
expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
expect(supplyDataResult.response).toBeInstanceOf(StructuredToolkit);
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('MyTool1');
});
it('should support header auth', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'MyTool1',
description: 'MyTool1 does something',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
],
});
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
getNodeParameter: jest.fn((key, _index) => {
const parameters: Record<string, any> = {
include: 'except',
excludeTools: ['MyTool2'],
authentication: 'headerAuth',
sseEndpoint: 'https://my-mcp-endpoint.ai/sse',
};
return parameters[key];
}),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
getCredentials: jest.fn().mockResolvedValue({ name: 'my-header', value: 'header-value' }),
}),
0,
);
expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
expect(supplyDataResult.response).toBeInstanceOf(StructuredToolkit);
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(mock());
const url = new URL('https://my-mcp-endpoint.ai/sse');
expect(SSEClientTransport).toHaveBeenCalledTimes(1);
expect(SSEClientTransport).toHaveBeenCalledWith(url, {
eventSourceInit: { fetch: expect.any(Function) },
fetch: expect.any(Function),
requestInit: { headers: { 'my-header': 'header-value' } },
});
const customFetch = jest.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any);
expect(fetchSpy).toHaveBeenCalledWith(url, {
headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
});
});
it('should support bearer auth', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'MyTool1',
description: 'MyTool1 does something',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
],
});
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
getNodeParameter: jest.fn((key, _index) => {
const parameters: Record<string, any> = {
include: 'except',
excludeTools: ['MyTool2'],
authentication: 'bearerAuth',
sseEndpoint: 'https://my-mcp-endpoint.ai/sse',
};
return parameters[key];
}),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
getCredentials: jest.fn().mockResolvedValue({ token: 'my-token' }),
}),
0,
);
expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
expect(supplyDataResult.response).toBeInstanceOf(StructuredToolkit);
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(mock());
const url = new URL('https://my-mcp-endpoint.ai/sse');
expect(SSEClientTransport).toHaveBeenCalledTimes(1);
expect(SSEClientTransport).toHaveBeenCalledWith(url, {
eventSourceInit: { fetch: expect.any(Function) },
fetch: expect.any(Function),
requestInit: { headers: { Authorization: 'Bearer my-token' } },
});
const customFetch = jest.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any);
expect(fetchSpy).toHaveBeenCalledWith(url, {
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
});
});
it('should successfully execute a tool', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest
.spyOn(Client.prototype, 'callTool')
.mockResolvedValue({ toolResult: 'Sunny', content: [] });
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'Weather Tool',
description: 'Gets the current weather',
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
},
],
});
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
getNode: jest.fn(() =>
mock<INode>({
typeVersion: 1,
}),
),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
}),
0,
);
expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
expect(supplyDataResult.response).toBeInstanceOf(StructuredToolkit);
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
const toolResult = await tools[0].invoke({ location: 'Berlin' });
expect(toolResult).toEqual('Sunny');
});
it('should handle tool errors', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
isError: true,
toolResult: 'Weather unknown at location',
content: [{ text: 'Weather unknown at location' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'Weather Tool',
description: 'Gets the current weather',
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
},
],
});
const supplyDataFunctions = mock<ISupplyDataFunctions>({
getNode: jest.fn(() =>
mock<INode>({
typeVersion: 1,
}),
),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
});
const supplyDataResult = await new McpClientTool().supplyData.call(supplyDataFunctions, 0);
expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
expect(supplyDataResult.response).toBeInstanceOf(StructuredToolkit);
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
const toolResult = await tools[0].invoke({ location: 'Berlin' });
expect(toolResult).toEqual('Weather unknown at location');
expect(supplyDataFunctions.addOutputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiTool,
0,
new NodeOperationError(supplyDataFunctions.getNode(), 'Weather unknown at location'),
);
});
it('should support setting a timeout', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
const callToolSpy = jest
.spyOn(Client.prototype, 'callTool')
.mockRejectedValue(
new McpError(ErrorCode.RequestTimeout, 'Request timed out', { timeout: 200 }),
);
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'SlowTool',
description: 'SlowTool throws a timeout',
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
},
],
});
const mockNode = mock<INode>({ typeVersion: 1 });
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
getNode: jest.fn(() => mockNode),
getNodeParameter: jest.fn((key, _index) => {
const parameters: Record<string, any> = {
'options.timeout': 200,
};
return parameters[key];
}),
logger: { debug: jest.fn(), error: jest.fn() },
addInputData: jest.fn(() => ({ index: 0 })),
}),
0,
);
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
await expect(tools[0].invoke({ input: 'foo' })).resolves.toEqual(
'MCP error -32001: Request timed out',
);
expect(callToolSpy).toHaveBeenCalledWith(
expect.any(Object), // params
expect.any(Object), // schema
expect.objectContaining({ timeout: 200 }),
); // options
});
});
describe('execute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should execute tool when tool name is in item.json.tool (from agent)', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
content: [{ type: 'text', text: 'Weather is sunny' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'get_weather',
description: 'Gets the weather',
inputSchema: {
type: 'object',
properties: { location: { type: 'string' } },
},
},
],
});
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
getNode: jest.fn(() => mockNode),
getInputData: jest.fn(() => [
{
json: {
tool: 'get_weather',
location: 'Berlin',
},
},
]),
getNodeParameter: jest.fn((key) => {
const params: Record<string, any> = {
include: 'all',
includeTools: [],
excludeTools: [],
authentication: 'none',
sseEndpoint: 'https://test.com/sse',
'options.timeout': 60000,
};
return params[key];
}),
});
const result = await new McpClientTool().execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: {
response: [{ type: 'text', text: 'Weather is sunny' }],
},
pairedItem: { item: 0 },
},
],
]);
expect(Client.prototype.callTool).toHaveBeenCalledWith(
{
name: 'get_weather',
arguments: { location: 'Berlin' },
},
expect.anything(),
expect.anything(),
);
});
it.each([false, undefined])(
'should filter out tool arguments when additionalProperties is %s',
async (additionalProperties) => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
content: [{ type: 'text', text: 'Weather is sunny' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'get_weather',
description: 'Gets the weather',
inputSchema: {
type: 'object',
properties: { location: { type: 'string' } },
additionalProperties,
},
},
],
});
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
getNode: jest.fn(() => mockNode),
getInputData: jest.fn(() => [
{
json: {
tool: 'get_weather',
location: 'Berlin',
foo: 'bar',
sessionId: '123',
},
},
]),
getNodeParameter: jest.fn((key) => {
const params: Record<string, any> = {
include: 'all',
includeTools: [],
excludeTools: [],
authentication: 'none',
sseEndpoint: 'https://test.com/sse',
'options.timeout': 60000,
};
return params[key];
}),
});
const result = await new McpClientTool().execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: {
response: [{ type: 'text', text: 'Weather is sunny' }],
},
pairedItem: { item: 0 },
},
],
]);
expect(Client.prototype.callTool).toHaveBeenCalledWith(
{
name: 'get_weather',
arguments: { location: 'Berlin' },
},
expect.anything(),
expect.anything(),
);
},
);
it('should pass all arguments when schema has additionalProperties: true', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
content: [{ type: 'text', text: 'Success' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'flexible_tool',
description: 'Accepts any arguments',
inputSchema: { type: 'object', additionalProperties: true },
},
],
});
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
getNode: jest.fn(() => mockNode),
getInputData: jest.fn(() => [
{
json: {
tool: 'flexible_tool',
foo: 'bar',
extra: 'data',
},
},
]),
getNodeParameter: jest.fn((key) => {
const params: Record<string, any> = {
include: 'all',
includeTools: [],
excludeTools: [],
authentication: 'none',
sseEndpoint: 'https://test.com/sse',
'options.timeout': 60000,
};
return params[key];
}),
});
await new McpClientTool().execute.call(mockExecuteFunctions);
expect(Client.prototype.callTool).toHaveBeenCalledWith(
{
name: 'flexible_tool',
arguments: { foo: 'bar', extra: 'data' },
},
expect.anything(),
expect.anything(),
);
});
it('should not execute if tool name does not match', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
content: [{ type: 'text', text: 'Should not be called' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'get_weather',
description: 'Gets the weather',
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
},
],
});
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
getNode: jest.fn(() => mockNode),
getInputData: jest.fn(() => [
{
json: {
tool: 'different_tool',
location: 'Berlin',
},
},
]),
getNodeParameter: jest.fn((key) => {
const params: Record<string, any> = {
include: 'all',
includeTools: [],
excludeTools: [],
authentication: 'none',
sseEndpoint: 'https://test.com/sse',
'options.timeout': 60000,
};
return params[key];
}),
});
const result = await new McpClientTool().execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
expect(Client.prototype.callTool).not.toHaveBeenCalled();
});
it('should throw error when MCP server connection fails', async () => {
jest.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Connection failed'));
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
getNode: jest.fn(() => mockNode),
getInputData: jest.fn(() => [
{
json: {
tool: 'get_weather',
location: 'Berlin',
},
},
]),
getNodeParameter: jest.fn((key) => {
const params: Record<string, any> = {
include: 'all',
includeTools: [],
excludeTools: [],
authentication: 'none',
sseEndpoint: 'https://test.com/sse',
'options.timeout': 60000,
};
return params[key];
}),
});
await expect(new McpClientTool().execute.call(mockExecuteFunctions)).rejects.toThrow(
NodeOperationError,
);
});
it('should handle multiple items', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest
.spyOn(Client.prototype, 'callTool')
.mockResolvedValueOnce({
content: [{ type: 'text', text: 'Weather in Berlin is sunny' }],
})
.mockResolvedValueOnce({
content: [{ type: 'text', text: 'Weather in London is rainy' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'get_weather',
description: 'Gets the weather',
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
},
],
});
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
getNode: jest.fn(() => mockNode),
getInputData: jest.fn(() => [
{
json: {
tool: 'get_weather',
location: 'Berlin',
},
},
{
json: {
tool: 'get_weather',
location: 'London',
},
},
]),
getNodeParameter: jest.fn((key) => {
const params: Record<string, any> = {
include: 'all',
includeTools: [],
excludeTools: [],
authentication: 'none',
sseEndpoint: 'https://test.com/sse',
'options.timeout': 60000,
};
return params[key];
}),
});
const result = await new McpClientTool().execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: {
response: [{ type: 'text', text: 'Weather in Berlin is sunny' }],
},
pairedItem: { item: 0 },
},
{
json: {
response: [{ type: 'text', text: 'Weather in London is rainy' }],
},
pairedItem: { item: 1 },
},
],
]);
expect(Client.prototype.callTool).toHaveBeenCalledTimes(2);
});
it('should respect tool filtering (selected tools)', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
content: [{ type: 'text', text: 'Weather is sunny' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'get_weather',
description: 'Gets the weather',
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
},
{
name: 'get_time',
description: 'Gets the time',
inputSchema: { type: 'object', properties: {} },
},
],
});
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
getNode: jest.fn(() => mockNode),
getInputData: jest.fn(() => [
{
json: {
tool: 'get_weather',
location: 'Berlin',
},
},
]),
getNodeParameter: jest.fn((key) => {
const params: Record<string, any> = {
include: 'selected',
includeTools: ['get_weather'],
excludeTools: [],
authentication: 'none',
sseEndpoint: 'https://test.com/sse',
'options.timeout': 60000,
};
return params[key];
}),
});
const result = await new McpClientTool().execute.call(mockExecuteFunctions);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.response).toEqual([{ type: 'text', text: 'Weather is sunny' }]);
});
it('should execute tool with timeout', async () => {
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
content: [{ type: 'text', text: 'Weather is sunny' }],
});
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
tools: [
{
name: 'get_weather',
description: 'Gets the weather',
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
},
],
});
const mockNode = mock<INode>({ typeVersion: 1.2, type: 'mcpClientTool' });
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getInputData.mockReturnValue([
{
json: {
tool: 'get_weather',
location: 'Berlin',
},
},
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((key, _idx, defaultValue) => {
const params = {
include: 'all',
authentication: 'none',
serverTransport: 'httpStreamable',
endpointUrl: 'https://test.com/mcp',
'options.timeout': 12345,
};
return params[key as keyof typeof params] ?? defaultValue;
});
await new McpClientTool().execute.call(mockExecuteFunctions);
expect(Client.prototype.callTool).toHaveBeenCalledWith(
{
name: 'get_weather',
arguments: { location: 'Berlin' },
},
CallToolResultSchema,
{ timeout: 12345 },
);
});
});
});
@@ -0,0 +1,45 @@
import { type ILoadOptionsFunctions, type INodePropertyOptions } from 'n8n-workflow';
import type { McpAuthenticationOption, McpServerTransport } from '../shared/types';
import {
connectMcpClient,
getAllTools,
getAuthHeaders,
mapToNodeOperationError,
tryRefreshOAuth2Token,
} from '../shared/utils';
export async function getTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const authentication = this.getNodeParameter('authentication') as McpAuthenticationOption;
const node = this.getNode();
let serverTransport: McpServerTransport;
let endpointUrl: string;
if (node.typeVersion === 1) {
serverTransport = 'sse';
endpointUrl = this.getNodeParameter('sseEndpoint') as string;
} else {
serverTransport = this.getNodeParameter('serverTransport') as McpServerTransport;
endpointUrl = this.getNodeParameter('endpointUrl') as string;
}
const { headers } = await getAuthHeaders(this, authentication);
const client = await connectMcpClient({
serverTransport,
endpointUrl,
headers,
name: node.type,
version: node.typeVersion,
onUnauthorized: async (headers) => await tryRefreshOAuth2Token(this, authentication, headers),
});
if (!client.ok) {
throw mapToNodeOperationError(node, client.error);
}
const tools = await getAllTools(client.result);
return tools.map((tool) => ({
name: tool.name,
value: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
}
@@ -0,0 +1 @@
export type McpToolIncludeMode = 'all' | 'selected' | 'except';
@@ -0,0 +1,109 @@
import { DynamicStructuredTool, type DynamicStructuredToolInput } from '@langchain/core/tools';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { type IDataObject } from 'n8n-workflow';
import { z } from 'zod';
import { convertJsonSchemaToZod } from '@utils/schemaParsing';
import type { McpToolIncludeMode } from './types';
import type { McpTool } from '../shared/types';
export function getSelectedTools({
mode,
includeTools,
excludeTools,
tools,
}: {
mode: McpToolIncludeMode;
includeTools?: string[];
excludeTools?: string[];
tools: McpTool[];
}) {
switch (mode) {
case 'selected': {
if (!includeTools?.length) return tools;
const include = new Set(includeTools);
return tools.filter((tool) => include.has(tool.name));
}
case 'except': {
const except = new Set(excludeTools ?? []);
return tools.filter((tool) => !except.has(tool.name));
}
case 'all':
default:
return tools;
}
}
export const getErrorDescriptionFromToolCall = (result: unknown): string | undefined => {
if (result && typeof result === 'object') {
if ('content' in result && Array.isArray(result.content)) {
const errorMessage = (result.content as Array<{ type: 'text'; text: string }>).find(
(content) => content && typeof content === 'object' && typeof content.text === 'string',
)?.text;
return errorMessage;
} else if ('toolResult' in result && typeof result.toolResult === 'string') {
return result.toolResult;
}
if ('message' in result && typeof result.message === 'string') {
return result.message;
}
}
return undefined;
};
export const createCallTool =
(name: string, client: Client, timeout: number, onError: (error: string) => void) =>
async (args: IDataObject) => {
let result: Awaited<ReturnType<Client['callTool']>>;
function handleError(error: unknown) {
const errorDescription =
getErrorDescriptionFromToolCall(error) ?? `Failed to execute tool "${name}"`;
onError(errorDescription);
return errorDescription;
}
try {
result = await client.callTool({ name, arguments: args }, CompatibilityCallToolResultSchema, {
timeout,
});
} catch (error) {
return handleError(error);
}
if (result.isError) {
return handleError(result);
}
if (result.toolResult !== undefined) {
return result.toolResult;
}
if (result.content !== undefined) {
return result.content;
}
return result;
};
export function mcpToolToDynamicTool(
tool: McpTool,
onCallTool: DynamicStructuredToolInput['func'],
): DynamicStructuredTool {
const rawSchema = convertJsonSchemaToZod(tool.inputSchema);
// Ensure we always have an object schema for structured tools
const objectSchema =
rawSchema instanceof z.ZodObject ? rawSchema : z.object({ value: rawSchema });
return new DynamicStructuredTool({
name: tool.name,
description: tool.description ?? '',
schema: objectSchema,
func: onCallTool,
metadata: { isFromToolkit: true },
});
}
@@ -0,0 +1,525 @@
import type { Tool } from '@langchain/core/tools';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type {
ServerRequest,
ServerNotification,
JSONRPCMessage,
} from '@modelcontextprotocol/sdk/types.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from 'crypto';
import type * as express from 'express';
import type { IncomingMessage } from 'http';
import type { Logger } from 'n8n-workflow';
import { jsonParse, OperationalError } from 'n8n-workflow';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ExecutionCoordinator } from './execution/ExecutionCoordinator';
import type { ExecutionStrategy } from './execution/ExecutionStrategy';
import { PendingCallsManager } from './execution/PendingCallsManager';
import { QueuedExecutionStrategy } from './execution/QueuedExecutionStrategy';
import { MessageFormatter } from './protocol/MessageFormatter';
import { MessageParser } from './protocol/MessageParser';
import type { McpToolCallInfo } from './protocol/types';
import { MCP_LIST_TOOLS_REQUEST_MARKER } from './protocol/types';
import { InMemorySessionStore } from './session/InMemorySessionStore';
import { SessionManager } from './session/SessionManager';
import type { SessionStore } from './session/SessionStore';
import type { SSETransport } from './transport/SSETransport';
import { StreamableHttpTransport } from './transport/StreamableHttpTransport';
import type { CompressionResponse, McpTransport } from './transport/Transport';
import { TransportFactory } from './transport/TransportFactory';
export interface HandlePostResult {
wasToolCall: boolean;
toolCallInfo?: McpToolCallInfo;
messageId?: string;
relaySessionId?: string;
needsListToolsRelay?: boolean;
}
interface PendingResponse {
sessionId: string;
messageId: string;
transport: McpTransport;
createdAt: Date;
}
export class McpServer {
private static instance_: McpServer;
private sessionManager: SessionManager;
private transportFactory: TransportFactory;
private executionCoordinator: ExecutionCoordinator;
private pendingCallsManager: PendingCallsManager;
private resolveFunctions: Record<string, () => void> = {};
private pendingResponses: Record<string, PendingResponse> = {};
private logger: Logger;
private constructor(logger: Logger) {
this.logger = logger;
this.sessionManager = new SessionManager(new InMemorySessionStore());
this.transportFactory = new TransportFactory();
this.pendingCallsManager = new PendingCallsManager();
this.executionCoordinator = new ExecutionCoordinator();
this.logger.debug('McpServer created');
}
static instance(logger: Logger): McpServer {
if (!McpServer.instance_) {
McpServer.instance_ = new McpServer(logger);
logger.debug('Created singleton McpServer');
}
return McpServer.instance_;
}
async handleSetupRequest(
_req: express.Request,
resp: CompressionResponse,
serverName: string,
postUrl: string,
tools: Tool[],
): Promise<void> {
const server = this.createServer(serverName);
const transport = this.transportFactory.createSSE(postUrl, resp);
await this.setupSession(server, transport, tools, resp);
}
async handleStreamableHttpSetup(
req: express.Request,
resp: CompressionResponse,
serverName: string,
tools: Tool[],
): Promise<void> {
const server = this.createServer(serverName);
const transport = this.transportFactory.createStreamableHttp(
{
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: async (sessionId) => {
this.logger.debug(`New session initialized: ${sessionId}`);
await this.sessionManager.registerSession(sessionId, server, transport, tools);
transport.onclose = async () => {
this.logger.debug(`Deleting transport for ${sessionId}`);
await this.cleanupSession(sessionId);
};
},
},
resp,
);
this.setupHandlers(server);
await server.connect(transport);
await transport.handleRequest(req as IncomingMessage, resp, req.body);
resp.flush?.();
}
async handlePostMessage(
req: express.Request,
resp: CompressionResponse,
tools: Tool[],
serverName?: string,
): Promise<HandlePostResult> {
const sessionId = this.getSessionId(req);
let transport = sessionId ? this.sessionManager.getTransport(sessionId) : undefined;
const rawBody = req.rawBody.toString();
let toolCallInfo = MessageParser.extractToolCallInfo(rawBody);
let messageId: string | undefined;
if (toolCallInfo) {
const tool = tools.find((t) => t.name === toolCallInfo!.toolName);
if (tool?.metadata?.sourceNodeName && typeof tool.metadata.sourceNodeName === 'string') {
toolCallInfo = { ...toolCallInfo, sourceNodeName: tool.metadata.sourceNodeName };
}
}
if (sessionId && !transport && req.headers['mcp-session-id'] && serverName) {
this.logger.debug(
`Recreating StreamableHTTP transport for session ${sessionId} on this main instance`,
);
const recreated = await this.recreateStreamableHttpTransport(
sessionId,
serverName,
tools,
resp,
);
if (!recreated) {
resp.status(404).send('Session not found');
return { wasToolCall: false };
}
transport = this.sessionManager.getTransport(sessionId);
}
const isToolCall = MessageParser.isToolCall(rawBody);
const isListToolsRequest = MessageParser.isListToolsRequest(rawBody);
if (
sessionId &&
!transport &&
req.query.sessionId &&
this.executionCoordinator.isQueueMode() &&
(isToolCall || isListToolsRequest)
) {
this.logger.debug(
`SSE queue mode: forwarding ${isToolCall ? 'tool call' : 'list tools'} for session ${sessionId} via pub/sub`,
);
const message = jsonParse(rawBody);
messageId = MessageParser.getRequestId(message);
resp.status(202).send('Accepted');
return {
wasToolCall: isToolCall,
toolCallInfo,
messageId,
relaySessionId: isListToolsRequest ? sessionId : undefined,
needsListToolsRelay: isListToolsRequest,
};
}
if (sessionId && transport) {
const message = jsonParse(rawBody);
messageId = MessageParser.getRequestId(message);
const callId = messageId ? `${sessionId}_${messageId}` : sessionId;
this.sessionManager.setTools(sessionId, tools);
try {
await new Promise<void>((resolve) => {
this.resolveFunctions[callId] = resolve;
void transport.handleRequest(req, resp, message as IncomingMessage);
});
} finally {
delete this.resolveFunctions[callId];
}
} else {
this.logger.warn(`No transport found for session ${sessionId}`);
resp.status(401).send('No transport found for sessionId');
}
resp.flush?.();
return {
wasToolCall: MessageParser.isToolCall(rawBody),
toolCallInfo,
messageId,
};
}
async handleDeleteRequest(req: express.Request, resp: CompressionResponse): Promise<void> {
const sessionId = this.getSessionId(req);
if (!sessionId) {
resp.status(400).send('No sessionId provided');
return;
}
const transport = this.sessionManager.getTransport(sessionId);
if (transport) {
this.pendingCallsManager.cleanupBySessionId(sessionId);
if (transport instanceof StreamableHttpTransport) {
await transport.handleRequest(req, resp);
return;
}
resp.status(405).send('Method Not Allowed');
return;
}
resp.status(404).send('Session not found');
}
getSessionId(req: express.Request): string | undefined {
return (req.query.sessionId ?? req.headers['mcp-session-id']) as string | undefined;
}
getMcpMetadata(req: express.Request): { sessionId: string; messageId: string } | undefined {
const sessionId = this.getSessionId(req);
if (!sessionId) return undefined;
const message = jsonParse(req.rawBody.toString());
const messageId = MessageParser.getRequestId(message);
return { sessionId, messageId: messageId ?? '' };
}
storePendingResponse(sessionId: string, messageId: string): void {
const transport = this.sessionManager.getTransport(sessionId);
if (!transport) {
this.logger.warn(`Cannot store pending response: no transport for session ${sessionId}`);
return;
}
const callId = messageId ? `${sessionId}_${messageId}` : sessionId;
this.pendingResponses[callId] = {
sessionId,
messageId,
transport,
createdAt: new Date(),
};
}
handleWorkerResponse(sessionId: string, messageId: string, result: unknown): void {
const callId = messageId ? `${sessionId}_${messageId}` : sessionId;
const pending = this.pendingResponses[callId];
const isListToolsRequest =
typeof result === 'object' &&
result !== null &&
'_listToolsRequest' in result &&
(result as { _listToolsRequest: boolean })._listToolsRequest;
if (isListToolsRequest) {
const transport = this.sessionManager.getTransport(sessionId);
if (transport && transport.transportType === 'sse' && messageId) {
this.logger.debug(
`SSE queue mode: handling relayed list tools request for session ${sessionId}`,
);
const tools = this.sessionManager.getTools(sessionId) ?? [];
const toolsList = tools.map((tool) => ({
name: tool.name,
description: tool.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
inputSchema: zodToJsonSchema(tool.schema as any, { removeAdditionalStrategy: 'strict' }),
}));
const response: JSONRPCMessage = {
jsonrpc: '2.0',
id: messageId,
result: { tools: toolsList },
};
void transport.send(response);
}
return;
}
const strategy = this.executionCoordinator.getStrategy();
if (strategy instanceof QueuedExecutionStrategy) {
if (strategy.resolveToolCall(callId, result)) {
// Resolved via pending tool call
} else {
const transport = this.sessionManager.getTransport(sessionId);
if (transport && transport.transportType === 'sse' && messageId) {
this.logger.debug(
`SSE queue mode: sending response directly via transport for session ${sessionId}`,
);
const formattedResult = MessageFormatter.formatToolResult(result);
const response: JSONRPCMessage = {
jsonrpc: '2.0',
id: messageId,
result: formattedResult,
};
void transport.send(response);
}
}
}
if (this.resolveFunctions[callId]) {
this.resolveFunctions[callId]();
delete this.resolveFunctions[callId];
}
if (pending) {
delete this.pendingResponses[callId];
}
}
removePendingResponse(sessionId: string, messageId: string): void {
const callId = messageId ? `${sessionId}_${messageId}` : sessionId;
delete this.pendingResponses[callId];
}
hasPendingResponse(sessionId: string, messageId: string): boolean {
const callId = messageId ? `${sessionId}_${messageId}` : sessionId;
return callId in this.pendingResponses;
}
get pendingResponseCount(): number {
return Object.keys(this.pendingResponses).length;
}
setSessionStore(store: SessionStore): void {
this.sessionManager.setStore(store);
}
setExecutionStrategy(strategy: ExecutionStrategy): void {
this.executionCoordinator.setStrategy(strategy);
}
isQueueMode(): boolean {
return this.executionCoordinator.isQueueMode();
}
getTransport(sessionId: string): McpTransport | undefined {
return this.sessionManager.getTransport(sessionId);
}
getTools(sessionId: string): Tool[] | undefined {
return this.sessionManager.getTools(sessionId);
}
getPendingCallsManager(): PendingCallsManager {
return this.pendingCallsManager;
}
private createServer(serverName: string): Server {
return new Server({ name: serverName, version: '0.1.0' }, { capabilities: { tools: {} } });
}
private async setupSession(
server: Server,
transport: SSETransport | StreamableHttpTransport,
tools: Tool[],
resp: CompressionResponse,
): Promise<void> {
this.setupHandlers(server);
const sessionId = transport.sessionId!;
await this.sessionManager.registerSession(sessionId, server, transport, tools);
resp.on('close', async () => {
this.logger.debug(`Deleting transport for ${sessionId}`);
await this.cleanupSession(sessionId);
});
await server.connect(transport);
resp.flush?.();
}
private async cleanupSession(sessionId: string): Promise<void> {
this.pendingCallsManager.cleanupBySessionId(sessionId);
for (const callId of Object.keys(this.pendingResponses)) {
if (this.pendingResponses[callId].sessionId === sessionId) {
if (this.resolveFunctions[callId]) {
this.resolveFunctions[callId]();
delete this.resolveFunctions[callId];
}
delete this.pendingResponses[callId];
}
}
await this.sessionManager.destroySession(sessionId);
}
private async recreateStreamableHttpTransport(
sessionId: string,
serverName: string,
tools: Tool[],
resp: CompressionResponse,
): Promise<boolean> {
const isValid = await this.sessionManager.isSessionValid(sessionId);
if (!isValid) {
this.logger.warn(`Rejecting recreate request for invalid session: ${sessionId}`);
return false;
}
const server = this.createServer(serverName);
const transport = this.transportFactory.recreateStreamableHttp(sessionId, resp);
await this.sessionManager.registerSession(sessionId, server, transport, tools);
transport.onclose = async () => {
this.logger.debug(`Deleting recreated transport for ${sessionId}`);
await this.cleanupSession(sessionId);
};
this.setupHandlers(server);
await server.connect(transport);
return true;
}
private setupHandlers(server: Server): void {
server.setRequestHandler(
ListToolsRequestSchema,
(_, extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => {
if (!extra.sessionId) {
throw new OperationalError('Require a sessionId for the listing of tools');
}
const tools = this.sessionManager.getTools(extra.sessionId) ?? [];
return {
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
inputSchema: zodToJsonSchema(tool.schema as any, {
removeAdditionalStrategy: 'strict',
}),
})),
};
},
);
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
if (!request.params?.name || !request.params?.arguments) {
throw new OperationalError('Require a name and arguments for the tool call');
}
if (!extra.sessionId) {
throw new OperationalError('Require a sessionId for the tool call');
}
const callId = extra.requestId ? `${extra.sessionId}_${extra.requestId}` : extra.sessionId;
const toolName = request.params.name;
const toolArguments =
typeof request.params.arguments === 'object' && request.params.arguments !== null
? request.params.arguments
: {};
const tools = this.sessionManager.getTools(extra.sessionId) ?? [];
const requestedTool = tools.find((tool) => tool.name === toolName);
if (!requestedTool) {
throw new OperationalError('Tool not found');
}
try {
if (this.executionCoordinator.isQueueMode()) {
const requestId = extra.requestId?.toString() ?? '';
this.storePendingResponse(extra.sessionId, requestId);
// Resolve handlePostMessage so webhook can return and enqueue execution.
// The handler continues running asynchronously, waiting for worker response.
if (this.resolveFunctions[callId]) {
this.resolveFunctions[callId]();
}
const strategy = this.executionCoordinator.getStrategy() as QueuedExecutionStrategy;
const result = await strategy.executeTool(requestedTool, toolArguments, {
sessionId: extra.sessionId,
messageId: requestId,
});
return MessageFormatter.formatToolResult(result);
}
const result = await this.executionCoordinator.executeTool(requestedTool, toolArguments, {
sessionId: extra.sessionId,
messageId: extra.requestId?.toString(),
});
if (this.resolveFunctions[callId]) {
this.resolveFunctions[callId]();
} else {
this.logger.warn(`No resolve function found for ${callId}`);
}
return MessageFormatter.formatToolResult(result);
} catch (error) {
this.logger.error(
`Error while executing Tool ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
);
const errorObject = error instanceof Error ? error : new Error(String(error));
return MessageFormatter.formatError(errorObject);
}
});
server.onclose = () => {
this.logger.debug('Closing MCP Server');
};
server.onerror = (error: unknown) => {
this.logger.error(`MCP Error: ${error instanceof Error ? error.message : String(error)}`);
};
}
}
export { MCP_LIST_TOOLS_REQUEST_MARKER };
@@ -0,0 +1,212 @@
import { McpServer, MCP_LIST_TOOLS_REQUEST_MARKER } from './McpServer';
import type { CompressionResponse } from './transport';
import { WebhookAuthorizationError } from 'n8n-nodes-base/dist/nodes/Webhook/error';
import { validateWebhookAuthentication } from 'n8n-nodes-base/dist/nodes/Webhook/utils';
import type { INodeTypeDescription, IWebhookFunctions, IWebhookResponseData } from 'n8n-workflow';
import { NodeConnectionTypes, Node, nodeNameToToolName } from 'n8n-workflow';
import { getConnectedTools } from '@utils/helpers';
const MCP_SSE_SETUP_PATH = 'sse';
const MCP_SSE_MESSAGES_PATH = 'messages';
export class McpTrigger extends Node {
description: INodeTypeDescription = {
displayName: 'MCP Server Trigger',
name: 'mcpTrigger',
icon: {
light: 'file:../mcp.svg',
dark: 'file:../mcp.dark.svg',
},
group: ['trigger'],
version: [1, 1.1, 2],
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.',
defaults: {
name: 'MCP Server Trigger',
},
codex: {
categories: ['AI', 'Core Nodes'],
subcategories: {
AI: ['Root Nodes', 'Model Context Protocol'],
'Core Nodes': ['Other Trigger Nodes'],
},
alias: ['Model Context Protocol', 'MCP Server'],
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger/',
},
],
},
},
triggerPanel: {
header: 'Listen for MCP events',
executionsHelp: {
inactive:
"This trigger has two modes: test and production.<br /><br /><b>Use test mode while you build your workflow</b>. Click the 'execute step' button, then make an MCP request to the test URL. The executions will show up in the editor.<br /><br /><b>Use production mode to run your workflow automatically</b>. Publish the workflow, then make requests to the production URL. These executions will show up in the <a data-key='executions'>executions list</a>, but not the editor.",
active:
"This trigger has two modes: test and production.<br /><br /><b>Use test mode while you build your workflow</b>. Click the 'execute step' button, then make an MCP request to the test URL. The executions will show up in the editor.<br /><br /><b>Use production mode to run your workflow automatically</b>. Since your workflow is activated, you can make requests to the production URL. These executions will show up in the <a data-key='executions'>executions list</a>, but not the editor.",
},
activationHint:
"Once you've finished building your workflow, run it without having to click this button by using the production URL.",
},
inputs: [
{
type: NodeConnectionTypes.AiTool,
displayName: 'Tools',
},
],
outputs: [],
credentials: [
{
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
name: 'httpBearerAuth',
required: true,
displayOptions: {
show: {
authentication: ['bearerAuth'],
},
},
},
{
name: 'httpHeaderAuth',
required: true,
displayOptions: {
show: {
authentication: ['headerAuth'],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{ name: 'None', value: 'none' },
{ name: 'Bearer Auth', value: 'bearerAuth' },
{ name: 'Header Auth', value: 'headerAuth' },
],
default: 'none',
description: 'The way to authenticate',
},
{
displayName: 'Path',
name: 'path',
type: 'string',
default: '',
placeholder: 'webhook',
required: true,
description: 'The base path for this MCP server',
},
],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
isFullPath: true,
path: `={{$parameter["path"]}}{{parseFloat($nodeVersion)<2 ? '/${MCP_SSE_SETUP_PATH}' : ''}}`,
nodeType: 'mcp',
ndvHideMethod: true,
ndvHideUrl: false,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
isFullPath: true,
path: `={{$parameter["path"]}}{{parseFloat($nodeVersion)<2 ? '/${MCP_SSE_MESSAGES_PATH}' : ''}}`,
nodeType: 'mcp',
ndvHideMethod: true,
ndvHideUrl: true,
},
{
name: 'default',
httpMethod: 'DELETE',
responseMode: 'onReceived',
isFullPath: true,
path: '={{$parameter["path"]}}',
nodeType: 'mcp',
ndvHideMethod: true,
ndvHideUrl: true,
},
],
};
async webhook(context: IWebhookFunctions): Promise<IWebhookResponseData> {
const webhookName = context.getWebhookName();
const req = context.getRequestObject();
const resp = context.getResponseObject() as unknown as CompressionResponse;
try {
await validateWebhookAuthentication(context, 'authentication');
} catch (error) {
if (error instanceof WebhookAuthorizationError) {
resp.writeHead(error.responseCode);
resp.end(error.message);
return { noWebhookResponse: true };
}
throw error;
}
const node = context.getNode();
const serverName = node.typeVersion > 1 ? nodeNameToToolName(node) : 'n8n-mcp-server';
const mcpServer = McpServer.instance(context.logger);
if (webhookName === 'setup') {
const postUrl =
node.typeVersion < 2
? req.path.replace(new RegExp(`/${MCP_SSE_SETUP_PATH}$`), `/${MCP_SSE_MESSAGES_PATH}`)
: req.path;
const connectedTools = await getConnectedTools(context, true);
await mcpServer.handleSetupRequest(req, resp, serverName, postUrl, connectedTools);
return { noWebhookResponse: true };
} else if (webhookName === 'default') {
if (req.method === 'DELETE') {
await mcpServer.handleDeleteRequest(req, resp);
} else {
const sessionId = mcpServer.getSessionId(req);
context.logger.debug('MCP POST request received for existing session');
if (sessionId) {
const connectedTools = await getConnectedTools(context, true);
const { wasToolCall, toolCallInfo, messageId, relaySessionId, needsListToolsRelay } =
await mcpServer.handlePostMessage(req, resp, connectedTools, serverName);
if (wasToolCall) {
const workflowData = {
...(toolCallInfo && { mcpToolCall: toolCallInfo }),
...(messageId && { mcpMessageId: messageId }),
};
return { noWebhookResponse: true, workflowData: [[{ json: workflowData }]] };
}
if (needsListToolsRelay && relaySessionId && messageId) {
const workflowData = {
mcpListToolsRelay: {
sessionId: relaySessionId,
messageId,
marker: MCP_LIST_TOOLS_REQUEST_MARKER,
},
};
return { noWebhookResponse: true, workflowData: [[{ json: workflowData }]] };
}
} else {
const connectedTools = await getConnectedTools(context, true);
await mcpServer.handleStreamableHttpSetup(req, resp, serverName, connectedTools);
}
}
return { noWebhookResponse: true };
}
return { workflowData: [[{ json: {} }]] };
}
}
@@ -0,0 +1,851 @@
# MCP Server
Model Context Protocol (MCP) server implementation for the n8n McpTrigger node.
## Overview
This module provides a clean, modular architecture for handling MCP connections. It separates concerns into distinct layers that can be tested and extended independently.
```mermaid
flowchart TB
subgraph Client["MCP Client"]
C[Claude Desktop / MCP Client]
end
subgraph McpServer["McpServer (Facade)"]
direction TB
MS[McpServer]
end
subgraph Layers["Core Layers"]
direction TB
subgraph Session["Session Layer"]
SM[SessionManager]
SS[(SessionStore)]
end
subgraph Transport["Transport Layer"]
TF[TransportFactory]
SSE[SSETransport]
HTTP[StreamableHttpTransport]
end
subgraph Execution["Execution Layer"]
EC[ExecutionCoordinator]
DS[DirectStrategy]
QS[QueuedStrategy]
PM[PendingCallsManager]
end
subgraph Protocol["Protocol Layer"]
MP[MessageParser]
MF[MessageFormatter]
end
end
C <-->|SSE/HTTP| MS
MS --> SM
MS --> TF
MS --> EC
MS --> MP
MS --> MF
SM --> SS
TF --> SSE
TF --> HTTP
EC --> DS
EC --> QS
QS --> PM
```
## Architecture
### Module Overview
| Module | Purpose |
|--------|---------|
| **McpServer** | Main entry point. Coordinates all subsystems. |
| **Session** | Manages client connections and tool registrations. |
| **Transport** | Handles communication protocols (SSE, Streamable HTTP). |
| **Execution** | Executes tools directly or via worker queue. |
| **Protocol** | Parses and formats MCP messages. |
### McpServer Facade
The `McpServer` class is the **main entry point** for all MCP operations. It implements the [Facade pattern](https://refactoring.guru/design-patterns/facade), providing a simplified interface that coordinates all the underlying subsystems.
#### Why a Facade?
Without the facade, consumers would need to:
1. Create and configure a SessionManager with a SessionStore
2. Create a TransportFactory
3. Create transports and wire up event handlers
4. Create an ExecutionCoordinator with a strategy
5. Parse incoming messages with MessageParser
6. Format responses with MessageFormatter
7. Wire everything together correctly
The `McpServer` facade handles all this complexity internally, exposing just a few high-level methods.
#### Singleton Pattern
```typescript
const mcpServer = McpServer.instance(logger);
```
`McpServer` is a **singleton** - only one instance exists per process. This ensures:
- All MCP requests share the same session registry
- Pending responses are tracked in one place
- Configuration changes (session store, execution strategy) apply globally
#### Request Flow Overview
```mermaid
flowchart TB
subgraph Incoming["Incoming Requests"]
GET["GET /sse (SSE setup)"]
POST_INIT["POST /mcp (Streamable HTTP init)"]
POST_MSG["POST /messages (tool call)"]
DELETE["DELETE /mcp (session close)"]
end
subgraph McpServer["McpServer Facade"]
HandleSetup[handleSetupRequest]
HandleStreamable[handleStreamableHttpSetup]
HandlePost[handlePostMessage]
HandleDelete[handleDeleteRequest]
HandleWorker[handleWorkerResponse]
StorePending[storePendingResponse]
end
subgraph Internal["Internal Coordination"]
CreateServer[createServer]
SetupSession[setupSession]
SetupHandlers[setupHandlers]
CleanupSession[cleanupSession]
RecreateTransport[recreateStreamableHttpTransport]
end
GET --> HandleSetup
POST_INIT --> HandleStreamable
POST_MSG --> HandlePost
DELETE --> HandleDelete
HandleSetup --> CreateServer
HandleSetup --> SetupSession
HandleStreamable --> CreateServer
HandleStreamable --> SetupHandlers
HandlePost --> RecreateTransport
HandleDelete --> CleanupSession
SetupSession --> SetupHandlers
```
#### Public Methods
| Method | Purpose |
|--------|---------|
| `instance(logger)` | Get the singleton instance |
| `handleSetupRequest(req, resp, serverName, postUrl, tools)` | Handle SSE connection setup (GET request) |
| `handleStreamableHttpSetup(req, resp, serverName, tools)` | Handle Streamable HTTP initialization (POST with `initialize` method) |
| `handlePostMessage(req, resp, tools, serverName?)` | Handle incoming tool calls or list-tools requests. Returns `HandlePostResult` |
| `handleDeleteRequest(req, resp)` | Handle session termination |
| `handleWorkerResponse(sessionId, messageId, result)` | Route worker results back to clients (queue mode) |
| `storePendingResponse(sessionId, messageId)` | Track a pending response awaiting worker result |
| `hasPendingResponse(sessionId, messageId)` | Check if a pending response exists |
| `removePendingResponse(sessionId, messageId)` | Remove a pending response |
| `pendingResponseCount` | Getter for the number of pending responses |
| `getMcpMetadata(req)` | Extract session ID and message ID from a request |
| `getSessionId(req)` | Extract session ID from query string or header |
| `getTransport(sessionId)` | Get the transport for a session |
| `getTools(sessionId)` | Get the tools registered for a session |
#### HandlePostResult Type
The `handlePostMessage` method returns a `HandlePostResult` object:
```typescript
interface HandlePostResult {
wasToolCall: boolean; // Whether the request was a tool call
toolCallInfo?: McpToolCallInfo; // Info about the tool call (if any)
messageId?: string; // The JSONRPC message ID
relaySessionId?: string; // Session ID for relayed requests (queue mode)
needsListToolsRelay?: boolean; // Whether this is a list-tools request needing relay
}
```
#### Configuration Methods
| Method | Purpose |
|--------|---------|
| `setSessionStore(store)` | Replace the session store (e.g., InMemory → Redis) |
| `setExecutionStrategy(strategy)` | Replace the execution strategy (e.g., Direct → Queued) |
| `isQueueMode()` | Check if using queued execution |
| `getPendingCallsManager()` | Get the pending calls manager (needed for QueuedExecutionStrategy) |
#### Internal Coordination
The facade coordinates these internal operations:
| Internal Method | What It Does |
|-----------------|--------------|
| `createServer(serverName)` | Creates an MCP SDK `Server` instance with capabilities |
| `setupSession(server, transport, tools, resp)` | Registers session, sets up close handlers, connects server to transport |
| `setupHandlers(server)` | Registers `tools/list` and `tools/call` request handlers on the MCP server |
| `cleanupSession(sessionId)` | Cleans up pending calls, pending responses, and destroys the session |
| `recreateStreamableHttpTransport(...)` | Recreates a transport for an existing session (multi-instance scenarios) |
#### Queue Mode Behavior
In queue mode (multi-instance deployment), the facade has additional responsibilities:
```mermaid
sequenceDiagram
participant Client
participant Main as McpServer (Main)
participant Redis
participant Worker
Client->>Main: POST /messages (tool call)
Main->>Main: storePendingResponse()
Main-->>Client: 202 Accepted
Main->>Redis: Enqueue job
Redis->>Worker: Dequeue job
Worker->>Worker: Execute tool
Worker->>Redis: Publish result
Redis->>Main: mcp-response event
Main->>Main: handleWorkerResponse()
Main-->>Client: Result via SSE/HTTP
```
Key queue mode methods:
- **`storePendingResponse()`** - Tracks that we're waiting for a worker result
- **`handleWorkerResponse()`** - Routes the worker's result back to the correct client
- **`hasPendingResponse()`** / **`removePendingResponse()`** - Manage pending response state
## Layers
### 1. Protocol Layer
The Protocol layer handles the translation between raw HTTP request bodies and strongly-typed MCP data structures. MCP uses [JSONRPC 2.0](https://www.jsonrpc.org/specification) as its wire protocol, so every message from an MCP client is a JSONRPC request.
#### Why This Layer Exists
When an MCP client sends a request (e.g., "call tool X with arguments Y"), it arrives as a raw JSON string in the HTTP request body. The Protocol layer:
1. **Parses and validates** the raw JSON against the JSONRPC schema
2. **Identifies the request type** (tool call, list tools, etc.)
3. **Extracts the relevant data** (tool name, arguments) into typed structures
4. **Formats responses** back into the MCP-expected format
This keeps the rest of the codebase working with clean, typed data instead of raw JSON.
```mermaid
flowchart LR
subgraph Incoming["Incoming Request"]
Raw["Raw JSON Body<br/>{ jsonrpc: '2.0', method: 'tools/call', ... }"]
end
subgraph MessageParser["MessageParser"]
Parse[parse]
IsToolCall[isToolCall]
IsListTools[isListToolsRequest]
GetId[getRequestId]
Extract[extractToolCallInfo]
end
subgraph Outgoing["Outgoing Response"]
Result["Tool Execution Result<br/>(string, object, Error)"]
end
subgraph MessageFormatter["MessageFormatter"]
FormatResult[formatToolResult]
FormatError[formatError]
end
Raw --> Parse
Parse --> IsToolCall
Parse --> IsListTools
Parse --> GetId
Parse --> Extract
Extract --> Info["McpToolCallInfo<br/>{ toolName, arguments }"]
Result --> FormatResult
Result --> FormatError
FormatResult --> McpResult["McpToolResult<br/>{ content: [{ type, text }] }"]
FormatError --> McpResult
```
#### Types
```typescript
// Extracted info from a tool call request
interface McpToolCallInfo {
toolName: string; // Name of the tool to invoke
arguments: Record<string, unknown>; // Arguments passed to the tool
sourceNodeName?: string; // Optional: n8n node that registered the tool
}
// Formatted result to send back to the client
interface McpToolResult {
content: Array<{ type: string; text: string }>; // MCP content blocks
isError?: boolean; // Flag for error responses
}
// Special marker returned when handling list-tools requests
const MCP_LIST_TOOLS_REQUEST_MARKER = { _listToolsRequest: true };
```
#### MessageParser Methods
| Method | Purpose |
|--------|---------|
| `parse(body)` | Parses a raw JSON string into a validated `JSONRPCMessage`. Returns `undefined` if invalid. |
| `isToolCall(body)` | Returns `true` if the message is a `tools/call` request (client wants to invoke a tool) |
| `isListToolsRequest(body)` | Returns `true` if the message is a `tools/list` request (client wants to discover available tools) |
| `getRequestId(message)` | Extracts the JSONRPC request ID (needed to correlate responses with requests) |
| `extractToolCallInfo(body)` | Extracts the tool name and arguments from a tool call request into `McpToolCallInfo` |
#### MessageFormatter Methods
| Method | Purpose |
|--------|---------|
| `formatToolResult(result)` | Converts a tool's return value (string, object, etc.) into an `McpToolResult` with proper content blocks |
| `formatError(error)` | Converts an Error into an `McpToolResult` with `isError: true` and the error message |
#### Example Flow
```typescript
// 1. Client sends a tool call
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"London"}}}';
// 2. Parse and identify
MessageParser.isToolCall(body); // true
MessageParser.isListToolsRequest(body); // false
// 3. Extract tool info
const info = MessageParser.extractToolCallInfo(body);
// { toolName: 'get_weather', arguments: { city: 'London' } }
// 4. Execute tool and format result
const result = await executeTool(info); // { temperature: 15, unit: 'celsius' }
const formatted = MessageFormatter.formatToolResult(result);
// { content: [{ type: 'text', text: '{"temperature":15,"unit":"celsius"}' }] }
// 5. Or format an error
const error = MessageFormatter.formatError(new Error('City not found'));
// { isError: true, content: [{ type: 'text', text: 'Error: City not found' }] }
```
**Files:**
- `types.ts` - Type definitions (`McpToolCallInfo`, `McpToolResult`, `MCP_LIST_TOOLS_REQUEST_MARKER`)
- `MessageParser.ts` - Parses raw JSON, identifies request types, extracts tool call info
- `MessageFormatter.ts` - Formats tool results and errors for MCP responses
### 2. Session Layer
The Session layer manages MCP client connections and their associated state (tools, transport, server instance). Each MCP client establishes a session when connecting, and that session persists for the lifetime of the connection.
#### Why Sessions Are Needed
MCP uses a stateful protocol where:
1. A client connects and establishes a session (via SSE or Streamable HTTP)
2. The client can then make multiple tool calls within that session
3. Each session has its own transport (for sending responses back) and set of available tools
Sessions allow the server to:
- Track which clients are connected
- Route responses back to the correct client
- Associate tools with specific client connections
- Validate that incoming requests belong to active sessions
```mermaid
flowchart TB
subgraph SessionManager["SessionManager (Coordinator)"]
direction LR
Register[registerSession]
Destroy[destroySession]
GetSession[getSession]
GetTransport[getTransport]
GetServer[getServer]
IsValid[isSessionValid]
Tools[getTools / setTools]
end
subgraph InMemoryState["In-Memory State (SessionManager)"]
SessionInfo["sessions: Record&lt;sessionId, SessionInfo&gt;"]
SI_Content["SessionInfo = { sessionId, server, transport }"]
end
subgraph SessionStore["SessionStore Interface"]
InMemory[InMemorySessionStore]
Redis[RedisSessionStore]
end
SessionManager --> InMemoryState
SessionManager --> SessionStore
InMemory -.->|implements| SessionStore
Redis -.->|implements| SessionStore
```
#### Two-Level Storage Architecture
The Session layer uses a two-level storage architecture:
| Storage Level | What It Stores | Why |
|---------------|----------------|-----|
| **SessionManager (in-memory)** | `SessionInfo` objects containing the MCP `Server` instance and `Transport` | These are runtime objects (WebSocket connections, SSE streams) that cannot be serialized or shared across processes |
| **SessionStore (pluggable)** | Session IDs (for validation) and Tools array | Can be backed by Redis for multi-instance deployments where sessions need to be validated across workers |
This separation allows:
- **Single-instance mode**: Use `InMemorySessionStore` (default) - everything stays in process memory
- **Multi-instance/queue mode**: Use `RedisSessionStore` - session validation and tools can be checked by any worker, while the actual transport/server objects remain on the main instance that holds the client connection
#### SessionStore Interface
```typescript
interface SessionStore {
register(sessionId: string): Promise<void>; // Register a new session
validate(sessionId: string): Promise<boolean>; // Check if session exists
unregister(sessionId: string): Promise<void>; // Remove a session
getTools(sessionId: string): Tool[] | undefined; // Get tools for session
setTools(sessionId: string, tools: Tool[]): void; // Associate tools with session
clearTools(sessionId: string): void; // Remove tools from session
}
```
#### SessionManager Methods
| Method | Purpose |
|--------|---------|
| `registerSession(sessionId, server, transport, tools?)` | Called when a new client connects. Stores the session info in memory and registers with the SessionStore |
| `destroySession(sessionId)` | Called when a client disconnects. Cleans up both in-memory state and SessionStore |
| `getSession(sessionId)` | Returns the full `SessionInfo` (sessionId, server, transport) |
| `getTransport(sessionId)` | Returns just the transport for sending responses back to the client |
| `getServer(sessionId)` | Returns the MCP Server instance for this session |
| `isSessionValid(sessionId)` | Delegates to SessionStore to check if session exists (useful in multi-instance setups) |
| `getTools(sessionId)` / `setTools(sessionId, tools)` | Manage the tools available for this session |
| `setStore(store)` / `getStore()` | Swap the SessionStore implementation (e.g., from InMemory to Redis) |
**Files:**
- `SessionStore.ts` - Interface for session storage
- `InMemorySessionStore.ts` - Default in-memory implementation (uses `Set` for sessions, `Record` for tools)
- `SessionManager.ts` - Coordinates session operations, holds runtime objects
### 3. Transport Layer
The Transport layer abstracts the communication protocol between the MCP server and clients. MCP supports multiple transport mechanisms, and this layer provides a unified interface so the rest of the code doesn't need to know which protocol is being used.
#### Why This Layer Exists
MCP clients can connect using different protocols:
- **SSE (Server-Sent Events)** - A long-lived HTTP connection where responses stream back to the client
- **Streamable HTTP** - Request-response based with optional streaming, more REST-like
Each protocol has different characteristics, but the server logic (handling tool calls, managing sessions) should be the same regardless. The Transport layer provides:
1. A **common interface** (`McpTransport`) that both protocols implement
2. A **factory** to create the right transport type
3. **Protocol-specific wrappers** that handle the differences internally
```mermaid
flowchart TB
subgraph Client["MCP Client"]
C[Claude Desktop / MCP Client]
end
subgraph TransportLayer["Transport Layer"]
subgraph McpTransport["McpTransport Interface"]
Send[send]
HandleReq[handleRequest]
Close[close]
end
subgraph Implementations["Implementations"]
SSE[SSETransport]
HTTP[StreamableHttpTransport]
end
subgraph Factory["TransportFactory"]
CreateSSE[createSSE]
CreateHTTP[createStreamableHttp]
Recreate[recreateStreamableHttp]
end
end
C <-->|"GET /sse + POST /messages"| SSE
C <-->|"POST /mcp"| HTTP
SSE -.->|implements| McpTransport
HTTP -.->|implements| McpTransport
Factory --> SSE
Factory --> HTTP
```
#### McpTransport Interface
```typescript
interface McpTransport {
readonly transportType: 'sse' | 'streamableHttp'; // Identifies the transport type
readonly sessionId: string | undefined; // Session ID for this connection
send(message: JSONRPCMessage): Promise<void>; // Send a message to the client
handleRequest(req, resp, body?): Promise<void>; // Handle an incoming request
close?(): Promise<void>; // Close the transport
onclose?: () => void | Promise<void>; // Callback when connection closes
}
```
#### SSE Transport
**Server-Sent Events** is a unidirectional streaming protocol where:
1. Client opens a long-lived GET connection to `/sse`
2. Server keeps the connection open and streams events (responses) back
3. Client sends tool calls via separate POST requests to `/messages`
```mermaid
sequenceDiagram
participant Client
participant Server
Client->>Server: GET /sse
Note over Server: Connection stays open
Server-->>Client: SSE: endpoint event (POST URL)
Client->>Server: POST /messages (tool call)
Server-->>Client: SSE: message event (result)
Client->>Server: POST /messages (another call)
Server-->>Client: SSE: message event (result)
Note over Client,Server: Connection persists until closed
```
**Characteristics:**
- Long-lived connection (held open for the session lifetime)
- Responses stream back on the same connection
- Tool calls arrive via separate POST requests
- Session ID passed as query parameter (`?sessionId=...`)
- Good for real-time, continuous interactions
**Implementation:** `SSETransport` extends the MCP SDK's `SSEServerTransport` and:
- Adds the `McpTransport` interface
- Flushes the response after each send (for compression middleware compatibility)
#### Streamable HTTP Transport
**Streamable HTTP** is a request-response protocol where:
1. Client sends POST requests to `/mcp`
2. Each request can optionally stream responses back
3. Session continuity via `mcp-session-id` header
```mermaid
sequenceDiagram
participant Client
participant Server
Client->>Server: POST /mcp (initialize)
Server-->>Client: Response + mcp-session-id header
Client->>Server: POST /mcp (tool call)<br/>Header: mcp-session-id
Server-->>Client: Response (result)
Client->>Server: DELETE /mcp<br/>Header: mcp-session-id
Server-->>Client: 200 OK (session closed)
```
**Characteristics:**
- Request-response based (more REST-like)
- Session ID passed via `mcp-session-id` header
- Supports session recreation on different server instances
- Better for stateless/load-balanced deployments
**Implementation:** `StreamableHttpTransport` extends the MCP SDK's `StreamableHTTPServerTransport` and:
- Adds the `McpTransport` interface
- Provides `markAsInitialized()` for recreating transports with existing sessions
- Flushes responses for compression compatibility
#### TransportFactory
The factory creates transport instances with the right configuration:
| Method | Purpose |
|--------|---------|
| `createSSE(postUrl, response)` | Creates an SSE transport. `postUrl` is the URL clients should POST tool calls to. |
| `createStreamableHttp(options, response)` | Creates a Streamable HTTP transport with session initialization callbacks. |
| `recreateStreamableHttp(sessionId, response)` | Recreates a transport for an existing session (multi-instance scenarios). |
#### Transport Recreation (Multi-Instance)
In multi-instance deployments, a client might have established a session on Instance A, but a subsequent request lands on Instance B. The `recreateStreamableHttp()` method handles this:
```mermaid
sequenceDiagram
participant Client
participant InstanceA as Instance A
participant InstanceB as Instance B
participant Redis
Client->>InstanceA: POST /mcp (initialize)
InstanceA->>Redis: Register session
InstanceA-->>Client: mcp-session-id: abc123
Note over Client,InstanceB: Load balancer routes to different instance
Client->>InstanceB: POST /mcp (tool call)<br/>Header: mcp-session-id: abc123
InstanceB->>Redis: Validate session exists
InstanceB->>InstanceB: recreateStreamableHttp(abc123)
InstanceB-->>Client: Response
```
The recreated transport is marked as already initialized (via `markAsInitialized()`) so it skips the initialization handshake.
#### CompressionResponse Type
```typescript
type CompressionResponse = Response & {
flush?: () => void;
};
```
This type extends Express's `Response` to include an optional `flush()` method. When using compression middleware (like `compression`), responses are buffered. Calling `flush()` forces buffered data to be sent immediately - important for SSE where responses need to arrive in real-time.
**Files:**
- `Transport.ts` - `McpTransport` interface and `CompressionResponse` type
- `SSETransport.ts` - SSE implementation wrapping MCP SDK's `SSEServerTransport`
- `StreamableHttpTransport.ts` - Streamable HTTP implementation wrapping MCP SDK's `StreamableHTTPServerTransport`
- `TransportFactory.ts` - Factory for creating transport instances
### 4. Execution Layer
Implements strategy pattern for tool execution, allowing different execution modes depending on deployment scenario.
```mermaid
flowchart TB
subgraph ExecutionCoordinator
Execute[executeTool]
SetStrategy[setStrategy]
end
subgraph Strategies["ExecutionStrategy Interface"]
Direct[DirectExecutionStrategy]
Queued[QueuedExecutionStrategy]
end
subgraph PendingCalls[PendingCallsManager]
Wait[waitForResult]
Resolve[resolve]
end
ExecutionCoordinator --> Strategies
Direct -->|invoke| Tool[Tool.invoke]
Queued --> PendingCalls
PendingCalls -.->|worker response| Resolve
```
#### ExecutionStrategy Interface
```typescript
interface ExecutionStrategy {
executeTool(
tool: Tool,
args: Record<string, unknown>,
context: ExecutionContext,
): Promise<unknown>;
}
interface ExecutionContext {
sessionId: string;
messageId?: string;
}
```
#### DirectExecutionStrategy
The default strategy that executes tools immediately in the same process:
```typescript
const strategy = new DirectExecutionStrategy();
const result = await strategy.executeTool(tool, args, context);
// Directly calls tool.invoke(args)
```
#### QueuedExecutionStrategy
For multi-instance deployments where tool execution happens on worker processes:
```typescript
const strategy = new QueuedExecutionStrategy(
pendingCallsManager,
timeoutMs // Optional, defaults to 120000ms (2 minutes)
);
// Methods for resolving calls from workers:
strategy.resolveToolCall(callId, result); // Returns true if call was pending
strategy.rejectToolCall(callId, error); // Returns true if call was pending
strategy.getPendingCallsManager(); // Access the pending calls manager
```
#### PendingCallsManager
Tracks tool calls waiting for results with automatic timeout handling:
```typescript
const manager = new PendingCallsManager();
// Wait for a result (with timeout)
const result = await manager.waitForResult(callId, toolName, args, timeoutMs);
// Resolve/reject from worker
manager.resolve(callId, result);
manager.reject(callId, error);
// Query and manage pending calls
manager.has(callId); // Check if call is pending
manager.get(callId); // Get pending call info
manager.remove(callId); // Remove without resolving
manager.cleanupBySessionId(sessionId); // Clean up all calls for a session
```
**Files:**
- `ExecutionStrategy.ts` - Strategy interface and ExecutionContext type
- `DirectExecutionStrategy.ts` - Executes tools directly on main instance
- `QueuedExecutionStrategy.ts` - Delegates to worker, waits for response (default timeout: 120s)
- `PendingCallsManager.ts` - Tracks pending tool calls with timeout support
- `ExecutionCoordinator.ts` - Selects and invokes strategy
## Usage
### Basic Setup (Normal Mode)
```typescript
import { McpServer } from './McpServer';
const mcpServer = McpServer.instance(logger);
// Handle SSE setup
await mcpServer.handleSetupRequest(req, resp, serverName, postUrl, tools);
// Handle POST messages
const result = await mcpServer.handlePostMessage(req, resp, tools, serverName);
```
### Queue Mode Setup
```typescript
import { McpServer } from './McpServer';
import { QueuedExecutionStrategy } from './execution';
import { RedisSessionStore } from './RedisSessionStore';
const mcpServer = McpServer.instance(logger);
// Configure Redis session store
mcpServer.setSessionStore(new RedisSessionStore(publisher, getKey, ttl));
// Configure queued execution
mcpServer.setExecutionStrategy(
new QueuedExecutionStrategy(mcpServer.getPendingCallsManager())
);
```
## Flow Diagrams
### SSE Connection Flow
```mermaid
sequenceDiagram
participant Client
participant McpServer
participant Transport
participant Session
Client->>McpServer: GET /sse (setup)
McpServer->>Transport: createSSE()
Transport-->>McpServer: SSETransport
McpServer->>Session: registerSession()
McpServer-->>Client: SSE stream opened
Client->>McpServer: POST /messages (tool call)
McpServer->>Session: getTransport()
McpServer->>Transport: handleRequest()
Note over McpServer: Execute tool
Transport-->>Client: Tool result via SSE
```
### Queue Mode Flow
```mermaid
sequenceDiagram
participant Client
participant Main
participant McpServer
participant Worker
participant Redis
Client->>Main: Tool call request
Main->>McpServer: handlePostMessage()
McpServer->>McpServer: storePendingResponse()
Main->>Redis: Enqueue job
Main-->>Client: 202 Accepted
Redis->>Worker: Dequeue job
Worker->>Worker: Execute tool
Worker->>Redis: Publish result
Redis->>Main: mcp-response event
Main->>McpServer: handleWorkerResponse()
McpServer-->>Client: Result via SSE
```
## Module Structure
```
McpTrigger/
├── McpServer.ts # Main facade coordinating all subsystems
├── McpTrigger.node.ts # n8n node implementation
├── protocol/ # JSONRPC message parsing & formatting
│ ├── MessageParser.ts
│ ├── MessageFormatter.ts
│ └── types.ts
├── session/ # Client connection & state management
│ ├── SessionManager.ts
│ ├── SessionStore.ts
│ └── InMemorySessionStore.ts
├── transport/ # SSE & Streamable HTTP protocols
│ ├── Transport.ts
│ ├── SSETransport.ts
│ ├── StreamableHttpTransport.ts
│ └── TransportFactory.ts
├── execution/ # Direct & queued execution strategies
│ ├── ExecutionStrategy.ts
│ ├── DirectExecutionStrategy.ts
│ ├── QueuedExecutionStrategy.ts
│ ├── PendingCallsManager.ts
│ └── ExecutionCoordinator.ts
└── __tests__/ # Comprehensive unit tests
```
### Imports
```typescript
// Main facade
import { McpServer, MCP_LIST_TOOLS_REQUEST_MARKER } from './McpServer';
import type { HandlePostResult } from './McpServer';
// Protocol
import { MessageParser, MessageFormatter } from './protocol';
import type { McpToolCallInfo, McpToolResult } from './protocol';
// Session
import { InMemorySessionStore, SessionManager } from './session';
import type { SessionStore } from './session';
// Transport
import { SSETransport, StreamableHttpTransport, TransportFactory } from './transport';
import type { McpTransport, CompressionResponse, TransportType } from './transport';
// Execution
import { DirectExecutionStrategy, QueuedExecutionStrategy, PendingCallsManager, ExecutionCoordinator } from './execution';
import type { ExecutionStrategy, ExecutionContext } from './execution';
```
@@ -0,0 +1,349 @@
import {
createMockLogger,
createMockRequest,
createMockRequestWithSessionId,
createMockRequestWithHeaderSessionId,
createMockResponse,
createMockTool,
createMockTransport,
createValidToolCallMessage,
createListToolsMessage,
createMockServer,
MCP_SESSION_ID_HEADER,
} from './helpers';
import { QueuedExecutionStrategy } from '../execution/QueuedExecutionStrategy';
import { McpServer } from '../McpServer';
import { InMemorySessionStore } from '../session/InMemorySessionStore';
describe('McpServer', () => {
let mcpServer: McpServer;
let mockLogger: ReturnType<typeof createMockLogger>;
beforeEach(() => {
// Reset singleton for testing
(McpServer as unknown as { instance_: McpServer | undefined }).instance_ = undefined;
mockLogger = createMockLogger();
mcpServer = McpServer.instance(mockLogger);
});
afterEach(() => {
// Clean up singleton
(McpServer as unknown as { instance_: McpServer | undefined }).instance_ = undefined;
});
describe('singleton pattern', () => {
it('should return same instance for subsequent calls', () => {
const instance1 = McpServer.instance(mockLogger);
const instance2 = McpServer.instance(mockLogger);
expect(instance1).toBe(instance2);
});
it('should log debug message when creating singleton', () => {
expect(mockLogger.debug).toHaveBeenCalledWith('McpServer created');
expect(mockLogger.debug).toHaveBeenCalledWith('Created singleton McpServer');
});
});
describe('getSessionId', () => {
it('should extract sessionId from query parameters', () => {
const req = createMockRequestWithSessionId('session-123', '{}');
expect(mcpServer.getSessionId(req)).toBe('session-123');
});
it('should extract sessionId from mcp-session-id header', () => {
const req = createMockRequestWithHeaderSessionId('header-session-456');
expect(mcpServer.getSessionId(req)).toBe('header-session-456');
});
it('should prefer query parameter over header', () => {
const req = createMockRequest({
query: { sessionId: 'query-session' },
headers: { [MCP_SESSION_ID_HEADER]: 'header-session' },
});
expect(mcpServer.getSessionId(req)).toBe('query-session');
});
it('should return undefined when no sessionId present', () => {
const req = createMockRequest({ query: {}, headers: {} });
expect(mcpServer.getSessionId(req)).toBeUndefined();
});
});
describe('getMcpMetadata', () => {
it('should extract sessionId and messageId from request', () => {
const rawBody = '{"jsonrpc":"2.0","id":"msg-123","method":"test"}';
const req = createMockRequestWithSessionId('session-1', rawBody);
const metadata = mcpServer.getMcpMetadata(req);
expect(metadata).toEqual({
sessionId: 'session-1',
messageId: 'msg-123',
});
});
it('should return empty messageId when not present in body', () => {
const rawBody = '{"jsonrpc":"2.0","method":"notification"}';
const req = createMockRequestWithSessionId('session-1', rawBody);
const metadata = mcpServer.getMcpMetadata(req);
expect(metadata).toEqual({
sessionId: 'session-1',
messageId: '',
});
});
it('should return undefined when no sessionId', () => {
const req = createMockRequest({ query: {}, headers: {} });
expect(mcpServer.getMcpMetadata(req)).toBeUndefined();
});
});
describe('handlePostMessage', () => {
it('should return 401 when no transport found for session', async () => {
const response = createMockResponse();
const request = createMockRequestWithSessionId('non-existent', '{}');
await mcpServer.handlePostMessage(request, response, []);
expect(response.status).toHaveBeenCalledWith(401);
expect(response.send).toHaveBeenCalledWith('No transport found for sessionId');
});
it('should identify tool call messages', async () => {
const response = createMockResponse();
const toolCallBody = createValidToolCallMessage('get_weather', { city: 'London' });
const request = createMockRequestWithSessionId('non-existent', toolCallBody);
const result = await mcpServer.handlePostMessage(request, response, []);
expect(result.wasToolCall).toBe(true);
expect(result.toolCallInfo).toEqual({
toolName: 'get_weather',
arguments: { city: 'London' },
});
});
it('should add sourceNodeName from tool metadata', async () => {
const response = createMockResponse();
const tools = [
createMockTool('get_weather', {
metadata: { sourceNodeName: 'Weather Node' },
}),
];
const toolCallBody = createValidToolCallMessage('get_weather', { city: 'London' });
const request = createMockRequestWithSessionId('non-existent', toolCallBody);
const result = await mcpServer.handlePostMessage(request, response, tools);
expect(result.toolCallInfo).toEqual({
toolName: 'get_weather',
arguments: { city: 'London' },
sourceNodeName: 'Weather Node',
});
});
it('should identify non-tool-call messages', async () => {
const response = createMockResponse();
const listToolsBody = createListToolsMessage();
const request = createMockRequestWithSessionId('non-existent', listToolsBody);
const result = await mcpServer.handlePostMessage(request, response, []);
expect(result.wasToolCall).toBe(false);
});
});
describe('handleDeleteRequest', () => {
it('should return 400 when no sessionId provided', async () => {
const response = createMockResponse();
const request = {
query: {},
headers: {},
rawBody: Buffer.from('{}'),
} as unknown as Parameters<typeof mcpServer.handleDeleteRequest>[0];
await mcpServer.handleDeleteRequest(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.send).toHaveBeenCalledWith('No sessionId provided');
});
it('should return 404 when session not found', async () => {
const response = createMockResponse();
const request = createMockRequestWithSessionId('non-existent', '{}');
await mcpServer.handleDeleteRequest(request, response);
expect(response.status).toHaveBeenCalledWith(404);
expect(response.send).toHaveBeenCalledWith('Session not found');
});
});
describe('configuration', () => {
it('should allow setting custom session store', () => {
const customStore = new InMemorySessionStore();
mcpServer.setSessionStore(customStore);
// Verify the store is used (indirect test)
expect(mcpServer).toBeDefined();
});
it('should allow setting execution strategy', () => {
const queuedStrategy = new QueuedExecutionStrategy(mcpServer.getPendingCallsManager());
mcpServer.setExecutionStrategy(queuedStrategy);
expect(mcpServer.isQueueMode()).toBe(true);
});
it('should not be in queue mode by default', () => {
expect(mcpServer.isQueueMode()).toBe(false);
});
});
describe('pending response management', () => {
it('should track and check pending responses', async () => {
// First register a session with transport
const sessionId = 'test-session';
const transport = createMockTransport(sessionId);
const server = createMockServer();
// Access private sessionManager to register session
const sessionManager = (
mcpServer as unknown as {
sessionManager: {
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
};
}
).sessionManager;
await sessionManager.registerSession(sessionId, server, transport);
mcpServer.storePendingResponse(sessionId, 'msg-1');
expect(mcpServer.hasPendingResponse(sessionId, 'msg-1')).toBe(true);
expect(mcpServer.hasPendingResponse(sessionId, 'msg-2')).toBe(false);
expect(mcpServer.pendingResponseCount).toBe(1);
});
it('should remove pending responses', async () => {
const sessionId = 'test-session';
const transport = createMockTransport(sessionId);
const server = createMockServer();
const sessionManager = (
mcpServer as unknown as {
sessionManager: {
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
};
}
).sessionManager;
await sessionManager.registerSession(sessionId, server, transport);
mcpServer.storePendingResponse(sessionId, 'msg-1');
mcpServer.removePendingResponse(sessionId, 'msg-1');
expect(mcpServer.hasPendingResponse(sessionId, 'msg-1')).toBe(false);
expect(mcpServer.pendingResponseCount).toBe(0);
});
it('should handle pending response without messageId', async () => {
const sessionId = 'test-session';
const transport = createMockTransport(sessionId);
const server = createMockServer();
const sessionManager = (
mcpServer as unknown as {
sessionManager: {
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
};
}
).sessionManager;
await sessionManager.registerSession(sessionId, server, transport);
mcpServer.storePendingResponse(sessionId, '');
expect(mcpServer.hasPendingResponse(sessionId, '')).toBe(true);
});
it('should warn when storing pending response without transport', () => {
mcpServer.storePendingResponse('no-transport-session', 'msg-1');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Cannot store pending response'),
);
});
});
describe('getTransport', () => {
it('should return undefined for unregistered session', () => {
expect(mcpServer.getTransport('non-existent')).toBeUndefined();
});
it('should return transport for registered session', async () => {
const sessionId = 'test-session';
const transport = createMockTransport(sessionId);
const server = createMockServer();
const sessionManager = (
mcpServer as unknown as {
sessionManager: {
registerSession: (s: string, srv: unknown, tr: unknown) => Promise<void>;
};
}
).sessionManager;
await sessionManager.registerSession(sessionId, server, transport);
expect(mcpServer.getTransport(sessionId)).toBe(transport);
});
});
describe('getTools', () => {
it('should return undefined for session without tools', () => {
expect(mcpServer.getTools('non-existent')).toBeUndefined();
});
});
describe('getPendingCallsManager', () => {
it('should return the pending calls manager', () => {
const manager = mcpServer.getPendingCallsManager();
expect(manager).toBeDefined();
expect(typeof manager.waitForResult).toBe('function');
expect(typeof manager.resolve).toBe('function');
expect(typeof manager.reject).toBe('function');
});
});
describe('handleWorkerResponse', () => {
it('should handle list tools request marker', async () => {
const sessionId = 'test-session';
const transport = createMockTransport(sessionId, 'sse');
const server = createMockServer();
const sessionManager = (
mcpServer as unknown as {
sessionManager: {
registerSession: (
s: string,
srv: unknown,
tr: unknown,
tools?: unknown[],
) => Promise<void>;
};
}
).sessionManager;
await sessionManager.registerSession(sessionId, server, transport, [
createMockTool('test-tool'),
]);
mcpServer.handleWorkerResponse(sessionId, 'msg-1', { _listToolsRequest: true });
// Should have attempted to send tools list via transport
expect(transport.send).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,424 @@
import { mock } from 'jest-mock-extended';
import type { INode, IWebhookFunctions, ICredentialDataDecryptedObject } from 'n8n-workflow';
import { createMockLogger, createMockRequest, createMockResponse } from './helpers';
import { McpTrigger } from '../McpTrigger.node';
import { McpServer } from '../McpServer';
// Mock the McpServer
jest.mock('../McpServer', () => ({
McpServer: {
instance: jest.fn(),
},
MCP_LIST_TOOLS_REQUEST_MARKER: 'mcp_list_tools_request',
}));
// Mock webhook utils from nodes-base
jest.mock('n8n-nodes-base/dist/nodes/Webhook/utils', () => ({
validateWebhookAuthentication: jest.fn(),
}));
// Mock getConnectedTools from utils
jest.mock('@utils/helpers', () => ({
getConnectedTools: jest.fn().mockResolvedValue([]),
}));
describe('McpTrigger', () => {
let mcpTrigger: McpTrigger;
let mockMcpServer: jest.Mocked<McpServer>;
let mockContext: jest.Mocked<IWebhookFunctions>;
let mockLogger: ReturnType<typeof createMockLogger>;
beforeEach(() => {
mcpTrigger = new McpTrigger();
mockLogger = createMockLogger();
mockMcpServer = {
handleSetupRequest: jest.fn().mockResolvedValue(undefined),
handlePostMessage: jest.fn().mockResolvedValue({
wasToolCall: false,
toolCallInfo: undefined,
messageId: undefined,
relaySessionId: undefined,
needsListToolsRelay: false,
}),
handleDeleteRequest: jest.fn().mockResolvedValue(undefined),
handleStreamableHttpSetup: jest.fn().mockResolvedValue(undefined),
getSessionId: jest.fn().mockReturnValue(undefined),
} as unknown as jest.Mocked<McpServer>;
(McpServer.instance as jest.Mock).mockReturnValue(mockMcpServer);
mockContext = mock<IWebhookFunctions>({
getWebhookName: jest.fn().mockReturnValue('setup'),
getRequestObject: jest.fn(),
getResponseObject: jest.fn(),
getNode: jest.fn(),
logger: mockLogger,
getCredentials: jest.fn().mockResolvedValue({} as ICredentialDataDecryptedObject),
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('description', () => {
it('should have the correct node metadata', () => {
expect(mcpTrigger.description.name).toBe('mcpTrigger');
expect(mcpTrigger.description.displayName).toBe('MCP Server Trigger');
expect(mcpTrigger.description.group).toContain('trigger');
});
it('should support multiple versions', () => {
expect(mcpTrigger.description.version).toEqual([1, 1.1, 2]);
});
it('should have authentication options', () => {
const authParam = mcpTrigger.description.properties?.find((p) => p.name === 'authentication');
expect(authParam).toBeDefined();
expect(authParam?.type).toBe('options');
expect(authParam?.options).toHaveLength(3);
});
it('should define webhook endpoints', () => {
const webhooks = mcpTrigger.description.webhooks;
expect(webhooks).toHaveLength(3);
const setupWebhook = webhooks?.find((w) => w.name === 'setup');
expect(setupWebhook?.httpMethod).toBe('GET');
const defaultWebhooks = webhooks?.filter((w) => w.name === 'default');
expect(defaultWebhooks).toHaveLength(2);
expect(defaultWebhooks?.map((w) => w.httpMethod)).toEqual(['POST', 'DELETE']);
});
});
describe('webhook - setup (GET)', () => {
it('should handle setup request for version 1', async () => {
const req = createMockRequest({ path: '/webhook/sse' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 1,
name: 'MCP Server Trigger',
});
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
const result = await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
it('should use n8n-mcp-server name for version 1', async () => {
const req = createMockRequest({ path: '/webhook/sse' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 1,
name: 'MCP Server Trigger',
});
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
req,
resp,
'n8n-mcp-server',
expect.any(String),
expect.any(Array),
);
});
it('should use sanitized node name for version > 1', async () => {
const req = createMockRequest({ path: '/webhook' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'My Custom MCP Server',
});
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
await mcpTrigger.webhook(mockContext);
// nodeNameToToolName converts "My Custom MCP Server" to a sanitized name
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
req,
resp,
expect.stringMatching(/^[a-z0-9_-]+$/i),
expect.any(String),
expect.any(Array),
);
});
it('should compute correct POST URL for version 1', async () => {
const req = createMockRequest({ path: '/webhook/sse' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 1,
name: 'MCP Server Trigger',
});
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
req,
resp,
expect.any(String),
'/webhook/messages',
expect.any(Array),
);
});
it('should use same path as POST URL for version 2', async () => {
const req = createMockRequest({ path: '/webhook' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
});
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalledWith(
req,
resp,
expect.any(String),
'/webhook',
expect.any(Array),
);
});
});
describe('webhook - default POST', () => {
it('should handle POST with existing session', async () => {
const req = createMockRequest({ method: 'POST', query: { sessionId: 'test-session' } });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
});
mockMcpServer.getSessionId.mockReturnValue('test-session');
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
const result = await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handlePostMessage).toHaveBeenCalled();
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',
});
mockMcpServer.getSessionId.mockReturnValue('test-session');
mockMcpServer.handlePostMessage.mockResolvedValue({
wasToolCall: true,
toolCallInfo: { toolName: 'test-tool', arguments: { arg1: 'value1' } },
messageId: 'msg-123',
relaySessionId: undefined,
needsListToolsRelay: false,
});
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
const result = await mcpTrigger.webhook(mockContext);
expect(result).toEqual({
noWebhookResponse: true,
workflowData: [
[
{
json: {
mcpToolCall: { toolName: 'test-tool', arguments: { arg1: 'value1' } },
mcpMessageId: 'msg-123',
},
},
],
],
});
});
it('should handle Streamable HTTP setup when no session exists', async () => {
const req = createMockRequest({ method: 'POST' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
});
mockMcpServer.getSessionId.mockReturnValue(undefined);
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
const result = await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handleStreamableHttpSetup).toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
});
describe('webhook - default DELETE', () => {
it('should handle DELETE request', async () => {
const req = createMockRequest({ method: 'DELETE' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
});
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
const result = await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handleDeleteRequest).toHaveBeenCalledWith(req, resp);
expect(result).toEqual({ noWebhookResponse: true });
});
});
describe('authentication', () => {
it('should rethrow non-authorization errors', async () => {
const { validateWebhookAuthentication } = jest.requireMock(
'n8n-nodes-base/dist/nodes/Webhook/utils',
);
const genericError = new Error('Something went wrong');
validateWebhookAuthentication.mockRejectedValue(genericError);
const req = createMockRequest({ path: '/webhook' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
});
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
await expect(mcpTrigger.webhook(mockContext)).rejects.toThrow('Something went wrong');
});
it('should return 401 for authentication errors', async () => {
const { WebhookAuthorizationError } = jest.requireActual(
'n8n-nodes-base/dist/nodes/Webhook/error',
);
const { validateWebhookAuthentication } = jest.requireMock(
'n8n-nodes-base/dist/nodes/Webhook/utils',
);
validateWebhookAuthentication.mockRejectedValue(
new WebhookAuthorizationError(401, 'Unauthorized'),
);
const req = createMockRequest({ path: '/webhook' });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
});
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
const result = await mcpTrigger.webhook(mockContext);
expect(resp.writeHead).toHaveBeenCalledWith(401);
expect(resp.end).toHaveBeenCalledWith('Unauthorized');
expect(result).toEqual({ noWebhookResponse: true });
});
});
describe('list tools relay', () => {
it('should return list tools relay data when needed', async () => {
// Reset validateWebhookAuthentication to resolve (not reject)
const { validateWebhookAuthentication } = jest.requireMock(
'n8n-nodes-base/dist/nodes/Webhook/utils',
);
validateWebhookAuthentication.mockResolvedValue(undefined);
const req = createMockRequest({ method: 'POST', query: { sessionId: 'test-session' } });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
});
mockMcpServer.getSessionId.mockReturnValue('test-session');
mockMcpServer.handlePostMessage.mockResolvedValue({
wasToolCall: false,
toolCallInfo: undefined,
messageId: 'msg-456',
relaySessionId: 'relay-session-789',
needsListToolsRelay: true,
});
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
const result = await mcpTrigger.webhook(mockContext);
expect(result).toEqual({
noWebhookResponse: true,
workflowData: [
[
{
json: {
mcpListToolsRelay: {
sessionId: 'relay-session-789',
messageId: 'msg-456',
marker: 'mcp_list_tools_request',
},
},
},
],
],
});
});
});
});
@@ -0,0 +1,18 @@
export {
createMockTool,
createMockTools,
} from './mock-langchain';
export {
createMockRequest,
createMockRequestWithSessionId,
createMockRequestWithHeaderSessionId,
createMockResponse,
createValidToolCallMessage,
createListToolsMessage,
MCP_SESSION_ID_HEADER,
} from './mock-express';
export {
createMockServer,
createMockTransport,
} from './mock-mcp-sdk';
export { createMockLogger } from './mock-logger';
@@ -0,0 +1,134 @@
import type { Request } from 'express';
import type { CompressionResponse } from '../../transport/Transport';
/** MCP session ID header name */
export const MCP_SESSION_ID_HEADER = 'mcp-session-id';
/**
* Creates a mock Express Response with compression support
*/
export function createMockResponse(): jest.Mocked<CompressionResponse> {
const response = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
write: jest.fn().mockReturnThis(),
writeHead: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
getHeader: jest.fn(),
flush: jest.fn(),
on: jest.fn().mockReturnThis(),
once: jest.fn().mockReturnThis(),
removeListener: jest.fn().mockReturnThis(),
emit: jest.fn().mockReturnValue(true),
headersSent: false,
} as unknown as jest.Mocked<CompressionResponse>;
return response;
}
/**
* Creates a mock Express Request with specified properties
*/
export function createMockRequest(
options: {
sessionId?: string;
body?: unknown;
rawBody?: string;
headers?: Record<string, string>;
query?: Record<string, string>;
method?: string;
path?: string;
} = {},
): jest.Mocked<Request> & { rawBody: Buffer } {
const {
sessionId,
body = {},
rawBody = '{}',
headers = {},
query = {},
method = 'POST',
path = '/mcp',
} = options;
const finalQuery: Record<string, string> = { ...query };
const finalHeaders: Record<string, string> = { ...headers };
if (sessionId) {
if (!query.sessionId && !headers[MCP_SESSION_ID_HEADER]) {
finalQuery.sessionId = sessionId;
}
}
return {
body,
rawBody: Buffer.from(rawBody),
headers: finalHeaders,
query: finalQuery,
method,
params: {},
url: path,
path,
get: jest.fn((name: string) => finalHeaders[name.toLowerCase()]),
} as unknown as jest.Mocked<Request> & { rawBody: Buffer };
}
/**
* Creates a mock request with a specific session ID in query params
*/
export function createMockRequestWithSessionId(
sessionId: string,
rawBody: string,
): jest.Mocked<Request> & { rawBody: Buffer } {
return createMockRequest({
sessionId,
rawBody,
query: { sessionId },
});
}
/**
* Creates a valid JSONRPC tool call message body
*/
export function createValidToolCallMessage(
toolName: string,
args: Record<string, unknown>,
id: string | number = 1,
): string {
return JSON.stringify({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: {
name: toolName,
arguments: args,
},
});
}
/**
* Creates a valid JSONRPC list tools request message body
*/
export function createListToolsMessage(id: string | number = 1): string {
return JSON.stringify({
jsonrpc: '2.0',
id,
method: 'tools/list',
});
}
/**
* Creates a mock request with session ID in the mcp-session-id header
*/
export function createMockRequestWithHeaderSessionId(
sessionId: string,
rawBody: string = '{}',
): jest.Mocked<Request> & { rawBody: Buffer } {
return createMockRequest({
rawBody,
headers: { [MCP_SESSION_ID_HEADER]: sessionId },
query: {},
});
}
@@ -0,0 +1,45 @@
import type { Tool } from '@langchain/core/tools';
import { z } from 'zod';
/**
* Creates a mock Tool for testing
*/
export function createMockTool(
toolName: string,
opts: {
description?: string;
invokeReturn?: unknown;
invokeError?: Error;
metadata?: Record<string, unknown>;
} = {},
): jest.Mocked<Tool> {
const {
description = `Mock tool: ${toolName}`,
invokeReturn = { result: 'success' },
invokeError,
metadata,
} = opts;
const invoke = jest.fn().mockImplementation(async () => {
await Promise.resolve();
if (invokeError) {
throw invokeError;
}
return invokeReturn;
});
return {
name: toolName,
description,
schema: z.object({}),
invoke,
metadata,
} as unknown as jest.Mocked<Tool>;
}
/**
* Creates multiple mock tools
*/
export function createMockTools(toolNames: string[]): Array<jest.Mocked<Tool>> {
return toolNames.map((n) => createMockTool(n));
}
@@ -0,0 +1,17 @@
import type { Logger } from 'n8n-workflow';
/**
* Creates a mock Logger for testing
*/
export function createMockLogger(): jest.Mocked<Logger> {
return {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
trace: jest.fn(),
log: jest.fn(),
verbose: jest.fn(),
scoped: jest.fn().mockReturnThis(),
} as unknown as jest.Mocked<Logger>;
}
@@ -0,0 +1,33 @@
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { McpTransport, TransportType } from '../../transport/Transport';
/**
* Creates a mock MCP Server
*/
export function createMockServer(): jest.Mocked<Server> {
return {
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
setRequestHandler: jest.fn(),
onclose: undefined,
onerror: undefined,
} as unknown as jest.Mocked<Server>;
}
/**
* Creates a mock McpTransport
*/
export function createMockTransport(
sessionId: string,
transportType: TransportType = 'sse',
): jest.Mocked<McpTransport> {
return {
transportType,
sessionId,
send: jest.fn().mockResolvedValue(undefined),
handleRequest: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
onclose: undefined,
} as unknown as jest.Mocked<McpTransport>;
}
@@ -0,0 +1,12 @@
/**
* Jest setup file for mcp/core tests
* Cleans up mocks between tests to ensure test isolation
*/
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
@@ -0,0 +1,13 @@
import type { Tool } from '@langchain/core/tools';
import type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
export class DirectExecutionStrategy implements ExecutionStrategy {
async executeTool(
tool: Tool,
args: Record<string, unknown>,
_context: ExecutionContext,
): Promise<unknown> {
return await tool.invoke(args);
}
}
@@ -0,0 +1,33 @@
import type { Tool } from '@langchain/core/tools';
import { DirectExecutionStrategy } from './DirectExecutionStrategy';
import type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
import { QueuedExecutionStrategy } from './QueuedExecutionStrategy';
export class ExecutionCoordinator {
private strategy: ExecutionStrategy;
constructor(strategy?: ExecutionStrategy) {
this.strategy = strategy ?? new DirectExecutionStrategy();
}
async executeTool(
tool: Tool,
args: Record<string, unknown>,
context: ExecutionContext,
): Promise<unknown> {
return await this.strategy.executeTool(tool, args, context);
}
setStrategy(strategy: ExecutionStrategy): void {
this.strategy = strategy;
}
getStrategy(): ExecutionStrategy {
return this.strategy;
}
isQueueMode(): boolean {
return this.strategy instanceof QueuedExecutionStrategy;
}
}
@@ -0,0 +1,14 @@
import type { Tool } from '@langchain/core/tools';
export interface ExecutionContext {
sessionId: string;
messageId?: string;
}
export interface ExecutionStrategy {
executeTool(
tool: Tool,
args: Record<string, unknown>,
context: ExecutionContext,
): Promise<unknown>;
}
@@ -0,0 +1,81 @@
export interface PendingCall {
toolName: string;
arguments: Record<string, unknown>;
resolve: (result: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
export class PendingCallsManager {
private pendingCalls: Record<string, PendingCall> = {};
async waitForResult(
callId: string,
toolName: string,
args: Record<string, unknown>,
timeoutMs: number,
): Promise<unknown> {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pendingCalls[callId]) {
this.reject(callId, new Error('Worker tool execution timeout'));
}
}, timeoutMs);
this.pendingCalls[callId] = {
toolName,
arguments: args,
resolve,
reject,
timer,
};
});
}
resolve(callId: string, result: unknown): boolean {
const pending = this.pendingCalls[callId];
if (pending) {
clearTimeout(pending.timer);
pending.resolve(result);
delete this.pendingCalls[callId];
return true;
}
return false;
}
reject(callId: string, error: Error): boolean {
const pending = this.pendingCalls[callId];
if (pending) {
clearTimeout(pending.timer);
pending.reject(error);
delete this.pendingCalls[callId];
return true;
}
return false;
}
get(callId: string): PendingCall | undefined {
return this.pendingCalls[callId];
}
has(callId: string): boolean {
return callId in this.pendingCalls;
}
remove(callId: string): void {
delete this.pendingCalls[callId];
}
cleanupBySessionId(sessionId: string): void {
for (const callId of Object.keys(this.pendingCalls)) {
if (callId.startsWith(`${sessionId}_`)) {
const pending = this.pendingCalls[callId];
if (pending) {
clearTimeout(pending.timer);
pending.resolve(undefined);
}
delete this.pendingCalls[callId];
}
}
}
}
@@ -0,0 +1,35 @@
import type { Tool } from '@langchain/core/tools';
import type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
import type { PendingCallsManager } from './PendingCallsManager';
const DEFAULT_TIMEOUT_MS = 120000;
export class QueuedExecutionStrategy implements ExecutionStrategy {
constructor(
private pendingCalls: PendingCallsManager,
private timeoutMs: number = DEFAULT_TIMEOUT_MS,
) {}
async executeTool(
tool: Tool,
args: Record<string, unknown>,
context: ExecutionContext,
): Promise<unknown> {
const callId = `${context.sessionId}_${context.messageId ?? 'default'}`;
return await this.pendingCalls.waitForResult(callId, tool.name, args, this.timeoutMs);
}
resolveToolCall(callId: string, result: unknown): boolean {
return this.pendingCalls.resolve(callId, result);
}
rejectToolCall(callId: string, error: Error): boolean {
return this.pendingCalls.reject(callId, error);
}
getPendingCallsManager(): PendingCallsManager {
return this.pendingCalls;
}
}
@@ -0,0 +1,93 @@
import { createMockTool } from '../../__tests__/helpers';
import { DirectExecutionStrategy } from '../DirectExecutionStrategy';
describe('DirectExecutionStrategy', () => {
let strategy: DirectExecutionStrategy;
beforeEach(() => {
strategy = new DirectExecutionStrategy();
});
describe('executeTool', () => {
it('should invoke tool with provided arguments', async () => {
const tool = createMockTool('test-tool', { invokeReturn: { result: 'success' } });
const result = await strategy.executeTool(
tool,
{ input: 'test' },
{ sessionId: 'session-1' },
);
expect(tool.invoke).toHaveBeenCalledWith({ input: 'test' });
expect(result).toEqual({ result: 'success' });
});
it('should propagate tool errors', async () => {
const tool = createMockTool('failing-tool', {
invokeError: new Error('Tool failed'),
});
await expect(strategy.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
'Tool failed',
);
});
it('should pass empty arguments correctly', async () => {
const tool = createMockTool('no-args-tool', { invokeReturn: 'result' });
await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
expect(tool.invoke).toHaveBeenCalledWith({});
});
it('should handle complex arguments', async () => {
const tool = createMockTool('complex-tool', { invokeReturn: 'result' });
const complexArgs = {
nested: { deep: { value: 123 } },
array: [1, 2, 3],
text: 'hello',
};
await strategy.executeTool(tool, complexArgs, { sessionId: 'session-1' });
expect(tool.invoke).toHaveBeenCalledWith(complexArgs);
});
it('should handle various return types', async () => {
const stringTool = createMockTool('string-tool', { invokeReturn: 'string result' });
const numberTool = createMockTool('number-tool', { invokeReturn: 42 });
const arrayTool = createMockTool('array-tool', { invokeReturn: [1, 2, 3] });
const nullTool = createMockTool('null-tool', { invokeReturn: null });
const context = { sessionId: 'session-1' };
expect(await strategy.executeTool(stringTool, {}, context)).toBe('string result');
expect(await strategy.executeTool(numberTool, {}, context)).toBe(42);
expect(await strategy.executeTool(arrayTool, {}, context)).toEqual([1, 2, 3]);
expect(await strategy.executeTool(nullTool, {}, context)).toBeNull();
});
it('should handle execution context with messageId', async () => {
const tool = createMockTool('test-tool', { invokeReturn: 'result' });
const result = await strategy.executeTool(
tool,
{ arg: 'value' },
{ sessionId: 'session-1', messageId: 'msg-123' },
);
expect(result).toBe('result');
expect(tool.invoke).toHaveBeenCalledWith({ arg: 'value' });
});
it('should propagate TypeError from tool', async () => {
const tool = createMockTool('type-error-tool', {
invokeError: new TypeError('Invalid type'),
});
await expect(strategy.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
TypeError,
);
});
});
});
@@ -0,0 +1,134 @@
import { createMockTool } from '../../__tests__/helpers';
import { DirectExecutionStrategy } from '../DirectExecutionStrategy';
import { ExecutionCoordinator } from '../ExecutionCoordinator';
import { PendingCallsManager } from '../PendingCallsManager';
import { QueuedExecutionStrategy } from '../QueuedExecutionStrategy';
describe('ExecutionCoordinator', () => {
describe('default behavior', () => {
it('should use DirectExecutionStrategy by default', () => {
const coordinator = new ExecutionCoordinator();
expect(coordinator.getStrategy()).toBeInstanceOf(DirectExecutionStrategy);
});
it('should not be in queue mode by default', () => {
const coordinator = new ExecutionCoordinator();
expect(coordinator.isQueueMode()).toBe(false);
});
});
describe('constructor with custom strategy', () => {
it('should accept custom strategy in constructor', () => {
const customStrategy = new DirectExecutionStrategy();
const coordinator = new ExecutionCoordinator(customStrategy);
expect(coordinator.getStrategy()).toBe(customStrategy);
});
it('should accept QueuedExecutionStrategy in constructor', () => {
const queuedStrategy = new QueuedExecutionStrategy(new PendingCallsManager());
const coordinator = new ExecutionCoordinator(queuedStrategy);
expect(coordinator.getStrategy()).toBe(queuedStrategy);
expect(coordinator.isQueueMode()).toBe(true);
});
});
describe('strategy management', () => {
it('should allow setting custom strategy', () => {
const coordinator = new ExecutionCoordinator();
const queuedStrategy = new QueuedExecutionStrategy(new PendingCallsManager());
coordinator.setStrategy(queuedStrategy);
expect(coordinator.getStrategy()).toBe(queuedStrategy);
});
it('should report queue mode when using QueuedExecutionStrategy', () => {
const coordinator = new ExecutionCoordinator();
coordinator.setStrategy(new QueuedExecutionStrategy(new PendingCallsManager()));
expect(coordinator.isQueueMode()).toBe(true);
});
it('should report non-queue mode when switching back to DirectExecutionStrategy', () => {
const coordinator = new ExecutionCoordinator();
coordinator.setStrategy(new QueuedExecutionStrategy(new PendingCallsManager()));
coordinator.setStrategy(new DirectExecutionStrategy());
expect(coordinator.isQueueMode()).toBe(false);
});
});
describe('executeTool', () => {
it('should delegate to current strategy', async () => {
const coordinator = new ExecutionCoordinator();
const tool = createMockTool('test', { invokeReturn: 'result' });
const result = await coordinator.executeTool(tool, { input: 'test' }, { sessionId: 's1' });
expect(tool.invoke).toHaveBeenCalledWith({ input: 'test' });
expect(result).toBe('result');
});
it('should use DirectExecutionStrategy by default', async () => {
const coordinator = new ExecutionCoordinator();
const tool = createMockTool('test', { invokeReturn: { data: 'from-tool' } });
const result = await coordinator.executeTool(tool, {}, { sessionId: 'session-1' });
expect(result).toEqual({ data: 'from-tool' });
expect(tool.invoke).toHaveBeenCalled();
});
it('should propagate errors from strategy', async () => {
const coordinator = new ExecutionCoordinator();
const tool = createMockTool('failing-tool', {
invokeError: new Error('Strategy error'),
});
await expect(coordinator.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
'Strategy error',
);
});
it('should pass context to strategy', async () => {
const mockStrategy = {
executeTool: jest.fn().mockResolvedValue('result'),
};
const coordinator = new ExecutionCoordinator(mockStrategy);
const tool = createMockTool('test', { invokeReturn: 'result' });
const context = { sessionId: 'session-1', messageId: 'msg-123' };
await coordinator.executeTool(tool, { arg: 'value' }, context);
expect(mockStrategy.executeTool).toHaveBeenCalledWith(tool, { arg: 'value' }, context);
});
});
describe('isQueueMode detection', () => {
it('should detect QueuedExecutionStrategy by constructor name', () => {
const coordinator = new ExecutionCoordinator();
const queuedStrategy = new QueuedExecutionStrategy(new PendingCallsManager());
coordinator.setStrategy(queuedStrategy);
expect(coordinator.isQueueMode()).toBe(true);
});
it('should return false for DirectExecutionStrategy', () => {
const coordinator = new ExecutionCoordinator(new DirectExecutionStrategy());
expect(coordinator.isQueueMode()).toBe(false);
});
it('should return false for anonymous strategy implementations', () => {
const coordinator = new ExecutionCoordinator();
const anonymousStrategy = {
executeTool: jest.fn().mockResolvedValue('result'),
};
coordinator.setStrategy(anonymousStrategy);
expect(coordinator.isQueueMode()).toBe(false);
});
});
});
@@ -0,0 +1,251 @@
import { PendingCallsManager } from '../PendingCallsManager';
describe('PendingCallsManager', () => {
let manager: PendingCallsManager;
beforeEach(() => {
manager = new PendingCallsManager();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('waitForResult', () => {
it('should resolve when result is provided via resolve()', async () => {
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
manager.resolve('call-1', { success: true });
await expect(resultPromise).resolves.toEqual({ success: true });
});
it('should reject on timeout with meaningful error', async () => {
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 1000);
jest.advanceTimersByTime(1001);
await expect(resultPromise).rejects.toThrow('Worker tool execution timeout');
});
it('should track pending call while waiting', async () => {
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
expect(manager.has('call-1')).toBe(true);
// Clean up to avoid unhandled rejection
manager.resolve('call-1', undefined);
await promise;
});
it('should remove call from pending after resolution', async () => {
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
manager.resolve('call-1', 'result');
await resultPromise;
expect(manager.has('call-1')).toBe(false);
});
it('should remove call from pending after timeout', async () => {
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 1000);
jest.advanceTimersByTime(1001);
await expect(resultPromise).rejects.toThrow();
expect(manager.has('call-1')).toBe(false);
});
it('should store tool name and arguments', async () => {
const args = { city: 'London' };
const promise = manager.waitForResult('call-1', 'get_weather', args, 5000);
const pendingCall = manager.get('call-1');
expect(pendingCall).toBeDefined();
expect(pendingCall?.toolName).toBe('get_weather');
expect(pendingCall?.arguments).toEqual(args);
// Clean up
manager.resolve('call-1', undefined);
await promise;
});
it('should handle multiple concurrent calls', async () => {
const promise1 = manager.waitForResult('call-1', 'tool-1', {}, 5000);
const promise2 = manager.waitForResult('call-2', 'tool-2', {}, 5000);
const promise3 = manager.waitForResult('call-3', 'tool-3', {}, 5000);
expect(manager.has('call-1')).toBe(true);
expect(manager.has('call-2')).toBe(true);
expect(manager.has('call-3')).toBe(true);
manager.resolve('call-1', 'result-1');
manager.resolve('call-2', 'result-2');
manager.resolve('call-3', 'result-3');
await expect(promise1).resolves.toBe('result-1');
await expect(promise2).resolves.toBe('result-2');
await expect(promise3).resolves.toBe('result-3');
});
});
describe('resolve', () => {
it('should return true when call exists', async () => {
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
expect(manager.resolve('call-1', 'result')).toBe(true);
await promise;
});
it('should return false when call does not exist', () => {
expect(manager.resolve('non-existent', 'result')).toBe(false);
});
it('should handle resolving with various result types', async () => {
const promise1 = manager.waitForResult('call-1', 'tool', {}, 5000);
const promise2 = manager.waitForResult('call-2', 'tool', {}, 5000);
const promise3 = manager.waitForResult('call-3', 'tool', {}, 5000);
const promise4 = manager.waitForResult('call-4', 'tool', {}, 5000);
manager.resolve('call-1', undefined);
manager.resolve('call-2', null);
manager.resolve('call-3', { complex: { nested: 'data' } });
manager.resolve('call-4', [1, 2, 3]);
await expect(promise1).resolves.toBeUndefined();
await expect(promise2).resolves.toBeNull();
await expect(promise3).resolves.toEqual({ complex: { nested: 'data' } });
await expect(promise4).resolves.toEqual([1, 2, 3]);
});
it('should only resolve once (subsequent resolves return false)', async () => {
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
expect(manager.resolve('call-1', 'first')).toBe(true);
expect(manager.resolve('call-1', 'second')).toBe(false);
await promise;
});
});
describe('reject', () => {
it('should reject pending call with error', async () => {
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
const error = new Error('Tool execution failed');
manager.reject('call-1', error);
await expect(resultPromise).rejects.toThrow('Tool execution failed');
});
it('should return true when call exists', async () => {
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
expect(manager.reject('call-1', new Error('test'))).toBe(true);
// Must await/catch the rejection to avoid unhandled rejection
await expect(promise).rejects.toThrow('test');
});
it('should return false when call does not exist', () => {
expect(manager.reject('non-existent', new Error('test'))).toBe(false);
});
it('should remove call from pending after rejection', async () => {
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
manager.reject('call-1', new Error('test'));
await expect(resultPromise).rejects.toThrow();
expect(manager.has('call-1')).toBe(false);
});
});
describe('cleanupBySessionId', () => {
it('should resolve all calls matching session prefix with underscore', async () => {
const promise1 = manager.waitForResult('session-1_msg-1', 'tool', {}, 5000);
const promise2 = manager.waitForResult('session-1_msg-2', 'tool', {}, 5000);
const promise3 = manager.waitForResult('session-2_msg-1', 'tool', {}, 5000);
manager.cleanupBySessionId('session-1');
await expect(promise1).resolves.toBeUndefined();
await expect(promise2).resolves.toBeUndefined();
expect(manager.has('session-1_msg-1')).toBe(false);
expect(manager.has('session-1_msg-2')).toBe(false);
expect(manager.has('session-2_msg-1')).toBe(true);
manager.resolve('session-2_msg-1', 'result');
await promise3;
});
it('should not cleanup calls without underscore separator', async () => {
const promise = manager.waitForResult('session-1', 'tool', {}, 5000);
manager.cleanupBySessionId('session-1');
expect(manager.has('session-1')).toBe(true);
manager.resolve('session-1', 'result');
await promise;
});
it('should handle cleanup when no matching sessions', async () => {
const promise = manager.waitForResult('other-session_msg-1', 'tool', {}, 5000);
expect(() => manager.cleanupBySessionId('session-1')).not.toThrow();
expect(manager.has('other-session_msg-1')).toBe(true);
manager.resolve('other-session_msg-1', 'result');
await promise;
});
it('should handle cleanup when no pending calls', () => {
expect(() => manager.cleanupBySessionId('session-1')).not.toThrow();
});
});
describe('get and has', () => {
it('should return call info for existing call', async () => {
const promise = manager.waitForResult('call-1', 'test-tool', { arg: 'value' }, 5000);
const info = manager.get('call-1');
expect(info).toBeDefined();
expect(info).toHaveProperty('resolve');
expect(info).toHaveProperty('reject');
expect(info).toHaveProperty('toolName', 'test-tool');
expect(info).toHaveProperty('arguments', { arg: 'value' });
manager.resolve('call-1', 'result');
await promise;
});
it('should return undefined for non-existent call', () => {
expect(manager.get('non-existent')).toBeUndefined();
});
it('should return true for existing call', async () => {
const promise = manager.waitForResult('call-1', 'tool', {}, 5000);
expect(manager.has('call-1')).toBe(true);
manager.resolve('call-1', 'result');
await promise;
});
it('should return false for non-existent call', () => {
expect(manager.has('non-existent')).toBe(false);
});
});
describe('remove', () => {
it('should remove pending call without resolving or rejecting', async () => {
const promise = manager.waitForResult('call-1', 'tool', {}, 5000);
manager.remove('call-1');
expect(manager.has('call-1')).toBe(false);
// Note: The promise will never resolve/reject when removed this way
// This is expected behavior - remove() is for cleanup when we don't care about the result
// We need to avoid the unhandled rejection by advancing time to trigger timeout
// but the promise was removed so it won't reject. We handle this by catching any potential rejection
await Promise.race([promise.catch(() => {}), Promise.resolve()]);
});
it('should handle removing non-existent call', () => {
expect(() => manager.remove('non-existent')).not.toThrow();
});
});
});
@@ -0,0 +1,166 @@
import { createMockTool } from '../../__tests__/helpers';
import type { PendingCallsManager } from '../PendingCallsManager';
import { QueuedExecutionStrategy } from '../QueuedExecutionStrategy';
describe('QueuedExecutionStrategy', () => {
let strategy: QueuedExecutionStrategy;
let mockPendingCalls: jest.Mocked<PendingCallsManager>;
beforeEach(() => {
mockPendingCalls = {
waitForResult: jest.fn(),
resolve: jest.fn(),
reject: jest.fn(),
get: jest.fn(),
has: jest.fn(),
remove: jest.fn(),
cleanupBySessionId: jest.fn(),
} as unknown as jest.Mocked<PendingCallsManager>;
strategy = new QueuedExecutionStrategy(mockPendingCalls);
});
describe('executeTool', () => {
it('should create callId from sessionId and messageId', async () => {
mockPendingCalls.waitForResult.mockResolvedValue('result');
const tool = createMockTool('test-tool');
await strategy.executeTool(
tool,
{ arg: 'value' },
{ sessionId: 'session-1', messageId: 'msg-1' },
);
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
'session-1_msg-1',
'test-tool',
{ arg: 'value' },
expect.any(Number),
);
});
it('should create callId from sessionId with default suffix when no messageId', async () => {
mockPendingCalls.waitForResult.mockResolvedValue('result');
const tool = createMockTool('test-tool');
await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
'session-1_default',
'test-tool',
{},
expect.any(Number),
);
});
it('should return result from pending calls manager', async () => {
mockPendingCalls.waitForResult.mockResolvedValue({ data: 'from-worker' });
const tool = createMockTool('test-tool');
const result = await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
expect(result).toEqual({ data: 'from-worker' });
});
it('should pass tool name and arguments to waitForResult', async () => {
mockPendingCalls.waitForResult.mockResolvedValue('result');
const tool = createMockTool('get_weather');
const args = { city: 'London', units: 'metric' };
await strategy.executeTool(tool, args, { sessionId: 'session-1' });
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
expect.any(String),
'get_weather',
args,
expect.any(Number),
);
});
it('should use default timeout', async () => {
mockPendingCalls.waitForResult.mockResolvedValue('result');
const tool = createMockTool('test-tool');
await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Object),
120000, // DEFAULT_TIMEOUT_MS
);
});
it('should propagate errors from pending calls manager', async () => {
mockPendingCalls.waitForResult.mockRejectedValue(new Error('Timeout'));
const tool = createMockTool('test-tool');
await expect(strategy.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
'Timeout',
);
});
});
describe('with custom timeout', () => {
it('should use custom timeout when provided', async () => {
const customTimeout = 60000;
const customStrategy = new QueuedExecutionStrategy(mockPendingCalls, customTimeout);
mockPendingCalls.waitForResult.mockResolvedValue('result');
const tool = createMockTool('test-tool');
await customStrategy.executeTool(tool, {}, { sessionId: 'session-1' });
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Object),
customTimeout,
);
});
});
describe('resolveToolCall', () => {
it('should delegate to pendingCalls.resolve', () => {
mockPendingCalls.resolve.mockReturnValue(true);
const result = strategy.resolveToolCall('call-1', { success: true });
expect(mockPendingCalls.resolve).toHaveBeenCalledWith('call-1', { success: true });
expect(result).toBe(true);
});
it('should return false when call does not exist', () => {
mockPendingCalls.resolve.mockReturnValue(false);
const result = strategy.resolveToolCall('non-existent', 'result');
expect(result).toBe(false);
});
});
describe('rejectToolCall', () => {
it('should delegate to pendingCalls.reject', () => {
mockPendingCalls.reject.mockReturnValue(true);
const error = new Error('test');
const result = strategy.rejectToolCall('call-1', error);
expect(mockPendingCalls.reject).toHaveBeenCalledWith('call-1', error);
expect(result).toBe(true);
});
it('should return false when call does not exist', () => {
mockPendingCalls.reject.mockReturnValue(false);
const result = strategy.rejectToolCall('non-existent', new Error('test'));
expect(result).toBe(false);
});
});
describe('getPendingCallsManager', () => {
it('should return the pending calls manager', () => {
expect(strategy.getPendingCallsManager()).toBe(mockPendingCalls);
});
});
});
@@ -0,0 +1,5 @@
export type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
export * from './DirectExecutionStrategy';
export * from './QueuedExecutionStrategy';
export * from './PendingCallsManager';
export * from './ExecutionCoordinator';
@@ -0,0 +1,4 @@
export { McpServer } from './McpServer';
export type { SessionStore } from './session';
export { RedisSessionStore, type RedisPublisher } from './session';
export { QueuedExecutionStrategy } from './execution';
@@ -0,0 +1,35 @@
import type { McpToolResult } from './types';
export class MessageFormatter {
static formatToolResult(result: unknown): McpToolResult {
if (typeof result === 'object' && result !== null) {
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
}
if (typeof result === 'string') {
return { content: [{ type: 'text', text: result }] };
}
if (result === null || result === undefined) {
return { content: [{ type: 'text', text: String(result) }] };
}
if (typeof result === 'number' || typeof result === 'boolean' || typeof result === 'bigint') {
return { content: [{ type: 'text', text: result.toString() }] };
}
// Remaining types: symbol, function - convert to string representation
return {
content: [
{ type: 'text', text: String(result as symbol | ((...args: unknown[]) => unknown)) },
],
};
}
static formatError(error: Error): McpToolResult {
const errorDetails = [`${error.name}: ${error.message}`];
if (error.stack) {
errorDetails.push(error.stack);
}
return {
isError: true,
content: [{ type: 'text', text: errorDetails.join('\n') }],
};
}
}
@@ -0,0 +1,76 @@
import {
JSONRPCMessageSchema,
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { McpToolCallInfo } from './types';
export class MessageParser {
static parse(body: string): JSONRPCMessage | undefined {
try {
const message: unknown = JSON.parse(body);
return JSONRPCMessageSchema.parse(message);
} catch {
return undefined;
}
}
static isToolCall(body: string): boolean {
const message = this.parse(body);
if (!message) return false;
return (
'method' in message &&
'id' in message &&
message.method === CallToolRequestSchema.shape.method.value
);
}
static isListToolsRequest(body: string): boolean {
const message = this.parse(body);
if (!message) return false;
return (
'method' in message &&
'id' in message &&
message.method === ListToolsRequestSchema.shape.method.value
);
}
static getRequestId(message: unknown): string | undefined {
try {
const parsed = JSONRPCMessageSchema.parse(message);
return 'id' in parsed ? String(parsed.id) : undefined;
} catch {
return undefined;
}
}
static extractToolCallInfo(body: string): McpToolCallInfo | undefined {
const message = this.parse(body);
if (!message) return undefined;
if (
'method' in message &&
'params' in message &&
message.method === CallToolRequestSchema.shape.method.value
) {
const params = message.params;
if (
typeof params === 'object' &&
params !== null &&
'name' in params &&
typeof params.name === 'string' &&
'arguments' in params &&
typeof params.arguments === 'object' &&
params.arguments !== null
) {
return {
toolName: params.name,
arguments: params.arguments as Record<string, unknown>,
};
}
}
return undefined;
}
}
@@ -0,0 +1,180 @@
import { MessageFormatter } from '../MessageFormatter';
describe('MessageFormatter', () => {
describe('formatToolResult', () => {
it('should format object result as JSON string in content array', () => {
const result = { data: 'value', count: 42 };
expect(MessageFormatter.formatToolResult(result)).toEqual({
content: [{ type: 'text', text: '{"data":"value","count":42}' }],
});
});
it('should format string result directly without double-quoting', () => {
expect(MessageFormatter.formatToolResult('hello world')).toEqual({
content: [{ type: 'text', text: 'hello world' }],
});
});
it('should format number as string', () => {
expect(MessageFormatter.formatToolResult(42)).toEqual({
content: [{ type: 'text', text: '42' }],
});
});
it('should format zero as string', () => {
expect(MessageFormatter.formatToolResult(0)).toEqual({
content: [{ type: 'text', text: '0' }],
});
});
it('should format negative number as string', () => {
expect(MessageFormatter.formatToolResult(-123)).toEqual({
content: [{ type: 'text', text: '-123' }],
});
});
it('should format float as string', () => {
expect(MessageFormatter.formatToolResult(3.14159)).toEqual({
content: [{ type: 'text', text: '3.14159' }],
});
});
it('should format boolean true as string', () => {
expect(MessageFormatter.formatToolResult(true)).toEqual({
content: [{ type: 'text', text: 'true' }],
});
});
it('should format boolean false as string', () => {
expect(MessageFormatter.formatToolResult(false)).toEqual({
content: [{ type: 'text', text: 'false' }],
});
});
it('should format null as JSON string "null"', () => {
expect(MessageFormatter.formatToolResult(null)).toEqual({
content: [{ type: 'text', text: 'null' }],
});
});
it('should format undefined as string "undefined"', () => {
expect(MessageFormatter.formatToolResult(undefined)).toEqual({
content: [{ type: 'text', text: 'undefined' }],
});
});
it('should handle nested objects correctly', () => {
const result = { outer: { inner: { deep: 'value' } } };
const formatted = MessageFormatter.formatToolResult(result);
expect(formatted.content[0].text).toBe(JSON.stringify(result));
});
it('should handle arrays', () => {
const result = [1, 2, 3];
expect(MessageFormatter.formatToolResult(result)).toEqual({
content: [{ type: 'text', text: '[1,2,3]' }],
});
});
it('should handle empty array', () => {
expect(MessageFormatter.formatToolResult([])).toEqual({
content: [{ type: 'text', text: '[]' }],
});
});
it('should handle empty object', () => {
expect(MessageFormatter.formatToolResult({})).toEqual({
content: [{ type: 'text', text: '{}' }],
});
});
it('should handle array of objects', () => {
const result = [{ id: 1 }, { id: 2 }];
const formatted = MessageFormatter.formatToolResult(result);
expect(formatted.content[0].text).toBe(JSON.stringify(result));
});
it('should handle object with special characters in values', () => {
const result = { message: 'Hello "world" with\nnewline' };
const formatted = MessageFormatter.formatToolResult(result);
expect(formatted.content[0].text).toBe(JSON.stringify(result));
});
it('should handle empty string result', () => {
expect(MessageFormatter.formatToolResult('')).toEqual({
content: [{ type: 'text', text: '' }],
});
});
it('should handle string with unicode characters', () => {
expect(MessageFormatter.formatToolResult('Hello')).toEqual({
content: [{ type: 'text', text: 'Hello' }],
});
});
});
describe('formatError', () => {
it('should format error with isError flag set to true', () => {
const error = new Error('Something went wrong');
const result = MessageFormatter.formatError(error);
expect(result.isError).toBe(true);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toContain('Error: Something went wrong');
});
it('should handle error with empty message', () => {
const error = new Error('');
const result = MessageFormatter.formatError(error);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Error: ');
});
it('should handle error with special characters in message', () => {
const error = new Error('Failed: "invalid" <value>');
const result = MessageFormatter.formatError(error);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Error: Failed: "invalid" <value>');
});
it('should handle error with newlines in message', () => {
const error = new Error('Line 1\nLine 2');
const result = MessageFormatter.formatError(error);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Error: Line 1\nLine 2');
});
it('should handle TypeError', () => {
const error = new TypeError('Cannot read property of undefined');
const result = MessageFormatter.formatError(error);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('TypeError: Cannot read property of undefined');
});
it('should handle custom error subclass', () => {
class CustomError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomError';
}
}
const error = new CustomError('Custom error message');
const result = MessageFormatter.formatError(error);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('CustomError: Custom error message');
});
it('should include stack trace when available', () => {
const error = new Error('Test error');
const result = MessageFormatter.formatError(error);
expect(result.content[0].text).toContain('Error: Test error');
expect(result.content[0].text).toContain('at ');
});
});
});
@@ -0,0 +1,258 @@
import { MessageParser } from '../MessageParser';
describe('MessageParser', () => {
describe('parse', () => {
it('should parse valid JSONRPC 2.0 message with all fields', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"test","params":{}}';
const result = MessageParser.parse(body);
expect(result).toEqual({
jsonrpc: '2.0',
id: 1,
method: 'test',
params: {},
});
});
it('should parse JSONRPC response message', () => {
const body = '{"jsonrpc":"2.0","id":1,"result":{"data":"test"}}';
const result = MessageParser.parse(body);
expect(result).toEqual({
jsonrpc: '2.0',
id: 1,
result: { data: 'test' },
});
});
it('should parse JSONRPC notification (no id)', () => {
const body = '{"jsonrpc":"2.0","method":"notifications/test"}';
const result = MessageParser.parse(body);
expect(result).toEqual({
jsonrpc: '2.0',
method: 'notifications/test',
});
});
it('should return undefined for empty string', () => {
expect(MessageParser.parse('')).toBeUndefined();
});
it('should return undefined for malformed JSON (missing closing brace)', () => {
expect(MessageParser.parse('{"jsonrpc":"2.0"')).toBeUndefined();
});
it('should return undefined for valid JSON but invalid JSONRPC (missing jsonrpc field)', () => {
expect(MessageParser.parse('{"id":1,"method":"test"}')).toBeUndefined();
});
it('should return undefined for JSONRPC 1.0 messages', () => {
expect(MessageParser.parse('{"jsonrpc":"1.0","id":1}')).toBeUndefined();
});
it('should return undefined for plain JSON that is not JSONRPC', () => {
expect(MessageParser.parse('{"name":"test","value":123}')).toBeUndefined();
});
it('should return undefined for array input', () => {
expect(MessageParser.parse('[1,2,3]')).toBeUndefined();
});
it('should return undefined for primitive JSON values', () => {
expect(MessageParser.parse('null')).toBeUndefined();
expect(MessageParser.parse('123')).toBeUndefined();
expect(MessageParser.parse('"string"')).toBeUndefined();
});
});
describe('isToolCall', () => {
it('should return true for valid tools/call request', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test","arguments":{}}}';
expect(MessageParser.isToolCall(body)).toBe(true);
});
it('should return true for tools/call with string id', () => {
const body =
'{"jsonrpc":"2.0","id":"abc-123","method":"tools/call","params":{"name":"test","arguments":{}}}';
expect(MessageParser.isToolCall(body)).toBe(true);
});
it('should return false for tools/list request (different method)', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}';
expect(MessageParser.isToolCall(body)).toBe(false);
});
it('should return false for notification (no id field)', () => {
const body = '{"jsonrpc":"2.0","method":"tools/call","params":{}}';
expect(MessageParser.isToolCall(body)).toBe(false);
});
it('should return false for response (no method field)', () => {
const body = '{"jsonrpc":"2.0","id":1,"result":{}}';
expect(MessageParser.isToolCall(body)).toBe(false);
});
it('should return false for empty body', () => {
expect(MessageParser.isToolCall('')).toBe(false);
});
it('should return false for malformed JSON', () => {
expect(MessageParser.isToolCall('{"jsonrpc":"2.0"')).toBe(false);
});
it('should return false for other MCP methods', () => {
expect(MessageParser.isToolCall('{"jsonrpc":"2.0","id":1,"method":"initialize"}')).toBe(
false,
);
expect(MessageParser.isToolCall('{"jsonrpc":"2.0","id":1,"method":"resources/list"}')).toBe(
false,
);
});
});
describe('isListToolsRequest', () => {
it('should return true for valid tools/list request', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}';
expect(MessageParser.isListToolsRequest(body)).toBe(true);
});
it('should return true for tools/list with params', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}';
expect(MessageParser.isListToolsRequest(body)).toBe(true);
});
it('should return false for tools/call request', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test","arguments":{}}}';
expect(MessageParser.isListToolsRequest(body)).toBe(false);
});
it('should return false for notification (no id)', () => {
const body = '{"jsonrpc":"2.0","method":"tools/list"}';
expect(MessageParser.isListToolsRequest(body)).toBe(false);
});
it('should return false for empty body', () => {
expect(MessageParser.isListToolsRequest('')).toBe(false);
});
});
describe('getRequestId', () => {
it('should extract numeric id and return as string', () => {
const message = { jsonrpc: '2.0', id: 42, method: 'test' };
expect(MessageParser.getRequestId(message)).toBe('42');
});
it('should extract string id as-is', () => {
const message = { jsonrpc: '2.0', id: 'abc-123', method: 'test' };
expect(MessageParser.getRequestId(message)).toBe('abc-123');
});
it('should extract id from response message', () => {
const message = { jsonrpc: '2.0', id: 99, result: {} };
expect(MessageParser.getRequestId(message)).toBe('99');
});
it('should return undefined for notification (no id)', () => {
const message = { jsonrpc: '2.0', method: 'test' };
expect(MessageParser.getRequestId(message)).toBeUndefined();
});
it('should return undefined for invalid message structure', () => {
expect(MessageParser.getRequestId({ invalid: true })).toBeUndefined();
});
it('should return undefined for null input', () => {
expect(MessageParser.getRequestId(null)).toBeUndefined();
});
it('should return undefined for string input', () => {
expect(MessageParser.getRequestId('string')).toBeUndefined();
});
it('should return undefined for undefined input', () => {
expect(MessageParser.getRequestId(undefined)).toBeUndefined();
});
it('should return undefined for array input', () => {
expect(MessageParser.getRequestId([1, 2, 3])).toBeUndefined();
});
});
describe('extractToolCallInfo', () => {
it('should extract tool name and arguments from valid call', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"London"}}}';
const result = MessageParser.extractToolCallInfo(body);
expect(result).toEqual({
toolName: 'get_weather',
arguments: { city: 'London' },
});
});
it('should handle empty arguments object', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"no_args_tool","arguments":{}}}';
const result = MessageParser.extractToolCallInfo(body);
expect(result).toEqual({
toolName: 'no_args_tool',
arguments: {},
});
});
it('should handle complex nested arguments', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"complex_tool","arguments":{"nested":{"deep":{"value":123}},"array":[1,2,3]}}}';
const result = MessageParser.extractToolCallInfo(body);
expect(result).toEqual({
toolName: 'complex_tool',
arguments: { nested: { deep: { value: 123 } }, array: [1, 2, 3] },
});
});
it('should return undefined when params.name is missing', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments":{}}}';
expect(MessageParser.extractToolCallInfo(body)).toBeUndefined();
});
it('should return undefined when params.arguments is missing', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test"}}';
expect(MessageParser.extractToolCallInfo(body)).toBeUndefined();
});
it('should return undefined when params.arguments is null', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test","arguments":null}}';
expect(MessageParser.extractToolCallInfo(body)).toBeUndefined();
});
it('should return undefined when params.arguments is not an object', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test","arguments":"string"}}';
expect(MessageParser.extractToolCallInfo(body)).toBeUndefined();
});
it('should return undefined when params.name is not a string', () => {
const body =
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":123,"arguments":{}}}';
expect(MessageParser.extractToolCallInfo(body)).toBeUndefined();
});
it('should return undefined for non-tool-call messages', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}';
expect(MessageParser.extractToolCallInfo(body)).toBeUndefined();
});
it('should return undefined when params is missing', () => {
const body = '{"jsonrpc":"2.0","id":1,"method":"tools/call"}';
expect(MessageParser.extractToolCallInfo(body)).toBeUndefined();
});
it('should return undefined for empty body', () => {
expect(MessageParser.extractToolCallInfo('')).toBeUndefined();
});
it('should return undefined for malformed JSON', () => {
expect(MessageParser.extractToolCallInfo('{"invalid')).toBeUndefined();
});
});
});
@@ -0,0 +1,3 @@
export * from './types';
export * from './MessageParser';
export * from './MessageFormatter';
@@ -0,0 +1,17 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
export interface McpToolCallInfo {
toolName: string;
arguments: Record<string, unknown>;
sourceNodeName?: string;
}
export interface McpToolResult {
[key: string]: unknown;
content: Array<{ type: string; text: string }>;
isError?: boolean;
}
export type { JSONRPCMessage };
export const MCP_LIST_TOOLS_REQUEST_MARKER = { _listToolsRequest: true } as const;
@@ -0,0 +1,37 @@
import type { Tool } from '@langchain/core/tools';
import type { SessionStore } from './SessionStore';
export class InMemorySessionStore implements SessionStore {
private sessions = new Set<string>();
private tools: Record<string, Tool[]> = {};
// eslint-disable-next-line @typescript-eslint/require-await
async register(sessionId: string): Promise<void> {
this.sessions.add(sessionId);
}
// eslint-disable-next-line @typescript-eslint/require-await
async validate(sessionId: string): Promise<boolean> {
return this.sessions.has(sessionId);
}
// eslint-disable-next-line @typescript-eslint/require-await
async unregister(sessionId: string): Promise<void> {
this.sessions.delete(sessionId);
delete this.tools[sessionId];
}
getTools(sessionId: string): Tool[] | undefined {
return this.tools[sessionId];
}
setTools(sessionId: string, tools: Tool[]): void {
this.tools[sessionId] = tools;
}
clearTools(sessionId: string): void {
delete this.tools[sessionId];
}
}
@@ -0,0 +1,45 @@
import type { Tool } from '@langchain/core/tools';
import type { SessionStore } from './SessionStore';
export interface RedisPublisher {
set(key: string, value: string, ttl: number): Promise<void>;
get(key: string): Promise<string | null>;
clear(key: string): Promise<void>;
}
export class RedisSessionStore implements SessionStore {
private tools: Record<string, Tool[]> = {};
constructor(
private publisher: RedisPublisher,
private getSessionKey: (sessionId: string) => string,
private ttl: number,
) {}
async register(sessionId: string): Promise<void> {
await this.publisher.set(this.getSessionKey(sessionId), '1', this.ttl);
}
async validate(sessionId: string): Promise<boolean> {
const result = await this.publisher.get(this.getSessionKey(sessionId));
return result !== null;
}
async unregister(sessionId: string): Promise<void> {
await this.publisher.clear(this.getSessionKey(sessionId));
delete this.tools[sessionId];
}
getTools(sessionId: string): Tool[] | undefined {
return this.tools[sessionId];
}
setTools(sessionId: string, tools: Tool[]): void {
this.tools[sessionId] = tools;
}
clearTools(sessionId: string): void {
delete this.tools[sessionId];
}
}
@@ -0,0 +1,68 @@
import type { Tool } from '@langchain/core/tools';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { SessionStore } from './SessionStore';
import type { McpTransport } from '../transport/Transport';
export interface SessionInfo {
sessionId: string;
server: Server;
transport: McpTransport;
}
export class SessionManager {
private sessions: Record<string, SessionInfo> = {};
constructor(private store: SessionStore) {}
async registerSession(
sessionId: string,
server: Server,
transport: McpTransport,
tools?: Tool[],
): Promise<void> {
if (!sessionId) return;
await this.store.register(sessionId);
this.sessions[sessionId] = { sessionId, server, transport };
if (tools) {
this.store.setTools(sessionId, tools);
}
}
async destroySession(sessionId: string): Promise<void> {
await this.store.unregister(sessionId);
delete this.sessions[sessionId];
}
getSession(sessionId: string): SessionInfo | undefined {
return this.sessions[sessionId];
}
getTransport(sessionId: string): McpTransport | undefined {
return this.sessions[sessionId]?.transport;
}
getServer(sessionId: string): Server | undefined {
return this.sessions[sessionId]?.server;
}
async isSessionValid(sessionId: string): Promise<boolean> {
return await this.store.validate(sessionId);
}
getTools(sessionId: string): Tool[] | undefined {
return this.store.getTools(sessionId);
}
setTools(sessionId: string, tools: Tool[]): void {
this.store.setTools(sessionId, tools);
}
setStore(store: SessionStore): void {
this.store = store;
}
getStore(): SessionStore {
return this.store;
}
}
@@ -0,0 +1,10 @@
import type { Tool } from '@langchain/core/tools';
export interface SessionStore {
register(sessionId: string): Promise<void>;
validate(sessionId: string): Promise<boolean>;
unregister(sessionId: string): Promise<void>;
getTools(sessionId: string): Tool[] | undefined;
setTools(sessionId: string, tools: Tool[]): void;
clearTools(sessionId: string): void;
}
@@ -0,0 +1,132 @@
import { createMockTool } from '../../__tests__/helpers';
import { InMemorySessionStore } from '../InMemorySessionStore';
describe('InMemorySessionStore', () => {
let store: InMemorySessionStore;
beforeEach(() => {
store = new InMemorySessionStore();
});
describe('session lifecycle', () => {
it('should register and validate a session', async () => {
await store.register('session-1');
expect(await store.validate('session-1')).toBe(true);
});
it('should return false for unregistered session', async () => {
expect(await store.validate('non-existent')).toBe(false);
});
it('should handle registering same session twice (idempotent)', async () => {
await store.register('session-1');
await store.register('session-1');
expect(await store.validate('session-1')).toBe(true);
});
it('should unregister session and invalidate it', async () => {
await store.register('session-1');
await store.unregister('session-1');
expect(await store.validate('session-1')).toBe(false);
});
it('should handle unregistering non-existent session gracefully', async () => {
await expect(store.unregister('non-existent')).resolves.not.toThrow();
});
it('should handle multiple sessions independently', async () => {
await store.register('session-1');
await store.register('session-2');
await store.register('session-3');
expect(await store.validate('session-1')).toBe(true);
expect(await store.validate('session-2')).toBe(true);
expect(await store.validate('session-3')).toBe(true);
await store.unregister('session-2');
expect(await store.validate('session-1')).toBe(true);
expect(await store.validate('session-2')).toBe(false);
expect(await store.validate('session-3')).toBe(true);
});
it('should handle empty string as session id', async () => {
await store.register('');
expect(await store.validate('')).toBe(true);
});
});
describe('tools management', () => {
const mockTools = [createMockTool('tool-1'), createMockTool('tool-2')];
it('should set and get tools for a session', () => {
store.setTools('session-1', mockTools);
expect(store.getTools('session-1')).toEqual(mockTools);
});
it('should return undefined for session without tools', () => {
expect(store.getTools('session-without-tools')).toBeUndefined();
});
it('should clear tools for a session', () => {
store.setTools('session-1', mockTools);
store.clearTools('session-1');
expect(store.getTools('session-1')).toBeUndefined();
});
it('should clear tools when session is unregistered', async () => {
await store.register('session-1');
store.setTools('session-1', mockTools);
await store.unregister('session-1');
expect(store.getTools('session-1')).toBeUndefined();
});
it('should handle clearing tools for non-existent session', () => {
expect(() => store.clearTools('non-existent')).not.toThrow();
});
it('should isolate tools between sessions', () => {
const tools1 = [createMockTool('tool-a')];
const tools2 = [createMockTool('tool-b')];
store.setTools('session-1', tools1);
store.setTools('session-2', tools2);
expect(store.getTools('session-1')).toEqual(tools1);
expect(store.getTools('session-2')).toEqual(tools2);
});
it('should overwrite tools when set again', () => {
const tools1 = [createMockTool('tool-a')];
const tools2 = [createMockTool('tool-b'), createMockTool('tool-c')];
store.setTools('session-1', tools1);
store.setTools('session-1', tools2);
expect(store.getTools('session-1')).toEqual(tools2);
});
it('should handle setting empty tools array', () => {
store.setTools('session-1', []);
expect(store.getTools('session-1')).toEqual([]);
});
it('should not affect tools when clearing non-existent session', () => {
const tools = [createMockTool('tool-a')];
store.setTools('session-1', tools);
store.clearTools('session-2');
expect(store.getTools('session-1')).toEqual(tools);
});
});
describe('combined session and tools operations', () => {
it('should allow setting tools before registering session', () => {
const tools = [createMockTool('tool-1')];
store.setTools('session-1', tools);
expect(store.getTools('session-1')).toEqual(tools);
});
it('should not delete tools when registering session with existing tools', async () => {
const tools = [createMockTool('tool-1')];
store.setTools('session-1', tools);
await store.register('session-1');
expect(store.getTools('session-1')).toEqual(tools);
});
});
});
@@ -0,0 +1,107 @@
import type { Tool } from '@langchain/core/tools';
import { mock } from 'jest-mock-extended';
import { RedisSessionStore, type RedisPublisher } from '../RedisSessionStore';
describe('RedisSessionStore', () => {
let store: RedisSessionStore;
let mockPublisher: jest.Mocked<RedisPublisher>;
const getSessionKey = (sessionId: string) => `mcp-session:${sessionId}`;
const ttl = 3600;
beforeEach(() => {
mockPublisher = {
set: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(null),
clear: jest.fn().mockResolvedValue(undefined),
};
store = new RedisSessionStore(mockPublisher, getSessionKey, ttl);
});
describe('register', () => {
it('should store session with TTL in Redis', async () => {
await store.register('session-123');
expect(mockPublisher.set).toHaveBeenCalledWith('mcp-session:session-123', '1', ttl);
});
});
describe('validate', () => {
it('should return true when session exists in Redis', async () => {
mockPublisher.get.mockResolvedValue('1');
const result = await store.validate('session-123');
expect(mockPublisher.get).toHaveBeenCalledWith('mcp-session:session-123');
expect(result).toBe(true);
});
it('should return false when session does not exist', async () => {
mockPublisher.get.mockResolvedValue(null);
const result = await store.validate('non-existent');
expect(result).toBe(false);
});
});
describe('unregister', () => {
it('should clear session from Redis and remove tools', async () => {
const mockTool = mock<Tool>();
store.setTools('session-123', [mockTool]);
await store.unregister('session-123');
expect(mockPublisher.clear).toHaveBeenCalledWith('mcp-session:session-123');
expect(store.getTools('session-123')).toBeUndefined();
});
});
describe('tool management', () => {
it('should store and retrieve tools', () => {
const mockTool1 = mock<Tool>();
const mockTool2 = mock<Tool>();
store.setTools('session-1', [mockTool1]);
store.setTools('session-2', [mockTool2]);
expect(store.getTools('session-1')).toEqual([mockTool1]);
expect(store.getTools('session-2')).toEqual([mockTool2]);
});
it('should return undefined for unknown session', () => {
expect(store.getTools('unknown')).toBeUndefined();
});
it('should clear tools for a session', () => {
const mockTool = mock<Tool>();
store.setTools('session-123', [mockTool]);
store.clearTools('session-123');
expect(store.getTools('session-123')).toBeUndefined();
});
});
describe('custom key function', () => {
it('should use custom key function for Redis operations', async () => {
const customKeyFn = (sessionId: string) => `custom:prefix:${sessionId}`;
const customStore = new RedisSessionStore(mockPublisher, customKeyFn, ttl);
await customStore.register('test-id');
expect(mockPublisher.set).toHaveBeenCalledWith('custom:prefix:test-id', '1', ttl);
});
});
describe('custom TTL', () => {
it('should use custom TTL for register', async () => {
const customTtl = 86400;
const customStore = new RedisSessionStore(mockPublisher, getSessionKey, customTtl);
await customStore.register('test-id');
expect(mockPublisher.set).toHaveBeenCalledWith(expect.any(String), '1', customTtl);
});
});
});
@@ -0,0 +1,186 @@
import { createMockServer, createMockTransport, createMockTool } from '../../__tests__/helpers';
import { SessionManager } from '../SessionManager';
import type { SessionStore } from '../SessionStore';
describe('SessionManager', () => {
let manager: SessionManager;
let mockStore: jest.Mocked<SessionStore>;
beforeEach(() => {
mockStore = {
register: jest.fn().mockResolvedValue(undefined),
validate: jest.fn().mockResolvedValue(true),
unregister: jest.fn().mockResolvedValue(undefined),
getTools: jest.fn(),
setTools: jest.fn(),
clearTools: jest.fn(),
};
manager = new SessionManager(mockStore);
});
describe('registerSession', () => {
it('should register session with server and transport', async () => {
const server = createMockServer();
const transport = createMockTransport('session-1');
await manager.registerSession('session-1', server, transport);
expect(mockStore.register).toHaveBeenCalledWith('session-1');
expect(manager.getSession('session-1')).toEqual({
sessionId: 'session-1',
server,
transport,
});
});
it('should store tools when provided', async () => {
const tools = [createMockTool('tool-1')];
await manager.registerSession(
'session-1',
createMockServer(),
createMockTransport('session-1'),
tools,
);
expect(mockStore.setTools).toHaveBeenCalledWith('session-1', tools);
});
it('should not call setTools when no tools provided', async () => {
await manager.registerSession(
'session-1',
createMockServer(),
createMockTransport('session-1'),
);
expect(mockStore.setTools).not.toHaveBeenCalled();
});
it('should not register if sessionId is empty', async () => {
await manager.registerSession('', createMockServer(), createMockTransport(''));
expect(mockStore.register).not.toHaveBeenCalled();
expect(manager.getSession('')).toBeUndefined();
});
it('should overwrite existing session when registering same sessionId', async () => {
const server1 = createMockServer();
const transport1 = createMockTransport('session-1');
const server2 = createMockServer();
const transport2 = createMockTransport('session-1');
await manager.registerSession('session-1', server1, transport1);
await manager.registerSession('session-1', server2, transport2);
const session = manager.getSession('session-1');
expect(session?.server).toBe(server2);
expect(session?.transport).toBe(transport2);
});
});
describe('destroySession', () => {
it('should remove session and delegate to store', async () => {
await manager.registerSession(
'session-1',
createMockServer(),
createMockTransport('session-1'),
);
await manager.destroySession('session-1');
expect(mockStore.unregister).toHaveBeenCalledWith('session-1');
expect(manager.getSession('session-1')).toBeUndefined();
});
it('should handle destroying non-existent session', async () => {
await expect(manager.destroySession('non-existent')).resolves.not.toThrow();
expect(mockStore.unregister).toHaveBeenCalledWith('non-existent');
});
});
describe('getSession', () => {
it('should return session info for registered session', async () => {
const server = createMockServer();
const transport = createMockTransport('session-1');
await manager.registerSession('session-1', server, transport);
const session = manager.getSession('session-1');
expect(session).toEqual({
sessionId: 'session-1',
server,
transport,
});
});
it('should return undefined for unregistered session', () => {
expect(manager.getSession('non-existent')).toBeUndefined();
});
});
describe('getTransport', () => {
it('should return transport for registered session', async () => {
const transport = createMockTransport('session-1');
await manager.registerSession('session-1', createMockServer(), transport);
expect(manager.getTransport('session-1')).toBe(transport);
});
it('should return undefined for unregistered session', () => {
expect(manager.getTransport('non-existent')).toBeUndefined();
});
});
describe('getServer', () => {
it('should return server for registered session', async () => {
const server = createMockServer();
await manager.registerSession('session-1', server, createMockTransport('session-1'));
expect(manager.getServer('session-1')).toBe(server);
});
it('should return undefined for unregistered session', () => {
expect(manager.getServer('non-existent')).toBeUndefined();
});
});
describe('isSessionValid', () => {
it('should delegate to store.validate and return true', async () => {
mockStore.validate.mockResolvedValue(true);
expect(await manager.isSessionValid('session-1')).toBe(true);
expect(mockStore.validate).toHaveBeenCalledWith('session-1');
});
it('should delegate to store.validate and return false', async () => {
mockStore.validate.mockResolvedValue(false);
expect(await manager.isSessionValid('session-1')).toBe(false);
});
});
describe('tools management', () => {
it('should delegate getTools to store', () => {
const tools = [createMockTool('tool-1')];
mockStore.getTools.mockReturnValue(tools);
expect(manager.getTools('session-1')).toBe(tools);
expect(mockStore.getTools).toHaveBeenCalledWith('session-1');
});
it('should delegate setTools to store', () => {
const tools = [createMockTool('tool-1')];
manager.setTools('session-1', tools);
expect(mockStore.setTools).toHaveBeenCalledWith('session-1', tools);
});
});
describe('store management', () => {
it('should allow swapping session store', () => {
const newStore = { ...mockStore } as jest.Mocked<SessionStore>;
manager.setStore(newStore);
expect(manager.getStore()).toBe(newStore);
});
it('should return current store', () => {
expect(manager.getStore()).toBe(mockStore);
});
});
});
@@ -0,0 +1,4 @@
export type { SessionStore } from './SessionStore';
export * from './InMemorySessionStore';
export * from './RedisSessionStore';
export * from './SessionManager';
@@ -0,0 +1,30 @@
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { IncomingMessage, ServerResponse } from 'http';
import type { CompressionResponse, McpTransport, TransportType } from './Transport';
export class SSETransport extends SSEServerTransport implements McpTransport {
readonly transportType: TransportType = 'sse';
constructor(
endpoint: string,
private response: CompressionResponse,
) {
super(endpoint, response);
}
async send(message: JSONRPCMessage): Promise<void> {
await super.send(message);
this.response.flush?.();
}
async handleRequest(
req: IncomingMessage,
resp: ServerResponse,
body: IncomingMessage,
): Promise<void> {
await super.handlePostMessage(req, resp, body);
this.response.flush?.();
}
}
@@ -0,0 +1,62 @@
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { StreamableHTTPServerTransportOptions } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { IncomingMessage, ServerResponse } from 'http';
import type { CompressionResponse, McpTransport, TransportType } from './Transport';
interface WebStandardTransportInternal {
_initialized: boolean;
sessionId: string;
}
interface StreamableHTTPTransportInternal {
_webStandardTransport?: WebStandardTransportInternal;
}
function getWebStandardTransport(transport: unknown): WebStandardTransportInternal | undefined {
if (typeof transport === 'object' && transport !== null && '_webStandardTransport' in transport) {
const internal = (transport as StreamableHTTPTransportInternal)._webStandardTransport;
if (typeof internal === 'object' && internal !== null) {
return internal;
}
}
return undefined;
}
export class StreamableHttpTransport extends StreamableHTTPServerTransport implements McpTransport {
readonly transportType: TransportType = 'streamableHttp';
private response: CompressionResponse;
constructor(options: StreamableHTTPServerTransportOptions, response: CompressionResponse) {
super(options);
this.response = response;
}
markAsInitialized(sessionId: string): void {
const webStandardTransport = getWebStandardTransport(this);
if (!webStandardTransport) {
throw new Error(
'Failed to initialize StreamableHttpTransport: internal transport state not found. ' +
'This may indicate an incompatible SDK version.',
);
}
webStandardTransport._initialized = true;
webStandardTransport.sessionId = sessionId;
}
async send(message: JSONRPCMessage): Promise<void> {
await super.send(message);
this.response.flush?.();
}
async handleRequest(
req: IncomingMessage,
resp: ServerResponse,
parsedBody?: unknown,
): Promise<void> {
await super.handleRequest(req, resp, parsedBody);
this.response.flush?.();
}
}
@@ -0,0 +1,20 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { Response } from 'express';
import type { IncomingMessage, ServerResponse } from 'http';
export type CompressionResponse = Response & {
flush?: () => void;
};
export type TransportType = 'sse' | 'streamableHttp';
export interface McpTransport {
readonly transportType: TransportType;
readonly sessionId: string | undefined;
send(message: JSONRPCMessage): Promise<void>;
handleRequest(req: IncomingMessage, resp: ServerResponse, body?: unknown): Promise<void>;
close?(): Promise<void>;
onclose?: () => void | Promise<void>;
}
@@ -0,0 +1,41 @@
import { randomUUID } from 'crypto';
import { SSETransport } from './SSETransport';
import { StreamableHttpTransport } from './StreamableHttpTransport';
import type { CompressionResponse } from './Transport';
export interface StreamableHttpOptions {
sessionIdGenerator?: () => string;
onsessioninitialized?: (sessionId: string) => Promise<void>;
}
export class TransportFactory {
createSSE(postUrl: string, response: CompressionResponse): SSETransport {
return new SSETransport(postUrl, response);
}
createStreamableHttp(
options: StreamableHttpOptions,
response: CompressionResponse,
): StreamableHttpTransport {
return new StreamableHttpTransport(
{
sessionIdGenerator: options.sessionIdGenerator ?? (() => randomUUID()),
onsessioninitialized: options.onsessioninitialized,
},
response,
);
}
recreateStreamableHttp(
sessionId: string,
response: CompressionResponse,
): StreamableHttpTransport {
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => sessionId },
response,
);
transport.markAsInitialized(sessionId);
return transport;
}
}
@@ -0,0 +1,55 @@
import { createMockResponse } from '../../__tests__/helpers';
import { SSETransport } from '../SSETransport';
describe('SSETransport', () => {
describe('constructor', () => {
it('should set transportType to sse', () => {
const response = createMockResponse();
const transport = new SSETransport('/messages', response);
expect(transport.transportType).toBe('sse');
});
it('should pass endpoint to parent SSEServerTransport', () => {
const response = createMockResponse();
const endpoint = '/api/messages';
const transport = new SSETransport(endpoint, response);
// Verify the transport is properly initialized with endpoint
// The endpoint is used by SSEServerTransport for message routing
expect(transport.transportType).toBe('sse');
expect(typeof transport.send).toBe('function');
});
});
describe('send', () => {
it('should have flush available on response', () => {
const response = createMockResponse();
const transport = new SSETransport('/messages', response);
// Verify flush is available - actual send requires full SSE connection setup
expect(transport.transportType).toBe('sse');
expect(typeof response.flush).toBe('function');
});
});
describe('handleRequest', () => {
it('should have handleRequest method', () => {
const response = createMockResponse();
const transport = new SSETransport('/messages', response);
expect(typeof transport.handleRequest).toBe('function');
});
});
describe('McpTransport interface', () => {
it('should implement McpTransport interface', () => {
const response = createMockResponse();
const transport = new SSETransport('/messages', response);
expect(transport.transportType).toBe('sse');
expect(typeof transport.send).toBe('function');
expect(typeof transport.handleRequest).toBe('function');
});
});
});
@@ -0,0 +1,117 @@
import { createMockResponse } from '../../__tests__/helpers';
import { StreamableHttpTransport } from '../StreamableHttpTransport';
describe('StreamableHttpTransport', () => {
describe('constructor', () => {
it('should set transportType to streamableHttp', () => {
const response = createMockResponse();
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => 'test-id' },
response,
);
expect(transport.transportType).toBe('streamableHttp');
});
it('should accept sessionIdGenerator option and use it for transport type', () => {
const response = createMockResponse();
const generator = jest.fn().mockReturnValue('custom-session');
const transport = new StreamableHttpTransport({ sessionIdGenerator: generator }, response);
expect(transport.transportType).toBe('streamableHttp');
expect(typeof transport.send).toBe('function');
});
it('should accept onsessioninitialized callback option', () => {
const response = createMockResponse();
const onInit = jest.fn();
const transport = new StreamableHttpTransport(
{
sessionIdGenerator: () => 'test-id',
onsessioninitialized: onInit,
},
response,
);
// Verify transport is properly configured with callback option
expect(transport.transportType).toBe('streamableHttp');
expect(typeof transport.handleRequest).toBe('function');
});
});
describe('markAsInitialized', () => {
it('should set sessionId when marked as initialized', () => {
const response = createMockResponse();
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => 'test-id' },
response,
);
transport.markAsInitialized('specific-session-id');
expect(transport.sessionId).toBe('specific-session-id');
});
it('should allow setting different sessionId than generator would produce', () => {
const response = createMockResponse();
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => 'generator-id' },
response,
);
transport.markAsInitialized('override-id');
expect(transport.sessionId).toBe('override-id');
});
});
describe('send', () => {
it('should have send method', () => {
const response = createMockResponse();
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => 'test-id' },
response,
);
expect(typeof transport.send).toBe('function');
});
});
describe('handleRequest', () => {
it('should have handleRequest method', () => {
const response = createMockResponse();
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => 'test-id' },
response,
);
expect(typeof transport.handleRequest).toBe('function');
});
});
describe('McpTransport interface', () => {
it('should implement McpTransport interface', () => {
const response = createMockResponse();
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => 'test-id' },
response,
);
expect(transport.transportType).toBe('streamableHttp');
expect(typeof transport.send).toBe('function');
expect(typeof transport.handleRequest).toBe('function');
});
it('should have onclose property', () => {
const response = createMockResponse();
const transport = new StreamableHttpTransport(
{ sessionIdGenerator: () => 'test-id' },
response,
);
expect('onclose' in transport).toBe(true);
});
});
});
@@ -0,0 +1,122 @@
import { createMockResponse } from '../../__tests__/helpers';
import { SSETransport } from '../SSETransport';
import { StreamableHttpTransport } from '../StreamableHttpTransport';
import { TransportFactory } from '../TransportFactory';
describe('TransportFactory', () => {
let factory: TransportFactory;
beforeEach(() => {
factory = new TransportFactory();
});
describe('createSSE', () => {
it('should create SSETransport with endpoint and response', () => {
const response = createMockResponse();
const transport = factory.createSSE('/messages', response);
expect(transport).toBeInstanceOf(SSETransport);
expect(transport.transportType).toBe('sse');
});
it('should create SSETransport with different endpoints', () => {
const response1 = createMockResponse();
const response2 = createMockResponse();
const transport1 = factory.createSSE('/api/mcp/messages', response1);
const transport2 = factory.createSSE('/custom/endpoint', response2);
expect(transport1).toBeInstanceOf(SSETransport);
expect(transport2).toBeInstanceOf(SSETransport);
});
});
describe('createStreamableHttp', () => {
it('should create StreamableHttpTransport', () => {
const response = createMockResponse();
const transport = factory.createStreamableHttp({}, response);
expect(transport).toBeInstanceOf(StreamableHttpTransport);
expect(transport.transportType).toBe('streamableHttp');
});
it('should pass sessionIdGenerator option', () => {
const response = createMockResponse();
const customGenerator = jest.fn().mockReturnValue('custom-session-id');
const transport = factory.createStreamableHttp(
{ sessionIdGenerator: customGenerator },
response,
);
expect(transport).toBeInstanceOf(StreamableHttpTransport);
});
it('should pass onsessioninitialized callback', () => {
const response = createMockResponse();
const onSessionInit = jest.fn();
const transport = factory.createStreamableHttp(
{ onsessioninitialized: onSessionInit },
response,
);
expect(transport).toBeInstanceOf(StreamableHttpTransport);
});
it('should use default sessionIdGenerator when not provided', () => {
const response = createMockResponse();
const transport = factory.createStreamableHttp({}, response);
expect(transport).toBeInstanceOf(StreamableHttpTransport);
});
});
describe('recreateStreamableHttp', () => {
it('should create transport with fixed sessionId', () => {
const response = createMockResponse();
const transport = factory.recreateStreamableHttp('existing-session-123', response);
expect(transport).toBeInstanceOf(StreamableHttpTransport);
expect(transport.sessionId).toBe('existing-session-123');
});
it('should mark transport as initialized', () => {
const response = createMockResponse();
const transport = factory.recreateStreamableHttp('session-id', response);
// The sessionId being set indicates markAsInitialized was called
expect(transport.sessionId).toBe('session-id');
});
it('should create unique transports for different sessions', () => {
const response1 = createMockResponse();
const response2 = createMockResponse();
const transport1 = factory.recreateStreamableHttp('session-1', response1);
const transport2 = factory.recreateStreamableHttp('session-2', response2);
expect(transport1).not.toBe(transport2);
expect(transport1.sessionId).toBe('session-1');
expect(transport2.sessionId).toBe('session-2');
});
});
describe('factory independence', () => {
it('should create independent transports from same factory', () => {
const response1 = createMockResponse();
const response2 = createMockResponse();
const sseTransport = factory.createSSE('/messages', response1);
const httpTransport = factory.createStreamableHttp({}, response2);
expect(sseTransport).not.toBe(httpTransport);
expect(sseTransport.transportType).toBe('sse');
expect(httpTransport.transportType).toBe('streamableHttp');
});
});
});
@@ -0,0 +1,4 @@
export type { CompressionResponse, TransportType, McpTransport } from './Transport';
export * from './SSETransport';
export * from './StreamableHttpTransport';
export * from './TransportFactory';
@@ -0,0 +1,7 @@
<svg width="180" height="180" viewBox="0 0 195 195" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="#fff" stroke-width="12" stroke-linecap="round">
<path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"/>
<path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"/>
<path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 735 B

@@ -0,0 +1,7 @@
<svg width="180" height="180" viewBox="0 0 195 195" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="#000" stroke-width="12" stroke-linecap="round">
<path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"/>
<path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"/>
<path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 735 B

@@ -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}`,
};
}