Add bundled Chrome extension and document extension/stdio MCP setup
This commit is contained in:
@@ -12,6 +12,50 @@
|
||||
|
||||
网关内部 spawn 一个 browser-mcp stdio 进程作为上游(自动重连),对外在同一端口暴露两类接口,所有接口均需 Bearer 认证(也支持 `?token=` 查询参数)。
|
||||
|
||||
## 配套组件
|
||||
|
||||
本项目网关依赖 browser-mcp 的两半:**Chrome 扩展**(驱动浏览器)和 **stdio MCP server**(与扩展配对)。本项目仓库已自带扩展,无需再装 npm 包来获取扩展。
|
||||
|
||||
### 1. 安装 Chrome 扩展(项目自带 `extension/`)
|
||||
|
||||
1. 打开 Chrome,地址栏输入 `chrome://extensions/`
|
||||
2. 右上角打开 **开发者模式**
|
||||
3. 点击 **加载已解压的扩展程序**,选择本项目下的 `extension/` 目录
|
||||
4. 点击工具栏扩展图标(Agent360 Browser MCP),确认与本地 server 配对连接(扩展会连接 `127.0.0.1:9876`)
|
||||
|
||||
> `extension/` 来自 npm 包 `@agent360/browser-mcp@1.30.0` 自带的未打包扩展(Manifest V3),升级时可从新版 npm 包重新拷贝。
|
||||
|
||||
### 2. 安装 stdio 版本 MCP server(本机使用)
|
||||
|
||||
```bash
|
||||
npm install -g @agent360/browser-mcp
|
||||
```
|
||||
|
||||
在 MCP client 的配置中加入(以 Kimi Code 的 `~/.kimi-code/mcp.json` 为例):
|
||||
|
||||
```json
|
||||
"browser-mcp": {
|
||||
"command": "node",
|
||||
"args": ["C:\\Users\\<用户名>\\AppData\\Roaming\\npm\\node_modules\\@agent360\\browser-mcp\\bin\\cli.js"]
|
||||
}
|
||||
```
|
||||
|
||||
Linux / macOS 全局安装后一般为:
|
||||
|
||||
```json
|
||||
"browser-mcp": {
|
||||
"command": "node",
|
||||
"args": ["/usr/local/lib/node_modules/@agent360/browser-mcp/bin/cli.js"]
|
||||
}
|
||||
```
|
||||
|
||||
> 网关(`gateway.js`)内部就是以上述 stdio 方式启动同一个 `cli.js` 作为上游,再由网关桥接到局域网。
|
||||
|
||||
### 来源与官网
|
||||
|
||||
- **本项目使用的分叉**:Agent360 Browser MCP —— GitHub: <https://github.com/Agent360dk/browser-mcp> ,npm: `@agent360/browser-mcp`
|
||||
- **原版 BrowserMCP**:官网 <https://browsermcp.io> ,GitHub: <https://github.com/browsermcp/mcp>
|
||||
|
||||
## 接口
|
||||
|
||||
| 路径 | 说明 |
|
||||
@@ -90,6 +134,7 @@ claude mcp add --transport http browser-remote http://10.1.1.6:9109/mcp \
|
||||
```
|
||||
gateway.js # 网关主程序:HTTP 服务 + 认证 + stdio 上游管理 + MCP 代理
|
||||
pages.js # /、/docs、/docs.json 三个页面的 HTML/JSON 渲染
|
||||
extension/ # 配套 Chrome 扩展(Agent360 Browser MCP,未打包)
|
||||
package.json # 仅依赖 @modelcontextprotocol/sdk
|
||||
token.txt # Bearer token(gitignore)
|
||||
start-gateway.bat # Windows 启动脚本
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Agent360 Browser MCP",
|
||||
"version": "1.30.0",
|
||||
"description": "Your agent needs a 2FA code? It asks you, then carries on in the tab you were already signed into. 40 tools, any client, MIT.",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"tabGroups",
|
||||
"cookies",
|
||||
"scripting",
|
||||
"activeTab",
|
||||
"storage",
|
||||
"alarms",
|
||||
"offscreen",
|
||||
"notifications",
|
||||
"webNavigation",
|
||||
"debugger"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:*"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script src="offscreen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Offscreen Document — Persistent WebSocket bridge (multi-session)
|
||||
*
|
||||
* Scans port range 9876-9895 and maintains connections to ALL active
|
||||
* MCP servers. Each Claude Code session gets its own port automatically.
|
||||
* Passes port ID with every command so background.js can track tab ownership.
|
||||
*
|
||||
* Flow: MCP Server(s) ←(WS)→ this ←(chrome.runtime.sendMessage)→ Service Worker → Chrome APIs
|
||||
*/
|
||||
|
||||
// Et offscreen-dokument har IKKE chrome.runtime.getManifest() — kaldet kaster
|
||||
// "chrome.runtime.getManifest is not a function". Versionen kommer derfor med i
|
||||
// dokumentets egen URL, sat af background.js da dokumentet blev oprettet. Det er
|
||||
// samtidig den rigtige semantik: et dokument oprettet af en aeldre udgave baerer
|
||||
// den aeldre version, saa `offscreenSvarer()` kan se forskel paa "svarer" og
|
||||
// "er den udgave vi koerer nu".
|
||||
// Plan 1.10 / R2 (Astra): versionsnummeret beviser ikke hvilken KODE der koerer - to kopier med samme nummer kan vaere
|
||||
// forskellige, og udgivelsens flowtest kunne derfor godkendes mod gammel kode. Udvidelsen sender derfor et fingeraftryk af
|
||||
// sin egen background.js. Kun et hash, og kun til 127.0.0.1: ingen kode forlader maskinen.
|
||||
// MAALT 12/9: aftrykket daekkede kun background.js. Samme dag aendrede offscreen.js sig, og den aendring var USYNLIG for
|
||||
// udgivelsens spaerre - en gammel kopi af broen kunne passere som kandidaten. Begge kodefiler taeller nu med, i fast
|
||||
// raekkefoelge. Det er stadig en byggekontrol: filerne laeses fra den indlaeste udvidelses egen mappe, saa aftrykket
|
||||
// beviser hvilke FILER der er indlaest - ikke hvilken kode der koerer i et gammelt offscreen-dokument.
|
||||
const KODEFILER = ['background.js', 'offscreen.js'];
|
||||
|
||||
// ── Parringsnoegle (issue #10) ────────────────────────────────────────────────
|
||||
// Uden noegle: nul konfiguration, praecis som i dag. Med noegle: udvidelsen taler KUN
|
||||
// med en server der kender den samme, og serveren svarer kun den udvidelse der kan den.
|
||||
//
|
||||
// Det loeser to ting paa én gang. Det oenskede - at parre én Chrome-profil med én server,
|
||||
// saa Arbejde og Privat ikke tager hinandens kommandoer - og et hul vi selv har skrevet
|
||||
// ned: broen er lokal og uautentificeret, saa ethvert program paa maskinen kan forbinde
|
||||
// til den. En delt hemmelighed lukker begge, og den koster intet for dem der ikke vil have den.
|
||||
//
|
||||
// Noeglen laeses ved opstart og caches, fordi haandtrykket bygges synkront naar soklen
|
||||
// aabner - samme grund som aftrykket eftersendes i stedet for at blokere hilsenen.
|
||||
let parringsnoegle = null;
|
||||
const parrede = new WeakSet();
|
||||
try {
|
||||
chrome.storage.local.get('parringsnoegle').then((v) => {
|
||||
parringsnoegle = (v && typeof v.parringsnoegle === 'string' && v.parringsnoegle.trim()) || null;
|
||||
}).catch(() => {});
|
||||
chrome.storage.onChanged.addListener((aendringer, omraade) => {
|
||||
if (omraade !== 'local' || !aendringer.parringsnoegle) return;
|
||||
const ny = aendringer.parringsnoegle.newValue;
|
||||
parringsnoegle = (typeof ny === 'string' && ny.trim()) || null;
|
||||
// Skift af noegle skal tage effekt med det samme, ikke naeste gang browseren starter.
|
||||
for (const [, sokkel] of connections) { try { sokkel.close(); } catch (e) { /* lukket */ } }
|
||||
connections.clear();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Offscreen] could not read the pairing key:', e?.message || e);
|
||||
}
|
||||
let kodeAftrykCache = null;
|
||||
async function kodeAftryk() {
|
||||
if (kodeAftrykCache) return kodeAftrykCache;
|
||||
try {
|
||||
const dele = [];
|
||||
for (const fil of KODEFILER) {
|
||||
const svar = await fetch(chrome.runtime.getURL(fil));
|
||||
dele.push(new Uint8Array(await svar.arrayBuffer()));
|
||||
}
|
||||
const samlet = new Uint8Array(dele.reduce((n, d) => n + d.length, 0));
|
||||
let i = 0;
|
||||
for (const d of dele) { samlet.set(d, i); i += d.length; }
|
||||
const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', samlet));
|
||||
kodeAftrykCache = Array.from(bytes.slice(0, 6)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch (e) {
|
||||
console.warn('[Offscreen] kunne ikke beregne kode-aftrykket:', e?.message || e);
|
||||
}
|
||||
return kodeAftrykCache;
|
||||
}
|
||||
|
||||
function minVersion() {
|
||||
try { return new URLSearchParams(location.search).get('v') || null; } catch { return null; }
|
||||
}
|
||||
|
||||
// Portomraadet kommer fra dokumentets egen adresse, sat af background.js ud fra chrome.storage.local.
|
||||
// Standarden er 9876-9895. En isoleret testbrowser kan flytte den UDEN at repoets filer aendrer sig, saa
|
||||
// kode-aftrykket - og dermed udgivelsens spaerre - er uroert. Vagten staar begge steder: en vaerdi er kun gyldig
|
||||
// hvis den er to tal i et fornuftigt spaend. (Astra 12/9.)
|
||||
function portOmraade() {
|
||||
try {
|
||||
const v = new URLSearchParams(location.search).get('porte');
|
||||
const m = /^(\d{4,5})-(\d{4,5})$/.exec(v || '');
|
||||
if (m) {
|
||||
const fra = Number(m[1]), til = Number(m[2]);
|
||||
if (fra >= 1024 && til <= 65535 && til >= fra && til - fra < 200) return [fra, til];
|
||||
}
|
||||
} catch {}
|
||||
return [9876, 9895];
|
||||
}
|
||||
const [BASE_PORT, MAX_PORT] = portOmraade();
|
||||
const connections = new Map(); // port → WebSocket
|
||||
|
||||
// ── Hvorfor porte foerst PROBES med fetch, og ikke bare aabnes (MAALT 22/8) ──────
|
||||
//
|
||||
// Chrome bremser nye WebSocket-haandtryk. Formlen staar i Chromiums egen kilde
|
||||
// (services/network/websocket_throttler.cc):
|
||||
//
|
||||
// forsinkelse = rand(1000..5000) ms × 2^min(p + f/(s+1), 16) / 65536
|
||||
//
|
||||
// p = haandtryk i luften lige nu · f = mislykkede · s = lykkedes. Bremsen er
|
||||
// PER RENDERER-PROCES, ikke per adresse — at sprede sig over 20 porte hjaelper
|
||||
// altsaa ingenting. Og den her funktion aabnede foer 20 WebSockets i ét smaek
|
||||
// hvert 2. sekund, hvoraf de fleste var mod doede porte. Det satte baade p og f
|
||||
// i vejret og laaste eksponenten paa loftet, saa HVER forbindelse betalte den
|
||||
// maksimale straf paa op til 5 sekunder.
|
||||
//
|
||||
// Dertil kom vores egen faelde: connectTimeout stod paa 2000 ms — UNDER bremsens
|
||||
// maksimum. Vi draebte altsaa systematisk forbindelser der bare stod og ventede,
|
||||
// og Chromiums header siger det rent ud: at destruere en PendingConnection uden
|
||||
// at haandtrykket er fuldfoert TAELLER SOM EN FEJL. Hvert drab gjorde bremsen
|
||||
// haardere, hvilket draebte flere. En spiral vi selv drev.
|
||||
//
|
||||
// Maalt effekt: den 10. chat var 15,5 sekunder om at komme op, og 5 af 19 kom
|
||||
// aldrig inden for 22 sekunder.
|
||||
//
|
||||
// Rettelsen er to ting:
|
||||
// 1. Find levende porte med et almindeligt HTTP-kald. En ws-server svarer
|
||||
// "426 Upgrade Required" paa et GET; en doed port afviser. HTTP-kald taeller
|
||||
// IKKE med i WebSocket-bremsen — maalt: 100 fejlede HTTP-probes kostede nul,
|
||||
// 400 fejlede WS-forsoeg kostede 14 sekunder. Vi aabner nu kun WebSockets
|
||||
// mod porte vi VED der sidder en server paa, saa f falder til ~0.
|
||||
// 2. connectTimeout haevet over bremsens 5-sekunders loft, saa vi ikke laengere
|
||||
// draeber vores egne ventende forbindelser.
|
||||
//
|
||||
// Sidegevinst: det er sikrere. Foer aabnede udvidelsen blindt en WebSocket mod
|
||||
// hvad der nu maatte lytte paa 9876-9895.
|
||||
const PROBE_TIMEOUT_MS = 400;
|
||||
let skanner = false;
|
||||
|
||||
async function harServer(port) {
|
||||
try {
|
||||
const svar = await fetch(`http://127.0.0.1:${port}/`, {
|
||||
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||
cache: 'no-store',
|
||||
});
|
||||
return svar.status === 426; // ws-serverens svar paa et almindeligt GET
|
||||
} catch {
|
||||
return false; // afvist, timeout eller ingen der lytter
|
||||
}
|
||||
}
|
||||
|
||||
async function scanPorts() {
|
||||
if (skanner) return; // skanningen er nu asynkron; undgaa overlap
|
||||
skanner = true;
|
||||
try {
|
||||
const kandidater = [];
|
||||
for (let port = BASE_PORT; port <= MAX_PORT; port++) {
|
||||
const existing = connections.get(port);
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
continue;
|
||||
}
|
||||
kandidater.push(port);
|
||||
}
|
||||
if (!kandidater.length) return;
|
||||
|
||||
// Probes koeres parallelt — de er gratis i bremsens regnskab.
|
||||
const levende = await Promise.all(
|
||||
kandidater.map(async (port) => (await harServer(port)) ? port : null),
|
||||
);
|
||||
for (const port of levende) {
|
||||
if (port !== null) tryConnect(port);
|
||||
}
|
||||
} finally {
|
||||
skanner = false;
|
||||
}
|
||||
}
|
||||
|
||||
function tryConnect(port) {
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// MAALT 21/8: en og samme udvidelse stod med TO aabne forbindelser til den samme
|
||||
// server. Aarsagen laa her: forbindelsen blev foerst skrevet i kortet i onopen.
|
||||
// scanPorts koerer hvert 2. sekund og springer kun over hvis kortet allerede har
|
||||
// en forbindelse der er OPEN eller CONNECTING — men i vinduet mellem `new WebSocket`
|
||||
// og onopen stod der intet i kortet. Naeste scan lavede derfor endnu en. Den foerste
|
||||
// blev forældreloes: aldrig lukket, aldrig i kortet, men fuldt aaben.
|
||||
// Registrering med det samme lukker vinduet.
|
||||
connections.set(port, ws);
|
||||
|
||||
// 8000, ikke 2000: Chromes bremse kan lovligt holde et haandtryk i op til 5000 ms,
|
||||
// og at lukke ned foer det taeller som en fejl der goer bremsen haardere. Se blokken
|
||||
// over scanPorts. En haengende port koster nu en plads i 8 sekunder — scanPorts
|
||||
// springer allerede CONNECTING over, saa det blokerer intet.
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) ws.close();
|
||||
}, 8000);
|
||||
|
||||
ws.onopen = async () => {
|
||||
clearTimeout(connectTimeout);
|
||||
connections.set(port, ws); // stadig vores? scanPorts har ikke lavet en nyere
|
||||
|
||||
// ── Identitets-haandtryk (MAALT 21/8) ──────────────────────────────────
|
||||
// To udgaver af udvidelsen kan vaere indlaest i den samme Chrome samtidig —
|
||||
// fx en "load unpacked"-kopi ved siden af en anden. Begge scanner de samme
|
||||
// porte, saa BEGGE forbinder til hver eneste MCP-server. Serveren havde kun
|
||||
// én socket-variabel, som hver ny forbindelse overskrev, saa kommandoerne
|
||||
// landede hos den der forbandt sidst — vilkaarligt hvilken af de to. Den
|
||||
// anden fortsatte med sit eget sessions-kort og sine egne fane-grupper.
|
||||
// Haandtrykket giver serveren det den mangler for at kunne se at der er to,
|
||||
// vaelge den nyeste, og sige det hoejt i stedet for at gaette i tavshed.
|
||||
// MAALT 22/8 mod en aegte Chrome: haandtrykket ankom ALDRIG, selv om fetch-probet
|
||||
// — som ligger i samme fil og samme dokument — virkede fint. Den tomme `catch {}`
|
||||
// slugte aarsagen i tavshed, saa fejlen var usynlig baade for serveren og for os.
|
||||
// To rettelser: haandtrykket sendes nu UANSET om manifest-opslaget lykkes (det er
|
||||
// selve beskeden serveren har brug for, ikke felterne i den), og en fejl bliver
|
||||
// logget i stedet for at forsvinde.
|
||||
// MAALT 11/9 af Astra (e2e runde 2): haenger hentningen af background.js, blev haandtrykket ALDRIG sendt - og
|
||||
// serveren saa en forbindelse uden version. Aftrykket er en bekvemmelighed for udgivelsens gate; hilsenen er ikke.
|
||||
// MAALT 12/9 af Astra (efterproevning): fristen paa 1 s var et kaploeb. Tog hentningen 1,3 s, sendte vi `kode: null`,
|
||||
// og udgivelsens gate afviste sin EGEN kandidat - et langsomt svar er ikke et forkert svar. Hilsenen sendes derfor
|
||||
// med det aftryk der allerede ER beregnet (som regel intet ved foerste forbindelse), og aftrykket EFTERSENDES.
|
||||
const kode = kodeAftrykCache;
|
||||
let hilsen = { type: 'hello', extensionId: null, version: minVersion(), name: null, kode, noegle: parringsnoegle };
|
||||
try {
|
||||
hilsen = { type: 'hello', extensionId: chrome.runtime.id, version: minVersion(), name: null, kode, noegle: parringsnoegle };
|
||||
} catch (e) {
|
||||
console.warn('[Offscreen] could not build the handshake:', e?.message || e);
|
||||
}
|
||||
try {
|
||||
ws.send(JSON.stringify(hilsen));
|
||||
} catch (e) {
|
||||
console.warn('[Offscreen] kunne ikke sende haandtrykket:', e?.message || e);
|
||||
}
|
||||
|
||||
// Eftersendelsen: naar aftrykket er beregnet, faar serveren det - uanset hvor lang tid hentningen tog.
|
||||
if (!kode) {
|
||||
kodeAftryk().then((k) => {
|
||||
if (!k || ws.readyState !== WebSocket.OPEN) return;
|
||||
try { ws.send(JSON.stringify({ type: 'kode', kode: k })); }
|
||||
catch (e) { console.warn('[Offscreen] kunne ikke eftersende kode-aftrykket:', e?.message || e); }
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Offscreen] Connected to MCP server on port ${port} (${connections.size} total)`);
|
||||
updateStatus();
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
let cmd;
|
||||
try { cmd = JSON.parse(event.data); } catch { return; }
|
||||
|
||||
// Parringen gaar begge veje. Serveren kvitterer med den noegle den selv kender, saa
|
||||
// udvidelsen kan se at den taler med SIN server - ikke med et vilkaarligt program der
|
||||
// naaede at lytte paa porten foerst. Uden noegle findes beskeden slet ikke.
|
||||
if (cmd && cmd.type === 'parring') {
|
||||
if (cmd.ok === true && parringsnoegle && cmd.noegle === parringsnoegle) {
|
||||
parrede.add(ws);
|
||||
} else {
|
||||
console.warn('[Offscreen] The server on port ' + port + ' does not know the pairing key - closing.');
|
||||
try { ws.close(); } catch (e) { /* lukket */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, method, params, pid } = cmd;
|
||||
|
||||
// Er der sat en noegle, udfoeres INTET foer serveren har kvitteret med den. En server
|
||||
// uden noegle kvitterer aldrig, og kan derfor ikke styre en parret browser.
|
||||
if (parringsnoegle && !parrede.has(ws)) {
|
||||
try { ws.send(JSON.stringify({ id, error: 'This extension is paired with a different server (pairing key).' })); } catch (e) { /* lukket */ }
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Include port so background.js knows which session owns this command
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: 'mcp_command',
|
||||
port,
|
||||
pid,
|
||||
method,
|
||||
params: params || {},
|
||||
});
|
||||
|
||||
if (result && result.__error) {
|
||||
ws.send(JSON.stringify({ id, error: result.__error }));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ id, result }));
|
||||
}
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ id, error: err.message || String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeout(connectTimeout);
|
||||
if (connections.get(port) === ws) {
|
||||
connections.delete(port);
|
||||
console.log(`[Offscreen] Disconnected from port ${port} (${connections.size} remaining)`);
|
||||
updateStatus();
|
||||
// Notify background to release tabs for this session
|
||||
chrome.runtime.sendMessage({ type: 'session_disconnect', port }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(connectTimeout);
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
const count = connections.size;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'ws_status',
|
||||
connected: count > 0,
|
||||
count,
|
||||
ports: [...connections.keys()],
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Svar paa hjerteslaget. Uden dette svar kan background.js ikke skelne et levende
|
||||
// dokument fra et der findes men aldrig fik sit script indlaest — og saa bliver et
|
||||
// doedt dokument aldrig erstattet.
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type !== 'bmcp_ping') return;
|
||||
// MAALT 22/8: svaret var bare { ok: true }. Det fortalte om broen var I LIVE, ikke
|
||||
// om den var OPDATERET — og det er to forskellige spoergsmaal. En bro fra en aeldre
|
||||
// udgave svarer lige saa villigt paa ping, saa ensureOffscreen regnede den for rask
|
||||
// og udskiftede den aldrig. Resultatet: kode-aendringer slog aldrig igennem uden en
|
||||
// fuld genstart af Chrome, heller ikke efter "Genindlaes" paa chrome://extensions.
|
||||
// Versionen med i svaret goer forskellen synlig.
|
||||
sendResponse({ ok: true, version: minVersion() });
|
||||
return true;
|
||||
});
|
||||
|
||||
// Listen for terminate signals from background.js (sent when last tab in a session closes)
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type !== 'terminate_mcp_session' || typeof msg.port !== 'number') return;
|
||||
const ws = connections.get(msg.port);
|
||||
if (!ws) return;
|
||||
try {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'terminate' }));
|
||||
}
|
||||
} catch {}
|
||||
try { ws.close(); } catch {}
|
||||
// ws.onclose handler removes from connections + notifies background
|
||||
});
|
||||
|
||||
// Initial scan + frequent rescan for new servers
|
||||
scanPorts();
|
||||
setInterval(scanPorts, 2000);
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { width: 320px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 16px; }
|
||||
h1 { font-size: 13px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
h1 span { color: #3b82f6; }
|
||||
.status { display: flex; align-items: center; gap: 8px; padding: 10px; border-radius: 8px; background: #1e293b; margin-bottom: 8px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.on { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
|
||||
.dot.off { background: #ef4444; }
|
||||
.label { font-size: 12px; }
|
||||
.section-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #475569; margin: 10px 0 4px; font-weight: 600; }
|
||||
.sessions { display: flex; flex-direction: column; gap: 6px; }
|
||||
.session-card { padding: 8px 10px; background: #1e293b; border-radius: 8px; border-left: 3px solid; }
|
||||
.session-header { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
|
||||
.session-tabs { font-size: 10px; color: #94a3b8; }
|
||||
.session-tabs div { margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty { font-size: 11px; color: #475569; text-align: center; padding: 12px; background: #1e293b; border-radius: 8px; }
|
||||
.log { max-height: 120px; overflow-y: auto; background: #1e293b; border-radius: 8px; padding: 6px 10px; }
|
||||
.log-entry { font-size: 10px; padding: 2px 0; border-bottom: 1px solid #0f172a; display: flex; gap: 6px; align-items: baseline; }
|
||||
.log-entry:last-child { border-bottom: none; }
|
||||
.log-time { color: #475569; min-width: 45px; }
|
||||
.log-method { font-weight: 500; }
|
||||
.log-session { font-size: 9px; color: #64748b; }
|
||||
.log-safe { color: #22c55e; }
|
||||
.log-sensitive { color: #f59e0b; }
|
||||
button { width: 100%; padding: 7px; border: none; border-radius: 6px; background: #1e293b; color: #94a3b8; font-size: 11px; cursor: pointer; margin-top: 4px; }
|
||||
button:hover { background: #334155; color: #e2e8f0; }
|
||||
.color-blue { border-color: #3b82f6; } .color-blue .session-header { color: #3b82f6; }
|
||||
.color-green { border-color: #22c55e; } .color-green .session-header { color: #22c55e; }
|
||||
.color-yellow { border-color: #eab308; } .color-yellow .session-header { color: #eab308; }
|
||||
.color-red { border-color: #ef4444; } .color-red .session-header { color: #ef4444; }
|
||||
.color-pink { border-color: #ec4899; } .color-pink .session-header { color: #ec4899; }
|
||||
.color-purple { border-color: #a855f7; } .color-purple .session-header { color: #a855f7; }
|
||||
.color-cyan { border-color: #06b6d4; } .color-cyan .session-header { color: #06b6d4; }
|
||||
.color-orange { border-color: #f97316; } .color-orange .session-header { color: #f97316; }
|
||||
/* Shown only while the MCP server is missing - the store can't install it for you */
|
||||
.setup { display: none; background: #1e293b; border: 1px solid #334155; border-left: 3px solid #f59e0b; border-radius: 8px; padding: 10px; margin-bottom: 8px; }
|
||||
.setup.show { display: block; }
|
||||
.setup p { font-size: 11px; line-height: 1.5; color: #94a3b8; margin-bottom: 8px; }
|
||||
.setup p b { color: #e2e8f0; }
|
||||
.setup code { display: block; background: #0f172a; border-radius: 6px; padding: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; color: #22c55e; word-break: break-all; margin-bottom: 6px; }
|
||||
/* De andre klienters kommandoer staar inde i en saetning - de maa ikke blive til blokke som hovedkommandoen. */
|
||||
.setup p.clients code { display: inline; padding: 1px 5px; margin: 0; font-size: 10px; }
|
||||
.setup .row { display: flex; gap: 6px; }
|
||||
.setup .row button, .setup .row a { flex: 1; margin: 0; padding: 6px; border-radius: 6px; background: #334155; color: #e2e8f0; font-size: 11px; text-align: center; text-decoration: none; border: none; cursor: pointer; }
|
||||
.setup .row a:hover, .setup .row button:hover { background: #475569; }
|
||||
/* Shown once both halves are up but the agent has not touched a tab yet - the
|
||||
moment a new user checks "did it work?" and needs to know what to say next. */
|
||||
.try { display: none; background: #1e293b; border: 1px solid #334155; border-left: 3px solid #22c55e; border-radius: 8px; padding: 10px; margin-bottom: 8px; }
|
||||
.try.show { display: block; }
|
||||
.try p { font-size: 11px; line-height: 1.5; color: #94a3b8; margin-bottom: 8px; }
|
||||
.try p b { color: #e2e8f0; }
|
||||
.try .prompt { display: block; background: #0f172a; border-radius: 6px; padding: 8px; font-size: 11px; color: #e2e8f0; margin-bottom: 6px; font-style: italic; }
|
||||
.try a { display: block; text-align: center; padding: 6px; border-radius: 6px; background: #334155; color: #e2e8f0; font-size: 11px; text-decoration: none; }
|
||||
.try a:hover { background: #475569; }
|
||||
.pairing p { margin: 0 0 6px; font-size: 11px; color: #6b7280; line-height: 1.45; }
|
||||
.pairing .row { display: flex; gap: 6px; align-items: center; }
|
||||
.pairing input { flex: 1; min-width: 0; padding: 5px 7px; border: 1px solid #d1d5db; border-radius: 5px; font: inherit; font-size: 12px; }
|
||||
.pairing button { width: auto; margin: 0; }
|
||||
.pairCmd { display: block; margin-top: 6px; }
|
||||
#copyPair { margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><span>Agent360</span> Browser MCP</h1>
|
||||
|
||||
<div class="status">
|
||||
<div class="dot off" id="dot"></div>
|
||||
<span class="label" id="label">Checking…</span>
|
||||
</div>
|
||||
|
||||
<div class="setup" id="setup">
|
||||
<p><b>Already registered the server?</b> Then this is normal: the server only takes a port the first time your agent actually uses the browser. Ask it for a screenshot once, and this turns into "Connected".</p>
|
||||
<p><b>Otherwise: this extension is only half of Browser MCP.</b> It needs a local MCP server to talk to - the Chrome Web Store cannot install that part. Register it once, then restart your agent. Claude Code:</p>
|
||||
<code id="setupCmd">claude mcp add --scope user browser-mcp -- npx @agent360/browser-mcp@latest</code>
|
||||
<p class="clients">Codex: <code>codex mcp add browser-mcp -- npx @agent360/browser-mcp@latest</code><br>
|
||||
Cursor, VS Code and the rest: run <code>npx @agent360/browser-mcp install</code> - it registers with the clients you have.</p>
|
||||
<div class="row">
|
||||
<button id="copyCmd">Copy command</button>
|
||||
<a href="https://browsermcp.dev/docs/install-claude-code/" target="_blank" rel="noopener">Setup guide</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="try" id="try">
|
||||
<p><b>Connected - you're ready.</b> Nothing happens until you ask your agent for something. Try saying:</p>
|
||||
<span class="prompt">"Take a screenshot of my current Chrome tab."</span>
|
||||
<span class="prompt">"Open my Gmail tab and tell me who sent my last 3 emails."</span>
|
||||
<a href="https://browsermcp.dev/#try" target="_blank" rel="noopener">More things to try</a>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Pairing (optional)</div>
|
||||
<div class="pairing" id="pairing">
|
||||
<p>Two Chrome profiles, two agents? Give each profile a key, and each server the same one. Then a profile only takes commands from its own server. Leave empty for the default: no key, no setup.</p>
|
||||
<div class="row">
|
||||
<input id="pairKey" type="text" spellcheck="false" autocomplete="off" placeholder="e.g. work-laptop">
|
||||
<button id="savePair">Save</button>
|
||||
</div>
|
||||
<code id="pairCmd" class="pairCmd" hidden></code>
|
||||
<button id="copyPair" hidden>Copy the server command</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Sessions</div>
|
||||
<div id="sessions" class="sessions">
|
||||
<div class="empty">No tabs claimed yet</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Action Log</div>
|
||||
<div class="log" id="log">
|
||||
<div class="empty">No actions yet</div>
|
||||
</div>
|
||||
|
||||
<button id="reconnect">Reconnect</button>
|
||||
<button id="clearLog" style="margin-top:4px">Clear log</button>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
// ── Status ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// mcpCount = agent clients on the socket. `sessions` = agents that have actually claimed a
|
||||
// tab. They differ, and showing "Connected - 1 session" above "No active sessions" read as a
|
||||
// contradiction at exactly the moment a new user checks whether the install worked.
|
||||
function paintStatus(connected, count, sessionCount) {
|
||||
document.getElementById('dot').className = `dot ${connected ? 'on' : 'off'}`;
|
||||
document.getElementById('label').textContent = !connected
|
||||
? 'Not connected - nothing on ports 9876-9895 yet'
|
||||
: sessionCount > 0
|
||||
? `Connected - ${sessionCount} active session${sessionCount === 1 ? '' : 's'}`
|
||||
: `Connected - ready (${count} agent${count === 1 ? '' : 's'} listening)`;
|
||||
// The Chrome Web Store can only install the extension. If nothing is listening on
|
||||
// ports 9876-9895, the user almost certainly never ran the npx install - say so.
|
||||
document.getElementById('setup').classList.toggle('show', !connected);
|
||||
// Connected but idle: they got it working and now need to know what to say.
|
||||
document.getElementById('try').classList.toggle('show', connected && sessionCount === 0);
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
chrome.storage.local.get({ mcpConnected: false, mcpCount: 0, sessions: {} }, (result) => {
|
||||
paintStatus(result.mcpConnected === true, result.mcpCount || 0,
|
||||
Object.keys(result.sessions || {}).length);
|
||||
});
|
||||
}
|
||||
|
||||
refreshStatus();
|
||||
|
||||
// ── Sessions with tabs ─────────────────────────────────────────────────────
|
||||
|
||||
function renderSessions() {
|
||||
chrome.storage.local.get({ sessions: {} }, ({ sessions }) => {
|
||||
const container = document.getElementById('sessions');
|
||||
const entries = Object.entries(sessions);
|
||||
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<div class="empty">No tabs claimed yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch tab info for each session
|
||||
const promises = entries.map(async ([port, session]) => {
|
||||
const tabInfos = [];
|
||||
for (const tabId of session.tabIds || []) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const url = tab.url || '';
|
||||
const display = url.length > 40 ? url.slice(0, 40) + '…' : url;
|
||||
tabInfos.push(display);
|
||||
} catch {}
|
||||
}
|
||||
return { port, session, tabInfos };
|
||||
});
|
||||
|
||||
Promise.all(promises).then(results => {
|
||||
container.innerHTML = results.map(({ port, session, tabInfos }) => {
|
||||
const color = session.color || 'blue';
|
||||
const tabHtml = tabInfos.length
|
||||
? tabInfos.map(u => `<div>• ${u}</div>`).join('')
|
||||
: '<div>No tabs</div>';
|
||||
return `
|
||||
<div class="session-card color-${color}">
|
||||
<div class="session-header">${session.label} <span style="font-weight:normal;font-size:10px;color:#64748b">port ${port}</span></div>
|
||||
<div class="session-tabs">${tabHtml}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderSessions();
|
||||
|
||||
// ── Action Log ──────────────────────────────────────────────────────────────
|
||||
|
||||
function renderLog() {
|
||||
chrome.storage.local.get({ actionLog: [] }, ({ actionLog }) => {
|
||||
const container = document.getElementById('log');
|
||||
if (!actionLog.length) {
|
||||
container.innerHTML = '<div class="empty">No actions yet</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = actionLog.slice(0, 30).map(entry => {
|
||||
const time = new Date(entry.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const cat = entry.category || 'safe';
|
||||
const cls = cat === 'sensitive' ? 'log-sensitive' : 'log-safe';
|
||||
return `<div class="log-entry"><span class="log-time">${time}</span><span class="log-method ${cls}">${entry.method}</span><span class="log-session">${entry.session || ''}</span></div>`;
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
renderLog();
|
||||
|
||||
// ── Buttons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('reconnect').addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'reconnect' });
|
||||
document.getElementById('label').textContent = 'Reconnecting…';
|
||||
setTimeout(() => {
|
||||
refreshStatus();
|
||||
renderSessions();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
document.getElementById('copyCmd').addEventListener('click', (e) => {
|
||||
navigator.clipboard.writeText(document.getElementById('setupCmd').textContent.trim());
|
||||
e.target.textContent = 'Copied!';
|
||||
setTimeout(() => { e.target.textContent = 'Copy command'; }, 1500);
|
||||
});
|
||||
|
||||
document.getElementById('clearLog').addEventListener('click', () => {
|
||||
chrome.storage.local.set({ actionLog: [] }, renderLog);
|
||||
});
|
||||
|
||||
// ── Parring (issue #10) ──────────────────────────────────────────────────────
|
||||
// Feltet er tomt som standard, og tomt betyder praecis det der sker i dag: ingen noegle,
|
||||
// ingen opsaetning. Skriver man én, vises den kommando serveren skal startes med - saa
|
||||
// noeglen ikke skal skrives af i haanden to steder.
|
||||
|
||||
const pairKey = document.getElementById('pairKey');
|
||||
const pairCmd = document.getElementById('pairCmd');
|
||||
const copyPair = document.getElementById('copyPair');
|
||||
|
||||
function visPairCmd(noegle) {
|
||||
const har = Boolean(noegle);
|
||||
pairCmd.hidden = !har;
|
||||
copyPair.hidden = !har;
|
||||
if (har) pairCmd.textContent = `BROWSER_MCP_TOKEN=${noegle} npx @agent360/browser-mcp@latest`;
|
||||
}
|
||||
|
||||
chrome.storage.local.get('parringsnoegle', (v) => {
|
||||
const noegle = (v && v.parringsnoegle) || '';
|
||||
pairKey.value = noegle;
|
||||
visPairCmd(noegle);
|
||||
});
|
||||
|
||||
document.getElementById('savePair').addEventListener('click', (e) => {
|
||||
const noegle = pairKey.value.trim();
|
||||
chrome.storage.local.set({ parringsnoegle: noegle }, () => {
|
||||
visPairCmd(noegle);
|
||||
e.target.textContent = 'Saved';
|
||||
setTimeout(() => { e.target.textContent = 'Save'; }, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
copyPair.addEventListener('click', (e) => {
|
||||
navigator.clipboard.writeText(pairCmd.textContent.trim());
|
||||
e.target.textContent = 'Copied!';
|
||||
setTimeout(() => { e.target.textContent = 'Copy the server command'; }, 1500);
|
||||
});
|
||||
Reference in New Issue
Block a user