Skip to content
Open
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/toolfile-base64url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Emitted `ToolFile` output now accepts base64url data, padded or unpadded, such as Gmail message bodies. Before, this failed with "Internal tool error". File data that is not valid base64 now shows a short note in place of the file, and the rest of the result still comes back.
2 changes: 1 addition & 1 deletion packages/core/execution/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const EXECUTE_SKILL_BODY = [
"- 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. 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 }`. A "file-returning tool" includes APIs that return file bytes inside a JSON field, such as Base64-encoded `content`; wrap those payloads in a `ToolFile` and `emit()` them. Emit any attachment with `emit(result.data)`.',
'- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. A "file-returning tool" includes APIs that return file bytes inside a JSON field, such as Base64-encoded `content`; wrap those payloads in a `ToolFile` and `emit()` them. `data` may be standard base64 or base64url (e.g. Gmail `payload.body.data`), padded or not — keep `encoding: "base64"` and pass it through unchanged. Emit any attachment with `emit(result.data)`.',
"- Never decode or transcode bytes yourself — the sandbox has no `Buffer`, `atob`, `btoa`, `TextDecoder`, or `TextEncoder`. For base64-encoded bytes in a JSON field, wrap the payload in a `ToolFile` with its `mimeType` and `emit()` it; to forward them to an upload tool, pass the `ToolFile`'s base64 `data` as that tool's `bodyBase64` (both sides speak base64, so nothing is decoded).",
'- 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
103 changes: 103 additions & 0 deletions packages/hosts/mcp/src/tool-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,109 @@ describe("MCP host server — native elicitation mode", () => {
});
});

it("execute tool decodes unpadded base64url text file output (Gmail message bodies)", async () => {
const engine = makeStubEngine({
execute: () =>
Effect.succeed({
result: null,
output: [
{
type: "file",
file: toolFile({
name: "body.txt",
mimeType: "text/plain; charset=UTF-8",
data: "5Lu25ZCNOiDjgZPjgpPjgavjgaHjga_kuJbnlYwg4oCUIOODhuOCueODiD8-Pg",
byteLength: 46,
}),
},
],
}),
});

await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
const result = await client.callTool({
name: "execute",
arguments: { code: "emit(body);" },
});

const content = result.content as Array<Record<string, unknown>>;
expect(content[1]).toMatchObject({
type: "text",
text: "件名: こんにちは世界 — テスト?>>",
});
expect(result.isError).toBeFalsy();
});
});

it("execute tool hands MCP clients standard padded base64 for base64url binary file output", async () => {
const engine = makeStubEngine({
execute: () =>
Effect.succeed({
result: null,
output: [
{
type: "file",
file: toolFile({
name: "blob.bin",
mimeType: "application/octet-stream",
data: "-_8",
byteLength: 2,
}),
},
],
}),
});

await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
const result = await client.callTool({
name: "execute",
arguments: { code: "emit(blob);" },
});

const content = result.content as Array<Record<string, unknown>>;
expect(content[1]).toMatchObject({
type: "resource",
resource: { blob: "+/8=" },
});
expect(result.isError).toBeFalsy();
});
});

it("execute tool reports undecodable file output instead of failing the call", async () => {
const engine = makeStubEngine({
execute: () =>
Effect.succeed({
result: { kept: true },
output: [
{
type: "file",
file: toolFile({
name: "body.txt",
mimeType: "text/plain",
data: "not base64!",
byteLength: 11,
}),
},
],
}),
});

await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
const result = await client.callTool({
name: "execute",
arguments: { code: "emit(body); return { kept: true };" },
});

const content = result.content as Array<Record<string, unknown>>;
expect(content[1]).toMatchObject({
type: "text",
text: "File output omitted: body.txt data is not valid base64 or base64url.",
});
expect(result.structuredContent).toMatchObject({ status: "completed" });
expect(result.isError).toBeFalsy();
});
});

it("execute tool surfaces failed engine effects as an opaque generic with correlation id", async () => {
const engine = makeStubEngine({
execute: () => Effect.fail(new TestExecutionError({ message: "Unexpected token ':'" })),
Expand Down
35 changes: 29 additions & 6 deletions packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,20 @@ const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resour
return "resource";
};

/**
* Agents wrap upstream payloads in a `ToolFile` verbatim, and some upstreams
* (Gmail message bodies) use unpadded base64url. Accept either alphabet and
* hand MCP clients standard padded base64. Returns null when the data is not
* base64 in either alphabet.
*/
const standardBase64 = (data: string): string | null => {
const alphabet = data.replace(/\s/g, "").replace(/-/g, "+").replace(/_/g, "/");
const unpadded = alphabet.replace(/=+$/, "");
if (!/^[A-Za-z0-9+/]*$/.test(unpadded) || unpadded.length % 4 === 1) return null;
const remainder = unpadded.length % 4;
return remainder === 0 ? unpadded : `${unpadded}${"=".repeat(4 - remainder)}`;
};

const bytesFromBase64 = (base64: string): Uint8Array => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
Expand All @@ -609,32 +623,41 @@ const bytesFromBase64 = (base64: string): Uint8Array => {
return bytes;
};

const decodeTextFile = (file: ToolFileValue): string => {
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data));
const decodeTextFile = (base64: string): string => {
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(base64));
if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text;
return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${
text.length - TEXT_FILE_CONTENT_MAX_CHARS
} characters]`;
};

const toolFileContent = (file: ToolFileValue): ContentBlock[] => {
const data = standardBase64(file.data);
if (data === null) {
return [
{
type: "text",
text: `File output omitted: ${toolFileName(file)} data is not valid base64 or base64url.`,
},
];
}
const kind = toolFileKind(file);
if (kind === "image") {
return [{ type: "image", data: file.data, mimeType: file.mimeType }];
return [{ type: "image", data, mimeType: file.mimeType }];
}
if (kind === "audio") {
return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
return [{ type: "audio", data, mimeType: file.mimeType }];
}
if (kind === "text") {
return [{ type: "text", text: decodeTextFile(file) }];
return [{ type: "text", text: decodeTextFile(data) }];
}
return [
{
type: "resource",
resource: {
uri: fileResourceUri(file),
mimeType: file.mimeType,
blob: file.data,
blob: data,
},
},
];
Expand Down
Loading