Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/emit-return-content.md
Original file line number Diff line number Diff line change
@@ -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**
2 changes: 1 addition & 1 deletion packages/core/execution/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
42 changes: 42 additions & 0 deletions packages/hosts/mcp/src/tool-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
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: () =>
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`);
}
Expand Down
Loading