mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix: Update runtime and MCP dependency APIs (no-changelog) (#40204)
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com> Co-authored-by: Michael Drury <michael.drury@n8n.io>
This commit is contained in:
parent
e67e5a9ef1
commit
03f7c76acd
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@
|
|||
"description": "Message to echo"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
"required": ["message"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
|
|
@ -54,7 +56,9 @@
|
|||
"description": "Second number"
|
||||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
"required": ["a", "b"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
|
|
@ -69,7 +73,9 @@
|
|||
"description": "Image caption"
|
||||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
"required": ["caption"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
|
|
@ -159,7 +165,12 @@
|
|||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_019kuzXjUdxZoB117HouDVUb",
|
||||
"content": "{\"content\":[{\"type\":\"text\",\"text\":\"stream works\"}]}"
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "<untrusted_data source=\"mcp:tools\" label=\"echo\">\n{\"content\":[{\"type\":\"text\",\"text\":\"stream works\"}]}\n</untrusted_data>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -176,7 +187,9 @@
|
|||
"description": "Message to echo"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
"required": ["message"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
|
|
@ -195,7 +208,9 @@
|
|||
"description": "Second number"
|
||||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
"required": ["a", "b"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
|
|
@ -210,7 +225,9 @@
|
|||
"description": "Image caption"
|
||||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
"required": ["caption"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
* for use in integration tests. No mocking of SDK internals.
|
||||
*/
|
||||
|
||||
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import http from 'http';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** 1×1 transparent PNG in base64 (smallest valid PNG). Used for image tool tests. */
|
||||
export const TINY_PNG =
|
||||
|
|
@ -20,84 +20,47 @@ export interface TestServer {
|
|||
}
|
||||
|
||||
/** Create an in-process MCP Server with three test tools: echo, add, and image. */
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
export function createTestMcpServer(): McpServer {
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
const server = new McpServer(
|
||||
{ name: 'test-mcp-server', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
const server = new McpServer({ name: 'test-mcp-server', version: '1.0.0' });
|
||||
server.registerTool(
|
||||
'echo',
|
||||
{
|
||||
description: 'Echo the message back as-is',
|
||||
inputSchema: { message: z.string().describe('Message to echo') },
|
||||
},
|
||||
async ({ message }) => ({ content: [{ type: 'text', text: message }] }),
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Echo the message back as-is',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string', description: 'Message to echo' } },
|
||||
required: ['message'],
|
||||
},
|
||||
server.registerTool(
|
||||
'add',
|
||||
{
|
||||
description: 'Add two numbers together',
|
||||
inputSchema: {
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().describe('Second number'),
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add two numbers together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' },
|
||||
},
|
||||
required: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
description: 'Return a small image with a caption',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { caption: { type: 'string', description: 'Image caption' } },
|
||||
required: ['caption'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
|
||||
if (name === 'echo') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return { content: [{ type: 'text', text: String(args.message ?? '') }] };
|
||||
}
|
||||
|
||||
if (name === 'add') {
|
||||
const sum = Number(args.a ?? 0) + Number(args.b ?? 0);
|
||||
return { content: [{ type: 'text', text: String(sum) }] };
|
||||
}
|
||||
|
||||
if (name === 'image') {
|
||||
return {
|
||||
content: [
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
{ type: 'text', text: String(args.caption ?? '') },
|
||||
{ type: 'image', data: TINY_PNG, mimeType: 'image/png' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
||||
};
|
||||
});
|
||||
},
|
||||
async ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] }),
|
||||
);
|
||||
server.registerTool(
|
||||
'image',
|
||||
{
|
||||
description: 'Return a small image with a caption',
|
||||
inputSchema: { caption: z.string().describe('Image caption') },
|
||||
},
|
||||
async ({ caption }) => ({
|
||||
content: [
|
||||
{ type: 'text', text: caption },
|
||||
{ type: 'image', data: TINY_PNG, mimeType: 'image/png' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/** Start an SSE MCP server on a random port. Returns the SSE endpoint URL and a close function. */
|
||||
export async function startSseServer(): Promise<TestServer> {
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Test the supported SSE compatibility path.
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
|
|
@ -107,7 +70,7 @@ export async function startSseServer(): Promise<TestServer> {
|
|||
// a single active transport reference and rejects a second connect() call
|
||||
// if the first transport hasn't been fully torn down yet.
|
||||
const mcpServer = createTestMcpServer();
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Test the supported SSE compatibility path.
|
||||
const transport = new SSEServerTransport('/message', res);
|
||||
transports.set(transport.sessionId, transport);
|
||||
await mcpServer.connect(transport);
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,12 +98,56 @@ type AiImport = typeof import('ai');
|
|||
// Mock generateText and streamText from the 'ai' package
|
||||
vi.mock('ai', async () => {
|
||||
const actual = await vi.importActual<AiImport>('ai');
|
||||
// Keep existing model fixtures in one place while exercising the current SDK result shape.
|
||||
const generateMock = vi.fn();
|
||||
const streamMock = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
embed: vi.fn(),
|
||||
embedMany: vi.fn(),
|
||||
generateText: vi.fn(),
|
||||
streamText: vi.fn(),
|
||||
generateText: new Proxy(generateMock, {
|
||||
apply: async (target, thisArg, args) => {
|
||||
const result: unknown = await Reflect.apply(target, thisArg, args);
|
||||
if (!result || typeof result !== 'object') return result;
|
||||
const legacy = result as {
|
||||
response?: { messages: unknown[] };
|
||||
responseMessages?: unknown[];
|
||||
providerMetadata?: unknown;
|
||||
finalStep?: unknown;
|
||||
};
|
||||
return {
|
||||
...legacy,
|
||||
responseMessages: legacy.responseMessages ?? legacy.response?.messages,
|
||||
finalStep: legacy.finalStep ?? { providerMetadata: legacy.providerMetadata },
|
||||
};
|
||||
},
|
||||
}),
|
||||
streamText: new Proxy(streamMock, {
|
||||
apply: (target, thisArg, args) => {
|
||||
const result: unknown = Reflect.apply(target, thisArg, args);
|
||||
if (!result || typeof result !== 'object') return result;
|
||||
const legacy = result as {
|
||||
response?: PromiseLike<{ messages: unknown[] }>;
|
||||
responseMessages?: PromiseLike<unknown[]>;
|
||||
providerMetadata?: PromiseLike<unknown>;
|
||||
finalStep?: PromiseLike<unknown>;
|
||||
};
|
||||
const responseMessages =
|
||||
legacy.responseMessages ??
|
||||
Promise.resolve(legacy.response).then((response) => response?.messages);
|
||||
// Aborted streams need no result. Mark the derived promise as handled.
|
||||
void Promise.resolve(responseMessages).catch(() => {});
|
||||
return {
|
||||
...legacy,
|
||||
responseMessages,
|
||||
finalStep:
|
||||
legacy.finalStep ??
|
||||
Promise.resolve(legacy.providerMetadata).then((providerMetadata) => ({
|
||||
providerMetadata,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
tool: vi.fn((config: unknown) => config),
|
||||
jsonSchema: vi.fn((schema: unknown) => ({ _type: 'jsonSchema', schema })),
|
||||
Output: {
|
||||
|
|
@ -1051,6 +1095,47 @@ describe('AgentRuntime — execution counters', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('preserves messages and final-step metadata in generate and stream results', async () => {
|
||||
const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
|
||||
const providerMetadata = { openai: { cachedPromptTokens: 3 } };
|
||||
const responseMessages = [{ role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }];
|
||||
generateText.mockResolvedValue({
|
||||
finishReason: 'stop',
|
||||
usage,
|
||||
responseMessages,
|
||||
finalStep: { providerMetadata },
|
||||
toolCalls: [],
|
||||
});
|
||||
streamText.mockReturnValue({
|
||||
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'Hello' }]),
|
||||
finishReason: Promise.resolve('stop'),
|
||||
usage: Promise.resolve(usage),
|
||||
responseMessages: Promise.resolve(responseMessages),
|
||||
finalStep: Promise.resolve({ providerMetadata }),
|
||||
toolCalls: Promise.resolve([]),
|
||||
});
|
||||
const { runtime } = createRuntime();
|
||||
const generated = await runtime.generate('hi');
|
||||
const streamed = await collectChunks((await runtime.stream('hi')).stream);
|
||||
const expectedUsage = {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
inputTokenDetails: { noCache: 7, cacheRead: 3 },
|
||||
};
|
||||
expect(generated.messages).toMatchObject(responseMessages);
|
||||
expect(generated.finishReason).toBe('stop');
|
||||
expect(generated.usage).toMatchObject(expectedUsage);
|
||||
expect(streamed).toContainEqual(
|
||||
expect.objectContaining({ type: 'text-delta', delta: 'Hello' }),
|
||||
);
|
||||
expect(streamed.at(-1)).toMatchObject({
|
||||
type: 'finish',
|
||||
finishReason: 'stop',
|
||||
usage: expectedUsage,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows system-role messages in generateText and streamText history', async () => {
|
||||
generateText.mockResolvedValue(makeGenerateSuccess());
|
||||
streamText.mockReturnValue(makeStreamSuccess());
|
||||
|
|
@ -10259,8 +10344,11 @@ describe('AgentRuntime — oversized tool results', () => {
|
|||
type: 'content' as const,
|
||||
value: [
|
||||
{
|
||||
type: 'file-data' as const,
|
||||
data: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.repeat(8_000),
|
||||
type: 'file' as const,
|
||||
data: {
|
||||
type: 'data' as const,
|
||||
data: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.repeat(8_000),
|
||||
},
|
||||
mediaType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
|
|
@ -10645,8 +10733,8 @@ describe('AgentRuntime — oversized tool results', () => {
|
|||
},
|
||||
];
|
||||
const filePart = {
|
||||
type: 'file-data' as const,
|
||||
data: 'base64-pdf',
|
||||
type: 'file' as const,
|
||||
data: { type: 'data' as const, data: 'base64-pdf' },
|
||||
mediaType: 'application/pdf',
|
||||
};
|
||||
const tool: BuiltTool = {
|
||||
|
|
|
|||
|
|
@ -70,9 +70,8 @@ function makeStreamSuccess(text = 'Hello') {
|
|||
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text }]),
|
||||
finishReason: Promise.resolve('stop'),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
|
||||
response: Promise.resolve({
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text }] }],
|
||||
}),
|
||||
responseMessages: Promise.resolve([{ role: 'assistant', content: [{ type: 'text', text }] }]),
|
||||
finalStep: Promise.resolve({ providerMetadata: undefined }),
|
||||
toolCalls: Promise.resolve([]),
|
||||
};
|
||||
}
|
||||
|
|
@ -82,14 +81,13 @@ function makeStreamWithToolCall(toolCallId: string, args: Record<string, unknown
|
|||
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'working...' }]),
|
||||
finishReason: Promise.resolve('tool-calls'),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
|
||||
response: Promise.resolve({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId, toolName: 'lookup', args }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
responseMessages: Promise.resolve([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId, toolName: 'lookup', input: args }],
|
||||
},
|
||||
]),
|
||||
finalStep: Promise.resolve({ providerMetadata: undefined }),
|
||||
toolCalls: Promise.resolve([{ toolCallId, toolName: 'lookup', input: args }]),
|
||||
};
|
||||
}
|
||||
|
|
@ -213,6 +211,20 @@ describe('step checkpoints + crash resume (durable-log RFC)', () => {
|
|||
const secondJson = JSON.stringify(stepSaves[1].state.messageList);
|
||||
expect(secondJson).toContain('tc-1');
|
||||
expect(secondJson).toContain('tc-2');
|
||||
for (const [toolCallId, value] of [
|
||||
['tc-1', 'first'],
|
||||
['tc-2', 'second'],
|
||||
]) {
|
||||
expect(stepSaves[1].state.messageList.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ toolCallId, input: { value } }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
// The completed run deleted its checkpoint (no leak).
|
||||
expect(store.deletes).toContain(result.runId);
|
||||
|
||||
|
|
@ -268,21 +280,20 @@ describe('step checkpoints + crash resume (durable-log RFC)', () => {
|
|||
stream: makeChunkStream([{ type: 'text-delta', id: 'text-1', text: 'asking...' }]),
|
||||
finishReason: Promise.resolve('tool-calls'),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }),
|
||||
response: Promise.resolve({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-hitl',
|
||||
toolName: 'approve',
|
||||
args: { question: 'ok?' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
responseMessages: Promise.resolve([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'tc-hitl',
|
||||
toolName: 'approve',
|
||||
input: { question: 'ok?' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
finalStep: Promise.resolve({ providerMetadata: undefined }),
|
||||
toolCalls: Promise.resolve([
|
||||
{ toolCallId: 'tc-hitl', toolName: 'approve', input: { question: 'ok?' } },
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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)({
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
||||
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
StreamableHTTPClientTransport: vi.fn(function (url: URL, options: unknown) {
|
||||
streamableHttpCtor(url, options);
|
||||
return { type: 'streamableHttp', url, options };
|
||||
|
|
@ -64,7 +65,8 @@ describe('McpConnection — custom fetch forwarding', () => {
|
|||
sseCtor.mockClear();
|
||||
streamableHttpCtor.mockClear();
|
||||
stdioCtor.mockClear();
|
||||
clientConnect.mockClear();
|
||||
clientConnect.mockReset().mockResolvedValue(undefined);
|
||||
clientClose.mockReset().mockResolvedValue(undefined);
|
||||
clientListTools.mockClear();
|
||||
});
|
||||
|
||||
|
|
@ -84,6 +86,55 @@ describe('McpConnection — custom fetch forwarding', () => {
|
|||
expect(options.fetch).toBe(customFetch);
|
||||
});
|
||||
|
||||
it('uses Streamable HTTP for an unconfigured URL', async () => {
|
||||
const conn = new McpConnection({ name: 'default', url: 'https://example.test/mcp' });
|
||||
await conn.connect();
|
||||
expect(streamableHttpCtor).toHaveBeenCalledTimes(1);
|
||||
expect(sseCtor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a legacy SSE endpoint when Streamable HTTP is unsupported', async () => {
|
||||
const { StreamableHTTPError } = await import(
|
||||
'@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
);
|
||||
clientConnect.mockRejectedValueOnce(new StreamableHTTPError(404, 'Not found'));
|
||||
const conn = new McpConnection({ name: 'legacy', url: 'https://example.test/sse' });
|
||||
await conn.connect();
|
||||
expect(streamableHttpCtor).toHaveBeenCalledTimes(1);
|
||||
expect(sseCtor).toHaveBeenCalledTimes(1);
|
||||
expect(clientClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shares the connection timeout with the SSE retry', async () => {
|
||||
const { StreamableHTTPError } = await import(
|
||||
'@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
clientConnect
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 90));
|
||||
throw new StreamableHTTPError(404, 'Not found');
|
||||
})
|
||||
.mockImplementationOnce(async () => await new Promise<void>(() => {}));
|
||||
const conn = new McpConnection({
|
||||
name: 'legacy',
|
||||
url: 'https://example.test/sse',
|
||||
connectionTimeoutMs: 100,
|
||||
});
|
||||
|
||||
const connecting = conn.connect();
|
||||
await vi.advanceTimersByTimeAsync(90);
|
||||
expect(sseCtor).toHaveBeenCalledTimes(1);
|
||||
const timedOut = expect(connecting).rejects.toThrow('connection timed out after 100ms');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await timedOut;
|
||||
expect(clientConnect).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards `fetch` to SSEClientTransport and to its eventSourceInit when provided', async () => {
|
||||
const customFetch = vi.fn();
|
||||
const conn = new McpConnection({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@ vi.mock('ai', async () => {
|
|||
const actual = await vi.importActual<typeof AiImport>('ai');
|
||||
return {
|
||||
...actual,
|
||||
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
|
||||
await mockGenerateText(call),
|
||||
generateText: async (call: GenerateTextCall) => {
|
||||
const result = await mockGenerateText(call);
|
||||
return { ...result, finalStep: { providerMetadata: result.providerMetadata } };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -313,7 +315,11 @@ describe('renderObserverTranscript', () => {
|
|||
Array.from({ length: 41 }, (_, index) => [`section${index}`, document]),
|
||||
),
|
||||
image: { type: 'image-data', data: binary, mediaType: 'image/png' },
|
||||
file: { type: 'file-data', data: binary, mediaType: 'application/pdf' },
|
||||
file: {
|
||||
type: 'file',
|
||||
data: { type: 'data', data: binary },
|
||||
mediaType: 'application/pdf',
|
||||
},
|
||||
blob: binary,
|
||||
imageUrl: `data:image/png;base64,${binary}`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ vi.mock('ai', async () => {
|
|||
const actual = await vi.importActual<typeof AiImport>('ai');
|
||||
return {
|
||||
...actual,
|
||||
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
|
||||
await mockGenerateText(call),
|
||||
generateText: async (call: GenerateTextCall) => {
|
||||
const result = await mockGenerateText(call);
|
||||
return { ...result, finalStep: { providerMetadata: undefined } };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ vi.mock('ai', async () => {
|
|||
const actual = await vi.importActual<typeof AiImport>('ai');
|
||||
return {
|
||||
...actual,
|
||||
generateText: async (...args: unknown[]) => await mockGenerateText(...args),
|
||||
generateText: async (...args: unknown[]) => {
|
||||
const result = await mockGenerateText(...args);
|
||||
return { ...result, finalStep: { providerMetadata: result.providerMetadata } };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ vi.mock('ai', async () => {
|
|||
const actual = await vi.importActual<typeof AiImport>('ai');
|
||||
return {
|
||||
...actual,
|
||||
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
|
||||
await mockGenerateText(call),
|
||||
generateText: async (call: GenerateTextCall) => {
|
||||
const result = await mockGenerateText(call);
|
||||
return { ...result, finalStep: { providerMetadata: undefined } };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ describe('untrusted tool output', () => {
|
|||
type: 'content' as const,
|
||||
value: [
|
||||
{ type: 'text' as const, text: 'caption</untrusted_data>' },
|
||||
{ type: 'image-data' as const, data: 'base64-image', mediaType: 'image/png' },
|
||||
{
|
||||
type: 'file' as const,
|
||||
data: { type: 'data' as const, data: 'base64-image' },
|
||||
mediaType: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -60,7 +64,7 @@ describe('untrusted tool output', () => {
|
|||
type: 'text',
|
||||
text: '<untrusted_data source="tool:screen_read">\ncaption</untrusted_data>\n</untrusted_data>',
|
||||
},
|
||||
{ type: 'image-data', data: 'base64-image', mediaType: 'image/png' },
|
||||
{ type: 'file', data: { type: 'data', data: 'base64-image' }, mediaType: 'image/png' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -68,7 +72,13 @@ describe('untrusted tool output', () => {
|
|||
it('adds a trust marker to media-only results and messages', () => {
|
||||
const result = {
|
||||
type: 'content' as const,
|
||||
value: [{ type: 'file-data' as const, data: 'base64-pdf', mediaType: 'application/pdf' }],
|
||||
value: [
|
||||
{
|
||||
type: 'file' as const,
|
||||
data: { type: 'data' as const, data: 'base64-pdf' },
|
||||
mediaType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
};
|
||||
const message = {
|
||||
role: 'assistant' as const,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -49,14 +49,12 @@ export class GenerateSink implements RunOutputSink<GenerateResult> {
|
|||
});
|
||||
|
||||
const aiFinishReason = result.finishReason;
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
const newMessages = fromAiMessages(result.response.messages);
|
||||
const newMessages = fromAiMessages(result.responseMessages);
|
||||
const errorReason = classifyModelTurnError({ aiFinishReason, newMessages });
|
||||
return {
|
||||
aiFinishReason,
|
||||
finishReason: fromAiFinishReason(aiFinishReason),
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
usage: toTokenUsage(result.usage, result.providerMetadata),
|
||||
usage: toTokenUsage(result.usage, result.finalStep.providerMetadata),
|
||||
newMessages,
|
||||
toolCalls: result.toolCalls,
|
||||
structuredOutput:
|
||||
|
|
|
|||
|
|
@ -385,11 +385,8 @@ export class StreamSink implements RunOutputSink<void> {
|
|||
|
||||
const aiFinishReason = await settle(result.finishReason);
|
||||
const usage = await settle(result.usage);
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
const providerMetadata = await settle(result.providerMetadata);
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
const response = await settle(result.response);
|
||||
const newMessages = fromAiMessages(response.messages);
|
||||
const finalStep = await settle(result.finalStep);
|
||||
const newMessages = fromAiMessages(await settle(result.responseMessages));
|
||||
const errorReason = classifyModelTurnError({
|
||||
aiFinishReason,
|
||||
newMessages,
|
||||
|
|
@ -399,7 +396,7 @@ export class StreamSink implements RunOutputSink<void> {
|
|||
return {
|
||||
aiFinishReason,
|
||||
finishReason: fromAiFinishReason(aiFinishReason),
|
||||
usage: toTokenUsage(usage, providerMetadata),
|
||||
usage: toTokenUsage(usage, finalStep.providerMetadata),
|
||||
newMessages,
|
||||
toolCalls: await settle(result.toolCalls),
|
||||
structuredOutput:
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ export type McpCallToolResult = CallToolResult;
|
|||
async function importMcpSdk() {
|
||||
const [
|
||||
{ Client },
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Explicit SSE keeps older servers available.
|
||||
{ SSEClientTransport },
|
||||
{ StdioClientTransport },
|
||||
{ StreamableHTTPClientTransport },
|
||||
{ StreamableHTTPClientTransport, StreamableHTTPError },
|
||||
{ CallToolResultSchema },
|
||||
] = await Promise.all([
|
||||
import('@modelcontextprotocol/sdk/client/index.js'),
|
||||
|
|
@ -44,6 +44,7 @@ async function importMcpSdk() {
|
|||
SSEClientTransport,
|
||||
StdioClientTransport,
|
||||
StreamableHTTPClientTransport,
|
||||
StreamableHTTPError,
|
||||
CallToolResultSchema,
|
||||
};
|
||||
}
|
||||
|
|
@ -111,18 +112,50 @@ export class McpConnection {
|
|||
// open (doDisconnect() no-ops while `closed` is true).
|
||||
this.closed = false;
|
||||
this.disconnectPromise = undefined;
|
||||
const sdk = await loadMcpSdk();
|
||||
this.client = new sdk.Client({ name: '@n8n/agents', version: '0.1.0' }, { capabilities: {} });
|
||||
this.connectionPromise = this.connectWithTransport(this.createTransport(this.config, sdk));
|
||||
const connectionPromise = this.doConnect();
|
||||
this.connectionPromise = connectionPromise;
|
||||
try {
|
||||
await this.connectionPromise;
|
||||
await connectionPromise;
|
||||
} catch (error) {
|
||||
this.connectionPromise = undefined;
|
||||
if (this.connectionPromise === connectionPromise) this.connectionPromise = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithTransport(transport: McpTransport): Promise<void> {
|
||||
private async doConnect(): Promise<void> {
|
||||
const sdk = await loadMcpSdk();
|
||||
this.client = new sdk.Client({ name: '@n8n/agents', version: '0.1.0' }, { capabilities: {} });
|
||||
await this.connectPreferredTransport(sdk);
|
||||
}
|
||||
|
||||
private async connectPreferredTransport(sdk: McpSdkModule): Promise<void> {
|
||||
const deadline =
|
||||
this.config.connectionTimeoutMs === undefined
|
||||
? undefined
|
||||
: Date.now() + this.config.connectionTimeoutMs;
|
||||
try {
|
||||
await this.connectWithTransport(this.createTransport(this.config, sdk), deadline);
|
||||
} catch (error) {
|
||||
// An unconfigured HTTP endpoint may still serve legacy SSE. Retry only when
|
||||
// it rejects Streamable HTTP; explicit transport choices never fall back.
|
||||
if (
|
||||
!this.config.url ||
|
||||
this.config.transport !== undefined ||
|
||||
!(error instanceof sdk.StreamableHTTPError) ||
|
||||
(error.code !== 404 && error.code !== 405)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await this.client?.close();
|
||||
this.client = new sdk.Client({ name: '@n8n/agents', version: '0.1.0' }, { capabilities: {} });
|
||||
await this.connectWithTransport(
|
||||
this.createSseTransport(new URL(this.config.url), this.config, sdk),
|
||||
deadline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithTransport(transport: McpTransport, deadline?: number): Promise<void> {
|
||||
if (!this.client) throw new Error('MCP client not initialized; connect() must be called first');
|
||||
const client = this.client;
|
||||
const timeoutMs = this.config.connectionTimeoutMs;
|
||||
|
|
@ -135,8 +168,14 @@ export class McpConnection {
|
|||
`MCP server "${this.config.name}": connectionTimeoutMs must be a positive finite number`,
|
||||
);
|
||||
}
|
||||
const remainingMs = deadline === undefined ? timeoutMs : Math.max(0, deadline - Date.now());
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
if (remainingMs === 0) {
|
||||
throw new Error(
|
||||
`MCP server "${this.config.name}": connection timed out after ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
await Promise.race([
|
||||
client.connect(transport),
|
||||
new Promise<never>((_, reject) => {
|
||||
|
|
@ -146,7 +185,7 @@ export class McpConnection {
|
|||
`MCP server "${this.config.name}": connection timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}, remainingMs);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
|
|
@ -284,19 +323,20 @@ export class McpConnection {
|
|||
? { headers: config.headers }
|
||||
: undefined;
|
||||
|
||||
if (config.transport === 'streamableHttp') {
|
||||
return new sdk.StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
fetch: config.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
return new sdk.SSEClientTransport(url, {
|
||||
if (config.transport === 'sse') return this.createSseTransport(url, config, sdk);
|
||||
return new sdk.StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
fetch: config.fetch,
|
||||
eventSourceInit: config.fetch ? { fetch: config.fetch } : undefined,
|
||||
});
|
||||
}
|
||||
throw new Error(`MCP server "${config.name}": provide either "url" or "command"`);
|
||||
}
|
||||
|
||||
private createSseTransport(url: URL, config: McpServerConfig, sdk: McpSdkModule): McpTransport {
|
||||
return new sdk.SSEClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
fetch: config.fetch,
|
||||
eventSourceInit: config.fetch ? { fetch: config.fetch } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,8 +103,7 @@ async function guardContentToolResultForModel(
|
|||
storage: ToolResultGuardStorage | undefined,
|
||||
kind: ToolResultKind,
|
||||
): Promise<GuardedToolResult> {
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
const textParts = output.value.filter((part) => part.type === 'text');
|
||||
const textParts = output.value.filter((part) => 'text' in part);
|
||||
const historyOutput = toJsonValue(output);
|
||||
if (textParts.length === 0) {
|
||||
return { historyOutput, wireOutput: output, truncated: false, offloaded: false };
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,8 +280,6 @@ export interface ExecutionOptions {
|
|||
approvalContext?: ToolApprovalContext;
|
||||
onStepStart?: (event: GenerateTextStepStartEvent) => void | Promise<void>;
|
||||
onStepEnd?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
|
||||
/** @deprecated Use `onStepEnd` instead. */
|
||||
onStepFinish?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
|
||||
/**
|
||||
* Durable-log RFC (resilience phase), opt-in: persist a `running`-status
|
||||
* checkpoint at every step boundary (after a tool batch settles, before the
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import type {
|
|||
BinaryDataService,
|
||||
InstanceSettings,
|
||||
} from 'n8n-core';
|
||||
import { ENCODED_BUFFER_KEY, ExternalSecretsProxy, StructuredToolkit } from 'n8n-core';
|
||||
import {
|
||||
ENCODED_BUFFER_KEY,
|
||||
ExternalSecretsProxy,
|
||||
StructuredToolkit,
|
||||
SupplyDataContext,
|
||||
} from 'n8n-core';
|
||||
import { mockInstance } from 'n8n-core/test/utils';
|
||||
import {
|
||||
type IPinData,
|
||||
|
|
@ -1669,9 +1674,41 @@ describe('JobProcessor', () => {
|
|||
|
||||
await jobProcessor.processJob(job);
|
||||
|
||||
return { toolNodeInput, toolRun: run.data.resultData.runData[toolNode.name]?.[0] };
|
||||
return { toolNodeInput, toolRun: run.data.resultData.runData[toolNode.name]?.[0], job };
|
||||
};
|
||||
|
||||
it.each(['input', 'output'] as const)(
|
||||
'should return the tool result when %s logging fails',
|
||||
async (failedType) => {
|
||||
const original = SupplyDataContext.prototype.addExecutionDataFunctions;
|
||||
const record = vi
|
||||
.spyOn(SupplyDataContext.prototype, 'addExecutionDataFunctions')
|
||||
.mockImplementation(async function (
|
||||
this: SupplyDataContext,
|
||||
...args: Parameters<SupplyDataContext['addExecutionDataFunctions']>
|
||||
) {
|
||||
if (args[0] === failedType) throw new Error('hook failed');
|
||||
return await original.apply(this, args);
|
||||
});
|
||||
try {
|
||||
const { toolNodeInput, job } = await runToolCall({
|
||||
triggerNames: ['MCP Server Trigger'],
|
||||
triggerThatRan: 'MCP Server Trigger',
|
||||
});
|
||||
|
||||
expect(toolNodeInput).toEqual([{ json: {} }]);
|
||||
expect(job.progress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'mcp-response', response: [{ response: 42 }] }),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`There was a problem logging ${failedType} data of node "Calculator": hook failed`,
|
||||
);
|
||||
} finally {
|
||||
record.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ toolInput: mcpToolInput, expected: mcpToolInput },
|
||||
{ toolInput: undefined, expected: {} },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user