forked from
lass.pds.lassul.us/superconfig
This repository has no description
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();