machine-bridge-mcp 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,406 @@
1
+ let socket = null;
2
+ let reconnectTimer = null;
3
+ let reconnectAttempt = 0;
4
+ let keepaliveTimer = null;
5
+ const MAX_RESULT_BYTES = 7 * 1024 * 1024;
6
+ const activeRequests = new Map();
7
+
8
+ chrome.runtime.onInstalled.addListener(() => { ensureReconnectAlarm(); void connectFromStorage(); });
9
+ chrome.runtime.onStartup.addListener(() => { ensureReconnectAlarm(); void connectFromStorage(); });
10
+ chrome.alarms.onAlarm.addListener((alarm) => {
11
+ if (alarm.name === "machine-bridge-reconnect") void connectFromStorage();
12
+ });
13
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
14
+ if (message?.type !== "pair") return false;
15
+ pairConfiguration(message.endpoint, message.token, { replace: false, senderUrl: sender.url || "" })
16
+ .then(sendResponse)
17
+ .catch(() => sendResponse({ ok: false }));
18
+ return true;
19
+ });
20
+ chrome.action.onClicked.addListener((tab) => void handleActionClick(tab));
21
+
22
+ async function handleActionClick(tab) {
23
+ if (tab?.id && /^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(tab.url || ""))) {
24
+ await confirmRepairFromTab(tab);
25
+ return;
26
+ }
27
+ const current = await chrome.storage.local.get(["endpoint"]);
28
+ const pairUrl = pairingUrlFromEndpoint(current.endpoint) || "http://127.0.0.1:39393/pair";
29
+ await chrome.tabs.create({ url: pairUrl });
30
+ }
31
+
32
+ function pairingUrlFromEndpoint(endpoint) {
33
+ const match = /^ws:\/\/127\.0\.0\.1:(\d+)\/extension$/.exec(String(endpoint || ""));
34
+ return match ? `http://127.0.0.1:${match[1]}/pair` : "";
35
+ }
36
+
37
+ function setConnectionState(state) {
38
+ const badge = state === "connected" ? "ON" : state === "connecting" ? "..." : state === "unconfigured" ? "?" : "!";
39
+ const title = state === "connected"
40
+ ? "Machine Bridge Browser: connected"
41
+ : state === "connecting"
42
+ ? "Machine Bridge Browser: connecting"
43
+ : state === "unconfigured"
44
+ ? "Machine Bridge Browser: click to open pairing"
45
+ : "Machine Bridge Browser: disconnected; click to open pairing";
46
+ try { ignoreOptionalPromise(chrome.action.setBadgeText({ text: badge })); } catch {}
47
+ try { ignoreOptionalPromise(chrome.action.setTitle({ title })); } catch {}
48
+ }
49
+
50
+ function ignoreOptionalPromise(value) {
51
+ if (value && typeof value.catch === "function") value.catch(() => {});
52
+ }
53
+
54
+ async function pairConfiguration(rawEndpoint, rawToken, { replace, senderUrl }) {
55
+ const endpoint = String(rawEndpoint || "");
56
+ const token = String(rawToken || "");
57
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(senderUrl || ""))) return { ok: false, error: "invalid_pairing_page" };
58
+ if (!/^ws:\/\/127\.0\.0\.1:\d+\/extension$/.test(endpoint) || !/^[A-Za-z0-9_-]{32,100}$/.test(token)) return { ok: false, error: "invalid_pairing_material" };
59
+ const current = await chrome.storage.local.get(["endpoint", "token"]);
60
+ const alreadyPaired = Boolean(current.endpoint && current.token);
61
+ if (alreadyPaired && !replace && (current.endpoint !== endpoint || current.token !== token)) {
62
+ return { ok: false, requires_manual_repair: true };
63
+ }
64
+ await chrome.storage.local.set({ endpoint, token });
65
+ setConnectionState("connecting");
66
+ connect(endpoint, token);
67
+ return { ok: true, replaced: alreadyPaired && (current.endpoint !== endpoint || current.token !== token) };
68
+ }
69
+
70
+ async function confirmRepairFromTab(tab) {
71
+ if (!tab?.id || !/^http:\/\/127\.0\.0\.1:\d+\/pair(?:$|[?#])/.test(String(tab.url || ""))) return;
72
+ try {
73
+ const [result] = await chrome.scripting.executeScript({
74
+ target: { tabId: tab.id },
75
+ func: () => ({
76
+ endpoint: `ws://127.0.0.1:${document.querySelector('meta[name="machine-bridge-browser-port"]')?.content || ""}/extension`,
77
+ token: document.querySelector('meta[name="machine-bridge-browser-token"]')?.content || "",
78
+ }),
79
+ });
80
+ const paired = await pairConfiguration(result?.result?.endpoint, result?.result?.token, { replace: true, senderUrl: tab.url });
81
+ await chrome.scripting.executeScript({
82
+ target: { tabId: tab.id },
83
+ func: (ok) => {
84
+ const status = document.getElementById("status");
85
+ if (status) status.textContent = ok ? "Paired. You may close this tab." : "Pairing failed.";
86
+ },
87
+ args: [paired.ok === true],
88
+ });
89
+ } catch {}
90
+ }
91
+
92
+ ensureReconnectAlarm();
93
+ void connectFromStorage();
94
+
95
+ function ensureReconnectAlarm() {
96
+ chrome.alarms.create("machine-bridge-reconnect", { periodInMinutes: 1 });
97
+ }
98
+
99
+ async function connectFromStorage() {
100
+ if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
101
+ const value = await chrome.storage.local.get(["endpoint", "token"]);
102
+ if (value.endpoint && value.token) {
103
+ setConnectionState("connecting");
104
+ connect(value.endpoint, value.token);
105
+ } else {
106
+ setConnectionState("unconfigured");
107
+ }
108
+ }
109
+
110
+ function connect(endpoint, token) {
111
+ clearTimeout(reconnectTimer);
112
+ reconnectTimer = null;
113
+ if (socket) {
114
+ try { socket.close(); } catch {}
115
+ }
116
+ const ws = new WebSocket(endpoint, [`mbm.${token}`]);
117
+ socket = ws;
118
+ ws.onopen = () => {
119
+ if (socket !== ws) {
120
+ try { ws.close(); } catch {}
121
+ return;
122
+ }
123
+ reconnectAttempt = 0;
124
+ setConnectionState("connected");
125
+ clearInterval(keepaliveTimer);
126
+ keepaliveTimer = setInterval(() => { if (socket === ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
127
+ };
128
+ ws.onmessage = (event) => void handleMessage(ws, event.data);
129
+ ws.onerror = () => {};
130
+ ws.onclose = () => {
131
+ if (socket !== ws) return;
132
+ clearInterval(keepaliveTimer);
133
+ keepaliveTimer = null;
134
+ socket = null;
135
+ setConnectionState("disconnected");
136
+ scheduleReconnect(endpoint, token);
137
+ };
138
+ }
139
+
140
+ function scheduleReconnect(endpoint, token) {
141
+ clearTimeout(reconnectTimer);
142
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
143
+ reconnectTimer = setTimeout(() => connect(endpoint, token), delay);
144
+ }
145
+
146
+ async function handleMessage(ws, raw) {
147
+ let message;
148
+ try { message = JSON.parse(String(raw)); } catch { return; }
149
+ if (message?.type === "cancel" && typeof message.id === "string") {
150
+ const state = activeRequests.get(message.id);
151
+ if (state) state.cancelled = true;
152
+ return;
153
+ }
154
+ if (message?.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") return;
155
+ const state = { cancelled: false };
156
+ activeRequests.set(message.id, state);
157
+ try {
158
+ throwIfCancelled(state);
159
+ const result = await dispatch(message.method, message.params || {});
160
+ throwIfCancelled(state);
161
+ sendResponse(ws, message.id, true, result);
162
+ } catch (error) {
163
+ if (!state.cancelled) sendResponse(ws, message.id, false, null, String(error?.message || error).slice(0, 2000));
164
+ } finally {
165
+ activeRequests.delete(message.id);
166
+ }
167
+ }
168
+
169
+ function throwIfCancelled(state) {
170
+ if (state.cancelled) throw new Error("browser request cancelled");
171
+ }
172
+
173
+ function sendResponse(ws, id, ok, result, error = "") {
174
+ if (ws.readyState !== WebSocket.OPEN) return;
175
+ let payload = JSON.stringify({ type: "response", id, ok, ...(ok ? { result } : { error }) });
176
+ if (new TextEncoder().encode(payload).byteLength > MAX_RESULT_BYTES) {
177
+ payload = JSON.stringify({ type: "response", id, ok: false, error: "browser result exceeds maximum size" });
178
+ }
179
+ try { ws.send(payload); } catch {}
180
+ }
181
+
182
+ async function dispatch(method, params) {
183
+ if (method === "list_tabs") return listTabs(params);
184
+ if (method === "get_source") return getSource(params);
185
+ if (method === "inspect_page") return inspectPage(params);
186
+ if (method === "action") return browserAction(params);
187
+ if (method === "fill_form") return fillForm(params);
188
+ if (method === "upload_files") return uploadFiles(params);
189
+ if (method === "screenshot") return screenshot(params);
190
+ throw new Error(`unknown browser method: ${method}`);
191
+ }
192
+
193
+ async function listTabs(params) {
194
+ const query = params.currentWindow ? { currentWindow: true } : {};
195
+ const tabs = await chrome.tabs.query(query);
196
+ return {
197
+ tabs: tabs
198
+ .filter((tab) => params.includePinned !== false || !tab.pinned)
199
+ .map((tab) => ({
200
+ id: tab.id,
201
+ window_id: tab.windowId,
202
+ active: tab.active,
203
+ pinned: tab.pinned,
204
+ audible: tab.audible,
205
+ discarded: tab.discarded,
206
+ status: tab.status,
207
+ title: tab.title || "",
208
+ url: tab.url || "",
209
+ })),
210
+ };
211
+ }
212
+
213
+ function scriptTarget(tabId, frameId, allFrames) {
214
+ if (Number.isInteger(frameId)) return { tabId, frameIds: [frameId] };
215
+ if (allFrames) return { tabId, allFrames: true };
216
+ return { tabId };
217
+ }
218
+
219
+ async function resolveTab(tabId) {
220
+ if (Number.isInteger(tabId)) return chrome.tabs.get(tabId);
221
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
222
+ if (!tab?.id) throw new Error("no active browser tab");
223
+ return tab;
224
+ }
225
+
226
+ async function getSource(params) {
227
+ const tab = await resolveTab(params.tabId);
228
+ assertPageTab(tab);
229
+ const maxBytes = Number(params.maxBytes) || 1024 * 1024;
230
+ const executions = await chrome.scripting.executeScript({
231
+ target: scriptTarget(tab.id, params.frameId, params.allFrames === true),
232
+ func: (limit) => {
233
+ const doctype = document.doctype ? `<!DOCTYPE ${document.doctype.name}>\n` : "";
234
+ const source = `${doctype}${document.documentElement?.outerHTML || ""}`;
235
+ const encoder = new TextEncoder();
236
+ const bytes = encoder.encode(source);
237
+ if (bytes.byteLength <= limit) return { source, bytes: bytes.byteLength, truncated: false, url: location.href };
238
+ const decoder = new TextDecoder();
239
+ return { source: decoder.decode(bytes.slice(0, limit)), bytes: bytes.byteLength, truncated: true, url: location.href };
240
+ },
241
+ args: [maxBytes],
242
+ });
243
+ return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", frames: executions.map((item) => ({ frame_id: item.frameId, ...item.result })) };
244
+ }
245
+
246
+ async function inspectPage(params) {
247
+ const tab = await resolveTab(params.tabId);
248
+ assertPageTab(tab);
249
+ const executions = await executePageAutomation(
250
+ scriptTarget(tab.id, params.frameId, params.allFrames === true),
251
+ "inspect",
252
+ { maxElements: Number(params.maxElements) || 300, includeValues: params.includeValues === true },
253
+ );
254
+ return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", frames: executions.map((item) => ({ frame_id: item.frameId, ...item.result })) };
255
+ }
256
+
257
+ async function browserAction(params) {
258
+ const tab = await resolveTab(params.tabId);
259
+ if (params.action === "navigate") {
260
+ if (!params.url) throw new Error("navigate requires url");
261
+ const waiter = beginTabWait(tab.id, params.waitFor);
262
+ try {
263
+ const updated = await chrome.tabs.update(tab.id, { url: params.url });
264
+ await waiter.promise;
265
+ return publicTab(await chrome.tabs.get(updated.id));
266
+ } catch (error) {
267
+ waiter.cancel();
268
+ throw error;
269
+ }
270
+ }
271
+ if (params.action === "reload") {
272
+ const waiter = beginTabWait(tab.id, params.waitFor);
273
+ try {
274
+ await chrome.tabs.reload(tab.id);
275
+ await waiter.promise;
276
+ return publicTab(await chrome.tabs.get(tab.id));
277
+ } catch (error) {
278
+ waiter.cancel();
279
+ throw error;
280
+ }
281
+ }
282
+ if (params.action === "back" || params.action === "forward") {
283
+ const waiter = beginTabWait(tab.id, params.waitFor);
284
+ try {
285
+ if (params.action === "back") await chrome.tabs.goBack(tab.id);
286
+ else await chrome.tabs.goForward(tab.id);
287
+ await waiter.promise;
288
+ return publicTab(await chrome.tabs.get(tab.id));
289
+ } catch (error) {
290
+ waiter.cancel();
291
+ throw error;
292
+ }
293
+ }
294
+ assertPageTab(tab);
295
+ const waiter = beginTabWait(tab.id, params.waitFor);
296
+ let execution;
297
+ try {
298
+ [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
299
+ await waiter.promise;
300
+ } catch (error) {
301
+ waiter.cancel();
302
+ throw error;
303
+ }
304
+ return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
305
+ }
306
+
307
+ async function fillForm(params) {
308
+ const tab = await resolveTab(params.tabId);
309
+ assertPageTab(tab);
310
+ const waiter = beginTabWait(tab.id, params.waitFor);
311
+ let execution;
312
+ try {
313
+ [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "fillForm", params);
314
+ await waiter.promise;
315
+ } catch (error) {
316
+ waiter.cancel();
317
+ throw error;
318
+ }
319
+ return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
320
+ }
321
+
322
+ async function uploadFiles(params) {
323
+ const tab = await resolveTab(params.tabId);
324
+ assertPageTab(tab);
325
+ const [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "uploadFiles", params);
326
+ return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
327
+ }
328
+
329
+ async function screenshot(params) {
330
+ const tab = await resolveTab(params.tabId);
331
+ await chrome.tabs.update(tab.id, { active: true });
332
+ await chrome.windows.update(tab.windowId, { focused: true });
333
+ const data = await chrome.tabs.captureVisibleTab(tab.windowId, {
334
+ format: params.format === "jpeg" ? "jpeg" : "png",
335
+ quality: Number(params.quality) || 90,
336
+ });
337
+ return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", data };
338
+ }
339
+
340
+ function publicTab(tab) {
341
+ return { tab_id: tab.id, window_id: tab.windowId, title: tab.title || "", url: tab.url || "", status: tab.status || "" };
342
+ }
343
+
344
+ function assertPageTab(tab) {
345
+ const url = String(tab.url || "");
346
+ if (!/^(https?|file):/i.test(url)) throw new Error("this page cannot be scripted by a browser extension");
347
+ }
348
+
349
+ function beginTabWait(tabId, mode) {
350
+ if (!mode || mode === "none") return { promise: Promise.resolve(), cancel() {} };
351
+ let settled = false;
352
+ let navigationStarted = false;
353
+ let pollTimer = null;
354
+ let timeout = null;
355
+ let resolveWait;
356
+ let rejectWait;
357
+ const promise = new Promise((resolvePromise, rejectPromise) => {
358
+ resolveWait = resolvePromise;
359
+ rejectWait = rejectPromise;
360
+ });
361
+ const cleanup = () => {
362
+ clearTimeout(timeout);
363
+ clearTimeout(pollTimer);
364
+ chrome.tabs.onUpdated.removeListener(listener);
365
+ };
366
+ const settle = (error = null) => {
367
+ if (settled) return;
368
+ settled = true;
369
+ cleanup();
370
+ if (error) rejectWait(error); else resolveWait();
371
+ };
372
+ const pollReadyState = async () => {
373
+ if (settled || !navigationStarted || mode !== "domcontentloaded") return;
374
+ try {
375
+ const [execution] = await chrome.scripting.executeScript({ target: { tabId }, func: () => document.readyState });
376
+ if (["interactive", "complete"].includes(execution?.result)) {
377
+ settle();
378
+ return;
379
+ }
380
+ } catch {}
381
+ pollTimer = setTimeout(pollReadyState, 100);
382
+ };
383
+ const listener = (updatedId, changeInfo, tab) => {
384
+ if (updatedId !== tabId || settled) return;
385
+ if (changeInfo.status === "loading" || typeof changeInfo.url === "string") navigationStarted = true;
386
+ if (!navigationStarted) return;
387
+ if (mode === "complete" && (changeInfo.status === "complete" || (typeof changeInfo.url === "string" && tab.status === "complete"))) settle();
388
+ if (mode === "domcontentloaded") void pollReadyState();
389
+ };
390
+ chrome.tabs.onUpdated.addListener(listener);
391
+ timeout = setTimeout(() => settle(new Error("browser navigation wait timed out")), 30000);
392
+ return { promise, cancel: () => settle() };
393
+ }
394
+
395
+ function executePageAutomation(target, method, params) {
396
+ return chrome.scripting.executeScript({ target, files: ["page-automation.js"] })
397
+ .then(() => chrome.scripting.executeScript({
398
+ target,
399
+ func: (operation, payload) => {
400
+ const api = globalThis.__machineBridgePageAutomation;
401
+ if (!api || typeof api[operation] !== "function") throw new Error("page automation module is unavailable");
402
+ return api[operation](payload);
403
+ },
404
+ args: [method, params],
405
+ }));
406
+ }