From 929374cdfd158a63a3a18d2844384b4059d3cc4d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:20:13 -0500 Subject: [PATCH] feat(core): parse JSON text results from MCP tools without an output schema (#48357) --- packages/core/src/tool/mcp.ts | 13 +++++- packages/core/test/mcp.test.ts | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 810cac466d9..8e00f59830e 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -99,8 +99,19 @@ export const layer = Layer.effect( }, ) const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") + const output = () => { + if (result.structured !== undefined) return result.structured + if (text === "") return null + // Agents assume JSON returned as text is already an object, so parse it when the server declares no schema. + if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) { + try { + return JSON.parse(text) + } catch {} + } + return text + } return { - output: result.structured ?? (text === "" ? null : text), + output: output(), ...(content.length === 0 ? {} : { content }), } }).pipe( diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index ac0ebde5b5e..bc40d2ce32f 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -324,6 +324,32 @@ const mcp = Layer.mock(Mcp.Service, { description: "Status", inputSchema: { type: "object", properties: {} }, }), + new Mcp.Tool({ + server: Mcp.ServerName.make("demo"), + name: "issues", + description: "Returns JSON as text", + inputSchema: { type: "object", properties: {} }, + }), + new Mcp.Tool({ + server: Mcp.ServerName.make("demo"), + name: "count", + description: "Returns a number as text", + inputSchema: { type: "object", properties: {} }, + }), + new Mcp.Tool({ + server: Mcp.ServerName.make("demo"), + name: "typed", + description: "Declares a string output and returns JSON as text", + inputSchema: { type: "object", properties: {} }, + outputSchema: { type: "string" }, + }), + new Mcp.Tool({ + server: Mcp.ServerName.make("direct"), + name: "issues", + codemode: false, + description: "Returns JSON as text", + inputSchema: { type: "object", properties: {} }, + }), new Mcp.Tool({ server: Mcp.ServerName.make("direct"), name: "lookup", @@ -374,6 +400,20 @@ const mcp = Layer.mock(Mcp.Service, { isError: false, content: [{ type: "text", text: "hello" }], }) + if (input.name === "issues" || input.name === "typed") + return new Mcp.ToolResult({ + server: Mcp.ServerName.make(input.server), + tool: input.name, + isError: false, + content: [{ type: "text", text: '{"issues":[{"id":1}]}' }], + }) + if (input.name === "count") + return new Mcp.ToolResult({ + server: Mcp.ServerName.make(input.server), + tool: input.name, + isError: false, + content: [{ type: "text", text: "42" }], + }) return new Mcp.ToolResult({ server: Mcp.ServerName.make(input.server), tool: input.name, @@ -1943,6 +1983,7 @@ it.effect("advertises MCP output schemas to Code Mode", () => expect(toolSet.definitions.map((tool) => tool.name)).toEqual([ "direct_fail", + "direct_issues", "direct_lookup", "direct_media", "execute", @@ -2033,6 +2074,39 @@ it.effect("returns content-only MCP results through Code Mode", () => }), ) +it.effect("parses JSON text results from MCP tools without an output schema", () => + Effect.gen(function* () { + assertion = yield* Deferred.make() + decision = Effect.void + const registry = yield* Tool.Service + const registration = yield* McpTool.Service + yield* registration.flush + const toolSet = yield* registry.snapshot() + + const run = (code: string) => + toolSet + .execute({ + sessionID: Session.ID.make("ses_mcp_json_text"), + ...toolIdentity, + call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } }, + }) + .pipe(Effect.map((execution) => execution.output.output)) + + expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1") + expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string") + expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string") + + // Outside Code Mode the content the model reads is the original text. + expect( + yield* toolSet.execute({ + sessionID: Session.ID.make("ses_mcp_json_text"), + ...toolIdentity, + call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} }, + }), + ).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] }) + }), +) + it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () => Effect.gen(function* () { const registry = yield* Tool.Service