This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / browser-mcp / extension / background.js
5.8 kB 195 lines
1/* Browser MCP - Firefox side of the browser-mcp bridge. 2 * 3 * Connects to the browser_mcp native messaging host, which relays requests 4 * from MCP servers over a unix socket. Everything runs through the regular 5 * WebExtension APIs, so the browser never enters WebDriver/Marionette mode: 6 * navigator.webdriver stays false and captcha providers see a normal browser. 7 * 8 * Request: {id, method, params} 9 * Response: {id, result} | {id, error} 10 */ 11 12const HOST = "browser_mcp"; 13const DEFAULT_LOAD_TIMEOUT_MS = 15000; 14 15let port = null; 16let retryDelay = 1000; 17 18function connect() { 19 port = browser.runtime.connectNative(HOST); 20 port.onMessage.addListener(onRequest); 21 port.onDisconnect.addListener((p) => { 22 console.warn("browser-mcp host disconnected:", p.error); 23 port = null; 24 setTimeout(connect, retryDelay); 25 retryDelay = Math.min(retryDelay * 2, 60000); 26 }); 27} 28 29async function onRequest(msg) { 30 // Any message means the host is up; reset backoff for the next reconnect. 31 retryDelay = 1000; 32 let reply; 33 try { 34 const handler = handlers[msg.method]; 35 if (!handler) throw new Error(`unknown method: ${msg.method}`); 36 reply = { id: msg.id, result: await handler(msg.params || {}) }; 37 } catch (e) { 38 reply = { id: msg.id, error: String(e && e.message ? e.message : e) }; 39 } 40 if (port) port.postMessage(reply); 41} 42 43function tabInfo(t) { 44 return { 45 id: t.id, 46 windowId: t.windowId, 47 active: t.active, 48 status: t.status, 49 title: t.title, 50 url: t.url, 51 }; 52} 53 54// Runs `start` (which returns the tab id) and resolves once that tab reports 55// status "complete", or after the timeout (the page keeps loading; the 56// caller gets loaded: false). The listener goes up before `start` so a fast 57// load can't complete unseen. 58function navigateAndWait(start, timeoutMs) { 59 return new Promise((resolve, reject) => { 60 const completed = new Set(); 61 let tabId = null; 62 let timer = null; 63 const cleanup = () => { 64 clearTimeout(timer); 65 browser.tabs.onUpdated.removeListener(listener); 66 }; 67 const finish = async (loaded) => { 68 cleanup(); 69 try { 70 resolve({ loaded, tab: tabInfo(await browser.tabs.get(tabId)) }); 71 } catch (e) { 72 reject(e); 73 } 74 }; 75 const listener = (id, change) => { 76 if (change.status !== "complete") return; 77 if (tabId === null) completed.add(id); 78 else if (id === tabId) finish(true); 79 }; 80 browser.tabs.onUpdated.addListener(listener); 81 start().then( 82 (id) => { 83 tabId = id; 84 if (completed.has(id)) finish(true); 85 else timer = setTimeout(() => finish(false), timeoutMs); 86 }, 87 (e) => { 88 cleanup(); 89 reject(e); 90 }, 91 ); 92 }); 93} 94 95// Content-side helpers live in the extension's isolated world of the page 96// (page.js), invisible to the page's own scripts. 97async function pageCall(tabId, op, args) { 98 const [present] = await browser.tabs.executeScript(tabId, { 99 code: "typeof __browserMcp !== 'undefined'", 100 }); 101 if (!present) await browser.tabs.executeScript(tabId, { file: "/page.js" }); 102 const [result] = await browser.tabs.executeScript(tabId, { 103 code: `__browserMcp.${op}(${JSON.stringify(args)})`, 104 }); 105 return result; 106} 107 108const handlers = { 109 async list_tabs() { 110 return (await browser.tabs.query({})).map(tabInfo); 111 }, 112 113 async open_tab({ 114 url, 115 active = false, 116 timeout_ms = DEFAULT_LOAD_TIMEOUT_MS, 117 }) { 118 return navigateAndWait( 119 async () => (await browser.tabs.create({ url, active })).id, 120 timeout_ms, 121 ); 122 }, 123 124 async navigate({ tab_id, url, timeout_ms = DEFAULT_LOAD_TIMEOUT_MS }) { 125 return navigateAndWait(async () => { 126 await browser.tabs.update(tab_id, { url }); 127 return tab_id; 128 }, timeout_ms); 129 }, 130 131 async history({ tab_id, direction, timeout_ms = DEFAULT_LOAD_TIMEOUT_MS }) { 132 if (direction !== "back" && direction !== "forward") { 133 throw new Error("direction must be 'back' or 'forward'"); 134 } 135 return navigateAndWait(async () => { 136 if (direction === "back") await browser.tabs.goBack(tab_id); 137 else await browser.tabs.goForward(tab_id); 138 return tab_id; 139 }, timeout_ms); 140 }, 141 142 async reload({ tab_id, timeout_ms = DEFAULT_LOAD_TIMEOUT_MS }) { 143 return navigateAndWait(async () => { 144 await browser.tabs.reload(tab_id); 145 return tab_id; 146 }, timeout_ms); 147 }, 148 149 async activate_tab({ tab_id }) { 150 const tab = await browser.tabs.update(tab_id, { active: true }); 151 await browser.windows.update(tab.windowId, { focused: true }); 152 return tabInfo(tab); 153 }, 154 155 async close_tab({ tab_id }) { 156 await browser.tabs.remove(tab_id); 157 return { closed: tab_id }; 158 }, 159 160 async snapshot({ tab_id, max_elements = 300 }) { 161 return pageCall(tab_id, "snapshot", { max_elements }); 162 }, 163 164 async text({ tab_id, max_length = 20000 }) { 165 return pageCall(tab_id, "text", { max_length }); 166 }, 167 168 async click({ tab_id, ref }) { 169 return pageCall(tab_id, "click", { ref }); 170 }, 171 172 async fill({ tab_id, ref, value }) { 173 return pageCall(tab_id, "fill", { ref, value }); 174 }, 175 176 async press_key({ tab_id, key, ref = null }) { 177 return pageCall(tab_id, "pressKey", { key, ref }); 178 }, 179 180 async evaluate({ tab_id, code }) { 181 // The body runs as an async function in the isolated content-script 182 // world; the JSON round trip turns DOM nodes etc. into a clear error 183 // instead of a structured-clone failure. 184 const [result] = await browser.tabs.executeScript(tab_id, { 185 code: `(async () => { ${code}\n})().then((v) => JSON.stringify(v === undefined ? null : v))`, 186 }); 187 return JSON.parse(result); 188 }, 189 190 async screenshot({ tab_id }) { 191 return browser.tabs.captureTab(tab_id, { format: "png" }); 192 }, 193}; 194 195connect();