Chrome Debug MCP Server

  • src
#!/usr/bin/env node /** * Chrome Debug MCP Server * * This server provides a Model Context Protocol (MCP) interface for controlling Chrome * through the Chrome DevTools Protocol (CDP) and Puppeteer. It allows: * - Launching Chrome with various configurations * - Injecting userscripts with GM_ function support * - Loading Chrome extensions * - Capturing console logs * - Evaluating JavaScript in the browser context * * @module ChromeDebugMCP */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, Request } from '@modelcontextprotocol/sdk/types.js'; import CDP from 'chrome-remote-interface'; import type { Client } from 'chrome-remote-interface'; import * as puppeteer from 'puppeteer'; import { toolDefinitions } from './tool-definitions.js'; import { ClickArgs, TypeArgs, SelectArgs, HoverArgs, WaitForSelectorArgs, ScreenshotArgs, NavigateArgs, GetTextArgs, GetAttributeArgs, SetViewportArgs } from './types/puppeteer-tools.js'; import { handleClick, handleType, handleSelect, handleHover, handleWaitForSelector, handleScreenshot, handleNavigate, handleGetText, handleGetAttribute, handleSetViewport, isClickArgs, isTypeArgs, isSelectArgs, isHoverArgs, isWaitForSelectorArgs, isScreenshotArgs, isNavigateArgs, isGetTextArgs, isGetAttributeArgs, isSetViewportArgs } from './handlers/puppeteer-handlers.js'; interface ConsoleAPICalledEvent { type: string; args: Array<{ value?: any; description?: string; }>; } import { readFile } from 'fs/promises'; import { join } from 'path'; // Enable verbose logging for debugging const DEBUG = true; const log = (...args: any[]) => DEBUG && console.log('[Chrome Debug MCP]', ...args); /** * Structure for console messages captured from Chrome */ interface ConsoleMessage { /** The type of console message (log, warn, error, etc.) */ type: string; /** The actual message content */ text: string; } /** * Arguments for launching Chrome with specific configurations */ interface LaunchChromeArgs { /** URL to navigate to after launch */ url?: string; /** Path to a specific Chrome executable (uses bundled Chrome if not provided) */ executablePath?: string; /** Path to a specific user data directory (optional, uses default Chrome profile if not provided) */ userDataDir?: string; /** Path to an unpacked Chrome extension to load */ loadExtension?: string; /** Path to extension that should remain enabled while others are disabled */ disableExtensionsExcept?: string; /** Whether to disable Chrome's "Automation Controlled" banner */ disableAutomationControlled?: boolean; /** Path to a userscript file to inject into the page */ userscriptPath?: string; } /** * Arguments for retrieving console logs */ interface GetConsoleLogsArgs { /** Whether to clear the logs after retrieving them */ clear?: boolean; } /** * Arguments for evaluating JavaScript in Chrome */ interface EvaluateArgs { /** JavaScript code to evaluate in the browser context */ expression?: string; } /** * Main server class that handles Chrome debugging and MCP communication */ class ChromeDebugServer { private server: Server; private browser: puppeteer.Browser | null = null; private cdpClient: Client | null = null; private consoleLogs: string[] = []; private activePage: puppeteer.Page | null = null; private pageMap: Map<string, puppeteer.Page> = new Map(); /** * Gets the active page, throwing an error if Chrome isn't running or no page is active */ /** * Gets a unique ID for a page */ private async getPageId(page: puppeteer.Page): Promise<string> { const url = page.url(); const title = await page.title(); // Create a unique ID from URL and title, fallback to timestamp if both empty return Buffer.from(`${url}-${title || Date.now()}`).toString('base64'); } /** * Updates the page map with all current pages */ private async updatePageMap(): Promise<void> { if (!this.browser) { throw new McpError( ErrorCode.InternalError, 'Chrome is not running. Call launch_chrome first.' ); } const pages = await this.browser.pages(); this.pageMap.clear(); for (const page of pages) { const id = await this.getPageId(page); this.pageMap.set(id, page); } } /** * Gets a page by its ID */ private async getPageById(tabId: string): Promise<puppeteer.Page> { await this.updatePageMap(); const page = this.pageMap.get(tabId); if (!page) { throw new McpError( ErrorCode.InvalidParams, `Tab not found: ${tabId}` ); } return page; } /** * Gets the active page, or the first available page */ private async getActivePage(): Promise<puppeteer.Page> { if (!this.browser) { throw new McpError( ErrorCode.InternalError, 'Chrome is not running. Call launch_chrome first.' ); } if (!this.activePage) { const pages = await this.browser.pages(); this.activePage = pages[0]; if (!this.activePage) { throw new McpError( ErrorCode.InternalError, 'No active page found' ); } await this.updatePageMap(); } return this.activePage; } /** * Handles listing all open tabs */ private async handleListTabs() { await this.updatePageMap(); const tabs = []; for (const [id, page] of this.pageMap) { const title = await page.title(); const url = page.url(); tabs.push({ id, title, url }); } return { content: [{ type: 'text', text: JSON.stringify(tabs, null, 2) }] }; } /** * Handles opening a new tab */ private async handleNewTab(args: { url?: string }) { const page = await this.browser?.newPage(); if (!page) { throw new McpError( ErrorCode.InternalError, 'Failed to create new tab' ); } if (args.url) { await page.goto(args.url, { waitUntil: 'networkidle0' }); } const id = await this.getPageId(page); this.pageMap.set(id, page); this.activePage = page; return { content: [{ type: 'text', text: `New tab created with ID: ${id}` }] }; } /** * Handles closing a specific tab */ private async handleCloseTab(args: { tabId: string }) { const page = await this.getPageById(args.tabId); await page.close(); // Clear and rebuild the page map after closing await this.updatePageMap(); if (this.activePage === page) { const pages = await this.browser?.pages(); this.activePage = pages?.[0] || null; } return { content: [{ type: 'text', text: `Closed tab: ${args.tabId}` }] }; } /** * Handles switching to a specific tab */ private async handleSwitchTab(args: { tabId: string }) { const page = await this.getPageById(args.tabId); await page.bringToFront(); this.activePage = page; return { content: [{ type: 'text', text: `Switched to tab: ${args.tabId}` }] }; } constructor() { // Initialize MCP server with basic configuration this.server = new Server( { name: 'chrome-debug-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); this.server.onerror = (error) => console.error('[MCP Error]', error); } /** * Sets up handlers for all supported MCP tools. * This includes tool listing and execution of individual tools. * * @private */ private setupToolHandlers() { // Handler for listing available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions, })); // Handler for executing tools this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const args = request.params.arguments || {}; switch (request.params.name) { case 'launch_chrome': return this.handleLaunchChrome(args as LaunchChromeArgs); case 'get_console_logs': return this.handleGetConsoleLogs(args as GetConsoleLogsArgs); case 'evaluate': return this.handleEvaluate(args as EvaluateArgs); case 'click': if (!isClickArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid click arguments'); return handleClick(await this.getActivePage(), args); case 'type': if (!isTypeArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid type arguments'); return handleType(await this.getActivePage(), args); case 'select': if (!isSelectArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid select arguments'); return handleSelect(await this.getActivePage(), args); case 'hover': if (!isHoverArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid hover arguments'); return handleHover(await this.getActivePage(), args); case 'wait_for_selector': if (!isWaitForSelectorArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid wait_for_selector arguments'); return handleWaitForSelector(await this.getActivePage(), args); case 'screenshot': if (!isScreenshotArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid screenshot arguments'); return handleScreenshot(await this.getActivePage(), args); case 'navigate': if (!isNavigateArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid navigate arguments'); return handleNavigate(await this.getActivePage(), args); case 'get_text': if (!isGetTextArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid get_text arguments'); return handleGetText(await this.getActivePage(), args); case 'get_attribute': if (!isGetAttributeArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid get_attribute arguments'); return handleGetAttribute(await this.getActivePage(), args); case 'set_viewport': if (!isSetViewportArgs(args)) throw new McpError(ErrorCode.InvalidParams, 'Invalid set_viewport arguments'); return handleSetViewport(await this.getActivePage(), args); case 'list_tabs': return this.handleListTabs(); case 'new_tab': return this.handleNewTab(args as { url?: string }); case 'close_tab': if (!args.tabId) throw new McpError(ErrorCode.InvalidParams, 'Tab ID is required'); return this.handleCloseTab(args as { tabId: string }); case 'switch_tab': if (!args.tabId) throw new McpError(ErrorCode.InvalidParams, 'Tab ID is required'); return this.handleSwitchTab(args as { tabId: string }); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } }); } /** * Handles the launch_chrome tool request. * This launches Chrome with specified configurations and sets up CDP connection. * * @param args - Configuration options for launching Chrome * @returns MCP response with launch status * @throws McpError if launch fails * @private */ private async handleLaunchChrome(args: LaunchChromeArgs): Promise<any> { try { // Close existing browser if any if (this.browser) { await this.browser.close(); } // Configure Chrome launch options const launchOptions: puppeteer.PuppeteerLaunchOptions = { headless: false, ignoreDefaultArgs: ['--disable-extensions'], // Prevent Puppeteer from disabling extensions args: [ '--remote-debugging-port=9222', // Enable CDP '--disable-web-security', // Allow cross-origin requests '--no-sandbox' // Required for some environments ] } as puppeteer.PuppeteerLaunchOptions; // Configure user data directory if specified if (args?.userDataDir) { log('Using custom user data directory:', args.userDataDir); launchOptions.userDataDir = args.userDataDir; } else { log('Using default Chrome profile'); } // Configure extension loading if requested if (args?.loadExtension) { log('Loading extension from:', args.loadExtension); launchOptions.args?.push(`--load-extension=${args.loadExtension}`); } if (args?.disableExtensionsExcept) { log('Disabling extensions except:', args.disableExtensionsExcept); launchOptions.args?.push(`--disable-extensions-except=${args.disableExtensionsExcept}`); } // Use specific Chrome executable if provided if (args?.executablePath) { log('Using custom Chrome executable:', args.executablePath); launchOptions.executablePath = args.executablePath; } else { log('Using bundled Chrome executable'); } // Handle automation mode configuration if (args?.disableAutomationControlled) { log('Disabling automation controlled mode'); if (!launchOptions.ignoreDefaultArgs) { launchOptions.ignoreDefaultArgs = []; } if (Array.isArray(launchOptions.ignoreDefaultArgs)) { launchOptions.ignoreDefaultArgs.push('--enable-automation'); } } // Launch Chrome using Puppeteer this.browser = await puppeteer.launch(launchOptions); const pages = await this.browser.pages(); const page = pages[0]; // Set up CDP client for advanced debugging capabilities this.cdpClient = await CDP(); // Enable console monitoring await this.cdpClient.Console.enable(); await this.cdpClient.Runtime.enable(); // Set up console message capture this.cdpClient.Runtime.consoleAPICalled((params: ConsoleAPICalledEvent) => { const { type, args } = params; const text = args.map((arg: { value?: any; description?: string }) => arg.value || arg.description).join(' '); this.consoleLogs.push(`[${type}] ${text}`); log('Console message:', type, text); }); if (args?.url) { // Navigate to specified URL log('Navigating to target URL...'); await page.goto(args.url, { waitUntil: 'networkidle0' }); // Handle userscript injection if requested let scriptContent = ''; if (args?.userscriptPath) { try { log('Reading userscript from:', args.userscriptPath); scriptContent = await readFile(args.userscriptPath, 'utf8'); } catch (error) { log('Error reading userscript:', error); throw new McpError( ErrorCode.InternalError, `Failed to read userscript: ${error}` ); } } // Inject Greasemonkey-style functions and userscript log('Injecting GM functions' + (scriptContent ? ' and userscript' : '')); await page.evaluate((content: string) => { try { // Define GM_ functions that userscripts can use const gmFunctionsScript = ` // Define script metadata window.GM_info = { script: { name: 'Injected Script', version: '1.0.0', description: 'Injected via Chrome Debug MCP', includes: ['*'], }, scriptHandler: 'Chrome Debug MCP', version: '1.0.0' }; // Storage functions window.GM_setValue = function(key, value) { localStorage.setItem('GM_' + key, JSON.stringify(value)); }; window.GM_getValue = function(key, defaultValue) { const value = localStorage.getItem('GM_' + key); return value ? JSON.parse(value) : defaultValue; }; window.GM_deleteValue = function(key) { localStorage.removeItem('GM_' + key); }; window.GM_listValues = function() { return Object.keys(localStorage) .filter(key => key.startsWith('GM_')) .map(key => key.slice(3)); }; // Enhanced HTTP requests window.GM_xmlhttpRequest = function(details) { const { url, method = 'GET', headers = {}, data = null, binary = false, timeout = 0, onload, onerror, onprogress, onreadystatechange } = details; const controller = new AbortController(); if (timeout) { setTimeout(() => controller.abort(), timeout); } fetch(url, { method, headers, body: data, signal: controller.signal }) .then(async response => { const responseData = binary ? await response.blob() : await response.text(); onload?.({ status: response.status, statusText: response.statusText, responseHeaders: Object.fromEntries([...response.headers]), responseText: binary ? undefined : responseData, response: responseData, readyState: 4 }); }) .catch(err => onerror?.(err)); }; // Enhanced clipboard support with fallback window.GM_setClipboard = async function(text, info = 'text') { try { // Focus page if needed if (!document.hasFocus()) { window.focus(); } if (info === 'html') { // Handle HTML content await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([text], { type: 'text/html' }), 'text/plain': new Blob([text.replace(/<[^>]*>/g, '')], { type: 'text/plain' }) }) ]); } else { // Handle plain text await navigator.clipboard.writeText(text); } console.log('Content copied to clipboard successfully'); } catch (error) { console.warn('Clipboard API failed, using fallback method'); // Fallback method const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { document.execCommand('copy'); console.log('Content copied to clipboard using fallback method'); } catch (err) { console.error('Clipboard copy failed:', err); } document.body.removeChild(textarea); } }; // Enhanced notifications with permissions handling window.GM_notification = async function(details) { const notificationDetails = typeof details === 'string' ? { text: details } : details; const { text, title = '', image = '', timeout = 0, onclick, ondone } = notificationDetails; function createNotificationElement() { const div = document.createElement('div'); const styles = { position: 'fixed', top: '20px', right: '20px', padding: '15px', backgroundColor: '#333', color: '#ffffff', borderRadius: '5px', zIndex: '999999', maxWidth: '300px', boxShadow: '0 4px 6px rgba(0,0,0,0.1)', opacity: '1', transition: 'opacity 0.3s' }; Object.assign(div.style, styles); const titleElement = document.createElement('div'); titleElement.style.marginBottom = '5px'; titleElement.style.fontWeight = 'bold'; titleElement.textContent = title; const textElement = document.createElement('div'); textElement.textContent = text; div.appendChild(titleElement); div.appendChild(textElement); return div; } function showFallbackNotification() { try { const div = createNotificationElement(); document.body.appendChild(div); if (onclick) { div.style.cursor = 'pointer'; div.onclick = onclick; } setTimeout(() => { div.style.opacity = '0'; setTimeout(() => { if (div.parentNode) { div.parentNode.removeChild(div); ondone?.(); } }, 300); }, timeout || 4700); } catch (error) { console.log('\u{1F514} ' + (title ? title + ': ' : '') + text); } } if (!('Notification' in window)) { showFallbackNotification(); return; } try { const permission = await Notification.requestPermission(); if (permission === 'granted') { const notification = new Notification(title, { body: text, icon: image, requireInteraction: Boolean(!timeout) }); if (onclick) { notification.onclick = () => { window.focus(); onclick(); }; } if (timeout) { setTimeout(() => { notification.close(); ondone?.(); }, timeout); } notification.onclose = () => ondone?.(); } else { showFallbackNotification(); } } catch (error) { showFallbackNotification(); } }; // Resources (stub implementation - would need actual resource management) const resources = new Map(); window.GM_getResourceText = function(name) { return resources.get(name) || ''; }; window.GM_getResourceURL = function(name) { return resources.get(name) || ''; }; // Add CSS to the page window.GM_addStyle = function(css) { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); }; // Open URL in new tab with more options window.GM_openInTab = function(url, options = {}) { const { active = true, insert = true, setParent = true } = options; const win = window.open(url, '_blank'); if (win && setParent) { win.opener = window; } return { close: () => win?.close(), closed: () => win?.closed || false, focus: () => win?.focus(), onclose: null }; }; // Register menu commands with better implementation const menuCommands = new Map(); window.GM_registerMenuCommand = function(name, fn, accessKey) { const id = Date.now().toString(); menuCommands.set(id, { name, fn, accessKey }); return id; }; window.GM_unregisterMenuCommand = function(id) { return menuCommands.delete(id); }; // Expose menu commands to the extension window.__GM_COMMANDS__ = menuCommands; // Initialize API key if needed if (!localStorage.getItem('GM_nzbgeekApiKey')) { localStorage.setItem('GM_nzbgeekApiKey', JSON.stringify('CuJU1bkXcsvYmuXjpK9HtyjTimWw8Zm0')); } `; // Inject GM functions const gmScript = document.createElement('script'); gmScript.textContent = gmFunctionsScript; document.head.appendChild(gmScript); // Inject userscript if provided if (content) { const userScript = document.createElement('script'); userScript.textContent = content; document.head.appendChild(userScript); console.log('[Chrome Debug MCP] GM functions and userscript injected successfully'); } else { console.log('[Chrome Debug MCP] GM functions injected successfully (no userscript provided)'); } } catch (error) { console.error('[Chrome Debug MCP] Failed to inject userscript:', error); } }, scriptContent); } // Get Chrome version info const version = await this.browser.version(); // Build detailed status message let statusMessage = `Chrome launched successfully in debug mode\n${version}`; // Add configuration details to status statusMessage += args?.executablePath ? `\nUsing custom executable: ${args.executablePath}` : '\nUsing bundled Chrome'; statusMessage += args?.userDataDir ? `\nUsing custom user data directory: ${args.userDataDir}` : '\nUsing default Chrome profile'; if (args?.loadExtension) { statusMessage += `\nLoaded extension: ${args.loadExtension}`; } if (args?.disableExtensionsExcept) { statusMessage += `\nDisabled extensions except: ${args.disableExtensionsExcept}`; } if (args?.disableAutomationControlled) { statusMessage += '\nAutomation controlled mode disabled'; } if (args?.userscriptPath) { statusMessage += `\nInjected userscript: ${args.userscriptPath}`; } return { content: [ { type: 'text', text: statusMessage, }, ], }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to launch Chrome: ${error}` ); } } /** * Handles the get_console_logs tool request. * Returns captured console messages and optionally clears the log. * * @param args - Configuration for log retrieval * @returns MCP response with console logs * @throws McpError if Chrome is not running * @private */ private async handleGetConsoleLogs(args: GetConsoleLogsArgs) { if (!this.browser) { throw new McpError( ErrorCode.InternalError, 'Chrome is not running. Call launch_chrome first.' ); } const logs = [...this.consoleLogs]; if (args?.clear) { this.consoleLogs = []; } return { content: [ { type: 'text', text: logs.join('\n') || 'No console logs available', }, ], }; } /** * Handles the evaluate tool request. * Executes JavaScript code in the browser context and returns the result. * * @param args - JavaScript code to evaluate * @returns MCP response with evaluation result * @throws McpError if Chrome is not running or evaluation fails * @private */ private async handleEvaluate(args: EvaluateArgs) { if (!this.browser || !this.cdpClient) { throw new McpError( ErrorCode.InternalError, 'Chrome is not running. Call launch_chrome first.' ); } if (!args?.expression) { throw new McpError( ErrorCode.InvalidParams, 'Expression is required' ); } try { const result = await this.cdpClient.Runtime.evaluate({ expression: args.expression, returnByValue: true, }); return { content: [ { type: 'text', text: JSON.stringify(result.result, null, 2), }, ], }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Evaluation failed: ${error}` ); } } /** * Starts the MCP server using stdio transport. * This allows the server to communicate with the MCP client through standard input/output. */ async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Chrome Debug MCP server running on stdio'); } /** * Performs cleanup when shutting down the server. * Closes Chrome and CDP connections gracefully. */ async cleanup() { if (this.cdpClient) { await this.cdpClient.close(); } if (this.browser) { await this.browser.close(); } await this.server.close(); } } // Create and start server instance const server = new ChromeDebugServer(); // Handle graceful shutdown process.on('SIGINT', async () => { await server.cleanup(); process.exit(0); }); // Start the server server.run().catch(console.error);