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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user