From 8e5a024345a9e0b3f4234eaff381bc34226cf4a5 Mon Sep 17 00:00:00 2001 From: Adir Duchan Date: Tue, 18 Mar 2025 11:37:02 +0200 Subject: [PATCH 1/5] add suport for browser context transport --- src/BROWSER_CONTEXT_TRANSPORT_README.md | 87 ++++ src/browser-context-transport.test.ts | 556 ++++++++++++++++++++++++ src/browser-context-transport.ts | 147 +++++++ 3 files changed, 790 insertions(+) create mode 100644 src/BROWSER_CONTEXT_TRANSPORT_README.md create mode 100644 src/browser-context-transport.test.ts create mode 100644 src/browser-context-transport.ts diff --git a/src/BROWSER_CONTEXT_TRANSPORT_README.md b/src/BROWSER_CONTEXT_TRANSPORT_README.md new file mode 100644 index 00000000..ddc3635d --- /dev/null +++ b/src/BROWSER_CONTEXT_TRANSPORT_README.md @@ -0,0 +1,87 @@ +# BrowserContextTransport + +Enables communication between different browser contexts using the MessageChannel API. + +## Motivation + +When building agentic chat applications in the browser with MCP, you need a reliable way for components to communicate across browser security boundaries. For example: + +- Chat UI running in a sandboxed iframe needs to talk to MCP clients/servers +- Agent tools executing in web workers need to communicate with the main thread +- Security-sensitive components isolated in separate contexts need to exchange messages + +Other transports don't work well for these browser scenarios: +- **InMemoryTransport**: Can't cross browser context boundaries (iframes, workers) +- **WebSocketTransport**: Requires network connection, server infrastructure, and adds latency +- **SSETransport**: Limited to one-way communication, requires network connection + +## Use Case + +```mermaid +flowchart LR + subgraph Browser["Browser"] + subgraph IFrame["Iframe"] + ChatUI["UI"] --> Client["Client"] + Client --> Transport["Transport"] + end + Transport --> Server["Server"] + Server --> AppLogic["App"] + end + + classDef component fill:#e1f5fe,stroke:#01579b,stroke-width:1px,rx:5px,ry:5px; + classDef container fill:#f5f5f5,stroke:#333,stroke-width:1px; + class Client,Server,AppLogic,ChatUI,Transport component; +``` + +## Quick Start + +### Creating a Transport Pair + +Most basic use case - both ends in same context: + +```typescript +const [clientTransport, serverTransport] = BrowserContextTransport.createChannelPair(); +const client = new Client(clientTransport); +const server = new Server(serverTransport); +``` + +### With Iframes + +Special code is needed for iframes because they're separate JavaScript execution contexts with their own memory space. You must use `postMessage` to transfer a `MessagePort`: + +```typescript +// Parent window +const iframe = document.getElementById('myIframe'); +const channel = new MessageChannel(); +const clientTransport = new BrowserContextTransport(channel.port1); +const client = new Client(clientTransport); +iframe.contentWindow.postMessage('init', '*', [channel.port2]); + +// Inside iframe +window.addEventListener('message', (event) => { + if (event.data === 'init' && event.ports[0]) { + const serverTransport = new BrowserContextTransport(event.ports[0]); + const server = new Server(serverTransport); + } +}); +``` + +### With Workers + +Workers also require special handling since they run in isolated threads. Like iframes, they need a `MessagePort` transferred via `postMessage`: + +```typescript +// Main thread +const worker = new Worker('worker.js'); +const channel = new MessageChannel(); +const clientTransport = new BrowserContextTransport(channel.port1); +worker.postMessage('init', [channel.port2]); + +// In worker +self.addEventListener('message', (event) => { + if (event.data === 'init') { + const serverTransport = new BrowserContextTransport(event.ports[0]); + const server = new Server(serverTransport); + } +}); +``` \ No newline at end of file diff --git a/src/browser-context-transport.test.ts b/src/browser-context-transport.test.ts new file mode 100644 index 00000000..5b109487 --- /dev/null +++ b/src/browser-context-transport.test.ts @@ -0,0 +1,556 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { JSONRPCMessage } from './types.js'; +import { BrowserContextTransport } from './browser-context-transport.js'; + +// Mock MessageChannel and MessagePort since they're browser APIs not available in Node.js test environment +class MockMessagePort { + onmessage: ((event: { data: any }) => void) | null = null; + onmessageerror: ((event: any) => void) | null = null; + + private _otherPort?: MockMessagePort; + private _started = false; + private _closed = false; + + constructor() {} + + connect(otherPort: MockMessagePort) { + this._otherPort = otherPort; + } + + start() { + this._started = true; + } + + close() { + this._closed = true; + this._otherPort = undefined; + } + + postMessage(data: any) { + if (this._closed) { + throw new Error('Cannot post message on closed port'); + } + + if (!this._started) { + throw new Error('Cannot post message before start'); + } + + if (!this._otherPort) { + throw new Error('No connected port'); + } + + // Simulate async message delivery + setTimeout(() => { + if (this._otherPort?.onmessage && !this._otherPort._closed) { + this._otherPort.onmessage({ data }); + } + }, 0); + } +} + +class MockMessageChannel { + port1: MockMessagePort; + port2: MockMessagePort; + + constructor() { + this.port1 = new MockMessagePort(); + this.port2 = new MockMessagePort(); + this.port1.connect(this.port2); + this.port2.connect(this.port1); + } +} + +// Mock iframe window and postMessage +class MockWindow { + private eventHandlers: Record void>> = {}; + + addEventListener(type: string, handler: (event: any) => void) { + if (!this.eventHandlers[type]) { + this.eventHandlers[type] = []; + } + this.eventHandlers[type].push(handler); + } + + dispatchEvent(type: string, event: any) { + if (this.eventHandlers[type]) { + this.eventHandlers[type].forEach(handler => handler(event)); + } + } +} + +class MockIframe { + contentWindow: { + postMessage: jest.Mock; + }; + + constructor() { + this.contentWindow = { + postMessage: jest.fn() + }; + } +} + +// Mock Worker +class MockWorker { + onmessage: ((event: { data: any }) => void) | null = null; + postMessage: jest.Mock; + + constructor() { + this.postMessage = jest.fn(); + } + + // Helper to simulate receiving a message + simulateMessage(data: any) { + if (this.onmessage) { + this.onmessage({ data }); + } + } +} + +// Replace global MessageChannel with our mock implementation for testing +global.MessageChannel = MockMessageChannel as any; + +// Type for Jest done callback +type DoneCallback = (error?: any) => void; + +describe('BrowserContextTransport', () => { + let transport1: BrowserContextTransport; + let transport2: BrowserContextTransport; + let mockPort1: MockMessagePort; + let mockPort2: MockMessagePort; + + beforeEach(() => { + const channel = new MockMessageChannel(); + mockPort1 = channel.port1; + mockPort2 = channel.port2; + + transport1 = new BrowserContextTransport(mockPort1 as unknown as MessagePort); + transport2 = new BrowserContextTransport(mockPort2 as unknown as MessagePort); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should set the sessionId', () => { + // Check that sessionId matches the expected format (timestamp in base36-randomsuffix) + expect(transport1.sessionId).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); + }); + + it('should throw an error if port is not provided', () => { + expect(() => new BrowserContextTransport(null as unknown as MessagePort)).toThrow('MessagePort is required'); + }); + + it('should setup event listeners on the port', () => { + expect(mockPort1.onmessage).not.toBeNull(); + expect(mockPort1.onmessageerror).not.toBeNull(); + }); + }); + + describe('createChannelPair', () => { + it('should create a pair of connected transports', () => { + const [t1, t2] = BrowserContextTransport.createChannelPair(); + expect(t1).toBeInstanceOf(BrowserContextTransport); + expect(t2).toBeInstanceOf(BrowserContextTransport); + }); + + it('should use the same session ID for both transports', () => { + const [t1, t2] = BrowserContextTransport.createChannelPair(); + expect(t1.sessionId).toBe(t2.sessionId); + expect(t1.sessionId).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); + }); + }); + + describe('start', () => { + it('should start the MessagePort', async () => { + const startSpy = jest.spyOn(mockPort1, 'start'); + await transport1.start(); + expect(startSpy).toHaveBeenCalled(); + }); + + it('should throw if already started', async () => { + await transport1.start(); + await expect(transport1.start()).rejects.toThrow('already started'); + }); + + it('should throw if closed', async () => { + await transport1.close(); + await expect(transport1.start()).rejects.toThrow('closed'); + }); + }); + + describe('send', () => { + beforeEach(async () => { + await transport1.start(); + await transport2.start(); + }); + + it('should send a message through the MessagePort', async () => { + const postMessageSpy = jest.spyOn(mockPort1, 'postMessage'); + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: { hello: 'world' }, + id: 1 + }; + + await transport1.send(message); + expect(postMessageSpy).toHaveBeenCalledWith(message); + }); + + it('should throw if transport is closed', async () => { + await transport1.close(); + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + id: 2 + }; + + await expect(transport1.send(message)).rejects.toThrow('closed'); + }); + + it('should call onerror and reject if postMessage throws', async () => { + const error = new Error('Test error'); + jest.spyOn(mockPort1, 'postMessage').mockImplementation(() => { + throw error; + }); + + const onErrorSpy = jest.fn(); + transport1.onerror = onErrorSpy; + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + id: 3 + }; + + await expect(transport1.send(message)).rejects.toThrow('Test error'); + expect(onErrorSpy).toHaveBeenCalledWith(error); + }); + }); + + describe('close', () => { + it('should close the MessagePort', async () => { + const closeSpy = jest.spyOn(mockPort1, 'close'); + await transport1.close(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should call onclose if defined', async () => { + const onCloseSpy = jest.fn(); + transport1.onclose = onCloseSpy; + await transport1.close(); + expect(onCloseSpy).toHaveBeenCalled(); + }); + + it('should be idempotent', async () => { + const closeSpy = jest.spyOn(mockPort1, 'close'); + await transport1.close(); + await transport1.close(); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('message handling', () => { + let onMessageSpy: jest.Mock; + let onErrorSpy: jest.Mock; + const validMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: { foo: 'bar' }, + id: 123 + }; + + beforeEach(async () => { + onMessageSpy = jest.fn(); + onErrorSpy = jest.fn(); + transport1.onmessage = onMessageSpy; + transport1.onerror = onErrorSpy; + await transport1.start(); + await transport2.start(); + }); + + it('should receive messages from the other transport', (done: DoneCallback) => { + transport2.send(validMessage); + + // Use setTimeout to wait for the async message delivery + setTimeout(() => { + expect(onMessageSpy).toHaveBeenCalledWith(validMessage); + done(); + }, 10); + }); + + it('should call onerror if message parsing fails', () => { + // Manually trigger onmessage with invalid data + mockPort1.onmessage!({ data: 'not a valid JSON-RPC message' }); + expect(onErrorSpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Failed to parse message') + })); + }); + + it('should call onerror on messageerror event', () => { + const errorEvent = { type: 'messageerror', data: 'some error' }; + mockPort1.onmessageerror!(errorEvent); + + expect(onErrorSpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('MessagePort error') + })); + }); + }); + + describe('end-to-end test', () => { + it('should allow bidirectional communication', (done: DoneCallback) => { + // Define test message types based on a subset of JSONRPCMessage + type RequestMessage = { + jsonrpc: string; + method: string; + params?: Record; + id: number; + }; + + type ResponseMessage = { + jsonrpc: string; + result: Record; + id: number; + }; + + const messages1: ResponseMessage[] = []; + const messages2: RequestMessage[] = []; + + transport1.onmessage = (msg) => { + // Type assertion for test purposes + messages1.push(msg as unknown as ResponseMessage); + if (messages1.length === 3 && messages2.length === 3) { + checkResults(); + } + }; + + transport2.onmessage = (msg) => { + // Type assertion for test purposes + messages2.push(msg as unknown as RequestMessage); + if (messages1.length === 3 && messages2.length === 3) { + checkResults(); + } + }; + + transport1.start().then(() => { + transport2.start().then(() => { + // Send messages from transport1 to transport2 + transport1.send({ + jsonrpc: '2.0', + method: 'method1', + id: 1 + }); + + transport1.send({ + jsonrpc: '2.0', + method: 'method2', + params: { x: 1 }, + id: 2 + }); + + transport1.send({ + jsonrpc: '2.0', + method: 'method3', + params: { array: [1, 2, 3] }, + id: 3 + }); + + // Send messages from transport2 to transport1 + transport2.send({ + jsonrpc: '2.0', + result: { value: 'result1' }, + id: 1 + }); + + transport2.send({ + jsonrpc: '2.0', + result: { value: 'result2' }, + id: 2 + }); + + transport2.send({ + jsonrpc: '2.0', + result: { value: 'result3' }, + id: 3 + }); + }); + }); + + function checkResults() { + expect(messages1.length).toBe(3); + expect(messages2.length).toBe(3); + + expect(messages1.map(m => m.id)).toEqual([1, 2, 3]); + expect(messages2.map(m => m.id)).toEqual([1, 2, 3]); + + expect(messages1.every(m => 'result' in m)).toBe(true); + expect(messages2.every(m => 'method' in m)).toBe(true); + + done(); + } + }); + }); + + describe('iframe integration', () => { + let mockIframe: MockIframe; + let mockMainWindow: MockWindow; + let channel: MockMessageChannel; + let parentTransport: BrowserContextTransport; + + beforeEach(() => { + // Setup parent window context + mockIframe = new MockIframe(); + mockMainWindow = new MockWindow(); + channel = new MockMessageChannel(); + + // Create transport in parent window + parentTransport = new BrowserContextTransport(channel.port1 as unknown as MessagePort); + }); + + it('should verify the iframe example from README works correctly', (done: DoneCallback) => { + // This test verifies the pattern shown in the README + + // 1. Parent context creates channel and transport + expect(parentTransport).toBeInstanceOf(BrowserContextTransport); + + // 2. Parent sends port2 to iframe + mockIframe.contentWindow.postMessage('init', '*', [channel.port2]); + expect(mockIframe.contentWindow.postMessage).toHaveBeenCalledWith('init', '*', [channel.port2]); + + // 3. Setup message handler in iframe + const testMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'hello', + id: 123 + }; + + // Simulate iframe receiving message with port + setTimeout(() => { + // In real code, this would be an event listener in the iframe + const iframeTransport = new BrowserContextTransport(channel.port2 as unknown as MessagePort); + + let iframeReceivedMessage = false; + iframeTransport.onmessage = (msg) => { + expect(msg).toEqual(testMessage); + iframeReceivedMessage = true; + + // Test bidirectional communication + const response: JSONRPCMessage = { + jsonrpc: '2.0', + result: { status: 'success' }, + id: 123 + }; + + iframeTransport.send(response); + }; + + iframeTransport.start().then(() => { + // Setup parent transport to receive messages + let parentReceivedResponse = false; + parentTransport.onmessage = (msg) => { + expect(msg).toEqual({ + jsonrpc: '2.0', + result: { status: 'success' }, + id: 123 + }); + parentReceivedResponse = true; + + // Verify bidirectional communication worked + setTimeout(() => { + expect(iframeReceivedMessage).toBe(true); + expect(parentReceivedResponse).toBe(true); + done(); + }, 10); + }; + + parentTransport.start().then(() => { + // Send message from parent to iframe + parentTransport.send(testMessage); + }); + }); + }, 10); + }); + }); + + describe('worker integration', () => { + let mockWorker: MockWorker; + let channel: MockMessageChannel; + let mainThreadTransport: BrowserContextTransport; + + beforeEach(() => { + // Setup main thread context + mockWorker = new MockWorker(); + channel = new MockMessageChannel(); + + // Create transport in main thread + mainThreadTransport = new BrowserContextTransport(channel.port1 as unknown as MessagePort); + }); + + it('should verify the worker example from README works correctly', (done: DoneCallback) => { + // This test verifies the pattern shown in the README + + // 1. Main thread creates channel and transport + expect(mainThreadTransport).toBeInstanceOf(BrowserContextTransport); + + // 2. Main thread sends port2 to worker + mockWorker.postMessage('init', [channel.port2]); + expect(mockWorker.postMessage).toHaveBeenCalledWith('init', [channel.port2]); + + // 3. Setup message handler in worker (simulated) + const testMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'compute', + params: { data: [1, 2, 3] }, + id: 456 + }; + + // Simulate worker receiving message with port + setTimeout(() => { + // In real code, this would be an event listener in the worker + const workerTransport = new BrowserContextTransport(channel.port2 as unknown as MessagePort); + + let workerReceivedMessage = false; + workerTransport.onmessage = (msg) => { + expect(msg).toEqual(testMessage); + workerReceivedMessage = true; + + // Test bidirectional communication + const response: JSONRPCMessage = { + jsonrpc: '2.0', + result: { computed: 6 }, + id: 456 + }; + + workerTransport.send(response); + }; + + workerTransport.start().then(() => { + // Setup main thread transport to receive messages + let mainThreadReceivedResponse = false; + mainThreadTransport.onmessage = (msg) => { + expect(msg).toEqual({ + jsonrpc: '2.0', + result: { computed: 6 }, + id: 456 + }); + mainThreadReceivedResponse = true; + + // Verify bidirectional communication worked + setTimeout(() => { + expect(workerReceivedMessage).toBe(true); + expect(mainThreadReceivedResponse).toBe(true); + done(); + }, 10); + }; + + mainThreadTransport.start().then(() => { + // Send message from main thread to worker + mainThreadTransport.send(testMessage); + }); + }); + }, 10); + }); + }); +}); \ No newline at end of file diff --git a/src/browser-context-transport.ts b/src/browser-context-transport.ts new file mode 100644 index 00000000..83054d86 --- /dev/null +++ b/src/browser-context-transport.ts @@ -0,0 +1,147 @@ +import { Transport } from "./shared/transport.js"; +import { JSONRPCMessage, JSONRPCMessageSchema } from "./types.js"; + +/** + * Transport implementation that uses the browser's MessageChannel API for communication + * between different browser contexts (iframes, workers, tabs, windows, etc.). + */ +export class BrowserContextTransport implements Transport { + private _port: MessagePort; + private _started = false; + private _closed = false; + + sessionId: string; + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + /** + * Creates a new BrowserContextTransport using an existing MessagePort. + * + * @param port The MessagePort to use for communication. + * @param sessionId Optional session ID. If not provided, one will be generated. + */ + constructor(port: MessagePort, sessionId?: string) { + if (!port) { + throw new Error("MessagePort is required"); + } + + this._port = port; + this.sessionId = sessionId || this.generateId(); + + // Set up event listeners + this._port.onmessage = (event) => { + try { + const message = JSONRPCMessageSchema.parse(event.data); + this.onmessage?.(message); + } catch (error) { + const parseError = new Error(`Failed to parse message: ${error}`); + this.onerror?.(parseError); + } + }; + + this._port.onmessageerror = (event) => { + const messageError = new Error(`MessagePort error: ${JSON.stringify(event)}`); + this.onerror?.(messageError); + }; + } + + /** + * Internal method to generate a session ID. + * This is separated so it can be used by static methods. + */ + private static generateSessionId(): string { + // Current timestamp as prefix (in base 36 for shorter string) + const timePrefix = Date.now().toString(36); + + // Random suffix + const randomSuffix = Math.random().toString(36).substring(2, 10); + + return `${timePrefix}-${randomSuffix}`; + } + + /** + * Generates a simple unique identifier using timestamp and random values. + * This is not a true UUID but is sufficient for session identification. + */ + private generateId(): string { + return BrowserContextTransport.generateSessionId(); + } + + /** + * Starts processing messages on the transport. + * This starts the underlying MessagePort if it hasn't been started yet. + * + * @throws Error if the transport is already started or has been closed. + */ + async start(): Promise { + if (this._started) { + throw new Error( + "BrowserContextTransport already started! If using Client or Server class, note that connect() calls start() automatically." + ); + } + + if (this._closed) { + throw new Error("Cannot start a closed BrowserContextTransport"); + } + + this._started = true; + this._port.start(); + } + + /** + * Sends a JSON-RPC message over the MessagePort. + * + * @param message The JSON-RPC message to send. + * @throws Error if the transport is closed or the message cannot be sent. + */ + async send(message: JSONRPCMessage): Promise { + if (this._closed) { + throw new Error("Cannot send on a closed BrowserContextTransport"); + } + + return new Promise((resolve, reject) => { + try { + this._port.postMessage(message); + resolve(); + } catch (error) { + const sendError = error instanceof Error ? error : new Error(String(error)); + this.onerror?.(sendError); + reject(sendError); + } + }); + } + + /** + * Closes the MessagePort and marks the transport as closed. + * This method will call onclose if it's defined. + */ + async close(): Promise { + if (this._closed) { + return; + } + + this._closed = true; + this._port.close(); + this.onclose?.(); + } + + /** + * Creates a pair of linked BrowserContextTransport instances that can communicate with each other. + * One should be passed to a Client and one to a Server. + * Both instances will share the same session ID. + * + * @returns A tuple containing two BrowserContextTransport instances + */ + static createChannelPair(): [BrowserContextTransport, BrowserContextTransport] { + const channel = new MessageChannel(); + // Generate a single session ID for both transport instances + const sessionId = BrowserContextTransport.generateSessionId(); + + return [ + new BrowserContextTransport(channel.port1, sessionId), + new BrowserContextTransport(channel.port2, sessionId), + ]; + } +} \ No newline at end of file From 1a04ef4b55c5e5a497877649e10121154c069e98 Mon Sep 17 00:00:00 2001 From: Adir Duchan Date: Tue, 18 Mar 2025 12:21:41 +0200 Subject: [PATCH 2/5] remove browser context md --- src/BROWSER_CONTEXT_TRANSPORT_README.md | 87 ------------------------- 1 file changed, 87 deletions(-) delete mode 100644 src/BROWSER_CONTEXT_TRANSPORT_README.md diff --git a/src/BROWSER_CONTEXT_TRANSPORT_README.md b/src/BROWSER_CONTEXT_TRANSPORT_README.md deleted file mode 100644 index ddc3635d..00000000 --- a/src/BROWSER_CONTEXT_TRANSPORT_README.md +++ /dev/null @@ -1,87 +0,0 @@ -# BrowserContextTransport - -Enables communication between different browser contexts using the MessageChannel API. - -## Motivation - -When building agentic chat applications in the browser with MCP, you need a reliable way for components to communicate across browser security boundaries. For example: - -- Chat UI running in a sandboxed iframe needs to talk to MCP clients/servers -- Agent tools executing in web workers need to communicate with the main thread -- Security-sensitive components isolated in separate contexts need to exchange messages - -Other transports don't work well for these browser scenarios: -- **InMemoryTransport**: Can't cross browser context boundaries (iframes, workers) -- **WebSocketTransport**: Requires network connection, server infrastructure, and adds latency -- **SSETransport**: Limited to one-way communication, requires network connection - -## Use Case - -```mermaid -flowchart LR - subgraph Browser["Browser"] - subgraph IFrame["Iframe"] - ChatUI["UI"] --> Client["Client"] - Client --> Transport["Transport"] - end - Transport --> Server["Server"] - Server --> AppLogic["App"] - end - - classDef component fill:#e1f5fe,stroke:#01579b,stroke-width:1px,rx:5px,ry:5px; - classDef container fill:#f5f5f5,stroke:#333,stroke-width:1px; - class Client,Server,AppLogic,ChatUI,Transport component; -``` - -## Quick Start - -### Creating a Transport Pair - -Most basic use case - both ends in same context: - -```typescript -const [clientTransport, serverTransport] = BrowserContextTransport.createChannelPair(); -const client = new Client(clientTransport); -const server = new Server(serverTransport); -``` - -### With Iframes - -Special code is needed for iframes because they're separate JavaScript execution contexts with their own memory space. You must use `postMessage` to transfer a `MessagePort`: - -```typescript -// Parent window -const iframe = document.getElementById('myIframe'); -const channel = new MessageChannel(); -const clientTransport = new BrowserContextTransport(channel.port1); -const client = new Client(clientTransport); -iframe.contentWindow.postMessage('init', '*', [channel.port2]); - -// Inside iframe -window.addEventListener('message', (event) => { - if (event.data === 'init' && event.ports[0]) { - const serverTransport = new BrowserContextTransport(event.ports[0]); - const server = new Server(serverTransport); - } -}); -``` - -### With Workers - -Workers also require special handling since they run in isolated threads. Like iframes, they need a `MessagePort` transferred via `postMessage`: - -```typescript -// Main thread -const worker = new Worker('worker.js'); -const channel = new MessageChannel(); -const clientTransport = new BrowserContextTransport(channel.port1); -worker.postMessage('init', [channel.port2]); - -// In worker -self.addEventListener('message', (event) => { - if (event.data === 'init') { - const serverTransport = new BrowserContextTransport(event.ports[0]); - const server = new Server(serverTransport); - } -}); -``` \ No newline at end of file From 4c84e0b9ce0b92b504559870b7dccba7769ade1f Mon Sep 17 00:00:00 2001 From: Adir Duchan Date: Tue, 18 Mar 2025 14:10:19 +0200 Subject: [PATCH 3/5] update UTs --- src/browser-context-transport.test.ts | 182 ++++++++++++++++---------- 1 file changed, 116 insertions(+), 66 deletions(-) diff --git a/src/browser-context-transport.test.ts b/src/browser-context-transport.test.ts index 5b109487..9d4af25f 100644 --- a/src/browser-context-transport.test.ts +++ b/src/browser-context-transport.test.ts @@ -4,8 +4,8 @@ import { BrowserContextTransport } from './browser-context-transport.js'; // Mock MessageChannel and MessagePort since they're browser APIs not available in Node.js test environment class MockMessagePort { - onmessage: ((event: { data: any }) => void) | null = null; - onmessageerror: ((event: any) => void) | null = null; + onmessage: ((event: { data: JSONRPCMessage | unknown }) => void) | null = null; + onmessageerror: ((event: unknown) => void) | null = null; private _otherPort?: MockMessagePort; private _started = false; @@ -26,7 +26,7 @@ class MockMessagePort { this._otherPort = undefined; } - postMessage(data: any) { + postMessage(data: JSONRPCMessage) { if (this._closed) { throw new Error('Cannot post message on closed port'); } @@ -60,24 +60,6 @@ class MockMessageChannel { } } -// Mock iframe window and postMessage -class MockWindow { - private eventHandlers: Record void>> = {}; - - addEventListener(type: string, handler: (event: any) => void) { - if (!this.eventHandlers[type]) { - this.eventHandlers[type] = []; - } - this.eventHandlers[type].push(handler); - } - - dispatchEvent(type: string, event: any) { - if (this.eventHandlers[type]) { - this.eventHandlers[type].forEach(handler => handler(event)); - } - } -} - class MockIframe { contentWindow: { postMessage: jest.Mock; @@ -92,7 +74,7 @@ class MockIframe { // Mock Worker class MockWorker { - onmessage: ((event: { data: any }) => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; postMessage: jest.Mock; constructor() { @@ -100,7 +82,7 @@ class MockWorker { } // Helper to simulate receiving a message - simulateMessage(data: any) { + simulateMessage(data: unknown) { if (this.onmessage) { this.onmessage({ data }); } @@ -108,10 +90,7 @@ class MockWorker { } // Replace global MessageChannel with our mock implementation for testing -global.MessageChannel = MockMessageChannel as any; - -// Type for Jest done callback -type DoneCallback = (error?: any) => void; +global.MessageChannel = MockMessageChannel as unknown as typeof MessageChannel; describe('BrowserContextTransport', () => { let transport1: BrowserContextTransport; @@ -120,6 +99,7 @@ describe('BrowserContextTransport', () => { let mockPort2: MockMessagePort; beforeEach(() => { + // Arrange - Global setup for most tests const channel = new MockMessageChannel(); mockPort1 = channel.port1; mockPort2 = channel.port2; @@ -133,60 +113,95 @@ describe('BrowserContextTransport', () => { }); describe('constructor', () => { - it('should set the sessionId', () => { - // Check that sessionId matches the expected format (timestamp in base36-randomsuffix) + it('should generate a valid session ID in the expected format', () => { + // Arrange - Handled in beforeEach + + // Act - Constructor already called in beforeEach + + // Assert expect(transport1.sessionId).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); }); - it('should throw an error if port is not provided', () => { + it('should throw an error when port is not provided', () => { + // Arrange + + // Act & Assert - Combine for exception testing expect(() => new BrowserContextTransport(null as unknown as MessagePort)).toThrow('MessagePort is required'); }); - it('should setup event listeners on the port', () => { + it('should set up event listeners on the provided MessagePort', () => { + // Arrange - Handled in beforeEach + + // Act - Constructor already called in beforeEach + + // Assert expect(mockPort1.onmessage).not.toBeNull(); expect(mockPort1.onmessageerror).not.toBeNull(); }); }); - describe('createChannelPair', () => { - it('should create a pair of connected transports', () => { + describe('createChannelPair static method', () => { + it('should create two connected BrowserContextTransport instances', () => { + // Arrange + + // Act const [t1, t2] = BrowserContextTransport.createChannelPair(); + + // Assert expect(t1).toBeInstanceOf(BrowserContextTransport); expect(t2).toBeInstanceOf(BrowserContextTransport); }); - it('should use the same session ID for both transports', () => { + it('should assign the same session ID to both transport instances', () => { + // Arrange + + // Act const [t1, t2] = BrowserContextTransport.createChannelPair(); + + // Assert expect(t1.sessionId).toBe(t2.sessionId); expect(t1.sessionId).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); }); }); - describe('start', () => { - it('should start the MessagePort', async () => { + describe('start method', () => { + it('should call start on the underlying MessagePort', async () => { + // Arrange const startSpy = jest.spyOn(mockPort1, 'start'); + + // Act await transport1.start(); + + // Assert expect(startSpy).toHaveBeenCalled(); }); - it('should throw if already started', async () => { + it('should reject when called multiple times on the same transport', async () => { + // Arrange await transport1.start(); + + // Act & Assert await expect(transport1.start()).rejects.toThrow('already started'); }); - it('should throw if closed', async () => { + it('should reject when called on a closed transport', async () => { + // Arrange await transport1.close(); + + // Act & Assert await expect(transport1.start()).rejects.toThrow('closed'); }); }); - describe('send', () => { + describe('send method', () => { beforeEach(async () => { + // Additional setup for send tests await transport1.start(); await transport2.start(); }); - it('should send a message through the MessagePort', async () => { + it('should forward messages to the underlying MessagePort', async () => { + // Arrange const postMessageSpy = jest.spyOn(mockPort1, 'postMessage'); const message: JSONRPCMessage = { jsonrpc: '2.0', @@ -195,11 +210,15 @@ describe('BrowserContextTransport', () => { id: 1 }; + // Act await transport1.send(message); + + // Assert expect(postMessageSpy).toHaveBeenCalledWith(message); }); - it('should throw if transport is closed', async () => { + it('should reject when sending messages on a closed transport', async () => { + // Arrange await transport1.close(); const message: JSONRPCMessage = { jsonrpc: '2.0', @@ -207,10 +226,12 @@ describe('BrowserContextTransport', () => { id: 2 }; + // Act & Assert await expect(transport1.send(message)).rejects.toThrow('closed'); }); - it('should call onerror and reject if postMessage throws', async () => { + it('should call onerror handler and reject when underlying postMessage throws', async () => { + // Arrange const error = new Error('Test error'); jest.spyOn(mockPort1, 'postMessage').mockImplementation(() => { throw error; @@ -225,29 +246,45 @@ describe('BrowserContextTransport', () => { id: 3 }; + // Act & Assert await expect(transport1.send(message)).rejects.toThrow('Test error'); expect(onErrorSpy).toHaveBeenCalledWith(error); }); }); - describe('close', () => { - it('should close the MessagePort', async () => { + describe('close method', () => { + it('should call close on the underlying MessagePort', async () => { + // Arrange const closeSpy = jest.spyOn(mockPort1, 'close'); + + // Act await transport1.close(); + + // Assert expect(closeSpy).toHaveBeenCalled(); }); - it('should call onclose if defined', async () => { + it('should trigger the onclose callback when defined', async () => { + // Arrange const onCloseSpy = jest.fn(); transport1.onclose = onCloseSpy; + + // Act await transport1.close(); + + // Assert expect(onCloseSpy).toHaveBeenCalled(); }); - it('should be idempotent', async () => { + it('should be safe to call multiple times without triggering multiple close events', async () => { + // Arrange const closeSpy = jest.spyOn(mockPort1, 'close'); + + // Act await transport1.close(); await transport1.close(); + + // Assert expect(closeSpy).toHaveBeenCalledTimes(1); }); }); @@ -263,6 +300,7 @@ describe('BrowserContextTransport', () => { }; beforeEach(async () => { + // Arrange - Additional setup for message handling tests onMessageSpy = jest.fn(); onErrorSpy = jest.fn(); transport1.onmessage = onMessageSpy; @@ -271,36 +309,48 @@ describe('BrowserContextTransport', () => { await transport2.start(); }); - it('should receive messages from the other transport', (done: DoneCallback) => { + it('should receive and forward messages from the connected transport', (done) => { + // Arrange - Already set up in beforeEach + + // Act transport2.send(validMessage); - // Use setTimeout to wait for the async message delivery + // Assert - Using setTimeout to wait for async message delivery setTimeout(() => { expect(onMessageSpy).toHaveBeenCalledWith(validMessage); done(); }, 10); }); - it('should call onerror if message parsing fails', () => { - // Manually trigger onmessage with invalid data + it('should trigger onerror when receiving invalid message data', () => { + // Arrange - Already set up in beforeEach + + // Act mockPort1.onmessage!({ data: 'not a valid JSON-RPC message' }); + + // Assert expect(onErrorSpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Failed to parse message') })); }); - it('should call onerror on messageerror event', () => { + it('should trigger onerror when receiving a messageerror event', () => { + // Arrange const errorEvent = { type: 'messageerror', data: 'some error' }; + + // Act mockPort1.onmessageerror!(errorEvent); + // Assert expect(onErrorSpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('MessagePort error') })); }); }); - describe('end-to-end test', () => { - it('should allow bidirectional communication', (done: DoneCallback) => { + describe('bidirectional communication', () => { + it('should support two-way asynchronous communication between transports', (done) => { + // Arrange // Define test message types based on a subset of JSONRPCMessage type RequestMessage = { jsonrpc: string; @@ -334,6 +384,7 @@ describe('BrowserContextTransport', () => { } }; + // Act transport1.start().then(() => { transport2.start().then(() => { // Send messages from transport1 to transport2 @@ -378,6 +429,7 @@ describe('BrowserContextTransport', () => { }); }); + // Assert - Function to check results function checkResults() { expect(messages1.length).toBe(3); expect(messages2.length).toBe(3); @@ -395,26 +447,24 @@ describe('BrowserContextTransport', () => { describe('iframe integration', () => { let mockIframe: MockIframe; - let mockMainWindow: MockWindow; let channel: MockMessageChannel; let parentTransport: BrowserContextTransport; beforeEach(() => { - // Setup parent window context + // Arrange - Setup for iframe tests mockIframe = new MockIframe(); - mockMainWindow = new MockWindow(); channel = new MockMessageChannel(); // Create transport in parent window parentTransport = new BrowserContextTransport(channel.port1 as unknown as MessagePort); }); - it('should verify the iframe example from README works correctly', (done: DoneCallback) => { - // This test verifies the pattern shown in the README - + it('should facilitate communication between parent window and iframe contexts', (done) => { + // Arrange // 1. Parent context creates channel and transport expect(parentTransport).toBeInstanceOf(BrowserContextTransport); + // Act // 2. Parent sends port2 to iframe mockIframe.contentWindow.postMessage('init', '*', [channel.port2]); expect(mockIframe.contentWindow.postMessage).toHaveBeenCalledWith('init', '*', [channel.port2]); @@ -457,7 +507,7 @@ describe('BrowserContextTransport', () => { }); parentReceivedResponse = true; - // Verify bidirectional communication worked + // Assert setTimeout(() => { expect(iframeReceivedMessage).toBe(true); expect(parentReceivedResponse).toBe(true); @@ -474,13 +524,13 @@ describe('BrowserContextTransport', () => { }); }); - describe('worker integration', () => { + describe('web worker integration', () => { let mockWorker: MockWorker; let channel: MockMessageChannel; let mainThreadTransport: BrowserContextTransport; beforeEach(() => { - // Setup main thread context + // Arrange - Setup for worker tests mockWorker = new MockWorker(); channel = new MockMessageChannel(); @@ -488,12 +538,12 @@ describe('BrowserContextTransport', () => { mainThreadTransport = new BrowserContextTransport(channel.port1 as unknown as MessagePort); }); - it('should verify the worker example from README works correctly', (done: DoneCallback) => { - // This test verifies the pattern shown in the README - + it('should facilitate communication between main thread and worker thread', (done) => { + // Arrange // 1. Main thread creates channel and transport expect(mainThreadTransport).toBeInstanceOf(BrowserContextTransport); + // Act // 2. Main thread sends port2 to worker mockWorker.postMessage('init', [channel.port2]); expect(mockWorker.postMessage).toHaveBeenCalledWith('init', [channel.port2]); @@ -537,7 +587,7 @@ describe('BrowserContextTransport', () => { }); mainThreadReceivedResponse = true; - // Verify bidirectional communication worked + // Assert setTimeout(() => { expect(workerReceivedMessage).toBe(true); expect(mainThreadReceivedResponse).toBe(true); From 6c82ced4c0088113e485039c210de713c6653ca3 Mon Sep 17 00:00:00 2001 From: Adir Duchan Date: Tue, 18 Mar 2025 14:17:28 +0200 Subject: [PATCH 4/5] update docs --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index fe4caa3f..ce94b47e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - [Running Your Server](#running-your-server) - [stdio](#stdio) - [HTTP with SSE](#http-with-sse) + - [Browser Context Transport](#browser-context-transport) - [Testing and Debugging](#testing-and-debugging) - [Examples](#examples) - [Echo Server](#echo-server) @@ -239,6 +240,29 @@ app.post("/messages", async (req, res) => { app.listen(3001); ``` +### Browser Context Transport + +For in-browser applications, use the BrowserContextTransport to enable communication between browser contexts (same window, iframes, or web workers): + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { BrowserContextTransport } from "@modelcontextprotocol/sdk/browser-context-transport.js"; + +// Create paired transports +const [clientTransport, serverTransport] = BrowserContextTransport.createChannelPair(); + +// Set up server +const server = new McpServer({ + name: "browser-server", + version: "1.0.0" +}); + +// Connect server to its transport +await server.connect(serverTransport); + +// The clientTransport can be used with an MCP client +``` + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. From 016628e43feab8a2e8536f8ee4a78c4520c336b4 Mon Sep 17 00:00:00 2001 From: Adir Duchan Date: Mon, 24 Mar 2025 09:04:17 +0200 Subject: [PATCH 5/5] use randomUUID() for sessionId when avaialble --- src/browser-context-transport.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/browser-context-transport.ts b/src/browser-context-transport.ts index 83054d86..222aac79 100644 --- a/src/browser-context-transport.ts +++ b/src/browser-context-transport.ts @@ -52,12 +52,15 @@ export class BrowserContextTransport implements Transport { * This is separated so it can be used by static methods. */ private static generateSessionId(): string { + // Use the standard crypto API for UUID generation if available + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + // Fallback for environments where crypto.randomUUID is not available // Current timestamp as prefix (in base 36 for shorter string) const timePrefix = Date.now().toString(36); - - // Random suffix const randomSuffix = Math.random().toString(36).substring(2, 10); - return `${timePrefix}-${randomSuffix}`; }