mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
fix: Do not throw on tool errors, instead return error message (#17558)
This commit is contained in:
@@ -2,6 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import {
|
import {
|
||||||
|
NodeConnectionTypes,
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
type ILoadOptionsFunctions,
|
type ILoadOptionsFunctions,
|
||||||
type INode,
|
type INode,
|
||||||
@@ -284,5 +285,78 @@ describe('McpClientTool', () => {
|
|||||||
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
|
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({ content: 'Sunny' });
|
||||||
|
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(McpToolkit);
|
||||||
|
|
||||||
|
const tools = (supplyDataResult.response as McpToolkit).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, 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(McpToolkit);
|
||||||
|
|
||||||
|
const tools = (supplyDataResult.response as McpToolkit).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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logWrapper } from '@utils/logWrapper';
|
||||||
|
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||||
import {
|
import {
|
||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
@@ -7,9 +9,6 @@ import {
|
|||||||
type SupplyData,
|
type SupplyData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { logWrapper } from '@utils/logWrapper';
|
|
||||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
|
||||||
|
|
||||||
import { getTools } from './loadOptions';
|
import { getTools } from './loadOptions';
|
||||||
import type { McpServerTransport, McpAuthenticationOption, McpToolIncludeMode } from './types';
|
import type { McpServerTransport, McpAuthenticationOption, McpToolIncludeMode } from './types';
|
||||||
import {
|
import {
|
||||||
@@ -294,11 +293,10 @@ export class McpClientTool implements INodeType {
|
|||||||
logWrapper(
|
logWrapper(
|
||||||
mcpToolToDynamicTool(
|
mcpToolToDynamicTool(
|
||||||
tool,
|
tool,
|
||||||
createCallTool(tool.name, client.result, (error) => {
|
createCallTool(tool.name, client.result, (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.logger.error(`McpClientTool: Tool "${tool.name}" failed to execute`, { error });
|
||||||
throw new NodeOperationError(node, `Failed to execute tool "${tool.name}"`, {
|
|
||||||
description: error,
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { convertJsonSchemaToZod } from '@utils/schemaParsing';
|
||||||
import { Toolkit } from 'langchain/agents';
|
import { Toolkit } from 'langchain/agents';
|
||||||
import {
|
import {
|
||||||
createResultError,
|
createResultError,
|
||||||
@@ -13,12 +14,10 @@ import {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { convertJsonSchemaToZod } from '@utils/schemaParsing';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
McpAuthenticationOption,
|
McpAuthenticationOption,
|
||||||
McpTool,
|
|
||||||
McpServerTransport,
|
McpServerTransport,
|
||||||
|
McpTool,
|
||||||
McpToolIncludeMode,
|
McpToolIncludeMode,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -78,17 +77,24 @@ export const getErrorDescriptionFromToolCall = (result: unknown): string | undef
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createCallTool =
|
export const createCallTool =
|
||||||
(name: string, client: Client, onError: (error: string | undefined) => void) =>
|
(name: string, client: Client, onError: (error: string) => void) => async (args: IDataObject) => {
|
||||||
async (args: IDataObject) => {
|
|
||||||
let result: Awaited<ReturnType<Client['callTool']>>;
|
let result: Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
|
function handleError(error: unknown) {
|
||||||
|
const errorDescription =
|
||||||
|
getErrorDescriptionFromToolCall(error) ?? `Failed to execute tool "${name}"`;
|
||||||
|
onError(errorDescription);
|
||||||
|
return errorDescription;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = await client.callTool({ name, arguments: args }, CompatibilityCallToolResultSchema);
|
result = await client.callTool({ name, arguments: args }, CompatibilityCallToolResultSchema);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return onError(getErrorDescriptionFromToolCall(error));
|
return handleError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.isError) {
|
if (result.isError) {
|
||||||
return onError(getErrorDescriptionFromToolCall(result));
|
return handleError(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.toolResult !== undefined) {
|
if (result.toolResult !== undefined) {
|
||||||
@@ -105,7 +111,7 @@ export const createCallTool =
|
|||||||
export function mcpToolToDynamicTool(
|
export function mcpToolToDynamicTool(
|
||||||
tool: McpTool,
|
tool: McpTool,
|
||||||
onCallTool: DynamicStructuredToolInput['func'],
|
onCallTool: DynamicStructuredToolInput['func'],
|
||||||
): DynamicStructuredTool<z.ZodObject<any, any, any, any>> {
|
): DynamicStructuredTool {
|
||||||
const rawSchema = convertJsonSchemaToZod(tool.inputSchema);
|
const rawSchema = convertJsonSchemaToZod(tool.inputSchema);
|
||||||
|
|
||||||
// Ensure we always have an object schema for structured tools
|
// Ensure we always have an object schema for structured tools
|
||||||
@@ -122,7 +128,7 @@ export function mcpToolToDynamicTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class McpToolkit extends Toolkit {
|
export class McpToolkit extends Toolkit {
|
||||||
constructor(public tools: Array<DynamicStructuredTool<z.ZodObject<any, any, any, any>>>) {
|
constructor(public tools: DynamicStructuredTool[]) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user