Skip to main content
Glama

Token Saver MCP

by jerry426
cdp-client.ts14.3 kB
import { Buffer } from 'node:buffer' import CDP from 'chrome-remote-interface' import sharp from 'sharp' import { BrowserLauncher } from './browser-launcher' // Simple logger for CDP client const logger = { info: (msg: string, ...args: any[]) => console.error(`[CDP] ${msg}`, ...args), error: (msg: string, ...args: any[]) => console.error(`[CDP ERROR] ${msg}`, ...args), warn: (msg: string, ...args: any[]) => console.warn(`[CDP WARN] ${msg}`, ...args), } /** * CDP Client - Manages Chrome DevTools Protocol connections * Provides high-level API for browser interaction */ export interface ConsoleMessage { type: 'log' | 'error' | 'warning' | 'info' | 'debug' text: string timestamp: number args?: any[] stackTrace?: any url?: string line?: number column?: number } export interface NetworkRequest { requestId: string url: string method: string headers: any timestamp: number response?: { status: number headers: any body?: string } } export class CDPClient { private client: any = null private launcher: BrowserLauncher private consoleMessages: ConsoleMessage[] = [] private networkRequests: Map<string, NetworkRequest> = new Map() private maxConsoleMessages = 1000 private isConnected = false constructor(private options: { port?: number, launchBrowser?: boolean } = {}) { this.launcher = new BrowserLauncher({ port: options.port || 9222, }) } /** * Connect to Chrome DevTools Protocol */ async connect(): Promise<void> { if (this.isConnected) { logger.info('Already connected to CDP') return } // Launch browser if requested if (this.options.launchBrowser) { await this.launcher.launch() } try { // Connect to Chrome DevTools Protocol this.client = await CDP({ port: this.launcher.getPort(), }) // Enable necessary domains await this.enableDomains() // Set up event listeners this.setupEventListeners() this.isConnected = true logger.info('Connected to Chrome DevTools Protocol') } catch (error) { logger.error('Failed to connect to CDP:', error) throw error } } /** * Enable CDP domains we need */ private async enableDomains(): Promise<void> { const { Runtime, Console, Page, Network, DOM } = this.client await Runtime.enable() await Console.enable() await Page.enable() await Network.enable() await DOM.enable() logger.info('CDP domains enabled') } /** * Set up event listeners for CDP events */ private setupEventListeners(): void { const { Console, Network, Runtime } = this.client // Console events Console.messageAdded((params: any) => { const message: ConsoleMessage = { type: this.mapConsoleLevel(params.message.level), text: params.message.text, timestamp: Date.now(), url: params.message.url, line: params.message.line, column: params.message.column, stackTrace: params.message.stackTrace, } this.consoleMessages.push(message) // Trim if too many messages if (this.consoleMessages.length > this.maxConsoleMessages) { this.consoleMessages = this.consoleMessages.slice(-this.maxConsoleMessages) } logger.info(`[Browser Console] ${message.type}: ${message.text}`) }) // Network events Network.requestWillBeSent((params: any) => { const request: NetworkRequest = { requestId: params.requestId, url: params.request.url, method: params.request.method, headers: params.request.headers, timestamp: Date.now(), } this.networkRequests.set(params.requestId, request) }) Network.responseReceived((params: any) => { const request = this.networkRequests.get(params.requestId) if (request) { request.response = { status: params.response.status, headers: params.response.headers, } } }) // Runtime exceptions Runtime.exceptionThrown((params: any) => { const exception = params.exceptionDetails const message: ConsoleMessage = { type: 'error', text: exception.text || 'Uncaught exception', timestamp: Date.now(), stackTrace: exception.stackTrace, } this.consoleMessages.push(message) logger.error('[Browser Exception]', exception.text) }) } /** * Map CDP console levels to our types */ private mapConsoleLevel(level: string): ConsoleMessage['type'] { switch (level) { case 'error': return 'error' case 'warning': return 'warning' case 'info': return 'info' case 'debug': return 'debug' default: return 'log' } } /** * Execute JavaScript in the browser */ async execute(expression: string): Promise<any> { if (!this.isConnected) { throw new Error('Not connected to CDP') } try { const { Runtime } = this.client const result = await Runtime.evaluate({ expression, returnByValue: true, awaitPromise: true, userGesture: true, }) if (result.exceptionDetails) { throw new Error(result.exceptionDetails.text || 'Execution failed') } return result.result.value } catch (error) { logger.error('Failed to execute JavaScript:', error) throw error } } /** * Navigate to a URL */ async navigate(url: string): Promise<void> { if (!this.isConnected) { throw new Error('Not connected to CDP') } const { Page } = this.client await Page.navigate({ url }) await Page.loadEventFired() logger.info(`Navigated to: ${url}`) } /** * Get all console messages */ getConsoleMessages(filter?: ConsoleMessage['type']): ConsoleMessage[] { if (filter) { return this.consoleMessages.filter(msg => msg.type === filter) } return [...this.consoleMessages] } /** * Clear console messages */ clearConsoleMessages(): void { this.consoleMessages = [] } /** * Get network requests */ getNetworkRequests(): NetworkRequest[] { return Array.from(this.networkRequests.values()) } /** * Get DOM snapshot */ async getDOMSnapshot(): Promise<any> { if (!this.isConnected) { throw new Error('Not connected to CDP') } const expression = ` (() => { const snapshot = { url: window.location.href, title: document.title, bodyHTML: document.body.innerHTML.substring(0, 2000), forms: Array.from(document.forms).map(f => ({ name: f.name, action: f.action, method: f.method, fields: Array.from(f.elements).map(e => ({ name: e.name, type: e.type, value: e.type === 'password' ? '[hidden]' : e.value })) })), links: Array.from(document.links).slice(0, 50).map(a => ({ href: a.href, text: a.textContent?.trim() })), images: Array.from(document.images).slice(0, 20).map(img => ({ src: img.src, alt: img.alt })) } return snapshot })() ` return await this.execute(expression) } /** * Take a screenshot */ async screenshot(): Promise<string> { if (!this.isConnected) { throw new Error('Not connected to CDP') } const { Page } = this.client const result = await Page.captureScreenshot({ format: 'png' }) return result.data // Base64 encoded image } /** * Take a downsampled screenshot for AI analysis * Reduces image size to prevent token limits */ async screenshotDownsampled(maxWidth: number = 800, quality: number = 70): Promise<string> { if (!this.isConnected) { throw new Error('Not connected to CDP') } const { Page } = this.client const result = await Page.captureScreenshot({ format: 'png' }) // Convert base64 to buffer const buffer = Buffer.from(result.data, 'base64') // Downsample using sharp const downsampled = await sharp(buffer) .resize(maxWidth, null, { withoutEnlargement: true, fit: 'inside', }) .jpeg({ quality }) .toBuffer() // Return as base64 return downsampled.toString('base64') } /** * Get current page info */ async getPageInfo(): Promise<any> { if (!this.isConnected) { throw new Error('Not connected to CDP') } const url = await this.execute('window.location.href') const title = await this.execute('document.title') const readyState = await this.execute('document.readyState') return { url, title, readyState } } /** * Click an element (with React support) */ async click(selector: string): Promise<void> { const expression = ` (() => { const element = document.querySelector('${selector}') if (!element) return false // Try React onClick first const reactPropsKey = Object.keys(element).find(key => key.startsWith('__reactProps') || key.startsWith('__reactFiber') ) if (reactPropsKey) { const reactProps = element[reactPropsKey] if (reactProps && reactProps.onClick) { // Create a synthetic event that React expects const syntheticEvent = { target: element, currentTarget: element, preventDefault: () => {}, stopPropagation: () => {}, nativeEvent: new MouseEvent('click', { bubbles: true, cancelable: true }), bubbles: true, cancelable: true, timeStamp: Date.now(), type: 'click' } reactProps.onClick(syntheticEvent) return true } } // Fallback to standard click element.click() return true })() ` const clicked = await this.execute(expression) if (!clicked) { throw new Error(`Element not found: ${selector}`) } } /** * Type text into an input (with React support) */ async type(selector: string, text: string): Promise<void> { const expression = ` (() => { const element = document.querySelector('${selector}') if (!element) return { success: false, error: 'Element not found' } // Try React-specific approach first const reactPropsKey = Object.keys(element).find(key => key.startsWith('__reactProps') || key.startsWith('__reactFiber') ) if (reactPropsKey) { const reactProps = element[reactPropsKey] if (reactProps && reactProps.onChange) { // Use React's onChange handler directly element.value = '${text.replace(/'/g, '\\\'')}' reactProps.onChange({ target: { value: '${text.replace(/'/g, '\\\'')}', name: element.name, id: element.id }, currentTarget: element, bubbles: true, cancelable: true }) return { success: true, method: 'react' } } } // Fallback to standard DOM events element.value = '${text.replace(/'/g, '\\\'')}' const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set nativeInputValueSetter.call(element, '${text.replace(/'/g, '\\\'')}') // Dispatch events in the correct order for maximum compatibility element.dispatchEvent(new Event('input', { bubbles: true })) element.dispatchEvent(new Event('change', { bubbles: true })) return { success: true, method: 'dom' } })() ` const result = await this.execute(expression) if (!result.success) { throw new Error(`Input not found: ${selector}`) } logger.info(`Typed into ${selector} using ${result.method} method`) } /** * Wait for an element to appear */ async waitForSelector(selector: string, timeout = 5000): Promise<void> { const startTime = Date.now() while (Date.now() - startTime < timeout) { const exists = await this.execute(`!!document.querySelector('${selector}')`) if (exists) { return } await new Promise(resolve => setTimeout(resolve, 100)) } throw new Error(`Timeout waiting for selector: ${selector}`) } /** * Check if connected */ isActive(): boolean { return this.isConnected } /** * Check if WebSocket connection is healthy */ async isHealthy(): Promise<boolean> { if (!this.isActive()) { return false } try { // Try a simple CDP command to test the connection await this.client.Runtime.evaluate({ expression: '1+1' }) return true } catch (error: any) { // Check for WebSocket closed error if (error.message?.includes('WebSocket') || error.message?.includes('closed')) { logger.warn('WebSocket connection is closed') this.isConnected = false return false } // Other errors might be recoverable return true } } /** * Disconnect from CDP */ async disconnect(): Promise<void> { if (this.client) { await this.client.close() this.client = null this.isConnected = false logger.info('Disconnected from CDP') } if (this.options.launchBrowser) { await this.launcher.kill() } } } // Singleton instance let cdpClient: CDPClient | null = null export function getCDPClient(): CDPClient { if (!cdpClient) { cdpClient = new CDPClient({ launchBrowser: true }) } return cdpClient } /** * Reset the CDP client singleton (useful for recovering from closed connections) */ export function resetCDPClient(): void { if (cdpClient) { logger.info('Resetting CDP client singleton') // Try to disconnect cleanly if possible cdpClient.disconnect().catch(() => { // Ignore errors during disconnect }) cdpClient = null } }

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/jerry426/token-saver-mcp'

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