feat(core): parse JSON text results from MCP tools without an output schema (#48357)

This commit is contained in:
Aiden Cline
2026-09-10 15:20:13 -05:00
committed by GitHub
Unverified
parent cfa5ba700e
commit 929374cdfd
2 changed files with 86 additions and 1 deletions
+12 -1
View File
@@ -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(
+74
View File
@@ -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<Permission.AssertInput>()
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