From 42323f1e803bbe3b4625a8394c4178bdbe81f8c3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:34:58 -0700 Subject: [PATCH] Keep the returned value in MCP content when output was emitted --- .changeset/emit-return-content.md | 5 +++ packages/core/execution/src/skills.ts | 2 +- packages/hosts/mcp/src/tool-server.test.ts | 42 ++++++++++++++++++++++ packages/hosts/mcp/src/tool-server.ts | 5 +++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/emit-return-content.md diff --git a/.changeset/emit-return-content.md b/.changeset/emit-return-content.md new file mode 100644 index 000000000..bb7b3b2ee --- /dev/null +++ b/.changeset/emit-return-content.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +**Fix: `execute` scripts that both `emit()` output and `return` a value no longer lose the returned value in MCP clients that ignore `structuredContent` — the return value is now appended to the tool-result content after the emitted items** diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index e66a1b6a9..9a78e8a3d 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -46,7 +46,7 @@ const EXECUTE_SKILL_BODY = [ "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.", "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", "- `data` is the upstream payload itself. HTTP-backed tools (OpenAPI) also set `http: { status, headers }` beside `data` — read `result.http?.headers` for pagination (Link) or rate-limit headers.", - "- Use `emit(value)` to append user-visible output and return `undefined`. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME. Emitted output goes to the user, not back to you; the result envelope reports an `emitted` count so you can confirm it landed, but to read a value yourself, `return` it.", + "- Use `emit(value)` to append user-visible output. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME. Emitting and returning compose: emitted items come first in the tool result, the returned value follows, and the envelope reports an `emitted` count so you can confirm the items landed.", '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.', '- To emit MCP-native content directly, pass an MCP content block to `emit(...)`, such as `{ type: "image", data, mimeType }`, `{ type: "audio", data, mimeType }`, `{ type: "text", text }`, `{ type: "resource", resource }`, or `{ type: "resource_link", uri, name, ... }`.', "- `emit(ToolFile)` is MIME-based: `image/*` becomes MCP image content, `audio/*` becomes MCP audio content, text-like files become decoded text, and other binary files become embedded MCP resources.", diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 2f6a44511..d20edfca5 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -274,6 +274,44 @@ describe("MCP host server — native elicitation mode", () => { }); }); + it("execute tool keeps the returned value in content when output was also emitted", async () => { + const engine = makeStubEngine({ + execute: () => + Effect.succeed({ + result: { subject: "Flight receipt", total: 42 }, + output: [ + { + type: "content", + content: { + type: "text", + text: "Flight receipt", + }, + }, + ], + }), + }); + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + const result = await client.callTool({ + name: "execute", + arguments: { code: "emit(subject); return receipt;" }, + }); + + const content = result.content as Array>; + expect(content).toHaveLength(2); + expect(content[0]).toMatchObject({ type: "text", text: "Flight receipt" }); + expect(content[1]).toMatchObject({ + type: "text", + text: JSON.stringify({ subject: "Flight receipt", total: 42 }, null, 2), + }); + expect(result.structuredContent).toMatchObject({ + status: "completed", + result: { subject: "Flight receipt", total: 42 }, + }); + expect(result.isError).toBeFalsy(); + }); + }); + it("execute tool renders emitted MCP image content as MCP images", async () => { const engine = makeStubEngine({ execute: () => @@ -416,6 +454,10 @@ describe("MCP host server — native elicitation mode", () => { name: "remote.pdf", mimeType: "application/pdf", }, + { + type: "text", + text: JSON.stringify({ forwarded: true }, null, 2), + }, ]); expect(result.structuredContent).toMatchObject({ status: "completed", diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index ff477718b..f357fb639 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -574,6 +574,11 @@ const toMcpOutputResult = ( const extraText: string[] = []; if (result.error) { extraText.push(formatted.text); + } else if (result.result != null) { + // A script may both emit() and return: keep the returned value in the + // content channel too, or clients that ignore structuredContent drop it. + // formatted.text already renders the return value plus any logs. + extraText.push(formatted.text); } else if (result.logs && result.logs.length > 0) { extraText.push(`Logs:\n${result.logs.join("\n")}`); }