mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(MCP Trigger Node): Pass through MCP content arrays instead of double-wrapping tool results (#39131)
This commit is contained in:
parent
fddef57734
commit
f483ebd9d1
|
|
@ -665,6 +665,7 @@ export class McpServer {
|
|||
return MessageFormatter.formatToolResult(
|
||||
result,
|
||||
MessageFormatter.isErrorResult(result),
|
||||
requestedTool?.metadata?.isFromToolkit === true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -673,7 +674,11 @@ export class McpServer {
|
|||
messageId: extra.requestId?.toString(),
|
||||
});
|
||||
|
||||
return MessageFormatter.formatToolResult(result, MessageFormatter.isErrorResult(result));
|
||||
return MessageFormatter.formatToolResult(
|
||||
result,
|
||||
MessageFormatter.isErrorResult(result),
|
||||
requestedTool?.metadata?.isFromToolkit === true,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorObject = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(`Error while executing Tool ${toolName}: ${errorObject.message}`, {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@ import type { CredentialCheckResult } from 'n8n-workflow';
|
|||
import { isToolErrorResult } from '@utils/tool-error-result';
|
||||
|
||||
import type { McpToolResult } from './types';
|
||||
import { isMcpContentArray, type McpContentBlock } from '../../shared/utils';
|
||||
|
||||
/**
|
||||
* Parse a tool result string that may be a JSON-serialized MCP content array.
|
||||
*
|
||||
* Proxy mode passes the tool result through `DynamicStructuredTool.invoke`,
|
||||
* which serializes the tool's return value to a string before it leaves the
|
||||
* tool. The raw content array therefore arrives here as JSON text. Malformed
|
||||
* JSON and JSON that is not an MCP content array return `null` so the caller
|
||||
* wraps the original string.
|
||||
*/
|
||||
function parseMcpContentArrayString(value: string): McpContentBlock[] | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return isMcpContentArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of presenting a single missing credential's connection URL to the
|
||||
|
|
@ -17,25 +36,34 @@ export interface CredentialGateElicitationOutcome {
|
|||
}
|
||||
|
||||
export class MessageFormatter {
|
||||
static formatToolResult(result: unknown, isError = false): McpToolResult {
|
||||
let content: McpToolResult['content'];
|
||||
static formatToolResult(result: unknown, isError = false, isFromMcpTool = false): McpToolResult {
|
||||
let content: McpContentBlock[];
|
||||
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
content = [{ type: 'text', text: JSON.stringify(result) }];
|
||||
if (isFromMcpTool && isMcpContentArray(result)) {
|
||||
content = result; // pass-through
|
||||
} else if (typeof result === 'object' && result !== null) {
|
||||
content = [{ type: 'text' as const, text: JSON.stringify(result) }];
|
||||
} else if (typeof result === 'string') {
|
||||
content = [{ type: 'text', text: result }];
|
||||
// Proxy mode hands us a JSON string, so parse before wrapping. A failed
|
||||
// parse or a non-MCP value falls back to the original string, never the
|
||||
// parsed value.
|
||||
const parsedContent = isFromMcpTool ? parseMcpContentArrayString(result) : null;
|
||||
content = parsedContent ?? [{ type: 'text' as const, text: result }];
|
||||
} else if (result === null || result === undefined) {
|
||||
content = [{ type: 'text', text: String(result) }];
|
||||
content = [{ type: 'text' as const, text: String(result) }];
|
||||
} else if (
|
||||
typeof result === 'number' ||
|
||||
typeof result === 'boolean' ||
|
||||
typeof result === 'bigint'
|
||||
) {
|
||||
content = [{ type: 'text', text: result.toString() }];
|
||||
content = [{ type: 'text' as const, text: result.toString() }];
|
||||
} else {
|
||||
// Remaining types: symbol, function - convert to string representation
|
||||
content = [
|
||||
{ type: 'text', text: String(result as symbol | ((...args: unknown[]) => unknown)) },
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: String(result),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +77,7 @@ export class MessageFormatter {
|
|||
static formatError(error: Error): McpToolResult {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `${error.name}: ${error.message}` }],
|
||||
content: [{ type: 'text' as const, text: `${error.name}: ${error.message}` }],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +105,7 @@ export class MessageFormatter {
|
|||
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text }],
|
||||
content: [{ type: 'text' as const, text }],
|
||||
credentialGate: result,
|
||||
};
|
||||
}
|
||||
|
|
@ -108,7 +136,7 @@ export class MessageFormatter {
|
|||
);
|
||||
}
|
||||
|
||||
const content = [{ type: 'text', text: lines.join('\n') }];
|
||||
const content = [{ type: 'text' as const, text: lines.join('\n') }];
|
||||
// Omit `isError` when nothing is left to connect, matching formatToolResult.
|
||||
return skipped.length > 0 ? { isError: true, content } : { content };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,431 @@
|
|||
import type { CredentialCheckResult } from 'n8n-workflow';
|
||||
|
||||
import { MessageFormatter } from '../MessageFormatter';
|
||||
import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
function getTextContent(blocks: ContentBlock[]): string {
|
||||
return (blocks[0] as { type: 'text'; text: string }).text;
|
||||
}
|
||||
|
||||
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}' }],
|
||||
describe('MCP content array pass-through', () => {
|
||||
it('should pass through single text content block', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through mixed text and image content blocks', () => {
|
||||
const result = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through with isError=true', () => {
|
||||
const result = [{ type: 'text', text: 'Error occurred' }];
|
||||
expect(MessageFormatter.formatToolResult(result, true, true)).toEqual({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error occurred' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through audio content block', () => {
|
||||
const result = [{ type: 'audio', data: 'base64audio', mimeType: 'audio/mpeg' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource content block', () => {
|
||||
const result = [
|
||||
{ type: 'resource', resource: { uri: 'file:///test.txt', text: 'content' } },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource content block with blob payload', () => {
|
||||
const result = [
|
||||
{ type: 'resource', resource: { uri: 'file:///test.bin', blob: 'base64data' } },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource content block containing text and blob', () => {
|
||||
const result = [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: { uri: 'file:///test.txt', text: 'content', blob: 'base64data' },
|
||||
},
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource_link content block', () => {
|
||||
const result = [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'https://example.com/resource',
|
||||
name: 'Example resource',
|
||||
},
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format string result directly without double-quoting', () => {
|
||||
expect(MessageFormatter.formatToolResult('hello world')).toEqual({
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
describe('MCP content array gating', () => {
|
||||
it('should wrap an MCP content array when isFromMcpTool is false (default)', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap an MCP content array when isFromMcpTool is explicitly false', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap an MCP content array from a non-MCP tool even with mixed blocks', () => {
|
||||
const result = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through an MCP content array when isFromMcpTool is true', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(42)).toEqual({
|
||||
content: [{ type: 'text', text: '42' }],
|
||||
describe('stringified MCP content array gating', () => {
|
||||
it('should wrap a stringified MCP content array when isFromMcpTool is false (default)', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: result }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap a stringified MCP content array when isFromMcpTool is explicitly false', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: result }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap a stringified MCP content array from a non-MCP tool even with mixed blocks', () => {
|
||||
const parsedContent = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
const raw = JSON.stringify(parsedContent);
|
||||
expect(MessageFormatter.formatToolResult(raw, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: raw }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through a stringified MCP content array when isFromMcpTool is true', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '{"success":true}' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format zero as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(0)).toEqual({
|
||||
content: [{ type: 'text', text: '0' }],
|
||||
describe('stringified MCP content array pass-through', () => {
|
||||
it('should pass through a JSON string containing a single text content block', () => {
|
||||
const result = JSON.stringify([{ type: 'text', text: 'Hello world' }]);
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through the exact payload from the reviewer manual test', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '{"success":true}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through a JSON string containing mixed text and image content blocks', () => {
|
||||
const parsedContent = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
expect(
|
||||
MessageFormatter.formatToolResult(JSON.stringify(parsedContent), false, true),
|
||||
).toEqual({
|
||||
content: parsedContent,
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when it is not valid JSON', () => {
|
||||
expect(MessageFormatter.formatToolResult('not json', false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'not json' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when valid JSON is not an MCP content array', () => {
|
||||
expect(MessageFormatter.formatToolResult('[1,2,3]', false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '[1,2,3]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when valid JSON is a plain object', () => {
|
||||
expect(MessageFormatter.formatToolResult('{"a":1}', false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '{"a":1}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when one content block is invalid', () => {
|
||||
const result = JSON.stringify([
|
||||
{ type: 'text', text: 'valid' },
|
||||
{ type: 'invalid', text: 'not valid' },
|
||||
]);
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: result }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve isError alongside a stringified MCP content array', () => {
|
||||
const result = '[{"type":"text","text":"Error occurred"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, true, true)).toEqual({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error occurred' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format negative number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(-123)).toEqual({
|
||||
content: [{ type: 'text', text: '-123' }],
|
||||
describe('fallback to stringify + wrap for invalid MCP content arrays', () => {
|
||||
it('should stringify array with invalid type', () => {
|
||||
const result = [{ type: 'invalid', text: 'hello' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"invalid","text":"hello"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with text block missing text', () => {
|
||||
const result = [{ type: 'text' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"text"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with image block missing data', () => {
|
||||
const result = [{ type: 'image', mimeType: 'image/png' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"image","mimeType":"image/png"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with image block missing mimeType', () => {
|
||||
const result = [{ type: 'image', data: 'base64data' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"image","data":"base64data"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource block missing nested uri', () => {
|
||||
const result = [{ type: 'resource', resource: { text: 'content' } }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource","resource":{"text":"content"}}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource block missing text or blob', () => {
|
||||
const result = [{ type: 'resource', resource: { uri: 'file:///test.txt' } }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource","resource":{"uri":"file:///test.txt"}}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource_link block missing uri', () => {
|
||||
const result = [{ type: 'resource_link', name: 'Example resource' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource_link","name":"Example resource"}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource_link block missing name', () => {
|
||||
const result = [{ type: 'resource_link', uri: 'https://example.com/resource' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource_link","uri":"https://example.com/resource"}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with mixed valid and invalid blocks', () => {
|
||||
const result = [
|
||||
{ type: 'text', text: 'valid' },
|
||||
{ type: 'invalid', text: 'not valid' },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"text","text":"valid"},{"type":"invalid","text":"not valid"}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify empty array', () => {
|
||||
const result: unknown[] = [];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[]' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format float as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(3.14159)).toEqual({
|
||||
content: [{ type: 'text', text: '3.14159' }],
|
||||
describe('existing behavior unchanged for non-MCP arrays', () => {
|
||||
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 boolean true as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(true)).toEqual({
|
||||
content: [{ type: 'text', text: 'true' }],
|
||||
it('should format string result directly without double-quoting', () => {
|
||||
expect(MessageFormatter.formatToolResult('hello world')).toEqual({
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format boolean false as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(false)).toEqual({
|
||||
content: [{ type: 'text', text: 'false' }],
|
||||
it('should format number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(42)).toEqual({
|
||||
content: [{ type: 'text', text: '42' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format null as JSON string "null"', () => {
|
||||
expect(MessageFormatter.formatToolResult(null)).toEqual({
|
||||
content: [{ type: 'text', text: 'null' }],
|
||||
it('should format zero as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(0)).toEqual({
|
||||
content: [{ type: 'text', text: '0' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format undefined as string "undefined"', () => {
|
||||
expect(MessageFormatter.formatToolResult(undefined)).toEqual({
|
||||
content: [{ type: 'text', text: 'undefined' }],
|
||||
it('should format negative number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(-123)).toEqual({
|
||||
content: [{ type: 'text', text: '-123' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 format float as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(3.14159)).toEqual({
|
||||
content: [{ type: 'text', text: '3.14159' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(MessageFormatter.formatToolResult([])).toEqual({
|
||||
content: [{ type: 'text', text: '[]' }],
|
||||
it('should format boolean true as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(true)).toEqual({
|
||||
content: [{ type: 'text', text: 'true' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
expect(MessageFormatter.formatToolResult({})).toEqual({
|
||||
content: [{ type: 'text', text: '{}' }],
|
||||
it('should format boolean false as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(false)).toEqual({
|
||||
content: [{ type: 'text', text: 'false' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 format null as JSON string "null"', () => {
|
||||
expect(MessageFormatter.formatToolResult(null)).toEqual({
|
||||
content: [{ type: 'text', text: 'null' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle string with unicode characters', () => {
|
||||
expect(MessageFormatter.formatToolResult('Hello')).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
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(getTextContent(formatted.content)).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(getTextContent(formatted.content)).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(getTextContent(formatted.content)).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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -183,7 +498,7 @@ describe('MessageFormatter', () => {
|
|||
it('should set isError when flag is true', () => {
|
||||
const result = MessageFormatter.formatToolResult('There was an error: "Unauthorized"', true);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toBe('There was an error: "Unauthorized"');
|
||||
expect(getTextContent(result.content)).toBe('There was an error: "Unauthorized"');
|
||||
});
|
||||
|
||||
it('should not set isError when flag is false', () => {
|
||||
|
|
@ -200,7 +515,7 @@ describe('MessageFormatter', () => {
|
|||
const errorObj = { error: { message: 'Bad request', name: 'NodeApiError' } };
|
||||
const result = MessageFormatter.formatToolResult(errorObj, true);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toBe(JSON.stringify(errorObj));
|
||||
expect(getTextContent(result.content)).toBe(JSON.stringify(errorObj));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -211,7 +526,7 @@ describe('MessageFormatter', () => {
|
|||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].type).toBe('text');
|
||||
expect(result.content[0].text).toContain('Error: Something went wrong');
|
||||
expect(getTextContent(result.content)).toContain('Error: Something went wrong');
|
||||
});
|
||||
|
||||
it('should handle error with empty message', () => {
|
||||
|
|
@ -219,7 +534,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Error: ');
|
||||
expect(getTextContent(result.content)).toContain('Error: ');
|
||||
});
|
||||
|
||||
it('should handle error with special characters in message', () => {
|
||||
|
|
@ -227,7 +542,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Error: Failed: "invalid" <value>');
|
||||
expect(getTextContent(result.content)).toContain('Error: Failed: "invalid" <value>');
|
||||
});
|
||||
|
||||
it('should handle error with newlines in message', () => {
|
||||
|
|
@ -235,7 +550,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Error: Line 1\nLine 2');
|
||||
expect(getTextContent(result.content)).toContain('Error: Line 1\nLine 2');
|
||||
});
|
||||
|
||||
it('should handle TypeError', () => {
|
||||
|
|
@ -243,7 +558,9 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('TypeError: Cannot read property of undefined');
|
||||
expect(getTextContent(result.content)).toContain(
|
||||
'TypeError: Cannot read property of undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom error subclass', () => {
|
||||
|
|
@ -257,7 +574,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('CustomError: Custom error message');
|
||||
expect(getTextContent(result.content)).toContain('CustomError: Custom error message');
|
||||
});
|
||||
|
||||
it('should not include stack trace in the response', () => {
|
||||
|
|
@ -266,8 +583,8 @@ describe('MessageFormatter', () => {
|
|||
'Error: Test error\n at Context.<anonymous> (test.ts:1:1)\n at /internal/path/node.js:100:5';
|
||||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.content[0].text).toBe('Error: Test error');
|
||||
expect(result.content[0].text).not.toContain('internal/path');
|
||||
expect(getTextContent(result.content)).toBe('Error: Test error');
|
||||
expect(getTextContent(result.content)).not.toContain('internal/path');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -290,9 +607,9 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatCredentialGate(gateResult);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('My Slack (slackOAuth2Api)');
|
||||
// The URL is emitted raw on its own line (not wrapped in prose).
|
||||
expect(result.content[0].text.split('\n')).toContain(
|
||||
expect(getTextContent(result.content).split('\n')).toContain(
|
||||
'https://n8n.test/rest/credentials/cred-1/authorize?resolverId=n8n',
|
||||
);
|
||||
// The structured field carries the full result (raw URLs) for programmatic clients.
|
||||
|
|
@ -321,7 +638,7 @@ describe('MessageFormatter', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const text = MessageFormatter.formatCredentialGate(gateResult).content[0].text;
|
||||
const text = getTextContent(MessageFormatter.formatCredentialGate(gateResult).content);
|
||||
|
||||
expect(text).toContain('Missing Cred (notionOAuth2Api)');
|
||||
expect(text).not.toContain('Connected Cred');
|
||||
|
|
@ -340,7 +657,7 @@ describe('MessageFormatter', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const text = MessageFormatter.formatCredentialGate(gateResult).content[0].text;
|
||||
const text = getTextContent(MessageFormatter.formatCredentialGate(gateResult).content);
|
||||
|
||||
expect(text).toContain('No URL Cred (httpHeaderAuth): not connected');
|
||||
});
|
||||
|
|
@ -353,10 +670,10 @@ describe('MessageFormatter', () => {
|
|||
]);
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0].text).toContain('retry the request');
|
||||
expect(result.content[0].text).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('retry the request');
|
||||
expect(getTextContent(result.content)).toContain('My Slack (slackOAuth2Api)');
|
||||
// The raw URL is never relayed as text on the elicitation path.
|
||||
expect(result.content[0].text).not.toContain('http');
|
||||
expect(getTextContent(result.content)).not.toContain('http');
|
||||
});
|
||||
|
||||
it('should flag an error and list credentials that were declined or cancelled', () => {
|
||||
|
|
@ -367,10 +684,10 @@ describe('MessageFormatter', () => {
|
|||
]);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(result.content[0].text).toContain('still need to be connected');
|
||||
expect(result.content[0].text).toContain('My Notion (notionOAuth2Api)');
|
||||
expect(result.content[0].text).toContain('My GitHub (githubOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('still need to be connected');
|
||||
expect(getTextContent(result.content)).toContain('My Notion (notionOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('My GitHub (githubOAuth2Api)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import type { McpContentBlock } from '../../shared/utils';
|
||||
|
||||
export interface McpToolCallInfo {
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
|
|
@ -8,7 +10,7 @@ export interface McpToolCallInfo {
|
|||
|
||||
export interface McpToolResult {
|
||||
[key: string]: unknown;
|
||||
content: Array<{ type: string; text: string }>;
|
||||
content: McpContentBlock[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
connectMcpClient,
|
||||
connectMcpClientForCredential,
|
||||
getAuthHeaders,
|
||||
isMcpContentArray,
|
||||
mapToNodeOperationError,
|
||||
tryRefreshOAuth2Token,
|
||||
} from '../utils';
|
||||
|
|
@ -51,6 +52,12 @@ describe('utils', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isMcpContentArray', () => {
|
||||
it('should reject sparse arrays', () => {
|
||||
expect(isMcpContentArray(new Array(1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryRefreshOAuth2Token', () => {
|
||||
it('should refresh an OAuth2 token without headers', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,18 @@ import {
|
|||
type McpTool,
|
||||
} from './types';
|
||||
|
||||
import { ContentBlockSchema, type ContentBlock } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type { ContentBlock as McpContentBlock };
|
||||
|
||||
/**
|
||||
* Strict MCP content array detection. Returns false for empty arrays so callers
|
||||
* fall back to default formatting.
|
||||
*/
|
||||
export function isMcpContentArray(data: unknown): data is ContentBlock[] {
|
||||
if (!Array.isArray(data) || data.length === 0) return false;
|
||||
return Array.from(data).every((item) => ContentBlockSchema.safeParse(item).success);
|
||||
}
|
||||
const MCP_SESSION_TERMINATION_TIMEOUT_MS = 1_000;
|
||||
|
||||
export async function getAllTools(client: Client, cursor?: string): Promise<McpTool[]> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user