fix: Update runtime and MCP dependency APIs (no-changelog) (#40204)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Drury <michael.drury@n8n.io>
This commit is contained in:
n8n-cat-bot[bot] 2026-10-08 15:43:51 +00:00 committed by GitHub
parent e67e5a9ef1
commit 03f7c76acd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 645 additions and 253 deletions

View File

@ -35,7 +35,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -54,7 +56,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -69,7 +73,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}
@ -175,7 +181,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -194,7 +202,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -209,7 +219,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}

View File

@ -35,7 +35,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -54,7 +56,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -69,7 +73,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}
@ -176,7 +182,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -195,7 +203,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -210,7 +220,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}

View File

@ -35,7 +35,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -54,7 +56,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -69,7 +73,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}
@ -176,7 +182,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -195,7 +203,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -210,7 +220,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}
@ -285,7 +297,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -304,7 +318,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -319,7 +335,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}
@ -426,7 +444,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -445,7 +465,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -460,7 +482,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}

View File

@ -51,7 +51,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -70,7 +72,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -85,7 +89,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}
@ -208,7 +214,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -227,7 +235,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -242,7 +252,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}

View File

@ -35,7 +35,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -54,7 +56,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -69,7 +73,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}
@ -159,7 +165,12 @@
{
"type": "tool_result",
"tool_use_id": "toolu_019kuzXjUdxZoB117HouDVUb",
"content": "{\"content\":[{\"type\":\"text\",\"text\":\"stream works\"}]}"
"content": [
{
"type": "text",
"text": "<untrusted_data source=\"mcp:tools\" label=\"echo\">\n{\"content\":[{\"type\":\"text\",\"text\":\"stream works\"}]}\n</untrusted_data>"
}
]
}
]
}
@ -176,7 +187,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -195,7 +208,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -210,7 +225,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}

View File

@ -35,7 +35,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -54,7 +56,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -69,7 +73,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}

View File

@ -35,7 +35,9 @@
"description": "Message to echo"
}
},
"required": ["message"]
"required": ["message"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -54,7 +56,9 @@
"description": "Second number"
}
},
"required": ["a", "b"]
"required": ["a", "b"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
},
@ -69,7 +73,9 @@
"description": "Image caption"
}
},
"required": ["caption"]
"required": ["caption"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"strict": false
}

View File

@ -25,7 +25,7 @@ describe('execution options integration', () => {
expect(fetchCalls.length).toBeGreaterThan(0);
});
it('calls onStepStart and onStepFinish for generate and stream', async () => {
it('calls onStepStart and onStepEnd for generate and stream', async () => {
const generateStepStart = vi.fn();
const generateStepFinish = vi.fn();
const streamStepStart = vi.fn();
@ -36,13 +36,13 @@ describe('execution options integration', () => {
const generateResult = await agent.generate('Say generate.', {
onStepStart: generateStepStart,
onStepFinish: generateStepFinish,
onStepEnd: generateStepFinish,
});
expect(generateResult.finishReason).toBe('stop');
expect(generateResult.finishReason, String(generateResult.error)).toBe('stop');
const { stream } = await agent.stream('Say stream.', {
onStepStart: streamStepStart,
onStepFinish: streamStepFinish,
onStepEnd: streamStepFinish,
});
await collectStreamChunks(stream);

View File

@ -127,6 +127,7 @@ describe_llm('agent stream() with MCP tool', () => {
// being wrapped in `message` envelopes.
const toolCallChunks = chunksOfType(chunks, 'tool-call');
expect(toolCallChunks.length).toBeGreaterThan(0);
expect(chunksOfType(chunks, 'finish').at(-1)?.finishReason).toBe('stop');
await client.close();
});

View File

@ -4,11 +4,11 @@
* for use in integration tests. No mocking of SDK internals.
*/
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import http from 'http';
import { z } from 'zod';
/** 1×1 transparent PNG in base64 (smallest valid PNG). Used for image tool tests. */
export const TINY_PNG =
@ -20,84 +20,47 @@ export interface TestServer {
}
/** Create an in-process MCP Server with three test tools: echo, add, and image. */
// oxlint-disable-next-line typescript/no-deprecated
export function createTestMcpServer(): McpServer {
// oxlint-disable-next-line typescript/no-deprecated
const server = new McpServer(
{ name: 'test-mcp-server', version: '1.0.0' },
{ capabilities: { tools: {} } },
const server = new McpServer({ name: 'test-mcp-server', version: '1.0.0' });
server.registerTool(
'echo',
{
description: 'Echo the message back as-is',
inputSchema: { message: z.string().describe('Message to echo') },
},
async ({ message }) => ({ content: [{ type: 'text', text: message }] }),
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'echo',
description: 'Echo the message back as-is',
inputSchema: {
type: 'object',
properties: { message: { type: 'string', description: 'Message to echo' } },
required: ['message'],
},
server.registerTool(
'add',
{
description: 'Add two numbers together',
inputSchema: {
a: z.number().describe('First number'),
b: z.number().describe('Second number'),
},
{
name: 'add',
description: 'Add two numbers together',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' },
},
required: ['a', 'b'],
},
},
{
name: 'image',
description: 'Return a small image with a caption',
inputSchema: {
type: 'object',
properties: { caption: { type: 'string', description: 'Image caption' } },
required: ['caption'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
if (name === 'echo') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return { content: [{ type: 'text', text: String(args.message ?? '') }] };
}
if (name === 'add') {
const sum = Number(args.a ?? 0) + Number(args.b ?? 0);
return { content: [{ type: 'text', text: String(sum) }] };
}
if (name === 'image') {
return {
content: [
// eslint-disable-next-line @typescript-eslint/no-base-to-string
{ type: 'text', text: String(args.caption ?? '') },
{ type: 'image', data: TINY_PNG, mimeType: 'image/png' },
],
};
}
return {
isError: true,
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
};
});
},
async ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] }),
);
server.registerTool(
'image',
{
description: 'Return a small image with a caption',
inputSchema: { caption: z.string().describe('Image caption') },
},
async ({ caption }) => ({
content: [
{ type: 'text', text: caption },
{ type: 'image', data: TINY_PNG, mimeType: 'image/png' },
],
}),
);
return server;
}
/** Start an SSE MCP server on a random port. Returns the SSE endpoint URL and a close function. */
export async function startSseServer(): Promise<TestServer> {
// oxlint-disable-next-line typescript/no-deprecated
// oxlint-disable-next-line typescript/no-deprecated -- Test the supported SSE compatibility path.
const transports = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
@ -107,7 +70,7 @@ export async function startSseServer(): Promise<TestServer> {
// a single active transport reference and rejects a second connect() call
// if the first transport hasn't been fully torn down yet.
const mcpServer = createTestMcpServer();
// oxlint-disable-next-line typescript/no-deprecated
// oxlint-disable-next-line typescript/no-deprecated -- Test the supported SSE compatibility path.
const transport = new SSEServerTransport('/message', res);
transports.set(transport.sessionId, transport);
await mcpServer.connect(transport);

View File

@ -34,7 +34,8 @@ function makeGenerateSuccess(text = 'OK') {
return {
finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
response: { messages: [{ role: 'assistant', content: [{ type: 'text', text }] }] },
responseMessages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
finalStep: { providerMetadata: undefined },
toolCalls: [],
};
}
@ -52,9 +53,8 @@ function makeStreamSuccess(text = 'OK') {
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text }]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
messages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
}),
responseMessages: Promise.resolve([{ role: 'assistant', content: [{ type: 'text', text }] }]),
finalStep: Promise.resolve({ providerMetadata: undefined }),
toolCalls: Promise.resolve([]),
};
}

