diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 85ed5a32d1..b464c599ef 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -236,6 +236,13 @@ export class ExecuteCodeTaskHandler extends TaskHandler { console.log("Code execution result:", result); + // transform a top-level Uint8Array result into a compact base64 envelope to avoid the + // ~10x JSON expansion that occurs when JSON.stringify serializes typed arrays as objects + // with numeric string keys (see penpot/penpot#9420) + if (result instanceof Uint8Array) { + result = ExecuteCodeTaskHandler.encodeBytesAsBase64Envelope(result); + } + // return result and captured log let resultData: ExecuteCodeTaskResultData = { result: result, @@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler { }; task.sendSuccess(resultData); } + + /** + * Base64-encodes the given bytes and wraps the result in a tagged envelope that the + * server side recognizes (see `ImageContent.byteData`). + * + * @param bytes - the raw binary data to encode + * @returns an envelope of the form `{ __type: "base64", data: }` + */ + private static encodeBytesAsBase64Envelope(bytes: Uint8Array): { __type: "base64"; data: string } { + // build the binary string in chunks; calling `String.fromCharCode(...bytes)` directly + // would overflow the call stack for large arrays + const chunkSize = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk as unknown as number[]); + } + return { __type: "base64", data: btoa(binary) }; + } } diff --git a/mcp/packages/server/src/ToolResponse.ts b/mcp/packages/server/src/ToolResponse.ts index 6055f24ccf..db5babe4db 100644 --- a/mcp/packages/server/src/ToolResponse.ts +++ b/mcp/packages/server/src/ToolResponse.ts @@ -37,19 +37,26 @@ export class ImageContent implements ImageItem { /** * Utility function for ensuring a consistent Uint8Array representation of byte data. - * Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array - * from the plugin). + * Input can be one of: + * - a `Uint8Array` (already in the desired form); + * - a base64 envelope `{ __type: "base64", data: }` produced by the plugin + * to avoid the ~10x JSON expansion of typed arrays (see penpot/penpot#9420); + * - a numeric-keyed object obtained from `JSON.stringify`-ing a `Uint8Array` (legacy fallback). * - * @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array) - * @return data as Uint8Array + * @param data - data as `Uint8Array`, base64 envelope, or numeric-keyed object + * @return data as `Uint8Array` */ public static byteData(data: Uint8Array | object): Uint8Array { - if (typeof data === "object") { - // convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array - return new Uint8Array(Object.values(data) as number[]); - } else { + if (data instanceof Uint8Array) { return data; } + // recognize the base64 envelope produced by the plugin's ExecuteCodeTaskHandler + const envelope = data as { __type?: unknown; data?: unknown }; + if (envelope.__type === "base64" && typeof envelope.data === "string") { + return new Uint8Array(Buffer.from(envelope.data, "base64")); + } + // legacy fallback: object (as obtained from JSON conversion of Uint8Array) back to Uint8Array + return new Uint8Array(Object.values(data) as number[]); } }