mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
f4cca2dc22
commit
67996ab75c
8 changed files with 36 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {}));
|
||||
|
|
|
|||
|
|
@ -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}' },
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue