UNPKG

@borgius/brop-mcp

Version:

Browser Remote Operations Protocol with multiplexed CDP relay support

1,357 lines (1,238 loc) 28.9 kB
#!/usr/bin/env node /** * BROP MCP Server - Official SDK Implementation * * Model Context Protocol server for Browser Remote Operations Protocol (BROP) * Built using the official @modelcontextprotocol/sdk */ import net from "node:net"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import WebSocket from "ws"; import { z } from "zod"; import { UnifiedBridgeServer } from "./bridge_server.js"; class BROPMCPServer { constructor() { this.isServerMode = false; this.bridgeServer = null; this.bropClient = null; this.cdpClient = null; this.isInitializing = false; this.isInitialized = false; this.cdpSessionId = null; this.cdpMessageCounter = 1; } log(message) { // Log to stderr to avoid interfering with STDIO transport console.error(`[BROP-MCP] ${new Date().toISOString()} ${message}`); } /** * Check if port is available * @returns {Promise<boolean>} true if port is available, false if occupied */ async checkPortAvailability(port) { return new Promise((resolve) => { const server = net.createServer(); server.listen(port, () => { server.close(() => { resolve(true); // Port is available }); }); server.on("error", (err) => { if (err.code === "EADDRINUSE") { resolve(false); // Port is occupied } else { resolve(false); // Other error, assume port is not available } }); }); } /** * Start in Server Mode - run bridge servers based on port availability */ async startServerMode() { this.log("Starting in SERVER MODE - will start bridge servers"); try { this.bridgeServer = new UnifiedBridgeServer({ mcpMode: true, logToStderr: true, }); await this.bridgeServer.startServers(); this.isServerMode = true; this.log("Server Mode: Bridge servers started successfully"); } catch (error) { this.log(`Failed to start server mode: ${error.message}`); throw error; } } /** * Start in Relay Mode - connect to existing servers */ async startRelayMode() { this.log("Starting in RELAY MODE - will connect to existing servers"); try { // Check and connect to BROP server if available const bropPortAvailable = await this.checkPortAvailability(9225); if (!bropPortAvailable) { await this.connectToBROPServer(); this.log("Relay Mode: Connected to BROP server successfully"); } else { this.log("BROP server not available on port 9225"); } // Check and connect to CDP server if available const cdpPortAvailable = await this.checkPortAvailability(9222); if (!cdpPortAvailable) { await this.connectToCDPServer(); this.log("Relay Mode: Connected to CDP server successfully"); } else { this.log("CDP server not available on port 9222"); } this.isServerMode = false; } catch (error) { this.log(`Failed to start relay mode: ${error.message}`); throw error; } } /** * Connect to existing BROP server as a client */ async connectToBROPServer() { return new Promise((resolve, reject) => { const ws = new WebSocket("ws://localhost:9225?name=mcp-stdio"); ws.on("open", () => { this.log("Connected to BROP server as relay client"); this.bropClient = ws; resolve(); }); ws.on("error", (error) => { this.log(`Failed to connect to BROP server: ${error.message}`); reject(error); }); ws.on("close", () => { this.log("Connection to BROP server closed"); this.bropClient = null; }); ws.on("message", (message) => { try { const data = JSON.parse(message.toString()); this.log( `Received from BROP server: ${data.type || data.method || "unknown"}`, ); } catch (error) { this.log(`Error parsing BROP message: ${error.message}`); } }); }); } /** * Connect to existing CDP server as a client */ async connectToCDPServer() { return new Promise((resolve, reject) => { const ws = new WebSocket( "ws://localhost:9222/devtools/browser/mcp-client", ); ws.on("open", () => { this.log("Connected to CDP server as relay client"); this.cdpClient = ws; resolve(); }); ws.on("error", (error) => { this.log(`Failed to connect to CDP server: ${error.message}`); reject(error); }); ws.on("close", () => { this.log("Connection to CDP server closed"); this.cdpClient = null; }); ws.on("message", (message) => { try { const data = JSON.parse(message.toString()); this.log( `Received from CDP server: ${data.method || data.id || "unknown"}`, ); // Handle CDP events and responses if (data.method && !data.id) { // This is a CDP event this.handleCDPEvent(data); } } catch (error) { this.log(`Error parsing CDP message: ${error.message}`); } }); }); } handleCDPEvent(event) { // Handle CDP events like Target.attachedToTarget if (event.method === "Target.attachedToTarget") { this.cdpSessionId = event.params.sessionId; this.log(`CDP session established: ${this.cdpSessionId}`); } } async initialize() { if (this.isInitializing || this.isInitialized) { return; } this.isInitializing = true; this.log("Initializing MCP Server..."); try { // Check if port 9224 is available (extension port) const extensionPortAvailable = await this.checkPortAvailability(9224); if (!extensionPortAvailable) { // Extension port is occupied - bridge server is already running this.log("Port 9224 is occupied - bridge server already running"); this.log("Starting in RELAY MODE"); await this.startRelayMode(); } else { // No bridge server running - start our own this.log("Port 9224 is available - no bridge server running"); this.log("Starting in SERVER MODE"); await this.startServerMode(); } this.isInitialized = true; this.log( `MCP Server initialized in ${this.isServerMode ? "SERVER" : "RELAY"} mode`, ); } catch (error) { this.log(`MCP initialization failed: ${error.message}`); throw error; } finally { this.isInitializing = false; } } async executeBROPCommand(toolName, args) { // Ensure BROP is initialized if (!this.isInitialized) { await this.initialize(); } const bropCommand = this.convertMCPToolToBROPCommand(toolName, args); // Try execution with retry logic for extension connection return await this.executeWithRetry(bropCommand); } async executeWithRetry(bropCommand, maxRetries = 1, retryDelay = 5000) { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { if (this.isServerMode && this.bridgeServer?.extensionClient) { // Server mode - use bridge server directly return await this.executeCommandInServerMode(bropCommand); } if (this.bropClient && this.bropClient.readyState === WebSocket.OPEN) { // Relay mode - send through BROP client return await this.executeCommandInRelayMode(bropCommand); } // No connection available if (attempt < maxRetries) { this.log( `No browser extension connected, waiting ${retryDelay / 1000}s before retry (attempt ${attempt + 1}/${maxRetries + 1})`, ); await this.sleep(retryDelay); continue; } throw new Error( "No BROP connection available - Chrome extension not connected", ); } catch (error) { if ( error.message.includes("Chrome extension not connected") && attempt < maxRetries ) { this.log( `Extension connection error, waiting ${retryDelay / 1000}s before retry (attempt ${attempt + 1}/${maxRetries + 1}): ${error.message}`, ); await this.sleep(retryDelay); continue; } throw error; } } } sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async executeCommandInServerMode(bropCommand) { if (!this.bridgeServer?.extensionClient) { throw new Error("Chrome extension not connected"); } return new Promise((resolve, reject) => { const messageId = `mcp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const timeout = setTimeout(() => { reject(new Error("Command timeout")); }, 10000); const responseHandler = (message) => { try { const data = JSON.parse(message); if (data.id === messageId) { clearTimeout(timeout); this.bridgeServer.extensionClient.off("message", responseHandler); if (data.success) { resolve(data.result); } else { reject(new Error(data.error || "Command failed")); } } } catch (error) { // Ignore parse errors for other messages } }; this.bridgeServer.extensionClient.on("message", responseHandler); const command = { ...bropCommand, id: messageId, type: "brop_command", }; this.bridgeServer.extensionClient.send(JSON.stringify(command)); }); } async executeCommandInRelayMode(bropCommand) { return new Promise((resolve, reject) => { const messageId = `mcp_relay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const timeout = setTimeout(() => { reject(new Error("Command timeout")); }, 10000); const responseHandler = (message) => { try { const data = JSON.parse(message); if (data.id === messageId) { clearTimeout(timeout); this.bropClient.off("message", responseHandler); if (data.success) { resolve(data.result); } else { reject(new Error(data.error || "Command failed")); } } } catch (error) { // Ignore parse errors for other messages } }; this.bropClient.on("message", responseHandler); const command = { ...bropCommand, id: messageId, }; this.bropClient.send(JSON.stringify(command)); }); } async executeCDPCommand(method, params = {}) { if (!this.isInitialized) { await this.initialize(); } if (this.isServerMode && this.bridgeServer?.extensionClient) { // Server mode - send through bridge return await this.executeCDPCommandInServerMode(method, params); } if (this.cdpClient && this.cdpClient.readyState === WebSocket.OPEN) { // Relay mode - send through CDP client return await this.executeCDPCommandInRelayMode(method, params); } throw new Error("No CDP connection available"); } async executeCDPCommandInServerMode(method, params) { return new Promise((resolve, reject) => { const messageId = this.cdpMessageCounter++; const timeout = setTimeout(() => { reject(new Error("CDP command timeout")); }, 10000); const responseHandler = (message) => { try { const data = JSON.parse(message); if (data.id === messageId) { clearTimeout(timeout); this.bridgeServer.extensionClient.off("message", responseHandler); if (data.result) { resolve(data.result); } else if (data.error) { reject(new Error(data.error.message || "CDP command failed")); } } } catch (error) { // Ignore parse errors for other messages } }; this.bridgeServer.extensionClient.on("message", responseHandler); const command = { type: "BROP_CDP", id: messageId, method: method, params: params, }; this.bridgeServer.extensionClient.send(JSON.stringify(command)); }); } async executeCDPCommandInRelayMode(method, params) { return new Promise((resolve, reject) => { const messageId = this.cdpMessageCounter++; const timeout = setTimeout(() => { reject(new Error("CDP command timeout")); }, 10000); const responseHandler = (message) => { try { const data = JSON.parse(message); if (data.id === messageId) { clearTimeout(timeout); this.cdpClient.off("message", responseHandler); if (data.result) { resolve(data.result); } else if (data.error) { reject(new Error(data.error.message || "CDP command failed")); } } } catch (error) { // Ignore parse errors for other messages } }; this.cdpClient.on("message", responseHandler); const command = { id: messageId, method: method, params: params, }; // Add session ID if we have one if (this.cdpSessionId && !method.startsWith("Target.")) { command.sessionId = this.cdpSessionId; } this.cdpClient.send(JSON.stringify(command)); }); } convertMCPToolToBROPCommand(toolName, args) { switch (toolName) { case "brop_navigate": return { method: "navigate", params: { url: args.url, tabId: args.tabId, }, }; case "brop_get_page_content": return { method: "get_page_content", params: { tabId: args.tabId, }, }; case "brop_get_simplified_content": return { method: "get_simplified_dom", params: { tabId: args.tabId, format: args.format, enableDetailedResponse: args.enableDetailedResponse || false, }, }; case "brop_execute_script": return { method: "execute_console", params: { code: args.script, tabId: args.tabId, }, }; case "brop_click_element": return { method: "click", params: { selector: args.selector, tabId: args.tabId, }, }; case "brop_type_text": return { method: "type", params: { selector: args.selector, text: args.text, tabId: args.tabId, }, }; case "brop_create_page": return { method: "create_tab", params: { url: args.url || "about:blank", active: args.active !== false, // Default to true unless explicitly false }, }; case "brop_close_tab": return { method: "close_tab", params: { tabId: args.tabId, }, }; case "brop_list_tabs": return { method: "list_tabs", params: { include_content: args.includeContent || false, }, }; case "brop_activate_tab": return { method: "activate_tab", params: { tabId: args.tabId, }, }; case "brop_start_console_capture": return { method: "start_console_capture", params: { tabId: args.tabId, }, }; case "brop_get_console_logs": return { method: "get_console_logs", params: { tabId: args.tabId, limit: args.limit, level: args.level, }, }; case "brop_clear_console_logs": return { method: "clear_console_logs", params: { tabId: args.tabId, }, }; case "brop_stop_console_capture": return { method: "stop_console_capture", params: { tabId: args.tabId, }, }; default: throw new Error(`Unknown tool: ${toolName}`); } } async shutdown() { this.log("Shutting down BROP MCP Server..."); if (this.bridgeServer) { await this.bridgeServer.shutdown(); } if (this.bropClient) { this.bropClient.close(); } process.exit(0); } } // Create server instance const bropServer = new BROPMCPServer(); const server = new McpServer({ name: "brop-mcp-server", version: "1.0.0", }); // Register BROP tools server.tool( "brop_navigate", "Navigate to a URL in the browser", { url: z.string().describe("URL to navigate to"), tabId: z.number().optional().describe("Optional tab ID to navigate in"), }, async ({ url, tabId }) => { try { const result = await bropServer.executeBROPCommand("brop_navigate", { url, tabId, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_get_page_content", "Get basic page content from the browser (raw HTML and text)", { tabId: z.number().describe("Tab ID to get content from"), }, async ({ tabId }) => { try { const result = await bropServer.executeBROPCommand( "brop_get_page_content", { tabId }, ); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_get_simplified_content", "Get simplified and cleaned page content in HTML or Markdown format", { tabId: z.number().describe("Tab ID to get content from"), format: z .enum(["html", "markdown"]) .describe( "Output format - html (using Readability) or markdown (semantic conversion)", ), enableDetailedResponse: z .boolean() .optional() .describe("Include detailed extraction statistics and metadata"), }, async ({ tabId, format, enableDetailedResponse }) => { try { const result = await bropServer.executeBROPCommand( "brop_get_simplified_content", { tabId, format, enableDetailedResponse }, ); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_execute_script", "Execute JavaScript in the browser", { script: z.string().describe("JavaScript code to execute"), tabId: z.number().optional().describe("Optional tab ID to execute in"), }, async ({ script, tabId }) => { try { const result = await bropServer.executeBROPCommand( "brop_execute_script", { script, tabId }, ); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_click_element", "Click an element on the page", { selector: z.string().describe("CSS selector for the element to click"), tabId: z.number().optional().describe("Optional tab ID"), }, async ({ selector, tabId }) => { try { const result = await bropServer.executeBROPCommand("brop_click_element", { selector, tabId, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_type_text", "Type text into an input field", { selector: z.string().describe("CSS selector for the input element"), text: z.string().describe("Text to type"), tabId: z.number().optional().describe("Optional tab ID"), }, async ({ selector, text, tabId }) => { try { const result = await bropServer.executeBROPCommand("brop_type_text", { selector, text, tabId, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_create_page", "Create a new browser page/tab", { url: z .string() .optional() .describe( "Optional URL to navigate to in the new page (defaults to about:blank)", ), active: z .boolean() .optional() .describe("Whether to make the new tab active (defaults to true)"), }, async ({ url, active }) => { try { const result = await bropServer.executeBROPCommand("brop_create_page", { url, active, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_close_tab", "Close a browser tab", { tabId: z.number().describe("ID of the tab to close"), }, async ({ tabId }) => { try { const result = await bropServer.executeBROPCommand("brop_close_tab", { tabId, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_list_tabs", "List all open browser tabs", { windowId: z .number() .optional() .describe( "Optional window ID to filter tabs (if not provided, lists tabs from all windows)", ), includeContent: z .boolean() .optional() .describe( "Whether to include page content in the response (defaults to false)", ), }, async ({ windowId, includeContent }) => { try { const result = await bropServer.executeBROPCommand("brop_list_tabs", { windowId, includeContent, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_activate_tab", "Switch to/activate a specific browser tab", { tabId: z.number().describe("ID of the tab to activate"), }, async ({ tabId }) => { try { const result = await bropServer.executeBROPCommand("brop_activate_tab", { tabId, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_get_server_status", "Get BROP server status and connection info", {}, async () => { try { const status = { mode: bropServer.isServerMode ? "server" : "relay", isInitialized: bropServer.isInitialized, hasExtensionConnection: bropServer.bridgeServer?.extensionClient && bropServer.bridgeServer.extensionClient.readyState === WebSocket.OPEN, hasBropConnection: bropServer.bropClient && bropServer.bropClient.readyState === WebSocket.OPEN, hasCdpConnection: bropServer.cdpClient && bropServer.cdpClient.readyState === WebSocket.OPEN, cdpSessionId: bropServer.cdpSessionId, status: "running", }; return { content: [ { type: "text", text: JSON.stringify(status, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); // Console Log Capture Tools server.tool( "brop_start_console_capture", "Start capturing console logs for a tab using Chrome Debugger API", { tabId: z.number().describe("Tab ID to start capturing logs from"), }, async ({ tabId }) => { try { const result = await bropServer.executeBROPCommand( "brop_start_console_capture", { tabId }, ); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_get_console_logs", "Retrieve console logs captured since capture was started (requires active capture session)", { tabId: z.number().describe("Tab ID to get logs from"), limit: z .number() .optional() .describe("Maximum logs to return (default: all captured)"), level: z .enum(["log", "warn", "error", "info", "debug"]) .optional() .describe("Filter by log level"), }, async ({ tabId, limit, level }) => { try { const result = await bropServer.executeBROPCommand( "brop_get_console_logs", { tabId, limit, level }, ); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_clear_console_logs", "Clear captured console logs without stopping the capture session", { tabId: z.number().describe("Tab ID to clear logs for"), }, async ({ tabId }) => { try { const result = await bropServer.executeBROPCommand( "brop_clear_console_logs", { tabId }, ); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "brop_stop_console_capture", "Stop console log collection and detach the debugger", { tabId: z.number().describe("Tab ID to stop capturing logs for"), }, async ({ tabId }) => { try { const result = await bropServer.executeBROPCommand( "brop_stop_console_capture", { tabId }, ); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); // CDP Tools server.tool( "cdp_execute_command", "Execute a Chrome DevTools Protocol command", { method: z .string() .describe("CDP method to execute (e.g., 'Page.navigate')"), params: z .object({}) .passthrough() .optional() .describe("Parameters for the CDP method"), }, async ({ method, params }) => { try { const result = await bropServer.executeCDPCommand(method, params || {}); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "cdp_create_page", "Create a new page using CDP and attach to it", { url: z .string() .optional() .describe("URL to navigate to (defaults to about:blank)"), }, async ({ url }) => { try { // Create a new target const createResult = await bropServer.executeCDPCommand( "Target.createTarget", { url: url || "about:blank", }, ); // Wait a bit for session to be established await new Promise((resolve) => setTimeout(resolve, 500)); return { content: [ { type: "text", text: JSON.stringify( { targetId: createResult.targetId, sessionId: bropServer.cdpSessionId, }, null, 2, ), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "cdp_navigate", "Navigate to a URL using CDP", { url: z.string().describe("URL to navigate to"), waitUntil: z .enum(["load", "domcontentloaded", "networkidle0", "networkidle2"]) .optional() .describe("When to consider navigation complete"), }, async ({ url, waitUntil }) => { try { const result = await bropServer.executeCDPCommand("Page.navigate", { url: url, waitUntil: waitUntil, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); server.tool( "cdp_evaluate", "Evaluate JavaScript in the page using CDP", { expression: z.string().describe("JavaScript expression to evaluate"), awaitPromise: z .boolean() .optional() .describe("Whether to await promise resolution"), }, async ({ expression, awaitPromise }) => { try { const result = await bropServer.executeCDPCommand("Runtime.evaluate", { expression: expression, awaitPromise: awaitPromise || false, }); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }, ); // Start the server async function main() { // Initialize BROP bridge servers immediately so extension can connect try { await bropServer.initialize(); } catch (error) { console.error("Warning: BROP initialization failed:", error.message); } const transport = new StdioServerTransport(); await server.connect(transport); console.error("BROP MCP Server running on stdio"); } // Handle shutdown signals process.on("SIGINT", async () => { await bropServer.shutdown(); }); process.on("SIGTERM", async () => { await bropServer.shutdown(); }); main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });