@mehmoodqureshi/chrome-mcp 0.1.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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/shared/download.d.ts +15 -0
- package/dist/shared/download.js +0 -0
- package/dist/shared/protocol.d.ts +114 -0
- package/dist/shared/protocol.js +55 -0
- package/dist/src/bridge/auth.d.ts +32 -0
- package/dist/src/bridge/auth.js +76 -0
- package/dist/src/bridge/connection.d.ts +48 -0
- package/dist/src/bridge/connection.js +192 -0
- package/dist/src/bridge/datadir.d.ts +8 -0
- package/dist/src/bridge/datadir.js +22 -0
- package/dist/src/bridge/server.d.ts +58 -0
- package/dist/src/bridge/server.js +178 -0
- package/dist/src/cli.d.ts +11 -0
- package/dist/src/cli.js +93 -0
- package/dist/src/config.d.ts +42 -0
- package/dist/src/config.js +188 -0
- package/dist/src/executor/cdp-executor.d.ts +131 -0
- package/dist/src/executor/cdp-executor.js +422 -0
- package/dist/src/executor/extension-executor.d.ts +102 -0
- package/dist/src/executor/extension-executor.js +124 -0
- package/dist/src/executor/manager.d.ts +43 -0
- package/dist/src/executor/manager.js +94 -0
- package/dist/src/executor/select.d.ts +23 -0
- package/dist/src/executor/select.js +53 -0
- package/dist/src/executor/stub-executor.d.ts +60 -0
- package/dist/src/executor/stub-executor.js +118 -0
- package/dist/src/executor/types.d.ts +192 -0
- package/dist/src/executor/types.js +24 -0
- package/dist/src/mcp/envelopes.d.ts +13 -0
- package/dist/src/mcp/envelopes.js +30 -0
- package/dist/src/mcp/helpers.d.ts +37 -0
- package/dist/src/mcp/helpers.js +71 -0
- package/dist/src/mcp/markdown-extract.d.ts +9 -0
- package/dist/src/mcp/markdown-extract.js +61 -0
- package/dist/src/mcp/server.d.ts +18 -0
- package/dist/src/mcp/server.js +82 -0
- package/dist/src/mcp/tools.d.ts +32 -0
- package/dist/src/mcp/tools.js +267 -0
- package/dist/src/mcp/validators.d.ts +32 -0
- package/dist/src/mcp/validators.js +104 -0
- package/dist/src/security/policy.d.ts +48 -0
- package/dist/src/security/policy.js +155 -0
- package/docs/BLUEPRINT.md +596 -0
- package/extension-dist/background.js +567 -0
- package/extension-dist/manifest.json +12 -0
- package/extension-dist/options.html +32 -0
- package/extension-dist/options.js +37 -0
- package/package.json +69 -0
- package/scripts/postinstall.js +50 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// shared/protocol.ts
|
|
4
|
+
var PROTOCOL_VERSION = 1;
|
|
5
|
+
var WIRE_METHODS = [
|
|
6
|
+
"tabs_list",
|
|
7
|
+
"tab_select",
|
|
8
|
+
"tab_new",
|
|
9
|
+
"tab_close",
|
|
10
|
+
"navigate",
|
|
11
|
+
"back",
|
|
12
|
+
"forward",
|
|
13
|
+
"reload",
|
|
14
|
+
"click",
|
|
15
|
+
"type",
|
|
16
|
+
"press",
|
|
17
|
+
"hover",
|
|
18
|
+
"scroll",
|
|
19
|
+
"screenshot",
|
|
20
|
+
"get_text",
|
|
21
|
+
"get_html",
|
|
22
|
+
"eval",
|
|
23
|
+
"wait_for",
|
|
24
|
+
"download_file",
|
|
25
|
+
"ping_probe"
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// extension/src/sw/ws-client.ts
|
|
29
|
+
function chromeVersion() {
|
|
30
|
+
const m = /Chrome\/(\d+)/.exec(navigator.userAgent);
|
|
31
|
+
return m ? m[1] : "0";
|
|
32
|
+
}
|
|
33
|
+
var WsClient = class {
|
|
34
|
+
constructor(deps) {
|
|
35
|
+
this.deps = deps;
|
|
36
|
+
}
|
|
37
|
+
ws = null;
|
|
38
|
+
state = "idle";
|
|
39
|
+
isConnected() {
|
|
40
|
+
return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
|
|
41
|
+
}
|
|
42
|
+
connect(port, token) {
|
|
43
|
+
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.setState("connecting");
|
|
47
|
+
let ws2;
|
|
48
|
+
try {
|
|
49
|
+
ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
this.setState("idle", `dial failed: ${String(err)}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.ws = ws2;
|
|
55
|
+
ws2.onopen = () => {
|
|
56
|
+
const hello = {
|
|
57
|
+
type: "hello",
|
|
58
|
+
v: PROTOCOL_VERSION,
|
|
59
|
+
token,
|
|
60
|
+
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() }
|
|
61
|
+
};
|
|
62
|
+
ws2.send(JSON.stringify(hello));
|
|
63
|
+
};
|
|
64
|
+
ws2.onmessage = (ev) => {
|
|
65
|
+
let frame;
|
|
66
|
+
try {
|
|
67
|
+
frame = JSON.parse(typeof ev.data === "string" ? ev.data : "");
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
switch (frame.type) {
|
|
72
|
+
case "welcome":
|
|
73
|
+
this.setState("connected");
|
|
74
|
+
this.deps.log("paired with server");
|
|
75
|
+
break;
|
|
76
|
+
case "unauthorized":
|
|
77
|
+
this.setState("unauthorized", frame.reason);
|
|
78
|
+
this.deps.log(`pairing rejected: ${frame.reason}`);
|
|
79
|
+
break;
|
|
80
|
+
case "ping":
|
|
81
|
+
this.send({ type: "pong", v: PROTOCOL_VERSION, ts: frame.ts });
|
|
82
|
+
break;
|
|
83
|
+
case "command":
|
|
84
|
+
this.deps.onCommand(frame);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
ws2.onclose = () => {
|
|
89
|
+
if (this.ws === ws2) this.ws = null;
|
|
90
|
+
if (this.state !== "unauthorized") this.setState("idle");
|
|
91
|
+
};
|
|
92
|
+
ws2.onerror = () => {
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Send any extension→server frame (result/error/event/pong). */
|
|
96
|
+
send(frame) {
|
|
97
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
98
|
+
this.ws.send(JSON.stringify(frame));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
close() {
|
|
102
|
+
try {
|
|
103
|
+
this.ws?.close();
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
this.ws = null;
|
|
107
|
+
}
|
|
108
|
+
setState(state, detail) {
|
|
109
|
+
this.state = state;
|
|
110
|
+
this.deps.onState(state, detail);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// shared/download.ts
|
|
115
|
+
var MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
116
|
+
var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
117
|
+
"exe",
|
|
118
|
+
"dll",
|
|
119
|
+
"scr",
|
|
120
|
+
"bat",
|
|
121
|
+
"cmd",
|
|
122
|
+
"com",
|
|
123
|
+
"msi",
|
|
124
|
+
"app",
|
|
125
|
+
"dmg",
|
|
126
|
+
"pkg",
|
|
127
|
+
"sh",
|
|
128
|
+
"bash",
|
|
129
|
+
"zsh",
|
|
130
|
+
"ps1",
|
|
131
|
+
"vbs",
|
|
132
|
+
"js",
|
|
133
|
+
"jar",
|
|
134
|
+
"lnk",
|
|
135
|
+
"reg",
|
|
136
|
+
"msc"
|
|
137
|
+
]);
|
|
138
|
+
var MAX_NAME_LEN = 200;
|
|
139
|
+
var ILLEGAL = new RegExp('[\\u0000-\\u001f<>:"|?*]', "g");
|
|
140
|
+
function sanitizeDownloadName(name) {
|
|
141
|
+
let n = (name ?? "").trim();
|
|
142
|
+
n = n.replace(/[/\\]+/g, "_").replace(/\.{2,}/g, "_");
|
|
143
|
+
n = n.replace(ILLEGAL, "_");
|
|
144
|
+
n = n.replace(/^\.+/, "");
|
|
145
|
+
n = n.replace(/\s+/g, " ").trim();
|
|
146
|
+
if (!n || /^[_.]+$/.test(n)) n = "download";
|
|
147
|
+
const dot = n.lastIndexOf(".");
|
|
148
|
+
if (dot > 0) {
|
|
149
|
+
const ext = n.slice(dot + 1).toLowerCase();
|
|
150
|
+
if (DANGEROUS_EXTENSIONS.has(ext)) n = `${n.slice(0, dot)}.download`;
|
|
151
|
+
}
|
|
152
|
+
if (n.length > MAX_NAME_LEN) {
|
|
153
|
+
const dot2 = n.lastIndexOf(".");
|
|
154
|
+
if (dot2 > 0 && n.length - dot2 <= 12) {
|
|
155
|
+
const ext = n.slice(dot2);
|
|
156
|
+
n = n.slice(0, MAX_NAME_LEN - ext.length) + ext;
|
|
157
|
+
} else {
|
|
158
|
+
n = n.slice(0, MAX_NAME_LEN);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return n;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// extension/src/sw/executor.ts
|
|
165
|
+
var CmdError = class extends Error {
|
|
166
|
+
constructor(code, message) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.code = code;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
var SESSION = crypto.randomUUID();
|
|
172
|
+
var CONTENT_SCHEME = /^(https?|file):/i;
|
|
173
|
+
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
174
|
+
function mint(tabId) {
|
|
175
|
+
return `ext:${SESSION}:${tabId}`;
|
|
176
|
+
}
|
|
177
|
+
function parseTabId(wire) {
|
|
178
|
+
const parts = wire.split(":");
|
|
179
|
+
if (parts.length !== 3 || parts[0] !== "ext") throw new CmdError("TARGET_GONE", `malformed tab handle: ${wire}`);
|
|
180
|
+
if (parts[1] !== SESSION) throw new CmdError("TARGET_GONE", "tab handle is from a previous session; call tabs_list again");
|
|
181
|
+
const id = Number(parts[2]);
|
|
182
|
+
if (!Number.isInteger(id)) throw new CmdError("TARGET_GONE", `bad tab id: ${wire}`);
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
async function currentTabId() {
|
|
186
|
+
const [active] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
187
|
+
if (active?.id !== void 0) return active.id;
|
|
188
|
+
const [any] = await chrome.tabs.query({ active: true });
|
|
189
|
+
if (any?.id !== void 0) return any.id;
|
|
190
|
+
throw new CmdError("NO_TARGET", "no active tab to operate on");
|
|
191
|
+
}
|
|
192
|
+
async function targetTab(cmd) {
|
|
193
|
+
return cmd.tabId ? parseTabId(cmd.tabId) : currentTabId();
|
|
194
|
+
}
|
|
195
|
+
async function execInTab(tabId, func, args = [], world) {
|
|
196
|
+
const [res] = await chrome.scripting.executeScript({
|
|
197
|
+
target: { tabId },
|
|
198
|
+
func,
|
|
199
|
+
args,
|
|
200
|
+
world
|
|
201
|
+
});
|
|
202
|
+
return res?.result;
|
|
203
|
+
}
|
|
204
|
+
async function waitComplete(tabId, timeoutMs = 3e4) {
|
|
205
|
+
const start = Date.now();
|
|
206
|
+
for (; ; ) {
|
|
207
|
+
const t = await chrome.tabs.get(tabId);
|
|
208
|
+
if (t.status === "complete") return;
|
|
209
|
+
if (Date.now() - start > timeoutMs) return;
|
|
210
|
+
await delay(100);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function selectorOf(cmd) {
|
|
214
|
+
const s = cmd.params.selector;
|
|
215
|
+
return typeof s === "string" ? s : void 0;
|
|
216
|
+
}
|
|
217
|
+
async function tabInfo(tab, index = 0) {
|
|
218
|
+
return {
|
|
219
|
+
tabId: mint(tab.id ?? -1),
|
|
220
|
+
url: tab.url ?? "",
|
|
221
|
+
title: tab.title ?? "",
|
|
222
|
+
active: tab.active ?? false,
|
|
223
|
+
index: tab.index ?? index
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
var HANDLED = /* @__PURE__ */ new Set([
|
|
227
|
+
"tabs_list",
|
|
228
|
+
"tab_select",
|
|
229
|
+
"tab_new",
|
|
230
|
+
"tab_close",
|
|
231
|
+
"navigate",
|
|
232
|
+
"back",
|
|
233
|
+
"forward",
|
|
234
|
+
"reload",
|
|
235
|
+
"click",
|
|
236
|
+
"type",
|
|
237
|
+
"press",
|
|
238
|
+
"hover",
|
|
239
|
+
"scroll",
|
|
240
|
+
"screenshot",
|
|
241
|
+
"get_text",
|
|
242
|
+
"get_html",
|
|
243
|
+
"eval",
|
|
244
|
+
"wait_for",
|
|
245
|
+
"download_file",
|
|
246
|
+
"ping_probe"
|
|
247
|
+
]);
|
|
248
|
+
var ChromeExecutor = class {
|
|
249
|
+
async run(cmd) {
|
|
250
|
+
switch (cmd.method) {
|
|
251
|
+
case "ping_probe":
|
|
252
|
+
return {};
|
|
253
|
+
// -- tabs --
|
|
254
|
+
case "tabs_list": {
|
|
255
|
+
const tabs = (await chrome.tabs.query({})).filter((t) => CONTENT_SCHEME.test(t.url ?? ""));
|
|
256
|
+
return Promise.all(tabs.map((t, i) => tabInfo(t, i)));
|
|
257
|
+
}
|
|
258
|
+
case "tab_select": {
|
|
259
|
+
const id = parseTabId(String(cmd.tabId));
|
|
260
|
+
const t = await chrome.tabs.update(id, { active: true });
|
|
261
|
+
return tabInfo(t ?? await chrome.tabs.get(id));
|
|
262
|
+
}
|
|
263
|
+
case "tab_new": {
|
|
264
|
+
const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
|
|
265
|
+
const t = await chrome.tabs.create({ url, active: false });
|
|
266
|
+
return tabInfo(t);
|
|
267
|
+
}
|
|
268
|
+
case "tab_close": {
|
|
269
|
+
const id = parseTabId(String(cmd.tabId));
|
|
270
|
+
await chrome.tabs.remove(id);
|
|
271
|
+
return { closed: true, tabId: cmd.tabId };
|
|
272
|
+
}
|
|
273
|
+
// -- navigation --
|
|
274
|
+
case "navigate": {
|
|
275
|
+
const id = await targetTab(cmd);
|
|
276
|
+
const url = String(cmd.params.url);
|
|
277
|
+
await chrome.tabs.update(id, { url });
|
|
278
|
+
await waitComplete(id);
|
|
279
|
+
const t = await chrome.tabs.get(id);
|
|
280
|
+
return { url: t.url ?? url, title: t.title ?? "" };
|
|
281
|
+
}
|
|
282
|
+
case "back": {
|
|
283
|
+
const id = await targetTab(cmd);
|
|
284
|
+
await chrome.tabs.goBack(id).catch(() => void 0);
|
|
285
|
+
const t = await chrome.tabs.get(id);
|
|
286
|
+
return { url: t.url ?? "", title: t.title ?? "" };
|
|
287
|
+
}
|
|
288
|
+
case "forward": {
|
|
289
|
+
const id = await targetTab(cmd);
|
|
290
|
+
await chrome.tabs.goForward(id).catch(() => void 0);
|
|
291
|
+
const t = await chrome.tabs.get(id);
|
|
292
|
+
return { url: t.url ?? "", title: t.title ?? "" };
|
|
293
|
+
}
|
|
294
|
+
case "reload": {
|
|
295
|
+
const id = await targetTab(cmd);
|
|
296
|
+
await chrome.tabs.reload(id);
|
|
297
|
+
await waitComplete(id);
|
|
298
|
+
const t = await chrome.tabs.get(id);
|
|
299
|
+
return { url: t.url ?? "", title: t.title ?? "" };
|
|
300
|
+
}
|
|
301
|
+
// -- reads (isolated world; CSP-safe) --
|
|
302
|
+
case "get_text": {
|
|
303
|
+
const id = await targetTab(cmd);
|
|
304
|
+
const text = await execInTab(
|
|
305
|
+
id,
|
|
306
|
+
(sel) => {
|
|
307
|
+
const el = sel ? document.querySelector(sel) : document.body;
|
|
308
|
+
return el ? el.innerText : "";
|
|
309
|
+
},
|
|
310
|
+
[selectorOf(cmd) ?? null]
|
|
311
|
+
);
|
|
312
|
+
return { text: text ?? "" };
|
|
313
|
+
}
|
|
314
|
+
case "get_html": {
|
|
315
|
+
const id = await targetTab(cmd);
|
|
316
|
+
const outer = cmd.params.outer === true;
|
|
317
|
+
const html = await execInTab(
|
|
318
|
+
id,
|
|
319
|
+
(sel, isOuter) => {
|
|
320
|
+
const el = sel ? document.querySelector(sel) : document.documentElement;
|
|
321
|
+
if (!el) return "";
|
|
322
|
+
return isOuter || !sel ? el.outerHTML : el.innerHTML;
|
|
323
|
+
},
|
|
324
|
+
[selectorOf(cmd) ?? null, outer]
|
|
325
|
+
);
|
|
326
|
+
return { html: html ?? "" };
|
|
327
|
+
}
|
|
328
|
+
// -- interaction (synthetic events in the isolated world) --
|
|
329
|
+
case "click": {
|
|
330
|
+
const id = await targetTab(cmd);
|
|
331
|
+
const found = await execInTab(
|
|
332
|
+
id,
|
|
333
|
+
(sel) => {
|
|
334
|
+
const el = document.querySelector(sel);
|
|
335
|
+
if (!el) return false;
|
|
336
|
+
el.scrollIntoView({ block: "center" });
|
|
337
|
+
el.click();
|
|
338
|
+
return true;
|
|
339
|
+
},
|
|
340
|
+
[requireSelector(cmd)]
|
|
341
|
+
);
|
|
342
|
+
if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${requireSelector(cmd)}`);
|
|
343
|
+
return { ok: true };
|
|
344
|
+
}
|
|
345
|
+
case "type": {
|
|
346
|
+
const id = await targetTab(cmd);
|
|
347
|
+
const text = String(cmd.params.text ?? "");
|
|
348
|
+
const clear = cmd.params.clear === true;
|
|
349
|
+
const found = await execInTab(
|
|
350
|
+
id,
|
|
351
|
+
(sel, value, doClear) => {
|
|
352
|
+
const el = document.querySelector(sel);
|
|
353
|
+
if (!el) return false;
|
|
354
|
+
el.focus();
|
|
355
|
+
if (doClear) el.value = "";
|
|
356
|
+
el.value = (el.value ?? "") + value;
|
|
357
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
358
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
359
|
+
return true;
|
|
360
|
+
},
|
|
361
|
+
[requireSelector(cmd), text, clear]
|
|
362
|
+
);
|
|
363
|
+
if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${requireSelector(cmd)}`);
|
|
364
|
+
return { ok: true };
|
|
365
|
+
}
|
|
366
|
+
case "press": {
|
|
367
|
+
const id = await targetTab(cmd);
|
|
368
|
+
const key = String(cmd.params.key ?? "");
|
|
369
|
+
await execInTab(
|
|
370
|
+
id,
|
|
371
|
+
(k) => {
|
|
372
|
+
const el = document.activeElement ?? document.body;
|
|
373
|
+
for (const type of ["keydown", "keypress", "keyup"]) {
|
|
374
|
+
el.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true }));
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
[key]
|
|
378
|
+
);
|
|
379
|
+
return { ok: true };
|
|
380
|
+
}
|
|
381
|
+
case "hover": {
|
|
382
|
+
const id = await targetTab(cmd);
|
|
383
|
+
await execInTab(
|
|
384
|
+
id,
|
|
385
|
+
(sel) => {
|
|
386
|
+
const el = document.querySelector(sel);
|
|
387
|
+
el?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
388
|
+
},
|
|
389
|
+
[requireSelector(cmd)]
|
|
390
|
+
);
|
|
391
|
+
return { ok: true };
|
|
392
|
+
}
|
|
393
|
+
case "scroll": {
|
|
394
|
+
const id = await targetTab(cmd);
|
|
395
|
+
await execInTab(
|
|
396
|
+
id,
|
|
397
|
+
(x, y, dx, dy) => {
|
|
398
|
+
if (x != null || y != null) window.scrollTo(x ?? 0, y ?? 0);
|
|
399
|
+
else window.scrollBy(dx ?? 0, dy ?? 0);
|
|
400
|
+
},
|
|
401
|
+
[cmd.params.x ?? null, cmd.params.y ?? null, cmd.params.deltaX ?? null, cmd.params.deltaY ?? null]
|
|
402
|
+
);
|
|
403
|
+
return { ok: true };
|
|
404
|
+
}
|
|
405
|
+
// -- screenshot (visible tab) --
|
|
406
|
+
case "screenshot": {
|
|
407
|
+
const id = await targetTab(cmd);
|
|
408
|
+
const t = await chrome.tabs.get(id);
|
|
409
|
+
const dataUrl = await chrome.tabs.captureVisibleTab(t.windowId, { format: "png" });
|
|
410
|
+
return {
|
|
411
|
+
dataBase64: dataUrl.split(",")[1] ?? "",
|
|
412
|
+
mimeType: "image/png",
|
|
413
|
+
width: 0,
|
|
414
|
+
height: 0,
|
|
415
|
+
truncated: false
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// -- eval (MAIN world; may be blocked by strict page CSP) --
|
|
419
|
+
case "eval": {
|
|
420
|
+
const id = await targetTab(cmd);
|
|
421
|
+
const expr = String(cmd.params.expression ?? "");
|
|
422
|
+
const result = await execInTab(
|
|
423
|
+
id,
|
|
424
|
+
(e) => {
|
|
425
|
+
try {
|
|
426
|
+
const v = (0, eval)(e);
|
|
427
|
+
return { ok: true, value: v, type: typeof v };
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return { ok: false, error: String(err) };
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
[expr],
|
|
433
|
+
"MAIN"
|
|
434
|
+
);
|
|
435
|
+
return result ?? { ok: false, error: "no result" };
|
|
436
|
+
}
|
|
437
|
+
// -- wait_for (poll the isolated world) --
|
|
438
|
+
case "wait_for": {
|
|
439
|
+
const id = await targetTab(cmd);
|
|
440
|
+
const timeout = typeof cmd.params.timeoutMs === "number" ? cmd.params.timeoutMs : 3e4;
|
|
441
|
+
const start = Date.now();
|
|
442
|
+
for (; ; ) {
|
|
443
|
+
const matched = await execInTab(
|
|
444
|
+
id,
|
|
445
|
+
(sel, text, gone) => {
|
|
446
|
+
let present;
|
|
447
|
+
if (sel) present = !!document.querySelector(sel);
|
|
448
|
+
else if (text) present = (document.body?.innerText ?? "").includes(text);
|
|
449
|
+
else present = true;
|
|
450
|
+
return gone ? !present : present;
|
|
451
|
+
},
|
|
452
|
+
[cmd.params.selector ?? null, cmd.params.textContains ?? null, cmd.params.gone === true]
|
|
453
|
+
);
|
|
454
|
+
if (matched) return { matched: true, waitedMs: Date.now() - start };
|
|
455
|
+
if (Date.now() - start > timeout) return { matched: false, waitedMs: Date.now() - start };
|
|
456
|
+
await delay(150);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// -- download (user's Downloads dir) --
|
|
460
|
+
case "download_file": {
|
|
461
|
+
const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
|
|
462
|
+
if (!url) throw new CmdError("DOWNLOAD_FAILED", "the extension download path requires a url");
|
|
463
|
+
const name = sanitizeDownloadName(
|
|
464
|
+
typeof cmd.params.suggestedName === "string" ? cmd.params.suggestedName : void 0
|
|
465
|
+
);
|
|
466
|
+
const downloadId = await chrome.downloads.download({ url, filename: name });
|
|
467
|
+
return { path: `(downloads)/${name}`, backend: "extension", bytes: 0, suggestedName: name };
|
|
468
|
+
}
|
|
469
|
+
default:
|
|
470
|
+
throw new CmdError("UNKNOWN_METHOD", `unhandled method: ${cmd.method}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
function requireSelector(cmd) {
|
|
475
|
+
const s = cmd.params.selector;
|
|
476
|
+
if (typeof s !== "string" || s.length === 0) {
|
|
477
|
+
throw new CmdError("BAD_ARGS", "this command needs a CSS selector (the scripting backend does not support refs)");
|
|
478
|
+
}
|
|
479
|
+
return s;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// extension/src/sw/router.ts
|
|
483
|
+
var CommandRouter = class {
|
|
484
|
+
constructor(deps) {
|
|
485
|
+
this.deps = deps;
|
|
486
|
+
for (const m of WIRE_METHODS) {
|
|
487
|
+
if (!HANDLED.has(m)) throw new Error(`router drift: no handler for wire method "${m}"`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async dispatch(cmd) {
|
|
491
|
+
try {
|
|
492
|
+
const data = await this.deps.exec.run(cmd);
|
|
493
|
+
const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
|
|
494
|
+
this.deps.send(frame);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
const code = err instanceof CmdError ? err.code : "CDP_ERROR";
|
|
497
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
498
|
+
this.deps.log(`command "${cmd.method}" failed: ${message}`);
|
|
499
|
+
const frame = {
|
|
500
|
+
type: "error",
|
|
501
|
+
v: PROTOCOL_VERSION,
|
|
502
|
+
id: cmd.id,
|
|
503
|
+
ok: false,
|
|
504
|
+
error: { code, message }
|
|
505
|
+
};
|
|
506
|
+
this.deps.send(frame);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// extension/src/sw/background.ts
|
|
512
|
+
var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
|
|
513
|
+
var executor = new ChromeExecutor();
|
|
514
|
+
var ws = new WsClient({
|
|
515
|
+
onCommand: (cmd) => void router.dispatch(cmd),
|
|
516
|
+
onState: (state) => void persistState(state),
|
|
517
|
+
log: (m) => console.debug("[chrome-mcp]", m)
|
|
518
|
+
});
|
|
519
|
+
var router = new CommandRouter({
|
|
520
|
+
exec: executor,
|
|
521
|
+
send: (frame) => ws.send(frame),
|
|
522
|
+
log: (m) => console.debug("[chrome-mcp]", m)
|
|
523
|
+
});
|
|
524
|
+
async function getConfig() {
|
|
525
|
+
const { wsPort, token } = await chrome.storage.local.get(["wsPort", "token"]);
|
|
526
|
+
if (typeof wsPort === "number" && typeof token === "string" && token.length > 0) {
|
|
527
|
+
return { wsPort, token };
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
async function persistState(state) {
|
|
532
|
+
await chrome.storage.local.set({ connState: state });
|
|
533
|
+
}
|
|
534
|
+
async function ensureConnected() {
|
|
535
|
+
if (ws.isConnected() || ws.state === "unauthorized") return;
|
|
536
|
+
const cfg = await getConfig();
|
|
537
|
+
if (!cfg) return;
|
|
538
|
+
ws.connect(cfg.wsPort, cfg.token);
|
|
539
|
+
}
|
|
540
|
+
async function keepalivePulse() {
|
|
541
|
+
await chrome.storage.local.get("connState");
|
|
542
|
+
await ensureConnected();
|
|
543
|
+
}
|
|
544
|
+
chrome.runtime.onInstalled.addListener(() => void bootstrap());
|
|
545
|
+
chrome.runtime.onStartup.addListener(() => void bootstrap());
|
|
546
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
547
|
+
if (alarm.name === KEEPALIVE_ALARM) void keepalivePulse();
|
|
548
|
+
});
|
|
549
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
550
|
+
if (area === "local" && (changes.wsPort || changes.token)) {
|
|
551
|
+
if (ws.state === "unauthorized") ws.state = "idle";
|
|
552
|
+
void ensureConnected();
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
chrome.runtime.onMessage.addListener((msg) => {
|
|
556
|
+
if (msg?.type === "reconnect") {
|
|
557
|
+
ws.close();
|
|
558
|
+
ws.state = "idle";
|
|
559
|
+
void ensureConnected();
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
async function bootstrap() {
|
|
563
|
+
await chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
|
|
564
|
+
await ensureConnected();
|
|
565
|
+
}
|
|
566
|
+
void bootstrap();
|
|
567
|
+
})();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Chrome MCP Bridge",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
|
|
6
|
+
"minimum_chrome_version": "116",
|
|
7
|
+
"background": { "service_worker": "background.js" },
|
|
8
|
+
"permissions": ["tabs", "scripting", "activeTab", "downloads", "storage", "alarms"],
|
|
9
|
+
"host_permissions": ["http://*/*", "https://*/*"],
|
|
10
|
+
"options_page": "options.html",
|
|
11
|
+
"action": { "default_title": "Chrome MCP — open options to pair" }
|
|
12
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Chrome MCP — Pairing</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font: 14px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 2rem auto; padding: 0 1rem; }
|
|
8
|
+
h1 { font-size: 1.2rem; }
|
|
9
|
+
label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
|
|
10
|
+
input { width: 100%; padding: 0.5rem; box-sizing: border-box; font: inherit; }
|
|
11
|
+
button { margin-top: 1rem; padding: 0.5rem 1rem; font: inherit; cursor: pointer; }
|
|
12
|
+
.status { margin-top: 1rem; padding: 0.5rem 0.75rem; border-radius: 0.4rem; background: #eef; }
|
|
13
|
+
code { background: #f3f3f3; padding: 0 0.25rem; }
|
|
14
|
+
.hint { color: #555; font-size: 0.9rem; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<h1>Pair with chrome-mcp</h1>
|
|
19
|
+
<p class="hint">
|
|
20
|
+
Run <code>npx chrome-mcp --print-pairing</code>, open the printed
|
|
21
|
+
<code>handshake.json</code>, and paste its <code>port</code> and
|
|
22
|
+
<code>token</code> below.
|
|
23
|
+
</p>
|
|
24
|
+
<label for="port">Port</label>
|
|
25
|
+
<input id="port" type="number" inputmode="numeric" placeholder="38017" />
|
|
26
|
+
<label for="token">Token</label>
|
|
27
|
+
<input id="token" type="password" placeholder="paste the handshake token" />
|
|
28
|
+
<button id="save">Save & connect</button>
|
|
29
|
+
<div class="status" id="status">Status: unknown</div>
|
|
30
|
+
<script src="options.js"></script>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// extension/src/options/options.ts
|
|
4
|
+
var portEl = document.getElementById("port");
|
|
5
|
+
var tokenEl = document.getElementById("token");
|
|
6
|
+
var saveEl = document.getElementById("save");
|
|
7
|
+
var statusEl = document.getElementById("status");
|
|
8
|
+
async function loadExisting() {
|
|
9
|
+
const { wsPort, connState } = await chrome.storage.local.get(["wsPort", "connState"]);
|
|
10
|
+
if (typeof wsPort === "number") portEl.value = String(wsPort);
|
|
11
|
+
render(typeof connState === "string" ? connState : "idle");
|
|
12
|
+
}
|
|
13
|
+
function render(state) {
|
|
14
|
+
const labels = {
|
|
15
|
+
connected: "\u2705 connected",
|
|
16
|
+
connecting: "\u2026 connecting",
|
|
17
|
+
unauthorized: "\u26D4 rejected (bad/stale token \u2014 re-paste)",
|
|
18
|
+
idle: "\u25CB not connected"
|
|
19
|
+
};
|
|
20
|
+
statusEl.textContent = `Status: ${labels[state] ?? state}`;
|
|
21
|
+
}
|
|
22
|
+
saveEl.addEventListener("click", async () => {
|
|
23
|
+
const wsPort = Number(portEl.value);
|
|
24
|
+
const token = tokenEl.value.trim();
|
|
25
|
+
if (!Number.isInteger(wsPort) || wsPort < 0 || !token) {
|
|
26
|
+
statusEl.textContent = "Status: enter a valid port and token";
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
await chrome.storage.local.set({ wsPort, token });
|
|
30
|
+
await chrome.runtime.sendMessage({ type: "reconnect" }).catch(() => void 0);
|
|
31
|
+
statusEl.textContent = "Status: \u2026 connecting";
|
|
32
|
+
});
|
|
33
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
34
|
+
if (area === "local" && changes.connState) render(String(changes.connState.newValue));
|
|
35
|
+
});
|
|
36
|
+
void loadExisting();
|
|
37
|
+
})();
|