import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; const DEBUGGER_PROTOCOL_VERSION = '1.3'; const DEFAULT_MAX_MESSAGES = 100; interface ConsoleToolParams { url?: string; includeExceptions?: boolean; maxMessages?: number; } interface ConsoleMessage { timestamp: number; level: string; text: string; args?: any[]; source?: string; url?: string; lineNumber?: number; stackTrace?: any; } interface ConsoleException { timestamp: number; text: string; url?: string; lineNumber?: number; columnNumber?: number; stackTrace?: any; } interface ConsoleResult { success: boolean; message: string; tabId: number; tabUrl: string; tabTitle: string; captureStartTime: number; captureEndTime: number; totalDurationMs: number; messages: ConsoleMessage[]; exceptions: ConsoleException[]; messageCount: number; exceptionCount: number; messageLimitReached: boolean; } /** * Tool for capturing console output from browser tabs */ class ConsoleTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.CONSOLE; async execute(args: ConsoleToolParams): Promise { const { url, includeExceptions = true, maxMessages = DEFAULT_MAX_MESSAGES } = args; let targetTab: chrome.tabs.Tab; try { if (url) { // Navigate to the specified URL targetTab = await this.navigateToUrl(url); } else { // Use current active tab const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!activeTab?.id) { return createErrorResponse('No active tab found and no URL provided.'); } targetTab = activeTab; } if (!targetTab?.id) { return createErrorResponse('Failed to identify target tab.'); } const tabId = targetTab.id; // Capture console messages (one-time capture) const result = await this.captureConsoleMessages(tabId, { includeExceptions, maxMessages, }); return { content: [ { type: 'text', text: JSON.stringify(result), }, ], isError: false, }; } catch (error: any) { console.error('ConsoleTool: Critical error during execute:', error); return createErrorResponse(`Error in ConsoleTool: ${error.message || String(error)}`); } } private async navigateToUrl(url: string): Promise { // Check if URL is already open const existingTabs = await chrome.tabs.query({ url }); if (existingTabs.length > 0 && existingTabs[0]?.id) { const tab = existingTabs[0]; // Activate the existing tab await chrome.tabs.update(tab.id!, { active: true }); await chrome.windows.update(tab.windowId, { focused: true }); return tab; } else { // Create new tab with the URL const newTab = await chrome.tabs.create({ url, active: true }); // Wait for tab to be ready await this.waitForTabReady(newTab.id!); return newTab; } } private async waitForTabReady(tabId: number): Promise { return new Promise((resolve) => { const checkTab = async () => { try { const tab = await chrome.tabs.get(tabId); if (tab.status === 'complete') { resolve(); } else { setTimeout(checkTab, 100); } } catch (error) { // Tab might be closed, resolve anyway resolve(); } }; checkTab(); }); } private formatConsoleArgs(args: any[]): string { if (!args || args.length === 0) return ''; return args .map((arg) => { if (arg.type === 'string') { return arg.value || ''; } else if (arg.type === 'number') { return String(arg.value || ''); } else if (arg.type === 'boolean') { return String(arg.value || ''); } else if (arg.type === 'object') { return arg.description || '[Object]'; } else if (arg.type === 'undefined') { return 'undefined'; } else if (arg.type === 'function') { return arg.description || '[Function]'; } else { return arg.description || arg.value || String(arg); } }) .join(' '); } private async captureConsoleMessages( tabId: number, options: { includeExceptions: boolean; maxMessages: number; }, ): Promise { const { includeExceptions, maxMessages } = options; const startTime = Date.now(); const messages: ConsoleMessage[] = []; const exceptions: ConsoleException[] = []; let limitReached = false; try { // Get tab information const tab = await chrome.tabs.get(tabId); // Check if debugger is already attached const targets = await chrome.debugger.getTargets(); const existingTarget = targets.find( (t) => t.tabId === tabId && t.attached && t.type === 'page', ); if (existingTarget && !existingTarget.extensionId) { throw new Error( `Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools).`, ); } // Attach debugger try { await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION); } catch (error: any) { if (error.message?.includes('Cannot attach to the target with an attached client')) { throw new Error( `Debugger is already attached to tab ${tabId}. This might be DevTools or another extension.`, ); } throw error; } // Set up event listener to collect messages const collectedMessages: any[] = []; const collectedExceptions: any[] = []; const eventListener = (source: chrome.debugger.Debuggee, method: string, params?: any) => { if (source.tabId !== tabId) return; if (method === 'Log.entryAdded' && params?.entry) { collectedMessages.push(params.entry); } else if (method === 'Runtime.consoleAPICalled' && params) { // Convert Runtime.consoleAPICalled to Log.entryAdded format const logEntry = { timestamp: params.timestamp, level: params.type || 'log', text: this.formatConsoleArgs(params.args || []), source: 'console-api', url: params.stackTrace?.callFrames?.[0]?.url, lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber, stackTrace: params.stackTrace, args: params.args, }; collectedMessages.push(logEntry); } else if ( method === 'Runtime.exceptionThrown' && includeExceptions && params?.exceptionDetails ) { collectedExceptions.push(params.exceptionDetails); } }; chrome.debugger.onEvent.addListener(eventListener); try { // Enable Runtime domain first to capture console API calls and exceptions await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); // Also enable Log domain to capture other log entries await chrome.debugger.sendCommand({ tabId }, 'Log.enable'); // Wait for all messages to be flushed await new Promise((resolve) => setTimeout(resolve, 2000)); // Process collected messages for (const entry of collectedMessages) { if (messages.length >= maxMessages) { limitReached = true; break; } const message: ConsoleMessage = { timestamp: entry.timestamp, level: entry.level || 'log', text: entry.text || '', source: entry.source, url: entry.url, lineNumber: entry.lineNumber, }; if (entry.stackTrace) { message.stackTrace = entry.stackTrace; } if (entry.args && Array.isArray(entry.args)) { message.args = entry.args; } messages.push(message); } // Process collected exceptions for (const exceptionDetails of collectedExceptions) { const exception: ConsoleException = { timestamp: Date.now(), text: exceptionDetails.text || exceptionDetails.exception?.description || 'Unknown exception', url: exceptionDetails.url, lineNumber: exceptionDetails.lineNumber, columnNumber: exceptionDetails.columnNumber, }; if (exceptionDetails.stackTrace) { exception.stackTrace = exceptionDetails.stackTrace; } exceptions.push(exception); } } finally { // Clean up chrome.debugger.onEvent.removeListener(eventListener); try { await chrome.debugger.sendCommand({ tabId }, 'Runtime.disable'); } catch (e) { console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e); } try { await chrome.debugger.sendCommand({ tabId }, 'Log.disable'); } catch (e) { console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e); } try { await chrome.debugger.detach({ tabId }); } catch (e) { console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e); } } const endTime = Date.now(); // Sort messages by timestamp messages.sort((a, b) => a.timestamp - b.timestamp); exceptions.sort((a, b) => a.timestamp - b.timestamp); return { success: true, message: `Console capture completed for tab ${tabId}. ${messages.length} messages, ${exceptions.length} exceptions captured.`, tabId, tabUrl: tab.url || '', tabTitle: tab.title || '', captureStartTime: startTime, captureEndTime: endTime, totalDurationMs: endTime - startTime, messages, exceptions, messageCount: messages.length, exceptionCount: exceptions.length, messageLimitReached: limitReached, }; } catch (error: any) { console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error); throw error; } } } export const consoleTool = new ConsoleTool();