diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/execution-options/calls-onstepstart-and-onstepfinish-for-generate-and-stream.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/execution-options/calls-onstepstart-and-onstepend-for-generate-and-stream.json
similarity index 100%
rename from packages/@n8n/agents/src/__tests__/integration/cassettes/execution-options/calls-onstepstart-and-onstepfinish-for-generate-and-stream.json
rename to packages/@n8n/agents/src/__tests__/integration/cassettes/execution-options/calls-onstepstart-and-onstepend-for-generate-and-stream.json
diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/calls-an-mcp-tool-during-generation-and-returns-the-result.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/calls-an-mcp-tool-during-generation-and-returns-the-result.json
index bd8e5e25e67..b9679d55ac2 100644
--- a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/calls-an-mcp-tool-during-generation-and-returns-the-result.json
+++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/calls-an-mcp-tool-during-generation-and-returns-the-result.json
@@ -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
}
diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/does-not-suspend-a-tool-not-listed-in-config-requireapproval.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/does-not-suspend-a-tool-not-listed-in-config-requireapproval.json
index 2d320d76516..7b44b885612 100644
--- a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/does-not-suspend-a-tool-not-listed-in-config-requireapproval.json
+++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/does-not-suspend-a-tool-not-listed-in-config-requireapproval.json
@@ -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
}
diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/mcp-connections-persist-across-multiple-generate-calls.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/mcp-connections-persist-across-multiple-generate-calls.json
index fd36feeadec..f3fe74d0b44 100644
--- a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/mcp-connections-persist-across-multiple-generate-calls.json
+++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/mcp-connections-persist-across-multiple-generate-calls.json
@@ -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
}
diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/merges-static-tools-and-mcp-tools-in-the-same-agent.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/merges-static-tools-and-mcp-tools-in-the-same-agent.json
index 4ad98f9e69f..9aead16f47e 100644
--- a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/merges-static-tools-and-mcp-tools-in-the-same-agent.json
+++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/merges-static-tools-and-mcp-tools-in-the-same-agent.json
@@ -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
}
diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/streams-a-response-that-includes-an-mcp-tool-call.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/streams-a-response-that-includes-an-mcp-tool-call.json
index a03e9b55499..8cfbe96449c 100644
--- a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/streams-a-response-that-includes-an-mcp-tool-call.json
+++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/streams-a-response-that-includes-an-mcp-tool-call.json
@@ -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": "\n{\"content\":[{\"type\":\"text\",\"text\":\"stream works\"}]}\n"
+ }
+ ]
}
]
}
@@ -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
}
diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-all-mcp-tools-when-config-requireapproval-true.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-all-mcp-tools-when-config-requireapproval-true.json
index e54321bb830..c9203ed5d5b 100644
--- a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-all-mcp-tools-when-config-requireapproval-true.json
+++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-all-mcp-tools-when-config-requireapproval-true.json
@@ -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
}
diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-only-the-listed-tool-when-config-requireapproval-is-a-string-array.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-only-the-listed-tool-when-config-requireapproval-is-a-string-array.json
index 7e61e8a589f..d04363860cd 100644
--- a/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-only-the-listed-tool-when-config-requireapproval-is-a-string-array.json
+++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/mcp-runtime/suspends-only-the-listed-tool-when-config-requireapproval-is-a-string-array.json
@@ -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
}
diff --git a/packages/@n8n/agents/src/__tests__/integration/execution-options.test.ts b/packages/@n8n/agents/src/__tests__/integration/execution-options.test.ts
index 3500a7e726d..10a4f9d6cb4 100644
--- a/packages/@n8n/agents/src/__tests__/integration/execution-options.test.ts
+++ b/packages/@n8n/agents/src/__tests__/integration/execution-options.test.ts
@@ -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);
diff --git a/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts b/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts
index 7946db431a6..3b59a32b681 100644
--- a/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts
+++ b/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts
@@ -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();
});
diff --git a/packages/@n8n/agents/src/__tests__/integration/mcp-server-helpers.ts b/packages/@n8n/agents/src/__tests__/integration/mcp-server-helpers.ts
index 334a8a7885d..39ee72f2a8a 100644
--- a/packages/@n8n/agents/src/__tests__/integration/mcp-server-helpers.ts
+++ b/packages/@n8n/agents/src/__tests__/integration/mcp-server-helpers.ts
@@ -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 {
- // oxlint-disable-next-line typescript/no-deprecated
+ // oxlint-disable-next-line typescript/no-deprecated -- Test the supported SSE compatibility path.
const transports = new Map();
const httpServer = http.createServer(async (req, res) => {
@@ -107,7 +70,7 @@ export async function startSseServer(): Promise {
// 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);
diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime-memory.otel.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime-memory.otel.test.ts
index 66a8cba524c..c8ac86d24a8 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime-memory.otel.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime-memory.otel.test.ts
@@ -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([]),
};
}
diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts
index da57b410a69..3174f84d746 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts
@@ -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('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;
+ providerMetadata?: PromiseLike;
+ finalStep?: PromiseLike;
+ };
+ 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 = {
diff --git a/packages/@n8n/agents/src/runtime/__tests__/crash-resume.test.ts b/packages/@n8n/agents/src/runtime/__tests__/crash-resume.test.ts
index 57f9519666c..e533deb0331 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/crash-resume.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/crash-resume.test.ts
@@ -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 {
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?' } },
]),
diff --git a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts
index f17c265ba99..dcd3a97f3ba 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts
@@ -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)({
diff --git a/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts b/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts
index 825560d4ff4..87d64d1225d 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts
@@ -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(() => {}));
+ 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({
diff --git a/packages/@n8n/agents/src/runtime/__tests__/mcp-tool-resolver.test.ts b/packages/@n8n/agents/src/runtime/__tests__/mcp-tool-resolver.test.ts
index 35726577164..801aaa422b1 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/mcp-tool-resolver.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/mcp-tool-resolver.test.ts
@@ -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',
diff --git a/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts b/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts
index 27f71f37676..4211127ef60 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts
@@ -41,8 +41,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual('ai');
return {
...actual,
- generateText: async (call: GenerateTextCall): Promise =>
- 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}`,
},
diff --git a/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts b/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts
index 863eb341536..d8c8303b306 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts
@@ -31,8 +31,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual('ai');
return {
...actual,
- generateText: async (call: GenerateTextCall): Promise =>
- await mockGenerateText(call),
+ generateText: async (call: GenerateTextCall) => {
+ const result = await mockGenerateText(call);
+ return { ...result, finalStep: { providerMetadata: undefined } };
+ },
};
});
diff --git a/packages/@n8n/agents/src/runtime/__tests__/title-generation-side-call.test.ts b/packages/@n8n/agents/src/runtime/__tests__/title-generation-side-call.test.ts
index 600eb848fbf..f706184fd16 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/title-generation-side-call.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/title-generation-side-call.test.ts
@@ -21,7 +21,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual('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 } };
+ },
};
});
diff --git a/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts b/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts
index db358f03698..673c74fe679 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts
@@ -23,8 +23,10 @@ vi.mock('ai', async () => {
const actual = await vi.importActual('ai');
return {
...actual,
- generateText: async (call: GenerateTextCall): Promise =>
- await mockGenerateText(call),
+ generateText: async (call: GenerateTextCall) => {
+ const result = await mockGenerateText(call);
+ return { ...result, finalStep: { providerMetadata: undefined } };
+ },
};
});
diff --git a/packages/@n8n/agents/src/runtime/__tests__/untrusted-tool-output.test.ts b/packages/@n8n/agents/src/runtime/__tests__/untrusted-tool-output.test.ts
index 2345c19547b..96df522ada0 100644
--- a/packages/@n8n/agents/src/runtime/__tests__/untrusted-tool-output.test.ts
+++ b/packages/@n8n/agents/src/runtime/__tests__/untrusted-tool-output.test.ts
@@ -49,7 +49,11 @@ describe('untrusted tool output', () => {
type: 'content' as const,
value: [
{ type: 'text' as const, text: 'caption' },
- { 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: '\ncaption</untrusted_data>\n',
},
- { 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,
diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts
index d019723b4c1..aed0fd565c4 100644
--- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts
+++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts
@@ -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(
{
diff --git a/packages/@n8n/agents/src/runtime/loop/generate-sink.ts b/packages/@n8n/agents/src/runtime/loop/generate-sink.ts
index c0a69612251..a0449d6476f 100644
--- a/packages/@n8n/agents/src/runtime/loop/generate-sink.ts
+++ b/packages/@n8n/agents/src/runtime/loop/generate-sink.ts
@@ -49,14 +49,12 @@ export class GenerateSink implements RunOutputSink {
});
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:
diff --git a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts
index 3382f36d0e7..717a8a9bf2b 100644
--- a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts
+++ b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts
@@ -385,11 +385,8 @@ export class StreamSink implements RunOutputSink {
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 {
return {
aiFinishReason,
finishReason: fromAiFinishReason(aiFinishReason),
- usage: toTokenUsage(usage, providerMetadata),
+ usage: toTokenUsage(usage, finalStep.providerMetadata),
newMessages,
toolCalls: await settle(result.toolCalls),
structuredOutput:
diff --git a/packages/@n8n/agents/src/runtime/mcp/mcp-connection.ts b/packages/@n8n/agents/src/runtime/mcp/mcp-connection.ts
index f29d9512c0b..91b50e69720 100644
--- a/packages/@n8n/agents/src/runtime/mcp/mcp-connection.ts
+++ b/packages/@n8n/agents/src/runtime/mcp/mcp-connection.ts
@@ -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 {
+ private async doConnect(): Promise {
+ 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 {
+ 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 {
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 | 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((_, 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,
+ });
+ }
}
diff --git a/packages/@n8n/agents/src/runtime/mcp/mcp-content.ts b/packages/@n8n/agents/src/runtime/mcp/mcp-content.ts
index 013772a1b80..d3fb7bcff68 100644
--- a/packages/@n8n/agents/src/runtime/mcp/mcp-content.ts
+++ b/packages/@n8n/agents/src/runtime/mcp/mcp-content.ts
@@ -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',
};
}
diff --git a/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts b/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts
index 7061208459f..20686b1d762 100644
--- a/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts
+++ b/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts
@@ -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),
};
};
diff --git a/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts b/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts
index 1a030fa3edc..4e3c9923e05 100644
--- a/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts
+++ b/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts
@@ -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({
diff --git a/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts b/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts
index eea86d8db4f..586967ae42a 100644
--- a/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts
+++ b/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts
@@ -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;
}
diff --git a/packages/@n8n/agents/src/runtime/memory/title-generation.ts b/packages/@n8n/agents/src/runtime/memory/title-generation.ts
index 6ae22864448..8d6efe53eed 100644
--- a/packages/@n8n/agents/src/runtime/memory/title-generation.ts
+++ b/packages/@n8n/agents/src/runtime/memory/title-generation.ts
@@ -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 };
diff --git a/packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts b/packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts
index 300929aa93e..d99e2072fb0 100644
--- a/packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts
+++ b/packages/@n8n/agents/src/runtime/memory/tool-result-memory.ts
@@ -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 };
}),
});
diff --git a/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts b/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts
index 70ea50e0284..d01fe7da743 100644
--- a/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts
+++ b/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts
@@ -103,8 +103,7 @@ async function guardContentToolResultForModel(
storage: ToolResultGuardStorage | undefined,
kind: ToolResultKind,
): Promise {
- // 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 };
diff --git a/packages/@n8n/agents/src/runtime/tools/untrusted-tool-output.ts b/packages/@n8n/agents/src/runtime/tools/untrusted-tool-output.ts
index a609607565d..949cc41a81c 100644
--- a/packages/@n8n/agents/src/runtime/tools/untrusted-tool-output.ts
+++ b/packages/@n8n/agents/src/runtime/tools/untrusted-tool-output.ts
@@ -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) };
});
diff --git a/packages/@n8n/agents/src/sdk/__tests__/agent-isolated-runtime.test.ts b/packages/@n8n/agents/src/sdk/__tests__/agent-isolated-runtime.test.ts
index 21c9a5208c1..3aca759fbfe 100644
--- a/packages/@n8n/agents/src/sdk/__tests__/agent-isolated-runtime.test.ts
+++ b/packages/@n8n/agents/src/sdk/__tests__/agent-isolated-runtime.test.ts
@@ -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: [],
};
}
diff --git a/packages/@n8n/agents/src/types/sdk/agent.ts b/packages/@n8n/agents/src/types/sdk/agent.ts
index cbef9f8fa0e..3b0670baf7b 100644
--- a/packages/@n8n/agents/src/types/sdk/agent.ts
+++ b/packages/@n8n/agents/src/types/sdk/agent.ts
@@ -280,8 +280,6 @@ export interface ExecutionOptions {
approvalContext?: ToolApprovalContext;
onStepStart?: (event: GenerateTextStepStartEvent) => void | Promise;
onStepEnd?: (event: GenerateTextStepEndEvent) => void | Promise;
- /** @deprecated Use `onStepEnd` instead. */
- onStepFinish?: (event: GenerateTextStepEndEvent) => void | Promise;
/**
* Durable-log RFC (resilience phase), opt-in: persist a `running`-status
* checkpoint at every step boundary (after a tool batch settles, before the
diff --git a/packages/@n8n/agents/src/types/sdk/mcp.ts b/packages/@n8n/agents/src/types/sdk/mcp.ts
index e5caea44af2..bcbf997a487 100644
--- a/packages/@n8n/agents/src/types/sdk/mcp.ts
+++ b/packages/@n8n/agents/src/types/sdk/mcp.ts
@@ -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
diff --git a/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts b/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts
index 960cb323d7e..eb043386b56 100644
--- a/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts
+++ b/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts
@@ -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',
+ },
+ ],
});
});
});
diff --git a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts
index fb6f7631af5..5a90eb2f6af 100644
--- a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts
+++ b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts
@@ -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
+ ) {
+ 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: {} },
diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts
index 89c8c3a9286..6f393cbfb3e 100644
--- a/packages/cli/src/scaling/job-processor.ts
+++ b/packages/cli/src/scaling/job-processor.ts
@@ -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;
}
diff --git a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts
index 7e09d2ca21a..b3670f7dd60 100644
--- a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts
+++ b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts
@@ -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;
}
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/ToolResultRenderer.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/ToolResultRenderer.test.ts
index a6108bd18ed..ff8a66086cd 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/ToolResultRenderer.test.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/ToolResultRenderer.test.ts
@@ -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: {
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ToolResultRenderer.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ToolResultRenderer.vue
index 0b35a89fa53..9c605065ce1 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ToolResultRenderer.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ToolResultRenderer.vue
@@ -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' &&