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
This commit is contained in:
Armin Ronacher 2026-10-12 14:16:19 +02:00
parent f4cca2dc22
commit 67996ab75c
8 changed files with 36 additions and 8 deletions

View file

@ -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

View file

@ -161,10 +161,10 @@ export function createMcpResultSchema(structuredContentSchema: Record<string, un
type: "object",
properties: {
content: { type: "array", items: CONTENT_BLOCK_SCHEMA },
// Servers may send structured content without declaring a schema for it.
// Servers may send structured content without declaring a schema for it, and it may be any JSON value.
structuredContent: structuredContentSchema
? relocateSchema(structuredContentSchema, "#/properties/structuredContent")
: { type: "object" },
: {},
isError: { type: "boolean" },
},
required: ["content"],

View file

@ -282,8 +282,9 @@ describe("MCP tools", () => {
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" }],

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Breaking Changes
- Changed `CallToolResult.structuredContent` from `Record<string, unknown>` 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)

View file

@ -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;
}

View file

@ -64,7 +64,8 @@ export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceL
export interface CallToolResult {
content: ContentBlock[];
structuredContent?: Record<string, unknown>;
/** Any JSON value, including arrays and `null` (MCP 2026-07-28; earlier revisions allowed only objects). */
structuredContent?: unknown;
isError?: boolean;
_meta?: Record<string, unknown>;
}

View file

@ -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(() => {}));

View file

@ -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}' },
]);