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
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:
@@ -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];
|
||||
}
|
||||
}
|
||||
+856
@@ -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<sessionId, SessionInfo>"]
|
||||
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: {},
|
||||
});
|
||||
}
|
||||
+45
@@ -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();
|
||||
});
|
||||
+13
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+93
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+134
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+251
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+166
@@ -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;
|
||||
}
|
||||
}
|
||||
+180
@@ -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 ');
|
||||
});
|
||||
});
|
||||
});
|
||||
+258
@@ -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;
|
||||
}
|
||||
+132
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+107
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+186
@@ -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?.();
|
||||
}
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+117
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+122
@@ -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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user