Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline 17550f3871 fix(mcp): narrow call tool results 2026-06-13 19:27:38 -05:00
Aiden Cline abd6e4e045 fix(mcp): handle tool result errors 2026-06-13 19:23:55 -05:00
2 changed files with 75 additions and 0 deletions
+20
View File
@@ -3,6 +3,7 @@ import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
type CallToolResult,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
@@ -63,6 +64,8 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
timeout,
},
)
if (!isCallToolResult(result)) return result
if (result.isError) throw new Error(errorText(result))
if (result.structuredContent === undefined || result.structuredContent === null) return result
return {
...result,
@@ -72,6 +75,23 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
})
}
function isCallToolResult(result: Awaited<ReturnType<Client["callTool"]>>): result is CallToolResult {
return "content" in result && Array.isArray(result.content)
}
function errorText(result: CallToolResult) {
const content = result.content.flatMap((item) => {
if (item.type === "text") return [item.text]
if (item.type === "resource" && "text" in item.resource) return [item.resource.text]
return []
})
if (result.structuredContent !== undefined && result.structuredContent !== null) {
const structured = JSON.stringify(result.structuredContent)
if (structured !== undefined) content.push(structured)
}
return [...new Set(content.filter((item) => item.trim().length > 0))].join("\n\n") || "MCP tool returned an error"
}
export function fetch<T extends { name: string }>(
clientName: string,
client: Client,
@@ -0,0 +1,55 @@
import { describe, expect, mock, test } from "bun:test"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { convertTool } from "../../src/mcp/catalog"
const definition = {
name: "example",
description: "Example tool",
inputSchema: { type: "object" as const, properties: {} },
}
function tool(result: CallToolResult | { toolResult: unknown }) {
const callTool = mock(async () => result)
const converted = convertTool(definition, { callTool } as unknown as Client)
if (!converted.execute) throw new Error("expected executable tool")
return { callTool, execute: converted.execute }
}
describe("mcp catalog", () => {
test("returns ordinary tool results", async () => {
const result = {
content: [{ type: "text" as const, text: "ordinary output" }],
structuredContent: { value: 42 },
}
const converted = tool(result)
await expect(converted.execute({}, { toolCallId: "call-1", messages: [] })).resolves.toEqual({
...result,
content: [{ type: "text", text: '{"value":42}' }],
})
expect(converted.callTool).toHaveBeenCalledTimes(1)
})
test("returns task tool results", async () => {
const result = { toolResult: { taskId: "task-1" } }
const converted = tool(result)
await expect(converted.execute({}, { toolCallId: "call-1", messages: [] })).resolves.toBe(result)
})
test("throws MCP tool errors with text and structured diagnostics", async () => {
const converted = tool({
isError: true,
content: [
{ type: "text", text: "validation failed" },
{ type: "resource", resource: { uri: "error://details", text: "labels must be an object" } },
],
structuredContent: { field: "labels", expected: "object" },
})
await expect(converted.execute({}, { toolCallId: "call-1", messages: [] })).rejects.toThrow(
'validation failed\n\nlabels must be an object\n\n{"field":"labels","expected":"object"}',
)
})
})