Skip to main content
Glama

autonomous-frontend-browser-tools

browser-connector.ts55 kB
#!/usr/bin/env node import express from "express"; import cors from "cors"; import bodyParser from "body-parser"; import { WebSocketServer, WebSocket } from "ws"; import { IncomingMessage } from "http"; import { Socket } from "net"; import os from "os"; import ScreenshotService from "./screenshot-service.js"; import dotenv from "dotenv"; dotenv.config({ override: true }); // Install global colored logger (subtle) import { installGlobalLogger } from "./modules/logger.js"; installGlobalLogger(); // Lightweight log helpers to tag levels for global logger filtering const logInfo = (...args: any[]) => console.log("[info]", ...args); const logDebug = (...args: any[]) => console.log("[debug]", ...args); // Local deps needed earlier that were removed when moving scaffolding // Moved scaffolding imports and helpers import { __filename as __top_filename, __dirname as __top_dirname, getScreenshotStoragePath, getActiveProjectName, MAX_DETAILED_NETWORK_LOG_CACHE, clearAllLogs as clearImportedLogs, detailedNetworkLogCache, truncateLogsToQueryLimit, } from "./modules/shared.js"; import { buildScreenshotConfig, buildScreenshotResponse, } from "./modules/screenshot.js"; import { buildNavigationMessage, parseNavigationResponse, } from "./modules/navigation.js"; // (unused import removed) import { formatSelectedElementDebugText } from "./modules/element-inspector.js"; import { filterNetworkLogs, sortNetworkLogs, projectNetworkLogDetails, limitResults, type NetworkFilterParams, } from "./modules/network-activity.js"; import { buildConsoleInspectionResponse, type ConsoleFilterParams, } from "./modules/console-inspector.js"; // (unused types removed) // Semantic embedding index utilities import { rebuildIndex as rebuildSemanticIndex, getStatus as getEmbedStatus, searchSemantic, resolveEmbeddingProvider, } from "./modules/semantic-index.js"; // Preserve original helper constant names by aliasing const __filename = __top_filename; const __dirname = __top_dirname; /** * convertPathForCurrentPlatform moved to modules/top-scaffold.ts * Using imported version to keep behavior identical. */ // Function to get default downloads folder /** * getDefaultDownloadsFolder moved to modules/top-scaffold.ts * Using imported version to keep behavior identical. */ // We store logs in memory const consoleLogs: any[] = []; const consoleErrors: any[] = []; const consoleWarnings: any[] = []; const networkErrors: any[] = []; const networkSuccess: any[] = []; // Removed unused allXhr cache // Store the current URL from the extension let currentUrl: string = ""; // Store the current tab ID from the extension let currentTabId: string | number | null = null; // Add settings state let currentSettings = { logLimit: 50, queryLimit: 30000, showRequestHeaders: false, showResponseHeaders: false, model: "claude-3-sonnet", stringSizeLimit: 500, maxLogSize: 20000, // Add server host configuration serverHost: process.env.SERVER_HOST || "0.0.0.0", // Default to all interfaces }; // Add new storage for selected element let selectedElement: any = null; // Add new state for tracking screenshot requests interface ScreenshotCallback { resolve: (value: { data: string; path?: string; autoPaste?: boolean; }) => void; reject: (reason: Error) => void; } const screenshotCallbacks = new Map<string, ScreenshotCallback>(); // Using imported getAvailablePort from shared.js // Start with requested port and find an available one const REQUESTED_PORT = parseInt(process.env.PORT || "3025", 10); let PORT = REQUESTED_PORT; // Create application and initialize middleware const app = express(); app.use(cors()); // Increase JSON body parser limit to 50MB to handle large screenshots app.use(bodyParser.json({ limit: "50mb" })); app.use(bodyParser.urlencoded({ limit: "50mb", extended: true })); /** * truncateStringsInData moved to modules/top-scaffold.ts * Using imported version to keep behavior identical. */ /** * processJsonString moved to modules/top-scaffold.ts * Using imported version to keep behavior identical. */ // Helper function to use imported truncateLogsToQueryLimit with current settings function truncateLogsWithCurrentSettings(logs: any[]): any[] { return truncateLogsToQueryLimit(logs, { queryLimit: currentSettings.queryLimit, showRequestHeaders: currentSettings.showRequestHeaders, showResponseHeaders: currentSettings.showResponseHeaders, }); } // Endpoint for the extension to POST data app.post("/extension-log", (req, res) => { if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] /extension-log hit", { hasData: !!req.body?.data, hasSettings: !!req.body?.settings, dataType: req.body?.data?.type, }); } logDebug("\n=== Received Extension Log ==="); logDebug("Request body:", { dataType: req.body.data?.type, timestamp: req.body.data?.timestamp, hasSettings: !!req.body.settings, }); const { data, settings } = req.body; // Update settings if provided if (settings) { logDebug("Updating settings:", settings); currentSettings = { ...currentSettings, ...settings, }; } if (!data) { logDebug("No data received in log request"); res.status(400).json({ status: "error", message: "No data provided" }); return; } logDebug(`Processing ${data.type} log entry`); switch (data.type) { case "page-navigated": // Handle page navigation event via HTTP POST // Note: This is also handled in the WebSocket message handler // as the extension may send navigation events through either channel logDebug("Received page navigation event with URL:", data.url); currentUrl = data.url; // Also update the tab ID if provided if (data.tabId) { logDebug("Updating tab ID from page navigation event:", data.tabId); currentTabId = data.tabId; } logDebug("Updated current URL:", currentUrl); break; case "console-log": logDebug("Adding console log:", { level: data.level, message: data.message?.substring(0, 100) + (data.message?.length > 100 ? "..." : ""), timestamp: data.timestamp, }); consoleLogs.push(data); if (consoleLogs.length > currentSettings.logLimit) { logDebug( `Console logs exceeded limit (${currentSettings.logLimit}), removing oldest entry` ); consoleLogs.shift(); } break; case "console-error": logDebug("Adding console error:", { level: data.level, message: data.message?.substring(0, 100) + (data.message?.length > 100 ? "..." : ""), timestamp: data.timestamp, }); consoleErrors.push(data); if (consoleErrors.length > currentSettings.logLimit) { logDebug( `Console errors exceeded limit (${currentSettings.logLimit}), removing oldest entry` ); consoleErrors.shift(); } break; case "console-warn": logDebug("Adding console warning:", { level: data.level, message: data.message?.substring(0, 100) + (data.message?.length > 100 ? "..." : ""), timestamp: data.timestamp, }); consoleWarnings.push(data); if (consoleWarnings.length > currentSettings.logLimit) { logDebug( `Console warnings exceeded limit (${currentSettings.logLimit}), removing oldest entry` ); consoleWarnings.shift(); } break; case "network-request": const logEntry = { url: data.url, method: data.method, status: data.status, timestamp: data.timestamp, requestHeaders: data.requestHeaders, responseHeaders: data.responseHeaders, requestBody: data.requestBody, responseBody: data.responseBody, }; logDebug("Adding network request:", { url: logEntry.url, method: logEntry.method, status: logEntry.status, timestamp: logEntry.timestamp, }); // Store the full request data in the detailedNetworkLogCache for the getNetworkRequestDetails tool console.log("[debug] Adding detailed network log to cache"); detailedNetworkLogCache.push(logEntry); if (detailedNetworkLogCache.length > MAX_CACHE_SIZE) { logDebug( `[DEBUG] Detailed network logs exceeded limit (${MAX_CACHE_SIZE}), removing oldest entry` ); detailedNetworkLogCache.shift(); } logDebug( `Current detailedNetworkLogCache size: ${detailedNetworkLogCache.length}` ); // Route network requests based on status code if (data.status >= 400) { networkErrors.push(data); if (networkErrors.length > currentSettings.logLimit) { logDebug( `Network errors exceeded limit (${currentSettings.logLimit}), removing oldest entry` ); networkErrors.shift(); } } else { networkSuccess.push(data); if (networkSuccess.length > currentSettings.logLimit) { logDebug( `Network success logs exceeded limit (${currentSettings.logLimit}), removing oldest entry` ); networkSuccess.shift(); } } break; case "selected-element": logDebug("Updating selected element:", { tagName: data.element?.tagName, id: data.element?.id, className: data.element?.className, }); selectedElement = data.element; break; default: logDebug("Unknown log type:", data.type); } logDebug("Current log counts:", { consoleLogs: consoleLogs.length, consoleErrors: consoleErrors.length, consoleWarnings: consoleWarnings.length, networkErrors: networkErrors.length, networkSuccess: networkSuccess.length, }); logDebug("=== End Extension Log ===\n"); res.json({ status: "ok" }); }); // Endpoint for the extension to POST screenshot data app.post("/screenshot", async (req, res) => { try { const { data, path, url } = req.body; if (!data) { res.status(400).json({ error: "No screenshot data provided" }); return; } // Get the screenshot service instance const screenshotService = ScreenshotService.getInstance(); // Get project configuration const projectScreenshotPath = getScreenshotStoragePath(); const projectName = getActiveProjectName(); // Build screenshot configuration const config = { baseDirectory: projectScreenshotPath || path, projectName: projectName, returnImageData: false, // We don't need to return the image data back to the extension }; // Save the screenshot using the service const result = await screenshotService.saveScreenshot(data, url, config); // Uniform info log for screenshots from both DevTools and MCP tool flows logInfo( `Screenshot saved to ${result.filePath} (projectDir=${result.projectDirectory}, category=${result.urlCategory})` ); res.json({ success: true, path: result.filePath, filename: result.filename, }); } catch (error: any) { console.error("Error saving screenshot:", error); res.status(500).json({ error: error.message || "Failed to save screenshot", }); } }); // Update GET endpoints to use the new function app.get("/console-logs", (req, res) => { // Processing is handled by truncateLogsWithCurrentSettings const truncatedLogs = truncateLogsWithCurrentSettings(consoleLogs); res.json(truncatedLogs); }); app.get("/console-errors", (req, res) => { const truncatedLogs = truncateLogsWithCurrentSettings(consoleErrors); res.json(truncatedLogs); }); app.get("/console-warnings", (req, res) => { const truncatedLogs = truncateLogsWithCurrentSettings(consoleWarnings); res.json(truncatedLogs); }); // New MCP tool endpoint: Console Inspector app.get("/console-inspection", (req, res) => { logInfo("Browser Connector: Received console inspection request"); // Parse query parameters for filtering const filters: ConsoleFilterParams = { level: (req.query.level as any) || "all", limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, since: req.query.since ? parseInt(req.query.since as string) : undefined, search: (req.query.search as string) || undefined, }; logDebug("Browser Connector: Console inspection filters:", filters); try { // Build comprehensive console inspection response const response = buildConsoleInspectionResponse( consoleLogs, consoleErrors, consoleWarnings, filters ); logInfo( `Browser Connector: Returning ${response.logs.length} console entries` ); logDebug(`Browser Connector: Stats:`, response.stats); res.json(response); } catch (error) { console.error("Browser Connector: Error in console inspection:", error); res.status(500).json({ error: error instanceof Error ? error.message : "Unknown error occurred", }); } }); app.get("/network-errors", (req, res) => { const truncatedLogs = truncateLogsWithCurrentSettings(networkErrors); res.json(truncatedLogs); }); app.get("/network-success", (req, res) => { const truncatedLogs = truncateLogsWithCurrentSettings(networkSuccess); res.json(truncatedLogs); }); app.get("/all-xhr", (req, res) => { // Merge and sort network success and error logs by timestamp const mergedLogs = [...networkSuccess, ...networkErrors].sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); const truncatedLogs = truncateLogsWithCurrentSettings(mergedLogs); res.json(truncatedLogs); }); // Add new endpoint for selected element app.post("/selected-element", (req, res) => { const { data } = req.body; selectedElement = data; res.json({ status: "ok" }); }); app.get("/selected-element", async (req, res) => { // Keep endpoint structure; formatting will be applied by tool when requested elsewhere res.json(selectedElement || { message: "No element selected" }); }); // Use imported detailedNetworkLogCache from top-scaffold const MAX_CACHE_SIZE = MAX_DETAILED_NETWORK_LOG_CACHE; // Limit cache size app.get("/.port", (req, res) => { res.send(PORT.toString()); }); // Add new identity endpoint with a unique signature app.get("/.identity", (req, res) => { if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] /.identity hit"); } res.json({ port: PORT, name: "frontend-browser-tools-server", version: "1.2.0", signature: "mcp-browser-connector-24x7", }); }); // Embedding API endpoints app.get("/api/embed/status", async (req, res) => { try { const project = req.query.project as string; const status = await getEmbedStatus(project); // Transform the response to match UI expectations const response = { exists: status.exists, provider: status.meta ? resolveEmbeddingProvider() : undefined, model: status.meta?.model || undefined, vectorCount: status.meta?.vectorCount || undefined, lastUpdated: status.meta?.builtAt || undefined, }; res.json(response); } catch (error: any) { console.error("[error] Failed to get embed status:", error); res.status(500).json({ error: error.message || "Failed to get status" }); } }); // Semantic search endpoint used by MCP tool api.searchEndpoints app.post("/api/embed/search", async (req, res) => { try { const projectHeader = (req.header("X-ACTIVE-PROJECT") as string) || (req.header("active-project") as string) || undefined; const { query, tag, method, limit } = req.body || {}; // Short info log logInfo( `[embed] Search request project=${projectHeader || "<default>"} query=${ query ? "yes" : "no" } tag=${tag || "none"} method=${method || "all"} limit=${ typeof limit === "number" ? limit : 10 }` ); // Detailed debug log logDebug("[embed] Search request details:", { headers: { xActiveProject: req.header("X-ACTIVE-PROJECT"), activeProject: req.header("active-project"), }, body: req.body, }); const results = await searchSemantic( { query, tag, method, limit }, projectHeader ); res.json(results); } catch (error: any) { console.error("[error] /api/embed/search failed:", error); res .status(500) .json({ error: error?.message || "Failed to perform embed search" }); } }); app.post("/api/embed/reindex", async (req, res) => { try { const { project } = req.body; console.log(`[index] Starting reindex for project: ${project}`); const result = await rebuildSemanticIndex(project); console.log(`[index] Reindex completed for project: ${project}`); res.json(result); } catch (error: any) { console.error("[error] Failed to reindex:", error); res.status(500).json({ error: error.message || "Failed to reindex" }); } }); /** * Server-side network request inspector (consolidated) * Query params: * - urlFilter: substring to filter URL (string) * - details: comma-separated keys (url,method,status,timestamp,requestHeaders,responseHeaders,requestBody,responseBody) * - includeTimestamp: "true" | "false" (default: true) * - timeStart/timeEnd: unix ms bounds (numbers) * - orderBy: "timestamp" | "url" (default: "timestamp") * - orderDirection: "asc" | "desc" (default: "desc") * - limit: number (default: 20) */ app.get("/network-request-details", (req, res) => { if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] /network-request-details hit", req.query); } try { const urlFilter = String(req.query.urlFilter ?? ""); const detailsCsv = String(req.query.details ?? "url,method,status"); const details = detailsCsv .split(",") .filter(Boolean) as any as NetworkFilterParams["details"]; const includeTimestamp = String(req.query.includeTimestamp ?? "true") === "true"; const timeStart = req.query.timeStart ? Number(req.query.timeStart) : undefined; const timeEnd = req.query.timeEnd ? Number(req.query.timeEnd) : undefined; const orderBy = String( req.query.orderBy ?? "timestamp" ) as any as NetworkFilterParams["orderBy"]; const orderDirection = String( req.query.orderDirection ?? "desc" ) as any as NetworkFilterParams["orderDirection"]; const limit = req.query.limit ? Number(req.query.limit) : 20; let results = filterNetworkLogs(detailedNetworkLogCache, { urlFilter, timeStart, timeEnd, }); results = sortNetworkLogs(results, orderBy, orderDirection); const projected = projectNetworkLogDetails( results, details, includeTimestamp ); const limited = limitResults(projected, limit); res.json(limited); } catch (e: any) { res .status(500) .json({ error: e?.message || "Failed to read network details" }); } }); export class BrowserConnector { private wss: WebSocketServer; private activeConnection: WebSocket | null = null; private app: express.Application; private server: any; // Connection health monitoring - optimized for autonomous operation private lastHeartbeatTime: number = 0; private heartbeatInterval: NodeJS.Timeout | null = null; private readonly HEARTBEAT_INTERVAL = 25000; // Reduced to 25 seconds for more frequent checks private readonly HEARTBEAT_TIMEOUT = 60000; // Increased to 60 seconds for network tolerance private connectionId: string = ""; // Track connection identity for better debugging constructor(app: express.Application, server: any) { this.app = app; this.server = server; // Initialize WebSocket server using the existing HTTP server this.wss = new WebSocketServer({ noServer: true, path: "/extension-ws", }); // Register the capture-screenshot endpoint this.app.post( "/capture-screenshot", async (req: express.Request, res: express.Response) => { console.log( "Browser Connector: Received request to /capture-screenshot endpoint" ); console.log( "Browser Connector: Active WebSocket connection:", !!this.activeConnection ); await this.captureScreenshot(req, res); } ); // Add connection health endpoint for autonomous operation monitoring this.app.get("/connection-health", (req, res) => { const status = this.getConnectionStatus(); const isHealthy = this.hasActiveConnection() && Date.now() - this.lastHeartbeatTime < this.HEARTBEAT_TIMEOUT; res.json({ ...status, healthy: isHealthy, pendingScreenshots: screenshotCallbacks.size, uptime: process.uptime(), timestamp: new Date().toISOString(), }); }); // Add navigation endpoint this.app.post( "/navigate-tab", async (req: express.Request, res: express.Response): Promise<void> => { await this.navigateTab(req, res); } ); // Add DOM action endpoint this.app.post( "/dom-action", async (req: express.Request, res: express.Response): Promise<void> => { await this.domAction(req, res); } ); // Handle upgrade requests for WebSocket this.server.on( "upgrade", (request: IncomingMessage, socket: Socket, head: Buffer) => { const urlPath = (request.url || "").split("?")[0]; if (urlPath === "/extension-ws") { this.wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { this.wss.emit("connection", ws, request); }); } } ); this.wss.on("error", (err) => { console.error("[error] WebSocket server error:", err); }); this.wss.on("connection", (ws: WebSocket) => { // Generate unique connection ID for debugging autonomous operation this.connectionId = `conn_${Date.now()}_${Math.random() .toString(36) .substr(2, 9)}`; logInfo( `Chrome extension connected via WebSocket [${this.connectionId}]` ); // Close any existing connection gracefully if (this.activeConnection) { logInfo( `Closing existing connection for new one [${this.connectionId}]` ); this.activeConnection.close(1000, "New connection established"); } this.activeConnection = ws; this.lastHeartbeatTime = Date.now(); // Start heartbeat monitoring this.startHeartbeatMonitoring(); // Track low-level pong frames from the browser client ws.on("pong", () => { this.lastHeartbeatTime = Date.now(); }); ws.on("error", (err) => { console.error("[error] WebSocket connection error:", err); this.handleConnectionClose(); }); ws.on("message", (message: string | Buffer | ArrayBuffer | Buffer[]) => { try { // Any inbound message indicates liveness this.lastHeartbeatTime = Date.now(); const data = JSON.parse(message.toString()); // Handle client-initiated heartbeats if (data.type === "heartbeat") { this.lastHeartbeatTime = Date.now(); try { ws.send( JSON.stringify({ type: "heartbeat-response", connectionId: this.connectionId, timestamp: Date.now(), }) ); } catch {} return; } // Handle heartbeat responses if (data.type === "heartbeat-response") { this.lastHeartbeatTime = Date.now(); logDebug( "Browser Connector: Received heartbeat response from extension" ); return; } // Log message without the base64 data logDebug("Received WebSocket message:", { ...data, data: data.data ? "[base64 data]" : undefined, }); // Handle URL response if (data.type === "current-url-response" && data.url) { logDebug("Received current URL from browser:", data.url); currentUrl = data.url; // Also update the tab ID if provided if (data.tabId) { logDebug("Updating tab ID from WebSocket message:", data.tabId); currentTabId = data.tabId; } } // Relay auth token retrieval responses back to waiting HTTP callers if (data.type === "RETRIEVE_AUTH_TOKEN_RESPONSE") { // Attach to request-scoped response via pending map if implemented later // For now, store on the websocket to be picked up by /retrieve-auth-token handler awaiting this message // This is handled via a temporary Promise in the HTTP handler. // No-op here; actual wiring happens within the HTTP request handler's message listener. } // Handle page navigation event via WebSocket // Note: This is intentionally duplicated from the HTTP handler in /extension-log // as the extension may send navigation events through either channel if (data.type === "page-navigated" && data.url) { logDebug("Page navigated to:", data.url); currentUrl = data.url; // Also update the tab ID if provided if (data.tabId) { logDebug( "Updating tab ID from page navigation event:", data.tabId ); currentTabId = data.tabId; } } // Handle screenshot response - enhanced for autonomous operation if (data.type === "screenshot-data" && data.data) { logDebug(`Received screenshot data [${this.connectionId}]`); // Find the specific callback for this request ID (if provided) if (data.requestId && screenshotCallbacks.has(data.requestId)) { const callback = screenshotCallbacks.get(data.requestId); logDebug( `Found specific callback for requestId: ${data.requestId} [${this.connectionId}]` ); if (callback) { callback.resolve({ data: data.data, }); screenshotCallbacks.delete(data.requestId); // Only delete this specific callback } } else { // Fallback: Get the most recent callback if no requestId (legacy support) const callbacks = Array.from(screenshotCallbacks.entries()); if (callbacks.length > 0) { const [oldestRequestId, callback] = callbacks[0]; // Use oldest pending callback logDebug( `Using oldest callback as fallback: ${oldestRequestId} [${this.connectionId}]` ); callback.resolve({ data: data.data, }); screenshotCallbacks.delete(oldestRequestId); // Only delete this specific callback } else { logDebug( `No callbacks found for screenshot data [${this.connectionId}]` ); } } } // Handle screenshot error - enhanced for autonomous operation else if (data.type === "screenshot-error") { logDebug( `Received screenshot error [${this.connectionId}]:`, data.error ); // Find the specific callback for this request ID (if provided) if (data.requestId && screenshotCallbacks.has(data.requestId)) { const callback = screenshotCallbacks.get(data.requestId); logDebug( `Found specific error callback for requestId: ${data.requestId} [${this.connectionId}]` ); if (callback) { callback.reject( new Error(data.error || "Screenshot capture failed") ); screenshotCallbacks.delete(data.requestId); // Only delete this specific callback } } else { // Fallback: Use most recent callback if no requestId const callbacks = Array.from(screenshotCallbacks.entries()); if (callbacks.length > 0) { const [oldestRequestId, callback] = callbacks[0]; logDebug( `Using oldest error callback as fallback: ${oldestRequestId} [${this.connectionId}]` ); callback.reject( new Error(data.error || "Screenshot capture failed") ); screenshotCallbacks.delete(oldestRequestId); // Only delete this specific callback } } } else { logDebug("Unhandled message type:", data.type); } } catch (error) { console.error("[error] Error processing WebSocket message:", error); } }); ws.on("close", (code: number, reason: Buffer) => { const reasonStr = reason.toString(); logInfo( `Chrome extension disconnected [${this.connectionId}] - Code: ${code}, Reason: ${reasonStr}` ); if (this.activeConnection === ws) { this.handleConnectionClose(); } // Log detailed disconnection info for autonomous operation debugging logInfo( `Connection closure details - Normal: ${ code === 1000 || code === 1001 }, Connection ID: ${this.connectionId}` ); }); }); // Register token retrieval HTTP endpoint this.registerRetrieveAuthTokenEndpoint(); } // Connection health monitoring methods private startHeartbeatMonitoring() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } this.heartbeatInterval = setInterval(() => { if ( !this.activeConnection || this.activeConnection.readyState !== WebSocket.OPEN ) { logInfo("WebSocket connection lost, clearing heartbeat monitor"); this.stopHeartbeatMonitoring(); return; } const now = Date.now(); const timeSinceLastHeartbeat = now - this.lastHeartbeatTime; // Check if connection is healthy if (timeSinceLastHeartbeat > this.HEARTBEAT_TIMEOUT) { console.warn( `Connection appears unhealthy [${this.connectionId}] - no heartbeat response received in ${timeSinceLastHeartbeat}ms (timeout: ${this.HEARTBEAT_TIMEOUT}ms)` ); // Enhanced callback cleanup for autonomous operation const callbacks = Array.from(screenshotCallbacks.entries()); logInfo( `Rejecting ${callbacks.length} pending screenshot callbacks due to heartbeat timeout [${this.connectionId}]` ); callbacks.forEach(([requestId, callback], index) => { callback.reject( new Error( `Connection timeout - heartbeat failed [${ this.connectionId }] - request ${requestId} (${index + 1}/${callbacks.length})` ) ); }); screenshotCallbacks.clear(); // Close the unhealthy connection try { logInfo( `Closing unhealthy connection [${this.connectionId}] due to heartbeat timeout` ); this.activeConnection?.close(1001, "Heartbeat timeout"); } catch (error) { console.error( `[error] Error closing unhealthy connection [${this.connectionId}]:`, error ); } this.handleConnectionClose(); } else { // Send heartbeat this.sendHeartbeat(); } }, this.HEARTBEAT_INTERVAL); } private stopHeartbeatMonitoring() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } } private sendHeartbeat() { if ( this.activeConnection && this.activeConnection.readyState === WebSocket.OPEN ) { try { // Add connection ID to heartbeat for debugging autonomous operation logDebug( `Browser Connector: Sending heartbeat to Chrome extension [${this.connectionId}]` ); this.activeConnection.send( JSON.stringify({ type: "heartbeat", connectionId: this.connectionId, timestamp: Date.now(), }) ); // Also send a low-level ping frame; browsers auto-respond with pong try { (this.activeConnection as any).ping?.(); } catch {} } catch (error) { console.error( `[error] Error sending heartbeat [${this.connectionId}]:`, error ); this.handleConnectionClose(); } } } // Send a heartbeat and wait briefly for a heartbeat-response to verify liveness private async awaitHeartbeatResponse( timeoutMs: number = 1200 ): Promise<boolean> { return new Promise<boolean>((resolve) => { if ( !this.activeConnection || this.activeConnection.readyState !== WebSocket.OPEN ) { resolve(false); return; } let settled = false; const messageHandler = ( message: string | Buffer | ArrayBuffer | Buffer[] ) => { try { const data = JSON.parse(message.toString()); if (data && data.type === "heartbeat-response") { this.activeConnection?.removeListener("message", messageHandler); if (!settled) { settled = true; resolve(true); } } } catch (_) {} }; this.activeConnection.on("message", messageHandler); // Send a heartbeat probe try { this.activeConnection.send( JSON.stringify({ type: "heartbeat", connectionId: this.connectionId, timestamp: Date.now(), }) ); try { (this.activeConnection as any).ping?.(); } catch {} } catch (_) { this.activeConnection?.removeListener("message", messageHandler); resolve(false); return; } setTimeout(() => { this.activeConnection?.removeListener("message", messageHandler); if (!settled) { resolve(false); } }, Math.max(200, timeoutMs)); }); } private handleConnectionClose() { const connectionInfo = this.connectionId || "unknown"; logInfo(`Handling connection close event [${connectionInfo}]`); if (this.activeConnection) { this.activeConnection = null; } this.stopHeartbeatMonitoring(); // Enhanced callback cleanup for autonomous operation reliability const callbacks = Array.from(screenshotCallbacks.values()); logInfo( `Cleaning up ${callbacks.length} pending screenshot callbacks due to connection loss [${connectionInfo}]` ); callbacks.forEach((callback, index) => { callback.reject( new Error( `WebSocket connection lost [${connectionInfo}] - callback ${ index + 1 }/${callbacks.length}` ) ); }); screenshotCallbacks.clear(); logInfo( `WebSocket connection closed [${connectionInfo}] - waiting for reconnection` ); // Reset connection ID this.connectionId = ""; } // Method to get detailed connection status for autonomous operation debugging public getConnectionStatus() { return { connected: this.activeConnection !== null, readyState: this.activeConnection?.readyState, readyStateText: this.getReadyStateText(this.activeConnection?.readyState), lastHeartbeat: this.lastHeartbeatTime, timeSinceLastHeartbeat: Date.now() - this.lastHeartbeatTime, connectionId: this.connectionId, heartbeatTimeout: this.HEARTBEAT_TIMEOUT, heartbeatInterval: this.HEARTBEAT_INTERVAL, }; } private getReadyStateText(state?: number): string { switch (state) { case 0: return "CONNECTING"; case 1: return "OPEN"; case 2: return "CLOSING"; case 3: return "CLOSED"; default: return "UNKNOWN"; } } // Enhanced method to check connection health for autonomous operation public hasActiveConnection(): boolean { const isActive = this.activeConnection !== null && this.activeConnection.readyState === WebSocket.OPEN; if (!isActive && this.connectionId) { logDebug( `Connection health check failed [${this.connectionId}] - State: ${ this.activeConnection?.readyState || "null" }` ); } return isActive; } // Add new endpoint for programmatic screenshot capture using unified service async captureScreenshot(req: express.Request, res: express.Response) { logInfo("Browser Connector: Starting captureScreenshot method"); if (!this.activeConnection) { logInfo( "Browser Connector: No active WebSocket connection to Chrome extension " ); return res.status(503).json({ error: "Chrome extension not connected. Please open Chrome DevTools and ensure the extension is loaded.", }); } // Extra health checks to avoid sending requests into a stale socket during reconnects if (!this.hasActiveConnection()) { return res.status(503).json({ error: "Chrome extension not connected (WebSocket not open). Please open DevTools on the target tab.", }); } const timeSinceHeartbeat = Date.now() - this.lastHeartbeatTime; if (timeSinceHeartbeat > this.HEARTBEAT_TIMEOUT) { logInfo( `Browser Connector: Connection unhealthy (no heartbeat for ${timeSinceHeartbeat}ms)` ); return res.status(503).json({ error: "Chrome extension connection is unhealthy. Open DevTools on the page and try again.", }); } // Probe for a quick heartbeat response to ensure we're not racing a reconnect const heartbeatOk = await this.awaitHeartbeatResponse(1200); if (!heartbeatOk) { return res.status(503).json({ error: "Chrome extension connection is not ready. Please ensure DevTools is open and retry.", }); } try { // Extract parameters from request body logDebug("Browser Connector: Starting screenshot capture..."); const { projectName, returnImageData, baseDirectory } = req.body || {}; const requestId = `${Date.now()}_${Math.random() .toString(36) .slice(2, 9)}`; logDebug("Browser Connector: Generated requestId:", requestId); // Create promise that will resolve when we get the screenshot data const screenshotPromise = new Promise<{ data: string; path?: string; autoPaste?: boolean; }>((resolve, reject) => { logDebug( `Browser Connector: Setting up screenshot callback for requestId: ${requestId}` ); // Store callback in map screenshotCallbacks.set(requestId, { resolve, reject }); logDebug( "Browser Connector: Current callbacks:", Array.from(screenshotCallbacks.keys()) ); // Set timeout to clean up if we don't get a response - increased for autonomous operation setTimeout(() => { if (screenshotCallbacks.has(requestId)) { logInfo( `Browser Connector: Screenshot capture timed out for requestId: ${requestId} [${this.connectionId}]` ); screenshotCallbacks.delete(requestId); reject( new Error( `Screenshot capture timed out - no response from Chrome extension [${this.connectionId}] after 30 seconds` ) ); } }, 30000); }); // Send screenshot request to extension const message = JSON.stringify({ type: "take-screenshot", requestId: requestId, }); logDebug( `Browser Connector: Sending WebSocket message to extension:`, message ); if ( !this.activeConnection || this.activeConnection.readyState !== WebSocket.OPEN ) { throw new Error( "WebSocket connection is not open to send screenshot request" ); } this.activeConnection.send(message); // Wait for screenshot data logDebug("Browser Connector: Waiting for screenshot data..."); const { data: base64Data, path: customPath, autoPaste, } = await screenshotPromise; logDebug( "Browser Connector: Received screenshot data, processing with unified service..." ); if (!base64Data) { throw new Error("No screenshot data received from Chrome extension"); } // Use the unified screenshot service const screenshotService = ScreenshotService.getInstance(); // Prepare configuration for screenshot service // Use project configuration for screenshot path, fallback to customPath if needed const projectScreenshotPath = getScreenshotStoragePath(); // Build config using tool helper (statically imported) const screenshotConfig = buildScreenshotConfig( projectScreenshotPath, customPath, projectName ); // Save screenshot using unified service const result = await screenshotService.saveScreenshot( base64Data, currentUrl, screenshotConfig ); logInfo( `Browser Connector: Screenshot saved successfully to: ${result.filePath}` ); logDebug( `Browser Connector: Project directory: ${result.projectDirectory}` ); logDebug(`Browser Connector: URL category: ${result.urlCategory}`); // Execute auto-paste if requested and on macOS if (os.platform() === "darwin" && autoPaste === true) { logDebug("Browser Connector: Executing auto-paste to Cursor..."); try { await screenshotService.executeAutoPaste(result.filePath); logDebug("Browser Connector: Auto-paste executed successfully"); } catch (autoPasteError) { console.error( "[error] Browser Connector: Auto-paste failed:", autoPasteError ); // Don't fail the screenshot save for auto-paste errors } } else { if (os.platform() === "darwin" && !autoPaste) { logDebug("Browser Connector: Auto-paste disabled, skipping"); } else { logDebug("Browser Connector: Not on macOS, skipping auto-paste"); } } // Build response object via tool helper const response: any = buildScreenshotResponse(result); logInfo("Browser Connector: Screenshot capture completed successfully"); res.json(response); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error( "[error] Browser Connector: Error capturing screenshot:", errorMessage ); res.status(500).json({ error: errorMessage, }); } } // Add navigation endpoint async navigateTab( req: express.Request, res: express.Response ): Promise<void> { if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] navigateTab handler", req.body); } logInfo("Browser Connector: Received navigateTab request"); logDebug("Browser Connector: Request body:", req.body); const { url } = req.body; if (!url) { res.status(400).json({ error: "Missing URL parameter" }); return; } if (!this.activeConnection) { res.status(503).json({ error: "Chrome extension not connected" }); return; } try { logDebug("Browser Connector: Sending navigation request to extension"); // Create a promise that will resolve when we get the navigation response const navigationPromise = new Promise<{ success: boolean; error?: string; }>((resolve, reject) => { const requestId = Date.now().toString(); // Set up a one-time message handler for this navigation request const messageHandler = ( message: string | Buffer | ArrayBuffer | Buffer[] ) => { try { const data = JSON.parse(message.toString()); // Parse navigation response using tool helper (statically imported) const parsed = parseNavigationResponse(data, requestId); if (parsed) { // Remove this listener once we get a response this.activeConnection?.removeListener("message", messageHandler); if (parsed.success) { resolve({ success: true }); } else { resolve({ success: false, error: parsed.error }); } } } catch (error) { // Ignore parsing errors for other messages } }; // Add temporary message handler this.activeConnection?.on("message", messageHandler); // Send navigation request to extension (using tool builder - statically imported) this.activeConnection?.send(buildNavigationMessage({ url }, requestId)); // Set timeout setTimeout(() => { this.activeConnection?.removeListener("message", messageHandler); reject(new Error("Navigation timeout")); }, 10000); // 10 second timeout }); const result = await navigationPromise; if (result.success) { logInfo("Browser Connector: Navigation completed successfully"); res.json({ success: true, url: url }); } else { console.error( "[error] Browser Connector: Navigation failed:", result.error ); res.status(500).json({ error: result.error || "Navigation failed" }); } } catch (error) { console.error( "[error] Browser Connector: Error during navigation:", error ); res.status(500).json({ error: error instanceof Error ? error.message : "An unknown error occurred during navigation", }); } } // DOM action relay to extension over WebSocket async domAction(req: express.Request, res: express.Response): Promise<void> { if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] domAction handler", req.body); } try { const payload = req.body || {}; if (!payload || typeof payload !== "object") { res.status(400).json({ error: "Invalid request body" }); return; } if (!this.activeConnection) { res.status(503).json({ error: "Chrome extension not connected" }); return; } const requestId = Date.now().toString(); const actionPromise = new Promise<{ success: boolean; details?: any; error?: string; }>((resolve, reject) => { const messageHandler = ( message: string | Buffer | ArrayBuffer | Buffer[] ) => { try { const data = JSON.parse(message.toString()); if ( data && data.type === "dom-action-response" && data.requestId === requestId ) { this.activeConnection?.removeListener("message", messageHandler); if (data.success) { resolve({ success: true, details: data.details }); } else { resolve({ success: false, error: data.error || "DOM action failed", }); } } } catch (_) {} }; this.activeConnection?.on("message", messageHandler); const message = JSON.stringify({ type: "dom-action", requestId, payload, }); this.activeConnection?.send(message); setTimeout(() => { this.activeConnection?.removeListener("message", messageHandler); reject(new Error("DOM action timeout")); }, 15000); }); const result = await actionPromise; if (result.success) { res.json({ success: true, details: result.details }); } else { res.status(500).json({ error: result.error || "DOM action failed" }); } } catch (error: any) { res.status(500).json({ error: error?.message || "Unknown error" }); } } // Endpoint to request auth token from the active tab via extension // Expects: { storageType: 'localStorage'|'sessionStorage'|'cookies', tokenKey: string, origin?: string, requestId?: string } private registerRetrieveAuthTokenEndpoint() { this.app.post( "/retrieve-auth-token", async (req: express.Request, res: express.Response): Promise<void> => { if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] /retrieve-auth-token hit", req.body); } try { if (!this.activeConnection) { res.status(503).json({ error: "Chrome extension not connected" }); return; } const payload = req.body || {}; const { storageType, tokenKey, origin } = payload; if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] token params", { storageType, tokenKey, origin, }); } if (!storageType || !tokenKey) { res .status(400) .json({ error: "storageType and tokenKey are required" }); return; } const requestId = Date.now().toString(); const tokenPromise = new Promise<{ success: boolean; token?: string; error?: string; }>((resolve, reject) => { const messageHandler = ( message: string | Buffer | ArrayBuffer | Buffer[] ) => { try { const data = JSON.parse(message.toString()); if ( data && data.type === "RETRIEVE_AUTH_TOKEN_RESPONSE" && data.requestId === requestId ) { this.activeConnection?.removeListener( "message", messageHandler ); if (data.token) { resolve({ success: true, token: data.token }); } else { resolve({ success: false, error: data.error || "Token not found", }); } } } catch (_) {} }; this.activeConnection?.on("message", messageHandler); // Send request to extension const msg = JSON.stringify({ type: "RETRIEVE_AUTH_TOKEN", requestId, origin: origin, storageType, tokenKey, }); if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] sending WS to extension", msg); } this.activeConnection?.send(msg); // Timeout setTimeout(() => { this.activeConnection?.removeListener("message", messageHandler); reject(new Error("Auth token retrieval timeout")); }, 10000); }); const result = await tokenPromise; if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log("[debug] tokenPromise result", result); } if (result.success) { res.json({ token: result.token }); } else { res.status(404).json({ error: result.error || "Token not found", context: { storageType, tokenKey, origin: origin || null, lastKnownUrl: currentUrl || null, activeProject: getActiveProjectName() || null, }, }); } } catch (error: any) { if ((process.env.LOG_LEVEL || "info").toLowerCase() === "debug") { console.log( "[debug] token retrieval error", error?.message || error ); } res.status(500).json({ error: error?.message || "Unknown error", context: { lastKnownUrl: currentUrl || null, activeProject: getActiveProjectName() || null, }, }); } } ); } // Add shutdown method public shutdown() { return new Promise<void>((resolve) => { logInfo("Shutting down WebSocket server..."); // Send close message to client if connection is active if ( this.activeConnection && this.activeConnection.readyState === WebSocket.OPEN ) { logInfo("Notifying client to close connection..."); try { this.activeConnection.send( JSON.stringify({ type: "server-shutdown" }) ); } catch (err) { console.error( "[error] Error sending shutdown message to client:", err ); } } // Set a timeout to force close after 2 seconds const forceCloseTimeout = setTimeout(() => { logDebug("Force closing connections after timeout..."); if (this.activeConnection) { this.activeConnection.terminate(); // Force close the connection this.activeConnection = null; } this.wss.close(); resolve(); }, 2000); // Close active WebSocket connection if exists if (this.activeConnection) { this.activeConnection.close(1000, "Server shutting down"); this.activeConnection = null; } // Close WebSocket server this.wss.close(() => { clearTimeout(forceCloseTimeout); logInfo("WebSocket server closed gracefully"); resolve(); }); }); } } // Import server lifecycle management import { initializeServer } from "./modules/server-lifecycle.js"; // Use an async IIFE to allow for async/await in the initial setup (async () => { await initializeServer(app, REQUESTED_PORT, currentSettings); })().catch((err) => { console.error("Unhandled error during server startup:", err); process.exit(1); });

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/Winds-AI/Frontend-development-MCP-tools-public'

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