From 67996ab75c2818847423549807eda888db218c1c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 12 Oct 2026 14:16:19 +0200 Subject: [PATCH] fix(mcp,coding-agent): accept any JSON value as tool structuredContent MCP 2026-07-28 allows structuredContent to be any JSON value, including arrays and null. callTool() rejected non-object values, failing the whole tool call. CallToolResult.structuredContent is now unknown, and MCP tools without an output schema declare structuredContent as unknown to codemode. refs #10416 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/extensions/mcp/tools.ts | 4 ++-- packages/coding-agent/test/mcp-extension.test.ts | 9 ++++++++- packages/mcp/CHANGELOG.md | 4 ++++ packages/mcp/src/client.ts | 8 ++++---- packages/mcp/src/protocol/content.ts | 3 ++- packages/mcp/test/client.test.ts | 10 ++++++++++ packages/mcp/test/content.test.ts | 5 +++++ 8 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8117a9466..3dd0ddd47 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -32,6 +32,7 @@ - Fixed `pi mcp login --help` and the CLI docs describing `--timeout` as only the wait for the browser; it limits the whole sign-in ([#10636](https://github.com/earendil-works/pi/issues/10636)) - Fixed `~` not being expanded in `!pattern`, `+path`, and `-path` entries of the `extensions`, `skills`, `prompts`, and `themes` settings, so entries like `!~/skills/pdf` matched nothing ([#10765](https://github.com/earendil-works/pi/issues/10765)) - Fixed MCP servers with resources registering no tools until every resource was listed; Pi no longer lists resources when connecting, so `/mcp` and `pi mcp list` show that a server offers resources instead of counting them ([#10526](https://github.com/earendil-works/pi/issues/10526)) +- Fixed MCP tool calls failing when the server returns structured content that is not an object (an array, `null`, or a primitive, as MCP 2026-07-28 allows); codemode declarations of MCP tools without an output schema now type `structuredContent` as `unknown` instead of an object ([#10416](https://github.com/earendil-works/pi/issues/10416)) ### Removed diff --git a/packages/coding-agent/src/extensions/mcp/tools.ts b/packages/coding-agent/src/extensions/mcp/tools.ts index 72fafd435..63f95dbb8 100644 --- a/packages/coding-agent/src/extensions/mcp/tools.ts +++ b/packages/coding-agent/src/extensions/mcp/tools.ts @@ -161,10 +161,10 @@ export function createMcpResultSchema(structuredContentSchema: Record { it("declares the CallToolResult scripts receive, keeping references in the server's output schema", () => { const content = 'content: Array<{ data?: string; mimeType?: string; text?: string; type: "text" | "image" | "audio" | "resource_link" | "resource"; [key: string]: unknown; }>; isError?: boolean;'; + // #10416: without an output schema, structured content may be any JSON value. expect(renderToolOutputType(createMcpResultSchema(undefined) as never)).toBe( - `{ ${content} structuredContent?: { [key: string]: unknown; }; [key: string]: unknown; }`, + `{ ${content} structuredContent?: unknown; [key: string]: unknown; }`, ); const hit = { type: "object", properties: { id: { type: "string" } }, required: ["id"] }; @@ -333,6 +334,12 @@ describe("MCP tools", () => { expect((await convertMcpResult("docs", "t", { content: [], structuredContent: { n: 1 } })).content).toEqual([ { type: "text", text: '{\n "n": 1\n}' }, ]); + // #10416: array structured content reaches the model as JSON and scripts as sent. + expect(await convertMcpResult("docs", "t", { content: [], structuredContent: [{ id: 1 }] })).toEqual({ + content: [{ type: "text", text: '[\n {\n "id": 1\n }\n]' }], + details: { server: "docs", tool: "t" }, + structuredContent: { content: [], structuredContent: [{ id: 1 }] }, + }); expect(await convertMcpResult("docs", "t", { content: [{ type: "text", text: "nope" }], isError: true })).toEqual( { content: [{ type: "text", text: "nope" }], diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 5e68cc441..f46a5d781 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Changed `CallToolResult.structuredContent` from `Record` to `unknown`: `callTool()` now accepts any JSON value as structured content, including arrays and `null`, as MCP 2026-07-28 allows, instead of failing with "Invalid MCP tools/call structured content" ([#10416](https://github.com/earendil-works/pi/issues/10416)) + ### Fixed - Fixed `authorizeMcp()` exchanging an authorization code whose `iss` parameter could not be checked because no authorization server metadata was discovered. It now throws `OAuthIssuerMismatchError`, whose `expected` is `undefined` in that case (RFC 9207). Reported by [@AizenvoltPrime](https://github.com/AizenvoltPrime) diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 6199ed682..e749f5cbf 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -139,14 +139,14 @@ function validateReadResourceResult(value: unknown): ReadResourceResult { return value as unknown as ReadResourceResult; } -/** `content` is required by the spec, but servers that only return `structuredContent` omit it (the SDK defaults it too). */ +/** + * `content` is required by the spec, but servers that only return `structuredContent` omit it (the SDK defaults it too). + * `structuredContent` may be any JSON value, as MCP 2026-07-28 allows. + */ function validateCallToolResult(value: unknown): CallToolResult { if (!isObject(value) || (value.content !== undefined && !Array.isArray(value.content))) { throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP tools/call result"); } - if (value.structuredContent !== undefined && !isObject(value.structuredContent)) { - throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP tools/call structured content"); - } return (value.content === undefined ? { ...value, content: [] } : value) as unknown as CallToolResult; } diff --git a/packages/mcp/src/protocol/content.ts b/packages/mcp/src/protocol/content.ts index 041329643..771ef32c4 100644 --- a/packages/mcp/src/protocol/content.ts +++ b/packages/mcp/src/protocol/content.ts @@ -64,7 +64,8 @@ export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceL export interface CallToolResult { content: ContentBlock[]; - structuredContent?: Record; + /** Any JSON value, including arrays and `null` (MCP 2026-07-28; earlier revisions allowed only objects). */ + structuredContent?: unknown; isError?: boolean; _meta?: Record; } diff --git a/packages/mcp/test/client.test.ts b/packages/mcp/test/client.test.ts index 9213da6c8..bd16fa99a 100644 --- a/packages/mcp/test/client.test.ts +++ b/packages/mcp/test/client.test.ts @@ -280,6 +280,16 @@ describe("McpClient", () => { await client.close(); }); + // #10416: MCP 2026-07-28 allows any JSON value as structured content. + it("accepts structured content that is not an object", async () => { + const { client, server } = await connect(); + for (const structuredContent of [[{ id: 1 }, { id: 2 }], null, "text", 3, false]) { + server.setHandler("tools/call", () => ({ content: [], structuredContent })); + expect(await client.callTool("structured")).toEqual({ content: [], structuredContent }); + } + await client.close(); + }); + it("does not send notifications/cancelled for a timed-out initialize", async () => { const { clientTransport, server } = await createServer(); server.setHandler("initialize", () => new Promise(() => {})); diff --git a/packages/mcp/test/content.test.ts b/packages/mcp/test/content.test.ts index 878b1abf6..c3d431078 100644 --- a/packages/mcp/test/content.test.ts +++ b/packages/mcp/test/content.test.ts @@ -24,6 +24,11 @@ describe("toLlmContent", () => { }); it("falls back to the structured content as JSON when there are no blocks", () => { + // #10416: structured content may be any JSON value. + expect(toLlmContent({ content: [], structuredContent: [1, 2] })).toEqual([ + { type: "text", text: "[\n 1,\n 2\n]" }, + ]); + expect(toLlmContent({ content: [], structuredContent: null })).toEqual([{ type: "text", text: "null" }]); expect(toLlmContent({ content: [], structuredContent: { n: 1 } })).toEqual([ { type: "text", text: '{\n "n": 1\n}' }, ]);