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
89 changes: 87 additions & 2 deletions sdks/typescript/server/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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;
});
});
35 changes: 2 additions & 33 deletions sdks/typescript/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions sdks/typescript/server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}
}
}