diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts index 75496bc1462..336335fdfc1 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts @@ -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}`, { diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/MessageFormatter.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/MessageFormatter.ts index 6ae61c64c97..a1ee60428b1 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/MessageFormatter.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/MessageFormatter.ts @@ -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 }; } diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/__tests__/MessageFormatter.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/__tests__/MessageFormatter.test.ts index 97e9ed2e74d..b722a163b97 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/__tests__/MessageFormatter.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/__tests__/MessageFormatter.test.ts @@ -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" '); + expect(getTextContent(result.content)).toContain('Error: Failed: "invalid" '); }); 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. (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)'); }); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/types.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/types.ts index 7dee21a5fbe..60bbec910c6 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/types.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/protocol/types.ts @@ -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; @@ -8,7 +10,7 @@ export interface McpToolCallInfo { export interface McpToolResult { [key: string]: unknown; - content: Array<{ type: string; text: string }>; + content: McpContentBlock[]; isError?: boolean; } diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts index 75941d978e1..b4d8c8f0ddd 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts @@ -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(); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts index 850152e01d5..d24ed403cb6 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts @@ -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 {