diff --git a/sdks/typescript/server/src/__tests__/utils.test.ts b/sdks/typescript/server/src/__tests__/utils.test.ts index fb552dd6..5c08f0f9 100644 --- a/sdks/typescript/server/src/__tests__/utils.test.ts +++ b/sdks/typescript/server/src/__tests__/utils.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { getAdditionalResourceProps } from '../utils.js'; +import { describe, it, expect, vi } from 'vitest'; +import { getAdditionalResourceProps, utf8ToBase64 } from '../utils.js'; import { UI_METADATA_PREFIX } from '../types.js'; describe('getAdditionalResourceProps', () => { @@ -59,3 +59,88 @@ describe('getAdditionalResourceProps', () => { }); }); }); + +describe('utf8ToBase64', () => { + it('should correctly encode a simple ASCII string', () => { + const str = 'hello world'; + const expected = 'aGVsbG8gd29ybGQ='; + expect(utf8ToBase64(str)).toBe(expected); + }); + + it('should correctly encode a string with UTF-8 characters', () => { + const str = '你好,世界'; + const expected = '5L2g5aW9LOS4lueVjA=='; + expect(utf8ToBase64(str)).toBe(expected); + }); + + it('should correctly encode an empty string', () => { + const str = ''; + const expected = ''; + expect(utf8ToBase64(str)).toBe(expected); + }); + + it('should correctly encode a string with various special characters', () => { + const str = '`~!@#$%^&*()_+-=[]{}\\|;\':",./<>?'; + const expected = 'YH4hQCMkJV4mKigpXystPVtde31cfDsnOiIsLi88Pj8='; + expect(utf8ToBase64(str)).toBe(expected); + }); + + it('should use TextEncoder and btoa when Buffer is not available', () => { + const str = 'hello world'; + const expected = 'aGVsbG8gd29ybGQ='; + + const bufferBackup = global.Buffer; + // @ts-expect-error - simulating Buffer not being available + delete global.Buffer; + + expect(utf8ToBase64(str)).toBe(expected); + + global.Buffer = bufferBackup; + }); + + it('should use fallback btoa when Buffer and TextEncoder are not available', () => { + const str = 'hello world'; + const expected = 'aGVsbG8gd29ybGQ='; + + const bufferBackup = global.Buffer; + const textEncoderBackup = global.TextEncoder; + + // @ts-expect-error - simulating Buffer not being available + delete global.Buffer; + // @ts-expect-error - simulating TextEncoder not being available + delete global.TextEncoder; + + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(utf8ToBase64(str)).toBe(expected); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'MCP-UI SDK: Buffer API and TextEncoder/btoa not available. Base64 encoding might not be UTF-8 safe.', + ); + + consoleWarnSpy.mockRestore(); + global.Buffer = bufferBackup; + global.TextEncoder = textEncoderBackup; + }); + + it('should throw an error if all encoding methods fail', () => { + const str = 'hello world'; + + const bufferBackup = global.Buffer; + const textEncoderBackup = global.TextEncoder; + const btoaBackup = global.btoa; + + // @ts-expect-error - simulating Buffer not being available + delete global.Buffer; + // @ts-expect-error - simulating TextEncoder not being available + delete global.TextEncoder; + // @ts-expect-error - simulating btoa not being available + delete global.btoa; + + expect(() => utf8ToBase64(str)).toThrow( + 'MCP-UI SDK: Suitable UTF-8 to Base64 encoding method not found, and fallback btoa failed.', + ); + + global.Buffer = bufferBackup; + global.TextEncoder = textEncoderBackup; + global.btoa = btoaBackup; + }); +}); diff --git a/sdks/typescript/server/src/index.ts b/sdks/typescript/server/src/index.ts index 6d701560..4a284c51 100644 --- a/sdks/typescript/server/src/index.ts +++ b/sdks/typescript/server/src/index.ts @@ -10,44 +10,13 @@ import { UIActionResultIntent, UIActionResultToolCall, } from './types.js'; -import { getAdditionalResourceProps } from './utils.js'; +import { getAdditionalResourceProps, utf8ToBase64 } from './utils.js'; export type UIResource = { type: 'resource'; resource: HTMLTextContent | Base64BlobContent; }; -/** - * Robustly encodes a UTF-8 string to Base64. - * Uses Node.js Buffer if available, otherwise TextEncoder and btoa. - * @param str The string to encode. - * @returns Base64 encoded string. - */ -function robustUtf8ToBase64(str: string): string { - if (typeof Buffer !== 'undefined') { - return Buffer.from(str, 'utf-8').toString('base64'); - } else if (typeof TextEncoder !== 'undefined' && typeof btoa !== 'undefined') { - const encoder = new TextEncoder(); - const uint8Array = encoder.encode(str); - let binaryString = ''; - uint8Array.forEach((byte) => { - binaryString += String.fromCharCode(byte); - }); - return btoa(binaryString); - } else { - console.warn( - 'MCP-UI SDK: Buffer API and TextEncoder/btoa not available. Base64 encoding might not be UTF-8 safe.', - ); - try { - return btoa(str); - } catch (e) { - throw new Error( - 'MCP-UI SDK: Suitable UTF-8 to Base64 encoding method not found, and fallback btoa failed.', - ); - } - } -} - /** * Creates a UIResource. * This is the object that should be included in the 'content' array of a toolResult. @@ -114,7 +83,7 @@ export function createUIResource(options: CreateUIResourceOptions): UIResource { resource = { uri: options.uri, mimeType: mimeType as MimeType, - blob: robustUtf8ToBase64(actualContentString), + blob: utf8ToBase64(actualContentString), ...getAdditionalResourceProps(options), }; break; diff --git a/sdks/typescript/server/src/utils.ts b/sdks/typescript/server/src/utils.ts index 1aa96c09..ee80237a 100644 --- a/sdks/typescript/server/src/utils.ts +++ b/sdks/typescript/server/src/utils.ts @@ -24,3 +24,39 @@ export function getAdditionalResourceProps( return additionalResourceProps; } + +/** + * Robustly encodes a UTF-8 string to Base64. + * Uses Node.js Buffer if available, otherwise TextEncoder and btoa. + * @param str The string to encode. + * @returns Base64 encoded string. + */ +export function utf8ToBase64(str: string): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(str, 'utf-8').toString('base64'); + } else if (typeof TextEncoder !== 'undefined' && typeof btoa !== 'undefined') { + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(str); + // Efficiently convert Uint8Array to binary string, handling large arrays in chunks + let binaryString = ''; + // 8192 is a common chunk size used in JavaScript for performance reasons. + // It tends to align well with internal buffer sizes and memory page sizes, + // and it's small enough to avoid stack overflow errors with String.fromCharCode. + const CHUNK_SIZE = 8192; + for (let i = 0; i < uint8Array.length; i += CHUNK_SIZE) { + binaryString += String.fromCharCode(...uint8Array.slice(i, i + CHUNK_SIZE)); + } + return btoa(binaryString); + } else { + console.warn( + 'MCP-UI SDK: Buffer API and TextEncoder/btoa not available. Base64 encoding might not be UTF-8 safe.', + ); + try { + return btoa(str); + } catch (e) { + throw new Error( + 'MCP-UI SDK: Suitable UTF-8 to Base64 encoding method not found, and fallback btoa failed.', + ); + } + } +}