diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 3182c874..70a0b654 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -1,6 +1,34 @@ #!/usr/bin/env node -const { WebSocket } = require('ws'); +function loadWebSocketImplementation() { + if (typeof globalThis.WebSocket === 'function') { + return globalThis.WebSocket; + } + + try { + const { WebSocket } = require('undici'); + if (typeof WebSocket === 'function') { + return WebSocket; + } + } catch { + // Fall through to the legacy ws dependency when available. + } + + try { + const { WebSocket } = require('ws'); + if (typeof WebSocket === 'function') { + return WebSocket; + } + } catch { + // Surface a dedicated error below if no implementation is available. + } + + throw new Error( + 'Browser MCP could not find a WebSocket implementation. Tried globalThis.WebSocket, undici, and ws.' + ); +} + +const WebSocket = loadWebSocketImplementation(); const PROTOCOL_VERSION = '2024-11-05'; const SERVER_NAME = 'ccs-browser'; @@ -29,6 +57,66 @@ const NAVIGATION_POLL_INTERVAL_MS = 100; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; +function addSocketListener(socket, eventName, handler) { + if (typeof socket.addEventListener === 'function') { + socket.addEventListener(eventName, handler); + return; + } + + if (typeof socket.on === 'function') { + socket.on(eventName, handler); + } +} + +async function getSocketMessageText(message) { + const data = message && typeof message === 'object' && 'data' in message ? message.data : message; + + if (typeof data === 'string') { + return data; + } + + if (Buffer.isBuffer(data)) { + return data.toString('utf8'); + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString('utf8'); + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8'); + } + + if (data && typeof data.text === 'function') { + return await data.text(); + } + + return String(data); +} + +function closeSocket(socket) { + if (typeof socket.close === 'function') { + socket.close(); + } +} + +function abortSocket(socket) { + if (typeof socket.terminate === 'function') { + socket.terminate(); + return; + } + + closeSocket(socket); +} + +function toSocketError(error) { + if (error instanceof Error) { + return error; + } + + return new Error('Browser MCP lost the DevTools websocket connection.'); +} + function shouldExposeTools() { return Boolean(process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL); } @@ -298,7 +386,7 @@ async function sendCdpCommand(page, method, params = {}) { const timer = setTimeout(() => { if (!settled) { settled = true; - ws.terminate(); + abortSocket(ws); reject(new Error('Browser MCP timed out waiting for a DevTools response.')); } }, CDP_TIMEOUT_MS); @@ -309,10 +397,10 @@ async function sendCdpCommand(page, method, params = {}) { } clearTimeout(timer); settled = true; - reject(error); + reject(toSocketError(error)); } - ws.on('open', () => { + addSocketListener(ws, 'open', () => { ws.send( JSON.stringify({ id: requestId, @@ -322,39 +410,43 @@ async function sendCdpCommand(page, method, params = {}) { ); }); - ws.on('message', (data) => { - if (settled) { - return; - } + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); - let message; - try { - message = JSON.parse(data.toString()); - } catch { - return; - } + if (settled) { + return; + } - if (message.id !== requestId) { - return; - } + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } - clearTimeout(timer); - settled = true; - ws.close(); + if (message.id !== requestId) { + return; + } - if (message.error) { - reject(new Error(message.error.message || 'DevTools request failed.')); - return; - } + clearTimeout(timer); + settled = true; + closeSocket(ws); - resolve(message.result || null); + if (message.error) { + reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + + resolve(message.result || null); + })().catch(settleError); }); - ws.on('error', (error) => { + addSocketListener(ws, 'error', (error) => { settleError(error); }); - ws.on('close', () => { + addSocketListener(ws, 'close', () => { if (settled) { return; } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 58cd0db8..a2ce77f6 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import * as http from 'node:http'; import { WebSocketServer } from 'ws'; import { tmpdir } from 'node:os'; @@ -50,7 +50,12 @@ type MockPageState = { >; }; -const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); +const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); + +type RunMcpRequestsOptions = { + serverPath?: string; + childEnv?: NodeJS.ProcessEnv; +}; function encodeMessage(message: unknown): string { return `${JSON.stringify(message)}\n`; @@ -162,7 +167,9 @@ function createMockBrowser(pagesInput: MockPageState[]) { }); } - async function start() { + async function start(options: RunMcpRequestsOptions = {}) { + const entryServerPath = options.serverPath || bundledServerPath; + const childEnv = options.childEnv || {}; tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); const port = await new Promise((resolve, reject) => { @@ -404,10 +411,11 @@ function createMockBrowser(pagesInput: MockPageState[]) { }); }); - const child = spawn('node', [serverPath], { + const child = spawn('node', [entryServerPath], { cwd: tempDir, env: { ...process.env, + ...childEnv, CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, }, stdio: ['pipe', 'pipe', 'pipe'], @@ -438,9 +446,13 @@ function createMockBrowser(pagesInput: MockPageState[]) { return { start, stop }; } -async function runMcpRequests(pages: MockPageState[], requests: JsonRpcMessage[]) { +async function runMcpRequests( + pages: MockPageState[], + requests: JsonRpcMessage[], + options: RunMcpRequestsOptions = {} +) { const browser = createMockBrowser(pages); - const child = await browser.start(); + const child = await browser.start(options); try { const responsesPromise = collectResponses(child, requests.length + 1); @@ -510,6 +522,45 @@ describe('ccs-browser MCP server', () => { } }); + it('works from an installed copy when ws is absent but undici is available via NODE_PATH', async () => { + const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-')); + const installedServerPath = join(installDir, 'ccs-browser-server.cjs'); + const nodeModulesDir = join(installDir, 'node_modules'); + + try { + cpSync(bundledServerPath, installedServerPath); + mkdirSync(nodeModulesDir, { recursive: true }); + cpSync(join(process.cwd(), 'node_modules', 'undici'), join(nodeModulesDir, 'undici'), { + recursive: true, + }); + + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_get_url_and_title', arguments: {} }, + }, + ], + { + serverPath: installedServerPath, + childEnv: { + NODE_PATH: nodeModulesDir, + }, + } + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(response)).toContain('title: Installed Copy'); + expect(getResponseText(response)).toContain('url: https://example.com/'); + } finally { + rmSync(installDir, { recursive: true, force: true }); + } + }); + it('navigates successfully after readiness polling', async () => { const responses = await runMcpRequests( [