View File

@ -98,12 +98,56 @@ type AiImport = typeof import('ai');
// Mock generateText and streamText from the 'ai' package
vi.mock('ai', async () => {
const actual = await vi.importActual<AiImport>('ai');
// Keep existing model fixtures in one place while exercising the current SDK result shape.
const generateMock = vi.fn();
const streamMock = vi.fn();
return {
...actual,
embed: vi.fn(),
embedMany: vi.fn(),
generateText: vi.fn(),
streamText: vi.fn(),
generateText: new Proxy(generateMock, {
apply: async (target, thisArg, args) => {
const result: unknown = await Reflect.apply(target, thisArg, args);
if (!result || typeof result !== 'object') return result;
const legacy = result as {
response?: { messages: unknown[] };
responseMessages?: unknown[];
providerMetadata?: unknown;
finalStep?: unknown;
};
return {
...legacy,
responseMessages: legacy.responseMessages ?? legacy.response?.messages,
finalStep: legacy.finalStep ?? { providerMetadata: legacy.providerMetadata },
};
},
}),
streamText: new Proxy(streamMock, {
apply: (target, thisArg, args) => {
const result: unknown = Reflect.apply(target, thisArg, args);
if (!result || typeof result !== 'object') return result;
const legacy = result as {
response?: PromiseLike<{ messages: unknown[] }>;
responseMessages?: PromiseLike<unknown[]>;
providerMetadata?: PromiseLike<unknown>;
finalStep?: PromiseLike<unknown>;
};
const responseMessages =
legacy.responseMessages ??
Promise.resolve(legacy.response).then((response) => response?.messages);
// Aborted streams need no result. Mark the derived promise as handled.
void Promise.resolve(responseMessages).catch(() => {});
return {
...legacy,
responseMessages,
finalStep:
legacy.finalStep ??
Promise.resolve(legacy.providerMetadata).then((providerMetadata) => ({
providerMetadata,
})),
};
},
}),
tool: vi.fn((config: unknown) => config),
jsonSchema: vi.fn((schema: unknown) => ({ _type: 'jsonSchema', schema })),
Output: {
@ -1051,6 +1095,47 @@ describe('AgentRuntime — execution counters', () => {
}
});
it('preserves messages and final-step metadata in generate and stream results', async () => {
const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
const providerMetadata = { openai: { cachedPromptTokens: 3 } };
const responseMessages = [{ role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }];
generateText.mockResolvedValue({
finishReason: 'stop',
usage,
responseMessages,
finalStep: { providerMetadata },
toolCalls: [],
});
streamText.mockReturnValue({
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'Hello' }]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve(usage),
responseMessages: Promise.resolve(responseMessages),
finalStep: Promise.resolve({ providerMetadata }),
toolCalls: Promise.resolve([]),
});
const { runtime } = createRuntime();
const generated = await runtime.generate('hi');
const streamed = await collectChunks((await runtime.stream('hi')).stream);
const expectedUsage = {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
inputTokenDetails: { noCache: 7, cacheRead: 3 },
};
expect(generated.messages).toMatchObject(responseMessages);
expect(generated.finishReason).toBe('stop');
expect(generated.usage).toMatchObject(expectedUsage);
expect(streamed).toContainEqual(
expect.objectContaining({ type: 'text-delta', delta: 'Hello' }),
);
expect(streamed.at(-1)).toMatchObject({
type: 'finish',
finishReason: 'stop',
usage: expectedUsage,
});
});
it('allows system-role messages in generateText and streamText history', async () => {
generateText.mockResolvedValue(makeGenerateSuccess());
streamText.mockReturnValue(makeStreamSuccess());
@ -10259,8 +10344,11 @@ describe('AgentRuntime — oversized tool results', () => {
type: 'content' as const,
value: [
{
type: 'file-data' as const,
data: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.repeat(8_000),
type: 'file' as const,
data: {
type: 'data' as const,
data: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.repeat(8_000),
},
mediaType: 'application/pdf',
},
],
@ -10645,8 +10733,8 @@ describe('AgentRuntime — oversized tool results', () => {
},
];
const filePart = {
type: 'file-data' as const,
data: 'base64-pdf',
type: 'file' as const,
data: { type: 'data' as const, data: 'base64-pdf' },
mediaType: 'application/pdf',
};
const tool: BuiltTool = {

View File

@ -70,9 +70,8 @@ function makeStreamSuccess(text = 'Hello') {
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text }]),
finishReason: Promise.resolve('stop'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
messages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
}),
responseMessages: Promise.resolve([{ role: 'assistant', content: [{ type: 'text', text }] }]),
finalStep: Promise.resolve({ providerMetadata: undefined }),
toolCalls: Promise.resolve([]),
};
}
@ -82,14 +81,13 @@ function makeStreamWithToolCall(toolCallId: string, args: Record<string, unknown
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'working...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
messages: [
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId, toolName: 'lookup', args }],
},
],
}),
responseMessages: Promise.resolve([
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId, toolName: 'lookup', input: args }],
},
]),
finalStep: Promise.resolve({ providerMetadata: undefined }),
toolCalls: Promise.resolve([{ toolCallId, toolName: 'lookup', input: args }]),
};
}
@ -213,6 +211,20 @@ describe('step checkpoints + crash resume (durable-log RFC)', () => {
const secondJson = JSON.stringify(stepSaves[1].state.messageList);
expect(secondJson).toContain('tc-1');
expect(secondJson).toContain('tc-2');
for (const [toolCallId, value] of [
['tc-1', 'first'],
['tc-2', 'second'],
]) {
expect(stepSaves[1].state.messageList.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
content: expect.arrayContaining([
expect.objectContaining({ toolCallId, input: { value } }),
]),
}),
]),
);
}
// The completed run deleted its checkpoint (no leak).
expect(store.deletes).toContain(result.runId);
@ -268,21 +280,20 @@ describe('step checkpoints + crash resume (durable-log RFC)', () => {
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'asking...' }]),
finishReason: Promise.resolve('tool-calls'),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
response: Promise.resolve({
messages: [
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-hitl',
toolName: 'approve',
args: { question: 'ok?' },
},
],
},
],
}),
responseMessages: Promise.resolve([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-hitl',
toolName: 'approve',
input: { question: 'ok?' },
},
],
},
]),
finalStep: Promise.resolve({ providerMetadata: undefined }),
toolCalls: Promise.resolve([
{ toolCallId: 'tc-hitl', toolName: 'approve', input: { question: 'ok?' } },
]),

