Skip to content

Commit ddfc703

Browse files
committed
Accept base64url ToolFile data in emitted output
Gmail returns message bodies as unpadded base64url. Agents wrap them in a ToolFile as documented, and the MCP host's atob() rejected `-`/`_`, which failed the whole execute call with an opaque internal error. Normalize ToolFile data to standard padded base64 before rendering it, so text files decode and image/audio/resource blocks hand clients standard base64. Data that is not base64 in either alphabet renders a note instead of failing the call. Fixes #2138
1 parent a0b0d91 commit ddfc703

4 files changed

Lines changed: 138 additions & 7 deletions

File tree

‎.changeset/toolfile-base64url.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
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.

‎packages/core/execution/src/skills.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const EXECUTE_SKILL_BODY = [
4747
"- 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`.",
4848
"- `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.",
4949
"- 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.",
50-
'- 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)`.',
50+
'- 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)`.',
5151
"- 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).",
5252
'- 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, ... }`.',
5353
"- `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.",

‎packages/hosts/mcp/src/tool-server.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,109 @@ describe("MCP host server — native elicitation mode", () => {
729729
});
730730
});
731731

732+
it("execute tool decodes unpadded base64url text file output (Gmail message bodies)", async () => {
733+
const engine = makeStubEngine({
734+
execute: () =>
735+
Effect.succeed({
736+
result: null,
737+
output: [
738+
{
739+
type: "file",
740+
file: toolFile({
741+
name: "body.txt",
742+
mimeType: "text/plain; charset=UTF-8",
743+
data: "5Lu25ZCNOiDjgZPjgpPjgavjgaHjga_kuJbnlYwg4oCUIOODhuOCueODiD8-Pg",
744+
byteLength: 46,
745+
}),
746+
},
747+
],
748+
}),
749+
});
750+
751+
await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
752+
const result = await client.callTool({
753+
name: "execute",
754+
arguments: { code: "emit(body);" },
755+
});
756+
757+
const content = result.content as Array<Record<string, unknown>>;
758+
expect(content[1]).toMatchObject({
759+
type: "text",
760+
text: "件名: こんにちは世界 — テスト?>>",
761+
});
762+
expect(result.isError).toBeFalsy();
763+
});
764+
});
765+
766+
it("execute tool hands MCP clients standard padded base64 for base64url binary file output", async () => {
767+
const engine = makeStubEngine({
768+
execute: () =>
769+
Effect.succeed({
770+
result: null,
771+
output: [
772+
{
773+
type: "file",
774+
file: toolFile({
775+
name: "blob.bin",
776+
mimeType: "application/octet-stream",
777+
data: "-_8",
778+
byteLength: 2,
779+
}),
780+
},
781+
],
782+
}),
783+
});
784+
785+
await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
786+
const result = await client.callTool({
787+
name: "execute",
788+
arguments: { code: "emit(blob);" },
789+
});
790+
791+
const content = result.content as Array<Record<string, unknown>>;
792+
expect(content[1]).toMatchObject({
793+
type: "resource",
794+
resource: { blob: "+/8=" },
795+
});
796+
expect(result.isError).toBeFalsy();
797+
});
798+
});
799+
800+
it("execute tool reports undecodable file output instead of failing the call", async () => {
801+
const engine = makeStubEngine({
802+
execute: () =>
803+
Effect.succeed({
804+
result: { kept: true },
805+
output: [
806+
{
807+
type: "file",
808+
file: toolFile({
809+
name: "body.txt",
810+
mimeType: "text/plain",
811+
data: "not base64!",
812+
byteLength: 11,
813+
}),
814+
},
815+
],
816+
}),
817+
});
818+
819+
await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
820+
const result = await client.callTool({
821+
name: "execute",
822+
arguments: { code: "emit(body); return { kept: true };" },
823+
});
824+
825+
const content = result.content as Array<Record<string, unknown>>;
826+
expect(content[1]).toMatchObject({
827+
type: "text",
828+
text: "File output omitted: body.txt data is not valid base64 or base64url.",
829+
});
830+
expect(result.structuredContent).toMatchObject({ status: "completed" });
831+
expect(result.isError).toBeFalsy();
832+
});
833+
});
834+
732835
it("execute tool surfaces failed engine effects as an opaque generic with correlation id", async () => {
733836
const engine = makeStubEngine({
734837
execute: () => Effect.fail(new TestExecutionError({ message: "Unexpected token ':'" })),

‎packages/hosts/mcp/src/tool-server.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,20 @@ const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resour
600600
return "resource";
601601
};
602602

603+
/**
604+
* Agents wrap upstream payloads in a `ToolFile` verbatim, and some upstreams
605+
* (Gmail message bodies) use unpadded base64url. Accept either alphabet and
606+
* hand MCP clients standard padded base64. Returns null when the data is not
607+
* base64 in either alphabet.
608+
*/
609+
const standardBase64 = (data: string): string | null => {
610+
const alphabet = data.replace(/\s/g, "").replace(/-/g, "+").replace(/_/g, "/");
611+
const unpadded = alphabet.replace(/=+$/, "");
612+
if (!/^[A-Za-z0-9+/]*$/.test(unpadded) || unpadded.length % 4 === 1) return null;
613+
const remainder = unpadded.length % 4;
614+
return remainder === 0 ? unpadded : `${unpadded}${"=".repeat(4 - remainder)}`;
615+
};
616+
603617
const bytesFromBase64 = (base64: string): Uint8Array => {
604618
const binary = atob(base64);
605619
const bytes = new Uint8Array(binary.length);
@@ -609,32 +623,41 @@ const bytesFromBase64 = (base64: string): Uint8Array => {
609623
return bytes;
610624
};
611625

612-
const decodeTextFile = (file: ToolFileValue): string => {
613-
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data));
626+
const decodeTextFile = (base64: string): string => {
627+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(base64));
614628
if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text;
615629
return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${
616630
text.length - TEXT_FILE_CONTENT_MAX_CHARS
617631
} characters]`;
618632
};
619633

620634
const toolFileContent = (file: ToolFileValue): ContentBlock[] => {
635+
const data = standardBase64(file.data);
636+
if (data === null) {
637+
return [
638+
{
639+
type: "text",
640+
text: `File output omitted: ${toolFileName(file)} data is not valid base64 or base64url.`,
641+
},
642+
];
643+
}
621644
const kind = toolFileKind(file);
622645
if (kind === "image") {
623-
return [{ type: "image", data: file.data, mimeType: file.mimeType }];
646+
return [{ type: "image", data, mimeType: file.mimeType }];
624647
}
625648
if (kind === "audio") {
626-
return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
649+
return [{ type: "audio", data, mimeType: file.mimeType }];
627650
}
628651
if (kind === "text") {
629-
return [{ type: "text", text: decodeTextFile(file) }];
652+
return [{ type: "text", text: decodeTextFile(data) }];
630653
}
631654
return [
632655
{
633656
type: "resource",
634657
resource: {
635658
uri: fileResourceUri(file),
636659
mimeType: file.mimeType,
637-
blob: file.data,
660+
blob: data,
638661
},
639662
},
640663
];

0 commit comments

Comments
 (0)