mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(MCP Client Tool Node): Prefix MCP tool names with server name (#28094)
This commit is contained in:
committed by
GitHub
parent
a23fc0a867
commit
f5402dd7f7
@@ -16,7 +16,7 @@ import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import { getTools } from './loadOptions';
|
||||
import type { McpToolIncludeMode } from './types';
|
||||
import { createCallTool, getSelectedTools, mcpToolToDynamicTool } from './utils';
|
||||
import { buildMcpToolName, createCallTool, getSelectedTools, mcpToolToDynamicTool } from './utils';
|
||||
import { credentials, transportSelect } from '../shared/descriptions';
|
||||
import type { McpAuthenticationOption, McpServerTransport } from '../shared/types';
|
||||
import {
|
||||
@@ -379,10 +379,11 @@ export class McpClientTool implements INodeType {
|
||||
);
|
||||
}
|
||||
|
||||
const tools = mcpTools.map((tool) =>
|
||||
logWrapper(
|
||||
const tools = mcpTools.map((tool) => {
|
||||
const prefixedName = buildMcpToolName(node.name, tool.name);
|
||||
return logWrapper(
|
||||
mcpToolToDynamicTool(
|
||||
tool,
|
||||
{ ...tool, name: prefixedName },
|
||||
createCallTool(
|
||||
tool.name,
|
||||
client,
|
||||
@@ -398,8 +399,8 @@ export class McpClientTool implements INodeType {
|
||||
),
|
||||
),
|
||||
this,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.debug(`McpClientTool: Connected to MCP Server with ${tools.length} tools`);
|
||||
|
||||
@@ -432,17 +433,17 @@ export class McpClientTool implements INodeType {
|
||||
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,
|
||||
});
|
||||
}
|
||||
// Check for tool name in item.json.tool (for toolkit execution from agent)
|
||||
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) {
|
||||
const toolName = item.json.tool;
|
||||
for (const tool of mcpTools) {
|
||||
const prefixedName = buildMcpToolName(node.name, tool.name);
|
||||
if (toolName === prefixedName) {
|
||||
// Extract the tool name from arguments before passing to MCP
|
||||
const { tool: _, ...toolArguments } = item.json;
|
||||
const schema: JSONSchema7 = tool.inputSchema;
|
||||
|
||||
+177
-30
@@ -16,6 +16,7 @@ import { proxyFetch } from '@n8n/ai-utilities';
|
||||
|
||||
import { getTools } from '../loadOptions';
|
||||
import { McpClientTool } from '../McpClientTool.node';
|
||||
import { buildMcpToolName } from '../utils';
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/sse.js');
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
@@ -91,7 +92,7 @@ describe('McpClientTool', () => {
|
||||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
}),
|
||||
@@ -130,6 +131,7 @@ describe('McpClientTool', () => {
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'MCP Client',
|
||||
}),
|
||||
),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
@@ -150,7 +152,7 @@ describe('McpClientTool', () => {
|
||||
|
||||
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('MyTool2');
|
||||
expect(tools[0].name).toBe(buildMcpToolName('MCP Client', 'MyTool2'));
|
||||
});
|
||||
|
||||
it('should support selecting tools to exclude', async () => {
|
||||
@@ -175,6 +177,7 @@ describe('McpClientTool', () => {
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'MCP Client',
|
||||
}),
|
||||
),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
@@ -195,7 +198,7 @@ describe('McpClientTool', () => {
|
||||
|
||||
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('MyTool1');
|
||||
expect(tools[0].name).toBe(buildMcpToolName('MCP Client', 'MyTool1'));
|
||||
});
|
||||
|
||||
it('should support header auth', async () => {
|
||||
@@ -214,7 +217,7 @@ describe('McpClientTool', () => {
|
||||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
include: 'except',
|
||||
@@ -265,7 +268,7 @@ describe('McpClientTool', () => {
|
||||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
getNodeParameter: jest.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
include: 'except',
|
||||
@@ -320,6 +323,7 @@ describe('McpClientTool', () => {
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'MCP Client',
|
||||
}),
|
||||
),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
@@ -357,6 +361,7 @@ describe('McpClientTool', () => {
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'MCP Client',
|
||||
}),
|
||||
),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
@@ -396,7 +401,7 @@ describe('McpClientTool', () => {
|
||||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
getExecutionCancelSignal: jest.fn(() => abortController.signal),
|
||||
@@ -432,7 +437,7 @@ describe('McpClientTool', () => {
|
||||
await expect(
|
||||
new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
getExecutionCancelSignal: jest.fn(() => abortController.signal),
|
||||
@@ -463,7 +468,7 @@ describe('McpClientTool', () => {
|
||||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
getExecutionCancelSignal: jest.fn(() => abortController.signal),
|
||||
@@ -500,7 +505,7 @@ describe('McpClientTool', () => {
|
||||
const errorLogger = jest.fn();
|
||||
|
||||
const supplyDataFunctions = mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: jest.fn(), error: errorLogger },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
getExecutionCancelSignal: jest.fn(() => abortController.signal),
|
||||
@@ -538,7 +543,7 @@ describe('McpClientTool', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1 });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, name: 'MCP Client' });
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
@@ -590,13 +595,13 @@ describe('McpClientTool', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
{
|
||||
json: {
|
||||
tool: 'get_weather',
|
||||
tool: buildMcpToolName('MCP Client', 'get_weather'),
|
||||
location: 'Berlin',
|
||||
},
|
||||
},
|
||||
@@ -658,13 +663,13 @@ describe('McpClientTool', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
{
|
||||
json: {
|
||||
tool: 'get_weather',
|
||||
tool: buildMcpToolName('MCP Client', 'get_weather'),
|
||||
location: 'Berlin',
|
||||
foo: 'bar',
|
||||
sessionId: '123',
|
||||
@@ -723,13 +728,13 @@ describe('McpClientTool', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
{
|
||||
json: {
|
||||
tool: 'flexible_tool',
|
||||
tool: buildMcpToolName('MCP Client', 'flexible_tool'),
|
||||
foo: 'bar',
|
||||
extra: 'data',
|
||||
},
|
||||
@@ -775,7 +780,7 @@ describe('McpClientTool', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
@@ -808,7 +813,7 @@ describe('McpClientTool', () => {
|
||||
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 mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
@@ -857,19 +862,19 @@ describe('McpClientTool', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
{
|
||||
json: {
|
||||
tool: 'get_weather',
|
||||
tool: buildMcpToolName('MCP Client', 'get_weather'),
|
||||
location: 'Berlin',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
tool: 'get_weather',
|
||||
tool: buildMcpToolName('MCP Client', 'get_weather'),
|
||||
location: 'London',
|
||||
},
|
||||
},
|
||||
@@ -929,13 +934,13 @@ describe('McpClientTool', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
{
|
||||
json: {
|
||||
tool: 'get_weather',
|
||||
tool: buildMcpToolName('MCP Client', 'get_weather'),
|
||||
location: 'Berlin',
|
||||
},
|
||||
},
|
||||
@@ -959,6 +964,64 @@ describe('McpClientTool', () => {
|
||||
expect(result[0][0].json.response).toEqual([{ type: 'text', text: 'Weather is sunny' }]);
|
||||
});
|
||||
|
||||
it('should call MCP server with original unprefixed tool name', 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',
|
||||
name: 'GitHub MCP',
|
||||
});
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [
|
||||
{
|
||||
json: {
|
||||
tool: buildMcpToolName('GitHub MCP', '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 new McpClientTool().execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(Client.prototype.callTool).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'get_weather',
|
||||
arguments: { location: 'Berlin' },
|
||||
},
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass abort signal to client.callTool in execute()', async () => {
|
||||
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
|
||||
const callToolSpy = jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
|
||||
@@ -976,14 +1039,14 @@ describe('McpClientTool', () => {
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1.2, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1.2, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(abortController.signal);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{
|
||||
json: {
|
||||
tool: 'get_weather',
|
||||
tool: buildMcpToolName('MCP Client', 'get_weather'),
|
||||
location: 'Berlin',
|
||||
},
|
||||
},
|
||||
@@ -1026,7 +1089,7 @@ describe('McpClientTool', () => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [{ json: { tool: 'get_weather', location: 'Berlin' } }]),
|
||||
@@ -1067,10 +1130,17 @@ describe('McpClientTool', () => {
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
getNode: jest.fn(() => mockNode),
|
||||
getInputData: jest.fn(() => [{ json: { tool: 'get_weather', location: 'Berlin' } }]),
|
||||
getInputData: jest.fn(() => [
|
||||
{
|
||||
json: {
|
||||
tool: buildMcpToolName('MCP Client', 'get_weather'),
|
||||
location: 'Berlin',
|
||||
},
|
||||
},
|
||||
]),
|
||||
getNodeParameter: jest.fn((key) => {
|
||||
const params: Record<string, any> = {
|
||||
include: 'all',
|
||||
@@ -1120,7 +1190,7 @@ describe('McpClientTool', () => {
|
||||
});
|
||||
|
||||
const mockSupplyDataFunctions = mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
getExecutionCancelSignal: jest.fn(() => abortController.signal),
|
||||
@@ -1155,7 +1225,7 @@ describe('McpClientTool', () => {
|
||||
});
|
||||
|
||||
const mockSupplyDataFunctions = mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
addOutputData: jest.fn(),
|
||||
@@ -1168,4 +1238,81 @@ describe('McpClientTool', () => {
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('supplyData tool name prefixing', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should prefix tool names with sanitized node name', async () => {
|
||||
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
|
||||
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
name: 'list_tools',
|
||||
description: 'Lists tools',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'GitHub MCP',
|
||||
}),
|
||||
),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
|
||||
expect(tools[0].name).toBe('GitHub_MCP_list_tools');
|
||||
});
|
||||
|
||||
it('should call MCP server with original tool name via supplyData tools', async () => {
|
||||
jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(Client.prototype, 'callTool')
|
||||
.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] });
|
||||
jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
name: 'get_weather',
|
||||
description: 'Gets weather',
|
||||
inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
name: 'Weather MCP',
|
||||
}),
|
||||
),
|
||||
logger: { debug: jest.fn(), error: jest.fn() },
|
||||
addInputData: jest.fn(() => ({ index: 0 })),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
const tools = (supplyDataResult.response as StructuredToolkit).getTools();
|
||||
expect(tools[0].name).toBe('Weather_MCP_get_weather');
|
||||
|
||||
await tools[0].invoke({ location: 'Berlin' });
|
||||
|
||||
expect(Client.prototype.callTool).toHaveBeenCalledWith(
|
||||
{ name: 'get_weather', arguments: { location: 'Berlin' } },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { buildMcpToolName } from '../utils';
|
||||
|
||||
jest.mock('@utils/schemaParsing', () => ({
|
||||
convertJsonSchemaToZod: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('buildMcpToolName', () => {
|
||||
it('should prefix tool name with sanitized server name', () => {
|
||||
expect(buildMcpToolName('MCP Client', 'get_weather')).toBe('MCP_Client_get_weather');
|
||||
});
|
||||
|
||||
it('should sanitize special characters in server name', () => {
|
||||
expect(buildMcpToolName('GitHub MCP (v2)', 'list_repos')).toBe('GitHub_MCP__v2__list_repos');
|
||||
});
|
||||
|
||||
it('should handle server name with only special characters', () => {
|
||||
expect(buildMcpToolName('---', 'tool')).toBe('____tool');
|
||||
});
|
||||
|
||||
it('should handle numeric server name', () => {
|
||||
expect(buildMcpToolName('123', 'tool')).toBe('123_tool');
|
||||
});
|
||||
|
||||
it('should handle empty server name', () => {
|
||||
expect(buildMcpToolName('', 'tool')).toBe('_tool');
|
||||
});
|
||||
|
||||
it('should not truncate when total length is exactly 64 characters', () => {
|
||||
const serverName = 'S';
|
||||
const toolName = 'a'.repeat(62); // S_ + 62 = 64
|
||||
const result = buildMcpToolName(serverName, toolName);
|
||||
expect(result).toBe(`S_${'a'.repeat(62)}`);
|
||||
expect(result).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('should truncate prefix when total length exceeds 64 characters', () => {
|
||||
const toolName = 'a'.repeat(50);
|
||||
const result = buildMcpToolName('MyServer', toolName);
|
||||
expect(result.length).toBeLessThanOrEqual(64);
|
||||
expect(result).toContain(toolName);
|
||||
});
|
||||
|
||||
it('should truncate at 65 characters', () => {
|
||||
const serverName = 'SS';
|
||||
const toolName = 'a'.repeat(62); // SS_ + 62 = 65, exceeds limit
|
||||
const result = buildMcpToolName(serverName, toolName);
|
||||
expect(result).toBe(`S_${'a'.repeat(62)}`);
|
||||
expect(result).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('should return original tool name when it alone exceeds 64 characters', () => {
|
||||
const longToolName = 'a'.repeat(65);
|
||||
expect(buildMcpToolName('Server', longToolName)).toBe(longToolName);
|
||||
});
|
||||
|
||||
it('should return original tool name when tool name is exactly 64 characters', () => {
|
||||
const toolName = 'a'.repeat(64); // maxPrefixLen = 64 - 64 - 1 = -1, so <= 0
|
||||
expect(buildMcpToolName('Server', toolName)).toBe(toolName);
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,18 @@ export const createCallTool =
|
||||
return result;
|
||||
};
|
||||
|
||||
const MAX_MCP_TOOL_NAME_LENGTH = 64;
|
||||
|
||||
export function buildMcpToolName(serverName: string, toolName: string): string {
|
||||
const sanitizedServerName = serverName.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const fullName = `${sanitizedServerName}_${toolName}`;
|
||||
if (fullName.length <= MAX_MCP_TOOL_NAME_LENGTH) {
|
||||
return fullName;
|
||||
}
|
||||
const maxPrefixLen = MAX_MCP_TOOL_NAME_LENGTH - toolName.length - 1;
|
||||
return maxPrefixLen > 0 ? `${sanitizedServerName.slice(0, maxPrefixLen)}_${toolName}` : toolName;
|
||||
}
|
||||
|
||||
export function mcpToolToDynamicTool(
|
||||
tool: McpTool,
|
||||
onCallTool: DynamicStructuredToolInput['func'],
|
||||
|
||||
+1
@@ -99,6 +99,7 @@ jest.mock('../../../mcp/shared/utils', () => ({
|
||||
jest.mock('../../../mcp/McpClientTool/utils', () => ({
|
||||
createCallTool: jest.fn(),
|
||||
mcpToolToDynamicTool: jest.fn(),
|
||||
buildMcpToolName: jest.requireActual('../../../mcp/McpClientTool/utils').buildMcpToolName,
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { type Activity, ActivityTypes } from '@microsoft/agents-activity';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { invokeAgent } from './langchain-utils';
|
||||
|
||||
import {
|
||||
McpToolServerConfigurationService,
|
||||
defaultToolingConfigurationProvider,
|
||||
@@ -37,7 +36,12 @@ import {
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StructuredToolkit } from 'n8n-core';
|
||||
import { connectMcpClient, getAllTools } from '../../mcp/shared/utils';
|
||||
import { createCallTool, mcpToolToDynamicTool } from '../../mcp/McpClientTool/utils';
|
||||
import {
|
||||
buildMcpToolName,
|
||||
createCallTool,
|
||||
mcpToolToDynamicTool,
|
||||
} from '../../mcp/McpClientTool/utils';
|
||||
export { buildMcpToolName };
|
||||
|
||||
export type MicrosoftAgent365Credentials = {
|
||||
clientId: string;
|
||||
@@ -127,17 +131,6 @@ export const microsoftMcpServers: INodePropertyOptions[] = [
|
||||
];
|
||||
|
||||
const MS_TENANT_ID_HEADER = 'x-ms-tenant-id';
|
||||
const MAX_MCP_TOOL_NAME_LENGTH = 64;
|
||||
|
||||
export function buildMcpToolName(serverName: string, toolName: string): string {
|
||||
const sanitizedServerName = serverName.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const fullName = `${sanitizedServerName}_${toolName}`;
|
||||
if (fullName.length <= MAX_MCP_TOOL_NAME_LENGTH) {
|
||||
return fullName;
|
||||
}
|
||||
const maxPrefixLen = MAX_MCP_TOOL_NAME_LENGTH - toolName.length - 1;
|
||||
return maxPrefixLen > 0 ? `${sanitizedServerName.slice(0, maxPrefixLen)}_${toolName}` : toolName;
|
||||
}
|
||||
|
||||
function isMicrosoftObservabilityEnabled(): boolean {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user