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' &&