UNPKG

@bschauer/webtools-mcp-server

Version:

MCP server providing web analysis tools including screenshot, debug, performance, security, accessibility, SEO, and asset optimization capabilities

600 lines (558 loc) 24.5 kB
import { logInfo, logError } from "../utils/logging.js"; import { BROWSER_HEADERS } from "../config/constants.js"; import { checkSiteAvailability } from "../utils/html.js"; import { fetchWithRetry } from "../utils/fetch.js"; import { getDeviceConfig } from "../config/devices.js"; /** * Debug a webpage by capturing console output, network requests, errors, and layout thrashing with advanced response size management * @param {Object} args - The tool arguments * @param {string} args.url - The URL to debug * @param {boolean} [args.captureConsole=true] - Whether to capture console output * @param {boolean} [args.captureNetwork=true] - Whether to capture network requests * @param {boolean} [args.captureErrors=true] - Whether to capture JavaScript errors * @param {boolean} [args.captureLayoutThrashing=false] - Whether to capture layout thrashing events * @param {number} [args.timeoutMs=15000] - Timeout in milliseconds * @param {boolean} [args.useProxy=false] - Whether to use a proxy * @param {boolean} [args.ignoreSSLErrors=false] - Whether to ignore SSL errors * @param {number} [args.maxConsoleEvents=20] - Maximum number of console events to include * @param {number} [args.maxNetworkEvents=30] - Maximum number of network events to include * @param {number} [args.maxErrorEvents=10] - Maximum number of error events to include * @param {number} [args.maxResourceEvents=15] - Maximum number of resource timing events to include * @param {boolean} [args.skipStackTraces=false] - Skip stack traces in layout thrashing events * @param {boolean} [args.compactFormat=false] - Use compact format for all sections * @param {boolean} [args.summarizeOnly=false] - Include only summary without detailed event data * @param {number} [args.page=1] - Page number for paginated results (starts at 1) * @param {number} [args.pageSize=20] - Number of events per page for paginated results * @param {Object} [args.deviceConfig] - Device configuration for emulation * @param {number} [args.deviceConfig.width=1920] - Viewport width in pixels * @param {number} [args.deviceConfig.height=1080] - Viewport height in pixels * @param {number} [args.deviceConfig.deviceScaleFactor=1] - Device scale factor (e.g., 2 for retina displays) * @param {boolean} [args.deviceConfig.isMobile=false] - Whether to emulate a mobile device * @param {boolean} [args.deviceConfig.hasTouch=false] - Whether to enable touch events * @param {boolean} [args.deviceConfig.isLandscape=false] - Whether to use landscape orientation * @param {string} [args.deviceConfig.userAgent] - Custom user agent string * @returns {Object} The tool response with managed response size */ export async function debug(args) { const { url, captureConsole = true, captureNetwork = true, captureErrors = true, captureLayoutThrashing = false, timeoutMs = 15000, useProxy = false, ignoreSSLErrors = false, // Output control parameters maxConsoleEvents = 20, maxNetworkEvents = 30, maxErrorEvents = 10, maxResourceEvents = 15, skipStackTraces = false, compactFormat = false, summarizeOnly = false, // Pagination parameters page: pageNumber = 1, pageSize = 20, } = args; // ✨ Use strong device defaults with validation const deviceConfig = getDeviceConfig(args); let puppeteer; try { // Check if puppeteer is available try { puppeteer = await import("puppeteer"); } catch (e) { return { content: [ { type: "text", text: JSON.stringify( { error: "Debug functionality not available", details: "Puppeteer is not installed", recommendation: "Please install Puppeteer to use debug functionality", retryable: false, url, }, null, 2 ), }, ], }; } // Check site availability first const availability = await checkSiteAvailability(url, { ignoreSSLErrors }, fetchWithRetry); if (!availability.available) { return { content: [ { type: "text", text: JSON.stringify( { error: "Site unavailable", details: availability.error, recommendation: availability.recommendation, retryable: true, url, }, null, 2 ), }, ], }; } const debugData = { url, timestamp: new Date().toISOString(), device: { config: deviceConfig, }, console: [], network: [], errors: [], layoutThrashing: [], performance: null, }; // Launch browser with increased timeout and better error handling const browser = await puppeteer .launch({ headless: "new", args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", useProxy ? `--proxy-server=${process.env.PROXY_URL || ""}` : "", ignoreSSLErrors ? "--ignore-certificate-errors" : ""].filter(Boolean), timeout: timeoutMs * 1.5, // Increase browser launch timeout }) .catch((error) => { logError("debug", "Browser launch failed", error); throw new Error(`Browser launch failed: ${error.message}`); }); try { const page = await browser.newPage(); await page.setExtraHTTPHeaders(BROWSER_HEADERS); // Set device configuration await page.setViewport({ width: deviceConfig.width, height: deviceConfig.height, deviceScaleFactor: deviceConfig.deviceScaleFactor, isMobile: deviceConfig.isMobile, hasTouch: deviceConfig.hasTouch, isLandscape: deviceConfig.isLandscape, }); await page.setUserAgent(deviceConfig.userAgent); // Capture console output if (captureConsole) { page.on("console", (msg) => { debugData.console.push({ type: msg.type(), text: msg.text(), timestamp: new Date().toISOString(), location: msg.location(), }); }); } // Capture network requests if (captureNetwork) { await page.setRequestInterception(true); page.on("request", (request) => { debugData.network.push({ type: "request", url: request.url(), method: request.method(), headers: request.headers(), timestamp: new Date().toISOString(), resourceType: request.resourceType(), }); request.continue(); }); page.on("response", (response) => { debugData.network.push({ type: "response", url: response.url(), status: response.status(), headers: response.headers(), timestamp: new Date().toISOString(), }); }); } // Capture JavaScript errors if (captureErrors) { page.on("pageerror", (error) => { debugData.errors.push({ type: "javascript", message: error.message, stack: error.stack, timestamp: new Date().toISOString(), }); }); page.on("error", (error) => { debugData.errors.push({ type: "page", message: error.message, timestamp: new Date().toISOString(), }); }); } // Start performance monitoring await page.evaluate(() => { try { performance.clearMarks(); performance.clearMeasures(); performance.mark("debug-start"); } catch (e) { console.warn("Performance API not fully supported:", e.message); } }); // Enable CDP session for layout thrashing detection if requested let client; if (captureLayoutThrashing) { const target = page.target(); client = await target.createCDPSession(); await client.send("DOM.enable"); await client.send("CSS.enable"); // Track layout operations that might cause thrashing client.on("DOM.documentUpdated", () => { debugData.layoutThrashing.push({ type: "DOM.documentUpdated", timestamp: new Date().toISOString(), message: "Document was updated, potentially forcing layout recalculation", }); }); client.on("CSS.styleSheetAdded", () => { debugData.layoutThrashing.push({ type: "CSS.styleSheetAdded", timestamp: new Date().toISOString(), message: "Style sheet was added, potentially forcing layout recalculation", }); }); } // Inject layout thrashing detection script if requested if (captureLayoutThrashing) { await page.evaluateOnNewDocument(() => { // Override methods that force layout/reflow const forcedLayoutMethods = ["offsetTop", "offsetLeft", "offsetWidth", "offsetHeight", "clientTop", "clientLeft", "clientWidth", "clientHeight", "getComputedStyle", "getBoundingClientRect", "scrollTop", "scrollLeft"]; // Track layout thrashing events window.__layoutThrashingEvents = []; // Create proxies for Element prototype methods that force layout forcedLayoutMethods.forEach((method) => { if (method === "getComputedStyle") { const original = window.getComputedStyle; window.getComputedStyle = function () { window.__layoutThrashingEvents.push({ method: "getComputedStyle", timestamp: new Date().toISOString(), trace: new Error().stack, }); return original.apply(this, arguments); }; } else if (Element.prototype[method] !== undefined) { const originalGetter = Object.getOwnPropertyDescriptor(Element.prototype, method).get; if (originalGetter) { Object.defineProperty(Element.prototype, method, { get: function () { window.__layoutThrashingEvents.push({ method: method, timestamp: new Date().toISOString(), trace: new Error().stack, }); return originalGetter.apply(this); }, }); } } }); }); } // Navigate to the page with improved error handling try { await page.goto(url, { waitUntil: "networkidle0", timeout: timeoutMs, }); } catch (navigationError) { // If navigation timeout occurs, continue with partial data debugData.errors.push({ type: "navigation", message: navigationError.message, timestamp: new Date().toISOString(), }); logError("debug", "Navigation error but continuing with partial data", navigationError); // Don't throw here, continue with partial data collection } // Wait for specified timeout with progress logging const waitStartTime = Date.now(); logInfo("debug", "Starting data collection wait period", { timeoutMs }); // Use a more robust wait mechanism with periodic checks await new Promise((resolve) => { const checkInterval = Math.min(2000, timeoutMs / 5); // Check at most every 2 seconds const intervalId = setInterval(() => { const elapsedTime = Date.now() - waitStartTime; if (elapsedTime >= timeoutMs) { clearInterval(intervalId); resolve(); } else { logInfo("debug", "Data collection in progress", { elapsedMs: elapsedTime, remainingMs: timeoutMs - elapsedTime, }); } }, checkInterval); // Also set a timeout as a fallback setTimeout(() => { clearInterval(intervalId); resolve(); }, timeoutMs); }); // Collect layout thrashing data if requested if (captureLayoutThrashing) { const layoutThrashingEvents = await page .evaluate(() => { return window.__layoutThrashingEvents || []; }) .catch((error) => { logError("debug", "Failed to collect layout thrashing data", error); return []; }); debugData.layoutThrashing = [...debugData.layoutThrashing, ...layoutThrashingEvents]; } // Collect performance metrics with better error handling debugData.performance = await page.evaluate(() => { try { performance.mark("debug-end"); performance.measure("debug-duration", "debug-start", "debug-end"); } catch (e) { console.warn("Performance measurement failed:", e.message); } try { const navigationTiming = performance.getEntriesByType("navigation")[0] || {}; const resourceTiming = performance.getEntriesByType("resource") || []; const measures = performance.getEntriesByType("measure") || []; return { navigation: { domComplete: navigationTiming.domComplete || 0, loadEventEnd: navigationTiming.loadEventEnd || 0, domInteractive: navigationTiming.domInteractive || 0, domContentLoadedEventEnd: navigationTiming.domContentLoadedEventEnd || 0, }, resources: resourceTiming.map((r) => ({ name: r.name, duration: r.duration, transferSize: r.transferSize, type: r.initiatorType, })), measures: measures.map((m) => ({ name: m.name, duration: m.duration, })), }; } catch (e) { return { error: e.message, navigation: { domComplete: 0, loadEventEnd: 0, domInteractive: 0, domContentLoadedEventEnd: 0, }, resources: [], measures: [], }; } }); // Helper function to paginate data function paginateData(data, currentPage, currentPageSize) { const totalPages = Math.ceil(data.length / currentPageSize); const validPage = Math.min(Math.max(1, currentPage), totalPages); const startIndex = (validPage - 1) * currentPageSize; const endIndex = Math.min(startIndex + currentPageSize, data.length); return { data: data.slice(startIndex, endIndex), pagination: { totalItems: data.length, itemsPerPage: currentPageSize, currentPage: validPage, totalPages: totalPages, hasNextPage: validPage < totalPages, hasPreviousPage: validPage > 1, } }; } // Apply pagination or limits based on parameters const usePagination = pageNumber > 1 || pageSize !== 20; // Use pagination if non-default values let consoleData, networkData, errorData, resourceData; let consolePagination, networkPagination, errorPagination, resourcePagination; if (usePagination) { // Use pagination for all sections const consolePaginated = paginateData(debugData.console, pageNumber, pageSize); const networkPaginated = paginateData(debugData.network, pageNumber, pageSize); const errorPaginated = paginateData(debugData.errors, pageNumber, pageSize); const resourcePaginated = paginateData(debugData.performance.resources || [], pageNumber, pageSize); consoleData = consolePaginated.data; networkData = networkPaginated.data; errorData = errorPaginated.data; resourceData = resourcePaginated.data; consolePagination = consolePaginated.pagination; networkPagination = networkPaginated.pagination; errorPagination = errorPaginated.pagination; resourcePagination = resourcePaginated.pagination; } else { // Use max limits (existing behavior) consoleData = debugData.console.slice(0, maxConsoleEvents); networkData = debugData.network.slice(0, maxNetworkEvents); errorData = debugData.errors.slice(0, maxErrorEvents); resourceData = (debugData.performance.resources || []).slice(0, maxResourceEvents); } // Format the debug data into a readable markdown report const report = summarizeOnly ? [ `# Debug Summary for ${url}`, `Generated at: ${debugData.timestamp}`, "", "## Summary", `- Console Events: ${debugData.console.length}`, `- Network Events: ${debugData.network.length}`, `- Error Events: ${debugData.errors.length}`, `- Layout Thrashing Events: ${debugData.layoutThrashing.length}`, `- Resource Events: ${debugData.performance.resources ? debugData.performance.resources.length : 0}`, "", "## Quick Stats", `- Page Load Time: ${debugData.performance.navigation ? debugData.performance.navigation.loadEventEnd : 'N/A'}ms`, `- DOM Interactive: ${debugData.performance.navigation ? debugData.performance.navigation.domInteractive : 'N/A'}ms`, "", "**Note**: Use summarizeOnly=false to see detailed event data.", ] : [ `# Debug Report for ${url}`, `Generated at: ${debugData.timestamp}`, "", "## Device Configuration", `- Profile: ${JSON.stringify(deviceConfig)}`, "", "## Console Output", consoleData.length > 0 ? [ consoleData.map((log) => { const logText = compactFormat ? `${log.type.toUpperCase()}: ${log.text}` : `[${log.timestamp}] ${log.type.toUpperCase()}: ${log.text}`; return `- ${logText}`; }).join("\n"), usePagination && consolePagination ? `\n**Console Pagination**: Page ${consolePagination.currentPage} of ${consolePagination.totalPages} (${consolePagination.totalItems} total events)` : (debugData.console.length > maxConsoleEvents ? `\n... and ${debugData.console.length - maxConsoleEvents} more console events (use maxConsoleEvents parameter to show more)` : "") ].filter(Boolean).join("") : "- No console output captured", "", "## Network Activity", networkData.length > 0 ? [ networkData.map((n) => { const networkText = compactFormat ? `${n.method || n.status} ${n.url.split('/').pop() || n.url}` : `[${n.timestamp}] ${n.type.toUpperCase()} ${n.method || n.status} ${n.url}`; return `- ${networkText}`; }).join("\n"), usePagination && networkPagination ? `\n**Network Pagination**: Page ${networkPagination.currentPage} of ${networkPagination.totalPages} (${networkPagination.totalItems} total events)` : (debugData.network.length > maxNetworkEvents ? `\n... and ${debugData.network.length - maxNetworkEvents} more network events (use maxNetworkEvents parameter to show more)` : "") ].filter(Boolean).join("") : "- No network activity captured", "", "## Errors", errorData.length > 0 ? [ errorData.map((err) => { const errorText = compactFormat ? `### ${err.type} Error\n\`\`\`\n${err.message}\n\`\`\`` : `### ${err.type} Error at ${err.timestamp}\n\`\`\`\n${err.message}\n${err.stack || ""}\n\`\`\``; return errorText; }).join("\n"), usePagination && errorPagination ? `\n**Error Pagination**: Page ${errorPagination.currentPage} of ${errorPagination.totalPages} (${errorPagination.totalItems} total events)` : (debugData.errors.length > maxErrorEvents ? `\n... and ${debugData.errors.length - maxErrorEvents} more error events (use maxErrorEvents parameter to show more)` : "") ].filter(Boolean).join("") : "- No errors captured", "", captureLayoutThrashing ? [ "## Layout Thrashing Detection", debugData.layoutThrashing.length > 0 ? [ `Detected ${debugData.layoutThrashing.length} potential layout thrashing events:`, "", debugData.layoutThrashing .map((event, index) => { if (index < 20) { // Limit to first 20 events to avoid overwhelming output return `### Event ${index + 1}: ${event.method || event.type}\n- Time: ${event.timestamp}\n${event.trace && !skipStackTraces ? `- Stack Trace:\n\`\`\`\n${event.trace}\n\`\`\`` : ""}${ event.message ? `\n- Message: ${event.message}` : "" }`; } else if (index === 20) { return `\n... and ${debugData.layoutThrashing.length - 20} more events (omitted for brevity)`; } return ""; }) .filter(Boolean) .join("\n\n"), "", "### Layout Thrashing Recommendations", "- Batch DOM reads and writes to avoid forcing layout recalculation", "- Use requestAnimationFrame for DOM manipulations", "- Consider using CSS transforms instead of properties that trigger layout", "- Cache layout values instead of repeatedly querying them", ].join("\n") : "No layout thrashing events detected", "", ].join("\n") : "", "## Performance Metrics", debugData.performance.error ? `Error collecting performance metrics: ${debugData.performance.error}` : [ "### Navigation Timing", `- DOM Complete: ${debugData.performance.navigation.domComplete}ms`, `- Load Event: ${debugData.performance.navigation.loadEventEnd}ms`, `- DOM Interactive: ${debugData.performance.navigation.domInteractive}ms`, "", "### Resource Loading", resourceData.length > 0 ? [ resourceData.map((r) => { const resourceText = compactFormat ? `- ${r.name.split('/').pop()}: ${r.duration}ms` : `- ${r.name}: ${r.duration}ms (${(r.transferSize / 1024).toFixed(2)}KB)`; return resourceText; }).join("\n"), usePagination && resourcePagination ? `\n**Resource Pagination**: Page ${resourcePagination.currentPage} of ${resourcePagination.totalPages} (${resourcePagination.totalItems} total resources)` : ((debugData.performance.resources || []).length > maxResourceEvents ? `\n... and ${(debugData.performance.resources || []).length - maxResourceEvents} more resources (use maxResourceEvents parameter to show more)` : "") ].filter(Boolean).join("") : "- No resource timing data available", ].join("\n"), ].join("\n"); return { content: [ { type: "text", text: report, }, ], }; } finally { await browser.close(); } } catch (error) { const errorDetails = { error: "Debug failed", details: error.message, recommendation: error.message.includes("net::ERR_PROXY_CONNECTION_FAILED") ? "Proxy connection failed. Please try without proxy" : "Please try again with different settings", retryable: true, url, useProxy: error.message.includes("net::ERR_PROXY_CONNECTION_FAILED") ? false : !useProxy, errorType: error.name, }; logError("debug", "Debug capture failed", error, errorDetails); return { content: [ { type: "text", text: JSON.stringify(errorDetails, null, 2), }, ], }; } }