Skip to main content
Glama

Low Cost Browsing MCP Server

by nightweb
cdp-browser-manager.ts•20.4 kB
import { Logger } from 'pino'; import { BrowserManager } from './browser-manager.js'; import { Config } from '../utils/config.js'; import { RemoteCDPClient } from '../utils/remote-cdp-client.js'; export interface CDPBrowserInfo { id: string; title: string; type: string; url: string; webSocketDebuggerUrl: string; version: string; description?: string; } export interface CDPConnectionOptions { host: string; port: number; timeout?: number; maxRetries?: number; retryDelay?: number; } export interface CDPContext { id: string; url: string; title: string; type: string; isActive: boolean; createdAt: number; lastUsed: number; } export class CDPBrowserManager { private contexts: Map<string, CDPContext> = new Map(); private activeConnections: Map<string, WebSocket> = new Map(); private reconnectAttempts: Map<string, number> = new Map(); private logger: Logger; private config: any; constructor(logger: Logger, config: any) { this.logger = logger; this.config = config; } /** * Detects running browsers with CDP enabled */ async detectBrowsers(): Promise<CDPBrowserInfo[]> { if (!this.config.browser.cdp.detection.enabled) { return []; } // Use remote detection if configured if (this.config.browser.cdp.detection.useRemote && this.config.browser.cdp.remote?.enabled) { return this.detectRemoteBrowsers(); } // Local detection return this.detectLocalBrowsers(); } /** * Detects browsers from remote CDP server */ private async detectRemoteBrowsers(): Promise<CDPBrowserInfo[]> { const remoteConfig = this.config.browser.cdp.remote; if (!remoteConfig?.url) { this.logger.warn('Remote CDP URL not configured'); return []; } try { this.logger.info({ url: remoteConfig.url }, 'Detecting browsers from remote CDP server'); const remoteClient = RemoteCDPClient.createFromConfig(this.logger, { url: remoteConfig.url, sslMode: remoteConfig.sslMode, apiKey: remoteConfig.apiKey, headers: remoteConfig.headers }); const response = await remoteClient.getAvailableBrowsers(); if (response.success) { this.logger.info({ count: response.browsers.length, browsers: response.browsers.map(b => ({ id: b.id, title: b.title })) }, 'Remote CDP browser detection completed'); return response.browsers; } else { this.logger.error({ error: response.error }, 'Remote CDP browser detection failed'); return []; } } catch (error) { this.logger.error({ error: error instanceof Error ? error.message : String(error) }, 'Remote CDP browser detection failed'); return []; } } /** * Detects browsers locally by scanning ports */ private async detectLocalBrowsers(): Promise<CDPBrowserInfo[]> { const browsers: CDPBrowserInfo[] = []; const ports = this.config.browser.cdp.detection.ports; const timeout = this.config.browser.cdp.detection.timeout; this.logger.info({ ports, timeout }, 'Scanning for local CDP-enabled browsers'); for (const port of ports) { try { const browserInfo = await this.checkBrowserPort(port, timeout); if (browserInfo) { browsers.push(browserInfo); this.logger.debug({ port, browserInfo }, 'Found CDP browser'); } } catch (error) { this.logger.debug({ port, error: error instanceof Error ? error.message : String(error) }, 'No CDP browser found on port'); } } this.logger.info({ count: browsers.length, browsers: browsers.map(b => ({ id: b.id, title: b.title, port: b.webSocketDebuggerUrl.split(':')[2] })) }, 'Local CDP browser detection completed'); return browsers; } /** * Checks if a browser is running on a specific port */ private async checkBrowserPort(port: number, timeout: number): Promise<CDPBrowserInfo | null> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(`http://localhost:${port}/json/version`, { signal: controller.signal, headers: { 'Accept': 'application/json', }, }); clearTimeout(timeoutId); if (!response.ok) { return null; } const data = await response.json(); // Get list of tabs/pages const tabsResponse = await fetch(`http://localhost:${port}/json`, { signal: controller.signal, headers: { 'Accept': 'application/json', }, }); if (!tabsResponse.ok) { return null; } const tabs = await tabsResponse.json(); return { id: data.id || `browser_${port}`, title: data.Browser || 'Chrome Browser', type: data.Browser || 'chrome', url: tabs[0]?.url || 'about:blank', webSocketDebuggerUrl: data.webSocketDebuggerUrl, version: data.Browser || 'Unknown', description: `Browser on port ${port}` }; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Timeout checking port ${port}`); } throw error; } } /** * Connects to a CDP browser */ async connectToBrowser(browserInfo: CDPBrowserInfo, options?: CDPConnectionOptions): Promise<string> { const host = options?.host || this.config.browser.cdp.host; const port = options?.port || this.config.browser.cdp.port; const timeout = options?.timeout || this.config.browser.cdp.connection.timeout; const maxRetries = options?.maxRetries || this.config.browser.cdp.maxRetries; const retryDelay = options?.retryDelay || this.config.browser.cdp.retryDelay; this.logger.info({ browserId: browserInfo.id, webSocketUrl: browserInfo.webSocketDebuggerUrl, host, port }, 'Connecting to CDP browser'); let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const contextId = await this.establishConnection(browserInfo, timeout); this.logger.info({ browserId: browserInfo.id, contextId, attempt }, 'Successfully connected to CDP browser'); return contextId; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); this.logger.warn({ browserId: browserInfo.id, attempt, maxRetries, error: lastError.message }, 'CDP connection attempt failed'); if (attempt < maxRetries) { await this.sleep(retryDelay); } } } throw new Error(`Failed to connect to CDP browser after ${maxRetries} attempts: ${lastError?.message}`); } /** * Establishes WebSocket connection to CDP browser */ private async establishConnection(browserInfo: CDPBrowserInfo, timeout: number): Promise<string> { return new Promise((resolve, reject) => { const wsUrl = browserInfo.webSocketDebuggerUrl; if (!wsUrl) { reject(new Error('No WebSocket URL provided')); return; } const ws = new WebSocket(wsUrl); const contextId = `cdp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Set up timeout const timeoutId = setTimeout(() => { ws.close(); reject(new Error(`Connection timeout after ${timeout}ms`)); }, timeout); ws.onopen = () => { clearTimeout(timeoutId); // Store connection this.activeConnections.set(contextId, ws); this.reconnectAttempts.set(contextId, 0); // Create context record const context: CDPContext = { id: contextId, url: browserInfo.url, title: browserInfo.title, type: 'cdp', isActive: true, createdAt: Date.now(), lastUsed: Date.now() }; this.contexts.set(contextId, context); // Set up message handling ws.onmessage = (event) => { this.handleCDPMessage(contextId, event.data); }; ws.onclose = () => { this.handleConnectionClose(contextId); }; ws.onerror = (error) => { this.logger.error({ contextId, error }, 'CDP WebSocket error'); }; // Send initial CDP commands this.initializeCDPSession(ws, contextId) .then(() => resolve(contextId)) .catch(reject); this.logger.debug({ contextId, wsUrl }, 'CDP WebSocket connection established'); }; ws.onerror = (error) => { clearTimeout(timeoutId); reject(new Error(`WebSocket connection failed: ${error}`)); }; ws.onclose = (event) => { clearTimeout(timeoutId); if (!event.wasClean) { reject(new Error(`WebSocket connection closed unexpectedly: ${event.code} ${event.reason}`)); } }; }); } /** * Initializes CDP session with basic commands */ private async initializeCDPSession(ws: WebSocket, contextId: string): Promise<void> { const commands = [ { id: 1, method: 'Runtime.enable' }, { id: 2, method: 'Page.enable' }, { id: 3, method: 'Network.enable' }, { id: 4, method: 'DOM.enable' } ]; for (const command of commands) { await this.sendCDPCommand(ws, command); } this.logger.debug({ contextId, commandsCount: commands.length }, 'CDP session initialized'); } /** * Sends a CDP command via WebSocket */ async sendCDPCommand(ws: WebSocket, command: any): Promise<any> { return new Promise((resolve, reject) => { if (ws.readyState !== WebSocket.OPEN) { reject(new Error('WebSocket connection is not open')); return; } const commandId = command.id || Date.now(); const commandWithId = { ...command, id: commandId }; // Set up response handler const responseHandler = (event: MessageEvent) => { try { const data = JSON.parse(event.data); if (data.id === commandId) { ws.removeEventListener('message', responseHandler); if (data.error) { reject(new Error(`CDP command failed: ${data.error.message}`)); } else { resolve(data.result); } } } catch (error) { ws.removeEventListener('message', responseHandler); reject(error); } }; ws.addEventListener('message', responseHandler); // Send command ws.send(JSON.stringify(commandWithId)); // Set timeout for response setTimeout(() => { ws.removeEventListener('message', responseHandler); reject(new Error(`CDP command timeout: ${command.method}`)); }, 30000); }); } /** * Handles incoming CDP messages */ private handleCDPMessage(contextId: string, data: string): void { try { const message = JSON.parse(data); // Update context last used time const context = this.contexts.get(contextId); if (context) { context.lastUsed = Date.now(); } // Handle different message types if (message.method) { this.logger.debug({ contextId, method: message.method }, 'CDP event received'); // Handle specific events switch (message.method) { case 'Page.loadEventFired': this.logger.debug({ contextId }, 'Page load event fired'); break; case 'Runtime.consoleAPICalled': this.handleConsoleMessage(contextId, message.params); break; case 'Network.responseReceived': this.logger.debug({ contextId, url: message.params.response.url }, 'Network response received'); break; } } } catch (error) { this.logger.error({ contextId, error: error instanceof Error ? error.message : String(error) }, 'Failed to parse CDP message'); } } /** * Handles console messages from CDP */ private handleConsoleMessage(contextId: string, params: any): void { const level = params.type || 'log'; const message = params.args?.map((arg: any) => arg.value || arg.description || 'undefined').join(' ') || ''; if (level === 'error') { this.logger.error({ contextId, consoleMessage: message }, 'Browser console error'); } else if (level === 'warning') { this.logger.warn({ contextId, consoleMessage: message }, 'Browser console warning'); } else { this.logger.debug({ contextId, consoleMessage: message }, 'Browser console message'); } } /** * Handles connection close */ private handleConnectionClose(contextId: string): void { const context = this.contexts.get(contextId); if (context) { context.isActive = false; } this.activeConnections.delete(contextId); this.reconnectAttempts.delete(contextId); this.logger.warn({ contextId }, 'CDP connection closed'); // Attempt reconnection if enabled if (this.config.browser.cdp.connection.reconnect && context) { const attempts = this.reconnectAttempts.get(contextId) || 0; if (attempts < this.config.browser.cdp.connection.maxReconnects) { this.reconnectAttempts.set(contextId, attempts + 1); this.attemptReconnection(contextId); } } } /** * Attempts to reconnect to a CDP browser */ private async attemptReconnection(contextId: string): Promise<void> { const context = this.contexts.get(contextId); if (!context) { return; } const attempts = this.reconnectAttempts.get(contextId) || 0; const delay = Math.min(1000 * Math.pow(2, attempts), 30000); // Exponential backoff, max 30s this.logger.info({ contextId, attempts, delay }, 'Attempting CDP reconnection'); await this.sleep(delay); try { // Try to detect browsers again and reconnect const browsers = await this.detectBrowsers(); if (browsers.length > 0) { const browser = browsers[0]; // Use first available browser await this.connectToBrowser(browser); this.logger.info({ contextId, attempts }, 'CDP reconnection successful'); } else { throw new Error('No browsers available for reconnection'); } } catch (error) { this.logger.error({ contextId, attempts, error: error instanceof Error ? error.message : String(error) }, 'CDP reconnection failed'); } } /** * Navigates to a URL using CDP */ async navigateToUrl(contextId: string, url: string): Promise<void> { const ws = this.activeConnections.get(contextId); if (!ws) { throw new Error(`No active CDP connection for context ${contextId}`); } this.logger.info({ contextId, url }, 'Navigating to URL via CDP'); try { await this.sendCDPCommand(ws, { method: 'Page.navigate', params: { url } }); // Wait for navigation to complete await this.waitForNavigation(ws); // Update context const context = this.contexts.get(contextId); if (context) { context.url = url; context.lastUsed = Date.now(); } this.logger.info({ contextId, url }, 'Navigation completed via CDP'); } catch (error) { this.logger.error({ contextId, url, error: error instanceof Error ? error.message : String(error) }, 'CDP navigation failed'); throw error; } } /** * Waits for navigation to complete */ private async waitForNavigation(ws: WebSocket): Promise<void> { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error('Navigation timeout')); }, 30000); const messageHandler = (event: MessageEvent) => { try { const data = JSON.parse(event.data); if (data.method === 'Page.loadEventFired') { clearTimeout(timeoutId); ws.removeEventListener('message', messageHandler); resolve(); } } catch (error) { // Ignore parsing errors } }; ws.addEventListener('message', messageHandler); }); } /** * Executes JavaScript in the browser context */ async executeScript(contextId: string, script: string): Promise<any> { const ws = this.activeConnections.get(contextId); if (!ws) { throw new Error(`No active CDP connection for context ${contextId}`); } this.logger.debug({ contextId, scriptLength: script.length }, 'Executing script via CDP'); try { const result = await this.sendCDPCommand(ws, { method: 'Runtime.evaluate', params: { expression: script, returnByValue: true } }); return result.result?.value; } catch (error) { this.logger.error({ contextId, error: error instanceof Error ? error.message : String(error) }, 'CDP script execution failed'); throw error; } } /** * Takes a screenshot using CDP */ async takeScreenshot(contextId: string, options?: { fullPage?: boolean; format?: string }): Promise<string> { const ws = this.activeConnections.get(contextId); if (!ws) { throw new Error(`No active CDP connection for context ${contextId}`); } this.logger.debug({ contextId, options }, 'Taking screenshot via CDP'); try { const result = await this.sendCDPCommand(ws, { method: 'Page.captureScreenshot', params: { format: options?.format || 'png', fullPage: options?.fullPage || false } }); return result.data; // Base64 encoded image } catch (error) { this.logger.error({ contextId, error: error instanceof Error ? error.message : String(error) }, 'CDP screenshot failed'); throw error; } } /** * Gets page content using CDP */ async getPageContent(contextId: string): Promise<string> { const ws = this.activeConnections.get(contextId); if (!ws) { throw new Error(`No active CDP connection for context ${contextId}`); } this.logger.debug({ contextId }, 'Getting page content via CDP'); try { const result = await this.sendCDPCommand(ws, { method: 'Runtime.evaluate', params: { expression: 'document.documentElement.outerHTML', returnByValue: true } }); return result.result?.value || ''; } catch (error) { this.logger.error({ contextId, error: error instanceof Error ? error.message : String(error) }, 'Failed to get page content via CDP'); throw error; } } /** * Closes a CDP context */ async closeContext(contextId: string): Promise<void> { const ws = this.activeConnections.get(contextId); if (ws) { ws.close(); this.activeConnections.delete(contextId); } this.contexts.delete(contextId); this.reconnectAttempts.delete(contextId); this.logger.info({ contextId }, 'CDP context closed'); } /** * Gets all active contexts */ getActiveContexts(): CDPContext[] { return Array.from(this.contexts.values()).filter(context => context.isActive); } /** * Gets context information */ getContext(contextId: string): CDPContext | undefined { return this.contexts.get(contextId); } /** * Cleanup method */ async cleanup(): Promise<void> { this.logger.info({ activeConnections: this.activeConnections.size }, 'Cleaning up CDP connections'); for (const [contextId, ws] of this.activeConnections) { try { ws.close(); } catch (error) { this.logger.warn({ contextId, error }, 'Error closing CDP connection'); } } this.activeConnections.clear(); this.contexts.clear(); this.reconnectAttempts.clear(); this.logger.info('CDP cleanup completed'); } /** * Utility method for sleeping */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nightweb/lc-browser-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server