View File

@ -54,7 +54,10 @@ describe('episodic memory defaults', () => {
},
],
});
return await Promise.resolve({ output: parsedOutput });
return await Promise.resolve({
output: parsedOutput,
finalStep: { providerMetadata: undefined },
});
});
await expect(
@ -77,7 +80,11 @@ describe('episodic memory defaults', () => {
mockGenerateText.mockImplementationOnce(async ({ output }) => {
const parsedOutput = output.schema.parse({ drop: [], merge: [] });
return await Promise.resolve({ output: parsedOutput, usage: { totalTokens: 13 } });
return await Promise.resolve({
output: parsedOutput,
usage: { totalTokens: 13 },
finalStep: { providerMetadata: undefined },
});
});
await createEpisodicMemoryReflectFn(fakeModel)({

View File

@ -44,7 +44,8 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
}),
}));
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', async (importOriginal) => ({
...(await importOriginal()),
StreamableHTTPClientTransport: vi.fn(function (url: URL, options: unknown) {
streamableHttpCtor(url, options);
return { type: 'streamableHttp', url, options };
@ -64,7 +65,8 @@ describe('McpConnection — custom fetch forwarding', () => {
sseCtor.mockClear();
streamableHttpCtor.mockClear();
stdioCtor.mockClear();
clientConnect.mockClear();
clientConnect.mockReset().mockResolvedValue(undefined);
clientClose.mockReset().mockResolvedValue(undefined);
clientListTools.mockClear();
});
@ -84,6 +86,55 @@ describe('McpConnection — custom fetch forwarding', () => {
expect(options.fetch).toBe(customFetch);
});
it('uses Streamable HTTP for an unconfigured URL', async () => {
const conn = new McpConnection({ name: 'default', url: 'https://example.test/mcp' });
await conn.connect();
expect(streamableHttpCtor).toHaveBeenCalledTimes(1);
expect(sseCtor).not.toHaveBeenCalled();
});
it('retries a legacy SSE endpoint when Streamable HTTP is unsupported', async () => {
const { StreamableHTTPError } = await import(
'@modelcontextprotocol/sdk/client/streamableHttp.js'
);
clientConnect.mockRejectedValueOnce(new StreamableHTTPError(404, 'Not found'));
const conn = new McpConnection({ name: 'legacy', url: 'https://example.test/sse' });
await conn.connect();
expect(streamableHttpCtor).toHaveBeenCalledTimes(1);
expect(sseCtor).toHaveBeenCalledTimes(1);
expect(clientClose).toHaveBeenCalled();
});
it('shares the connection timeout with the SSE retry', async () => {
const { StreamableHTTPError } = await import(
'@modelcontextprotocol/sdk/client/streamableHttp.js'
);
vi.useFakeTimers();
try {
clientConnect
.mockImplementationOnce(async () => {
await new Promise((resolve) => setTimeout(resolve, 90));
throw new StreamableHTTPError(404, 'Not found');
})
.mockImplementationOnce(async () => await new Promise<void>(() => {}));
const conn = new McpConnection({
name: 'legacy',
url: 'https://example.test/sse',
connectionTimeoutMs: 100,
});
const connecting = conn.connect();
await vi.advanceTimersByTimeAsync(90);
expect(sseCtor).toHaveBeenCalledTimes(1);
const timedOut = expect(connecting).rejects.toThrow('connection timed out after 100ms');
await vi.advanceTimersByTimeAsync(10);
await timedOut;
expect(clientConnect).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('forwards `fetch` to SSEClientTransport and to its eventSourceInit when provided', async () => {
const customFetch = vi.fn();
const conn = new McpConnection({

View File

@ -29,7 +29,7 @@ describe('McpToolResolver media output', () => {
type: 'content',
value: [
{ type: 'text', text: 'Current screenshot' },
{ type: 'image-data', data: 'base64-image', mediaType: 'image/png' },
{ type: 'file', data: { type: 'data', data: 'base64-image' }, mediaType: 'image/png' },
],
});
expect(await tool.toMessage?.(result)).toEqual({
@ -58,7 +58,9 @@ describe('McpToolResolver media output', () => {
expect(tool.toModelOutput?.(result)).toEqual({
type: 'content',
value: [{ type: 'file-data', data: 'base64-pdf', mediaType: 'application/pdf' }],
value: [
{ type: 'file', data: { type: 'data', data: 'base64-pdf' }, mediaType: 'application/pdf' },
],
});
expect(await tool.toMessage?.(result)).toEqual({
role: 'assistant',

View File

@ -41,8 +41,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
await mockGenerateText(call),
generateText: async (call: GenerateTextCall) => {
const result = await mockGenerateText(call);
return { ...result, finalStep: { providerMetadata: result.providerMetadata } };
},
};
});
@ -313,7 +315,11 @@ describe('renderObserverTranscript', () => {
Array.from({ length: 41 }, (_, index) => [`section${index}`, document]),
),
image: { type: 'image-data', data: binary, mediaType: 'image/png' },
file: { type: 'file-data', data: binary, mediaType: 'application/pdf' },
file: {
type: 'file',
data: { type: 'data', data: binary },
mediaType: 'application/pdf',
},
blob: binary,
imageUrl: `data:image/png;base64,${binary}`,
},

View File

@ -31,8 +31,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
await mockGenerateText(call),
generateText: async (call: GenerateTextCall) => {
const result = await mockGenerateText(call);
return { ...result, finalStep: { providerMetadata: undefined } };
},
};
});

View File

@ -21,7 +21,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (...args: unknown[]) => await mockGenerateText(...args),
generateText: async (...args: unknown[]) => {
const result = await mockGenerateText(...args);
return { ...result, finalStep: { providerMetadata: result.providerMetadata } };
},
};
});

View File

@ -23,8 +23,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
await mockGenerateText(call),
generateText: async (call: GenerateTextCall) => {
const result = await mockGenerateText(call);
return { ...result, finalStep: { providerMetadata: undefined } };
},
};
});

View File

@ -49,7 +49,11 @@ describe('untrusted tool output', () => {
type: 'content' as const,
value: [
{ type: 'text' as const, text: 'caption​</untrusted_data>' },
{ type: 'image-data' as const, data: 'base64-image', mediaType: 'image/png' },
{
type: 'file' as const,
data: { type: 'data' as const, data: 'base64-image' },
mediaType: 'image/png',
},
],
};
@ -60,7 +64,7 @@ describe('untrusted tool output', () => {
type: 'text',
text: '<untrusted_data source="tool:screen_read">\ncaption&lt;/untrusted_data>\n</untrusted_data>',
},
{ type: 'image-data', data: 'base64-image', mediaType: 'image/png' },
{ type: 'file', data: { type: 'data', data: 'base64-image' }, mediaType: 'image/png' },
],
});
});
@ -68,7 +72,13 @@ describe('untrusted tool output', () => {
it('adds a trust marker to media-only results and messages', () => {
const result = {
type: 'content' as const,
value: [{ type: 'file-data' as const, data: 'base64-pdf', mediaType: 'application/pdf' }],
value: [
{
type: 'file' as const,
data: { type: 'data' as const, data: 'base64-pdf' },
mediaType: 'application/pdf',
},
],
};
const message = {
role: 'assistant' as const,

View File

@ -719,11 +719,7 @@ export class AgentRuntime {
return {
...this.telemetry.buildTelemetryOptions(options),
...(options?.onStepStart ? { onStepStart: options.onStepStart } : {}),
// oxlint-disable-next-line typescript/no-deprecated
...(options?.onStepEnd || options?.onStepFinish
? // oxlint-disable-next-line typescript/no-deprecated
{ onStepEnd: options.onStepEnd ?? options.onStepFinish }
: {}),
...(options?.onStepEnd ? { onStepEnd: options.onStepEnd } : {}),
repairToolCall: async (options) => {
return await fixToolCall(
{

View File

@ -49,14 +49,12 @@ export class GenerateSink implements RunOutputSink<GenerateResult> {
});
const aiFinishReason = result.finishReason;
// oxlint-disable-next-line typescript/no-deprecated
const newMessages = fromAiMessages(result.response.messages);
const newMessages = fromAiMessages(result.responseMessages);
const errorReason = classifyModelTurnError({ aiFinishReason, newMessages });
return {
aiFinishReason,
finishReason: fromAiFinishReason(aiFinishReason),
// oxlint-disable-next-line typescript/no-deprecated
usage: toTokenUsage(result.usage, result.providerMetadata),
usage: toTokenUsage(result.usage, result.finalStep.providerMetadata),
newMessages,
toolCalls: result.toolCalls,
structuredOutput:

View File

@ -385,11 +385,8 @@ export class StreamSink implements RunOutputSink<void> {
const aiFinishReason = await settle(result.finishReason);
const usage = await settle(result.usage);
// oxlint-disable-next-line typescript/no-deprecated
const providerMetadata = await settle(result.providerMetadata);
// oxlint-disable-next-line typescript/no-deprecated
const response = await settle(result.response);
const newMessages = fromAiMessages(response.messages);
const finalStep = await settle(result.finalStep);
const newMessages = fromAiMessages(await settle(result.responseMessages));
const errorReason = classifyModelTurnError({
aiFinishReason,
newMessages,
@ -399,7 +396,7 @@ export class StreamSink implements RunOutputSink<void> {
return {
aiFinishReason,
finishReason: fromAiFinishReason(aiFinishReason),
usage: toTokenUsage(usage, providerMetadata),
usage: toTokenUsage(usage, finalStep.providerMetadata),
newMessages,
toolCalls: await settle(result.toolCalls),
structuredOutput:

View File

@ -27,10 +27,10 @@ export type McpCallToolResult = CallToolResult;
async function importMcpSdk() {
const [
{ Client },
// oxlint-disable-next-line typescript/no-deprecated
// oxlint-disable-next-line typescript/no-deprecated -- Explicit SSE keeps older servers available.
{ SSEClientTransport },
{ StdioClientTransport },
{ StreamableHTTPClientTransport },
{ StreamableHTTPClientTransport, StreamableHTTPError },
{ CallToolResultSchema },
] = await Promise.all([
import('@modelcontextprotocol/sdk/client/index.js'),
@ -44,6 +44,7 @@ async function importMcpSdk() {
SSEClientTransport,
StdioClientTransport,
StreamableHTTPClientTransport,
StreamableHTTPError,
CallToolResultSchema,
};
}
@ -111,18 +112,50 @@ export class McpConnection {
// open (doDisconnect() no-ops while `closed` is true).
this.closed = false;
this.disconnectPromise = undefined;
const sdk = await loadMcpSdk();
this.client = new sdk.Client({ name: '@n8n/agents', version: '0.1.0' }, { capabilities: {} });
this.connectionPromise = this.connectWithTransport(this.createTransport(this.config, sdk));
const connectionPromise = this.doConnect();
this.connectionPromise = connectionPromise;
try {
await this.connectionPromise;
await connectionPromise;
} catch (error) {
this.connectionPromise = undefined;
if (this.connectionPromise === connectionPromise) this.connectionPromise = undefined;
throw error;
}
}
private async connectWithTransport(transport: McpTransport): Promise<void> {
private async doConnect(): Promise<void> {
const sdk = await loadMcpSdk();
this.client = new sdk.Client({ name: '@n8n/agents', version: '0.1.0' }, { capabilities: {} });
await this.connectPreferredTransport(sdk);
}
private async connectPreferredTransport(sdk: McpSdkModule): Promise<void> {
const deadline =
this.config.connectionTimeoutMs === undefined
? undefined
: Date.now() + this.config.connectionTimeoutMs;
try {
await this.connectWithTransport(this.createTransport(this.config, sdk), deadline);
} catch (error) {
// An unconfigured HTTP endpoint may still serve legacy SSE. Retry only when
// it rejects Streamable HTTP; explicit transport choices never fall back.
if (
!this.config.url ||
this.config.transport !== undefined ||
!(error instanceof sdk.StreamableHTTPError) ||
(error.code !== 404 && error.code !== 405)
) {
throw error;
}
await this.client?.close();
this.client = new sdk.Client({ name: '@n8n/agents', version: '0.1.0' }, { capabilities: {} });
await this.connectWithTransport(
this.createSseTransport(new URL(this.config.url), this.config, sdk),
deadline,
);
}
}
private async connectWithTransport(transport: McpTransport, deadline?: number): Promise<void> {
if (!this.client) throw new Error('MCP client not initialized; connect() must be called first');
const client = this.client;
const timeoutMs = this.config.connectionTimeoutMs;
@ -135,8 +168,14 @@ export class McpConnection {
`MCP server "${this.config.name}": connectionTimeoutMs must be a positive finite number`,
);
}
const remainingMs = deadline === undefined ? timeoutMs : Math.max(0, deadline - Date.now());
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
if (remainingMs === 0) {
throw new Error(
`MCP server "${this.config.name}": connection timed out after ${timeoutMs}ms`,
);
}
await Promise.race([
client.connect(transport),
new Promise<never>((_, reject) => {
@ -146,7 +185,7 @@ export class McpConnection {
`MCP server "${this.config.name}": connection timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
}, remainingMs);
}),
]);
} catch (error) {
@ -284,19 +323,20 @@ export class McpConnection {
? { headers: config.headers }
: undefined;
if (config.transport === 'streamableHttp') {
return new sdk.StreamableHTTPClientTransport(url, {
requestInit,
fetch: config.fetch,
});
}
return new sdk.SSEClientTransport(url, {
if (config.transport === 'sse') return this.createSseTransport(url, config, sdk);
return new sdk.StreamableHTTPClientTransport(url, {
requestInit,
fetch: config.fetch,
eventSourceInit: config.fetch ? { fetch: config.fetch } : undefined,
});
}
throw new Error(`MCP server "${config.name}": provide either "url" or "command"`);
}
private createSseTransport(url: URL, config: McpServerConfig, sdk: McpSdkModule): McpTransport {
return new sdk.SSEClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
fetch: config.fetch,
eventSourceInit: config.fetch ? { fetch: config.fetch } : undefined,
});
}
}

View File

@ -6,8 +6,7 @@ type McpContentBlock = CallToolResult['content'][number];
export type McpModelContentPart =
| { type: 'text'; text: string }
| { type: 'image-data'; data: string; mediaType: string }
| { type: 'file-data'; data: string; mediaType: string };
| { type: 'file'; data: { type: 'data'; data: string }; mediaType: string };
export function hasMcpMediaContent(content: McpContentBlock[]): boolean {
return content.some(
@ -67,8 +66,8 @@ function mcpBlockToModelPart(block: McpContentBlock): McpModelContentPart | unde
if (block.type === 'image' && block.data) {
return {
type: 'image-data',
data: block.data,
type: 'file',
data: { type: 'data', data: block.data },
mediaType: block.mimeType || 'image/png',
};
}
@ -76,8 +75,8 @@ function mcpBlockToModelPart(block: McpContentBlock): McpModelContentPart | unde
if (block.type === 'resource') {
if ('blob' in block.resource) {
return {
type: 'file-data',
data: block.resource.blob,
type: 'file',
data: { type: 'data', data: block.resource.blob },
mediaType: block.resource.mimeType ?? 'application/octet-stream',
};
}

View File

@ -78,8 +78,7 @@ export function createEpisodicMemoryReflectFn(
incrementTokenCountFromUsage(input.executionCounter, response.usage);
return {
reflection: response.output,
// oxlint-disable-next-line typescript/no-deprecated - Tech debt. Changing to response.finalStep.providerMetadata breaks tests
usage: toTokenUsage(response.usage, response.providerMetadata),
usage: toTokenUsage(response.usage, response.finalStep.providerMetadata),
model: getModelIdString(model),
};
};

View File

@ -156,8 +156,7 @@ export function createObservationLogObserveFn(
options: CreateObservationLogObserveFnOptions = {},
): ObservationLogObserveFn {
return async (input) => {
// oxlint-disable-next-line typescript/no-deprecated
const { text, usage, providerMetadata } = await loadAi().generateText({
const { text, usage, finalStep } = await loadAi().generateText({
model: createModel(model),
instructions: options.observerPrompt ?? DEFAULT_OBSERVATION_LOG_OBSERVER_PROMPT,
prompt: buildObservationLogObserverPrompt(input),
@ -166,7 +165,7 @@ export function createObservationLogObserveFn(
});
incrementTokenCountFromUsage(input.executionCounter, usage);
const tokenUsage = toTokenUsage(usage, providerMetadata);
const tokenUsage = toTokenUsage(usage, finalStep.providerMetadata);
const modelId = getModelIdString(model);
if (options.onUsage && tokenUsage) {
await options.onUsage({
@ -325,8 +324,7 @@ export function createObservationLogReflectFn(
parentId: entry.parentId ? (referenceById.get(entry.parentId) ?? null) : null,
})),
);
// oxlint-disable-next-line typescript/no-deprecated
const { text, usage, providerMetadata } = await loadAi().generateText({
const { text, usage, finalStep } = await loadAi().generateText({
model: createModel(model),
instructions: options.reflectorPrompt ?? DEFAULT_OBSERVATION_LOG_REFLECTOR_PROMPT,
prompt: buildObservationLogReflectorPrompt({ ...input, renderedObservationLog }),
@ -334,7 +332,7 @@ export function createObservationLogReflectFn(
});
incrementTokenCountFromUsage(input.executionCounter, usage);
const tokenUsage = toTokenUsage(usage, providerMetadata);
const tokenUsage = toTokenUsage(usage, finalStep.providerMetadata);
const modelId = getModelIdString(model);
if (options.onUsage && tokenUsage) {
await options.onUsage({

View File

@ -440,7 +440,9 @@ function shouldStripBlob(
toolResult &&
key === 'data' &&
'type' in toolResult &&
(toolResult.type === 'image-data' || toolResult.type === 'file-data')
(toolResult.type === 'image-data' ||
toolResult.type === 'file-data' ||
toolResult.type === 'data')
) {
return true;
}

View File

@ -182,8 +182,7 @@ ${trimmed}
messages: [{ role: 'user', content: wrappedMessage }],
});
incrementTokenCountFromUsage(opts?.executionCounter, result.usage);
// oxlint-disable-next-line typescript/no-deprecated
const usage = toTokenUsage(result.usage, result.providerMetadata);
const usage = toTokenUsage(result.usage, result.finalStep.providerMetadata);
let text = result.text?.trim();
if (!text) return { title: null, usage };

View File

@ -67,8 +67,7 @@ function sanitizeOffloadedContentResult(output: ContentToolResultOutput): JSONVa
return toJsonValue({
type: 'content',
value: output.value.map((part) => {
// oxlint-disable-next-line typescript/no-deprecated
if (part.type !== 'text' || !isSerializedOffloadedToolResult(part.text)) return part;
if (!('text' in part) || !isSerializedOffloadedToolResult(part.text)) return part;
return { ...part, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
}),
});

View File

@ -103,8 +103,7 @@ async function guardContentToolResultForModel(
storage: ToolResultGuardStorage | undefined,
kind: ToolResultKind,
): Promise<GuardedToolResult> {
// oxlint-disable-next-line typescript/no-deprecated
const textParts = output.value.filter((part) => part.type === 'text');
const textParts = output.value.filter((part) => 'text' in part);
const historyOutput = toJsonValue(output);
if (textParts.length === 0) {
return { historyOutput, wireOutput: output, truncated: false, offloaded: false };

View File

@ -38,8 +38,7 @@ export function protectUntrustedToolResult(output: unknown, tool: ToolAttributio
let hasText = false;
const value = output.value.map((part): ContentToolResultOutput['value'][number] => {
// oxlint-disable-next-line typescript/no-deprecated
if (part.type !== 'text') return part;
if (!('text' in part)) return part;
hasText = true;
return { ...part, text: wrapToolText(part.text, tool) };
});

View File

@ -46,14 +46,8 @@ function makeGenerateSuccess(text: string) {
return {
finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
response: {
messages: [
{
role: 'assistant',
content: [{ type: 'text', text }],
},
],
},
responseMessages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
finalStep: { providerMetadata: undefined },
toolCalls: [],
};
}

View File

@ -280,8 +280,6 @@ export interface ExecutionOptions {
approvalContext?: ToolApprovalContext;
onStepStart?: (event: GenerateTextStepStartEvent) => void | Promise<void>;
onStepEnd?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
/** @deprecated Use `onStepEnd` instead. */
onStepFinish?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
/**
* Durable-log RFC (resilience phase), opt-in: persist a `running`-status
* checkpoint at every step boundary (after a tool batch settles, before the

View File

@ -40,7 +40,7 @@ export interface McpServerConfig {
// URL-based transports (exactly one of url or command must be provided)
/** Server endpoint URL for SSE or Streamable HTTP transport. */
url?: string;
/** Transport type for URL-based servers. Defaults to 'sse'. */
/** Transport type for URL-based servers. Defaults to Streamable HTTP. Legacy SSE is also supported. */
transport?: 'sse' | 'streamableHttp';
// Stdio transport

View File

@ -291,7 +291,11 @@ describe('createToolsFromLocalMcpServer', () => {
type: 'content',
value: [
{ type: 'text', text: 'current browser screenshot' },
{ type: 'image-data', data: 'base64-screenshot', mediaType: 'image/png' },
{
type: 'file',
data: { type: 'data', data: 'base64-screenshot' },
mediaType: 'image/png',
},
],
});
});
@ -326,7 +330,13 @@ describe('createToolsFromLocalMcpServer', () => {
expect(tool?.toModelOutput?.(PDF_RESULT)).toEqual({
type: 'content',
value: [{ type: 'file-data', data: 'base64-pdf', mediaType: 'application/pdf' }],
value: [
{
type: 'file',
data: { type: 'data', data: 'base64-pdf' },
mediaType: 'application/pdf',
},
],
});
});
});

View File

@ -10,7 +10,12 @@ import type {
BinaryDataService,
InstanceSettings,
} from 'n8n-core';
import { ENCODED_BUFFER_KEY, ExternalSecretsProxy, StructuredToolkit } from 'n8n-core';
import {
ENCODED_BUFFER_KEY,
ExternalSecretsProxy,
StructuredToolkit,
SupplyDataContext,
} from 'n8n-core';
import { mockInstance } from 'n8n-core/test/utils';
import {
type IPinData,
@ -1669,9 +1674,41 @@ describe('JobProcessor', () => {
await jobProcessor.processJob(job);
return { toolNodeInput, toolRun: run.data.resultData.runData[toolNode.name]?.[0] };
return { toolNodeInput, toolRun: run.data.resultData.runData[toolNode.name]?.[0], job };
};
it.each(['input', 'output'] as const)(
'should return the tool result when %s logging fails',
async (failedType) => {
const original = SupplyDataContext.prototype.addExecutionDataFunctions;
const record = vi
.spyOn(SupplyDataContext.prototype, 'addExecutionDataFunctions')
.mockImplementation(async function (
this: SupplyDataContext,
...args: Parameters<SupplyDataContext['addExecutionDataFunctions']>
) {
if (args[0] === failedType) throw new Error('hook failed');
return await original.apply(this, args);
});
try {
const { toolNodeInput, job } = await runToolCall({
triggerNames: ['MCP Server Trigger'],
triggerThatRan: 'MCP Server Trigger',
});
expect(toolNodeInput).toEqual([{ json: {} }]);
expect(job.progress).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'mcp-response', response: [{ response: 42 }] }),
);
expect(logger.warn).toHaveBeenCalledWith(
`There was a problem logging ${failedType} data of node "Calculator": hook failed`,
);
} finally {
record.mockRestore();
}
},
);
it.each([
{ toolInput: mcpToolInput, expected: mcpToolInput },
{ toolInput: undefined, expected: {} },

View File

@ -697,8 +697,26 @@ export class JobProcessor {
}
if (nodeType.execute && nodeType.description.outputs.includes(NodeConnectionTypes.AiTool)) {
// oxlint-disable-next-line typescript/no-deprecated
context.addInputData(NodeConnectionTypes.AiTool, [
const toolRunIndex = context.getNextRunIndex();
const recordToolData = async (
type: 'input' | 'output',
data: INodeExecutionData[][] | NodeOperationError,
) => {
try {
await context.addExecutionDataFunctions(
type,
data,
NodeConnectionTypes.AiTool,
toolNode.name,
toolRunIndex,
);
} catch (error) {
this.logger.warn(
`There was a problem logging ${type} data of node "${toolNode.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
};
await recordToolData('input', [
[{ json: validatedToolArgs as INodeExecutionData['json'] }],
]);
@ -708,10 +726,8 @@ export class JobProcessor {
} catch (error) {
// Record the failure so the tool node shows as errored, not stuck
// "running"; rethrow so the caller returns an error to the client.
// oxlint-disable-next-line typescript/no-deprecated
context.addOutputData(
NodeConnectionTypes.AiTool,
0,
await recordToolData(
'output',
error instanceof NodeOperationError
? error
: new NodeOperationError(toolNode, error as Error),
@ -724,8 +740,7 @@ export class JobProcessor {
response = result?.[0]?.flatMap((item: INodeExecutionData) => item.json);
}
// oxlint-disable-next-line typescript/no-deprecated
context.addOutputData(NodeConnectionTypes.AiTool, 0, [[{ json: { response } }]]);
await recordToolData('output', [[{ json: { response } }]]);
return response;
}

View File

@ -157,8 +157,19 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
this.abortSignal,
this.parentNode,
);
// oxlint-disable-next-line typescript/no-deprecated - Still used by some edge cases
context.addInputData(NodeConnectionTypes.AiTool, replacements.inputData);
void context
.addExecutionDataFunctions(
'input',
replacements.inputData,
NodeConnectionTypes.AiTool,
context.node.name,
context.getNextRunIndex(),
)
.catch((error: Error) => {
context.logger.warn(
`There was a problem logging input data of node "${context.node.name}": ${error.message}`,
);
});
return context;
}

View File

@ -5,6 +5,59 @@ import ToolResultRenderer from '../components/ToolResultRenderer.vue';
const renderComponent = createComponentRenderer(ToolResultRenderer);
describe('ToolResultRenderer', () => {
it.each([
{ mediaType: 'image/png', selector: 'img' },
{ mediaType: 'application/pdf', selector: 'iframe' },
])('renders tagged $mediaType data with and without text', ({ mediaType, selector }) => {
const file = { type: 'file', data: { type: 'data', data: 'YWJj' }, mediaType };
const { container, getByText, rerender } = renderComponent({
props: {
toolName: 'read_file',
result: { type: 'content', value: [file] },
},
});
expect(container.querySelector(selector)?.getAttribute('src')).toBe(
`data:${mediaType};base64,YWJj`,
);
if (mediaType === 'application/pdf') {
expect(container.querySelector('embed')).not.toBeInTheDocument();
expect(container.querySelector('iframe')?.getAttribute('title')).toBeTruthy();
}
return rerender({
result: { type: 'content', value: [{ type: 'text', text: 'file result' }, file] },
}).then(() => {
expect(getByText('file result')).toBeInTheDocument();
expect(container.querySelector(selector)?.getAttribute('src')).toBe(
`data:${mediaType};base64,YWJj`,
);
});
});
it.each([
null,
{ type: 'data', data: 123 },
{ type: 'url', url: 'https://example.test/file.png' },
{ type: 'reference', reference: 'file-123' },
])('ignores unsupported tagged file data: %j', (data) => {
const { container, getByText } = renderComponent({
props: {
toolName: 'read_file',
result: {
type: 'content',
value: [
{ type: 'text', text: 'file result' },
{ type: 'file', data, mediaType: 'image/png' },
],
},
},
});
expect(getByText('file result')).toBeInTheDocument();
expect(container.querySelector('img, iframe')).not.toBeInTheDocument();
});
it('renders MCP image content', () => {
const { container, getByText } = renderComponent({
props: {

View File

@ -52,6 +52,20 @@ function normalizeContentItem(item: unknown): McpContentItem | null {
if (item.type === 'text' && typeof item.text === 'string') {
return { type: 'text', text: item.text };
}
if (
item.type === 'file' &&
typeof item.mediaType === 'string' &&
isRecord(item.data) &&
item.data.type === 'data' &&
typeof item.data.data === 'string'
) {
return item.mediaType.startsWith('image/')
? { type: 'image', data: item.data.data, mimeType: item.mediaType }
: {
type: 'resource',
resource: { uri: '', blob: item.data.data, mimeType: item.mediaType },
};
}
if (
item.type === 'file-data' &&
typeof item.mediaType === 'string' &&