fix(MCP Trigger Node): Pass through MCP content arrays instead of double-wrapping tool results (#39131)

This commit is contained in:
Srinjoy 2026-10-15 10:08:42 +00:00 committed by GitHub
parent fddef57734
commit f483ebd9d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 479 additions and 108 deletions

View File

@ -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}`, {

View File

@ -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 };
}

View File

@ -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)');
});
});
});

View File

@ -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;
}

View File

@ -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>();

View File

@ -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[]> {