@agent360/browser-mcp 1.13.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/README.md +169 -0
- package/bin/cli.js +90 -0
- package/extension/background.js +1281 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +40 -0
- package/extension/offscreen.html +6 -0
- package/extension/offscreen.js +97 -0
- package/extension/popup.html +65 -0
- package/extension/popup.js +96 -0
- package/index.js +336 -0
- package/package.json +42 -0
- package/tools.js +319 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Agent360 Browser MCP",
|
|
4
|
+
"version": "1.13.0",
|
|
5
|
+
"description": "Lader Claude Code styre din browser — forbind tjenester, hent tokens, automatisér workflows. Multi-session support.",
|
|
6
|
+
"permissions": [
|
|
7
|
+
"tabs",
|
|
8
|
+
"tabGroups",
|
|
9
|
+
"cookies",
|
|
10
|
+
"scripting",
|
|
11
|
+
"activeTab",
|
|
12
|
+
"storage",
|
|
13
|
+
"alarms",
|
|
14
|
+
"offscreen",
|
|
15
|
+
"notifications",
|
|
16
|
+
"webNavigation",
|
|
17
|
+
"debugger"
|
|
18
|
+
],
|
|
19
|
+
"host_permissions": [
|
|
20
|
+
"<all_urls>"
|
|
21
|
+
],
|
|
22
|
+
"icons": {
|
|
23
|
+
"16": "icons/icon-16.png",
|
|
24
|
+
"48": "icons/icon-48.png",
|
|
25
|
+
"128": "icons/icon-128.png"
|
|
26
|
+
},
|
|
27
|
+
"content_security_policy": {
|
|
28
|
+
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:*"
|
|
29
|
+
},
|
|
30
|
+
"background": {
|
|
31
|
+
"service_worker": "background.js"
|
|
32
|
+
},
|
|
33
|
+
"action": {
|
|
34
|
+
"default_popup": "popup.html",
|
|
35
|
+
"default_icon": {
|
|
36
|
+
"16": "icons/icon-16.png",
|
|
37
|
+
"48": "icons/icon-48.png"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offscreen Document — Persistent WebSocket bridge (multi-session)
|
|
3
|
+
*
|
|
4
|
+
* Scans port range 9876-9885 and maintains connections to ALL active
|
|
5
|
+
* MCP servers. Each Claude Code session gets its own port automatically.
|
|
6
|
+
* Passes port ID with every command so background.js can track tab ownership.
|
|
7
|
+
*
|
|
8
|
+
* Flow: MCP Server(s) ←(WS)→ this ←(chrome.runtime.sendMessage)→ Service Worker → Chrome APIs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const BASE_PORT = 9876;
|
|
12
|
+
const MAX_PORT = 9885;
|
|
13
|
+
const connections = new Map(); // port → WebSocket
|
|
14
|
+
|
|
15
|
+
function scanPorts() {
|
|
16
|
+
for (let port = BASE_PORT; port <= MAX_PORT; port++) {
|
|
17
|
+
const existing = connections.get(port);
|
|
18
|
+
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
tryConnect(port);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tryConnect(port) {
|
|
26
|
+
let ws;
|
|
27
|
+
try {
|
|
28
|
+
ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const connectTimeout = setTimeout(() => {
|
|
34
|
+
if (ws.readyState !== WebSocket.OPEN) ws.close();
|
|
35
|
+
}, 2000);
|
|
36
|
+
|
|
37
|
+
ws.onopen = () => {
|
|
38
|
+
clearTimeout(connectTimeout);
|
|
39
|
+
connections.set(port, ws);
|
|
40
|
+
console.log(`[Offscreen] Connected to MCP server on port ${port} (${connections.size} total)`);
|
|
41
|
+
updateStatus();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
ws.onmessage = async (event) => {
|
|
45
|
+
let cmd;
|
|
46
|
+
try { cmd = JSON.parse(event.data); } catch { return; }
|
|
47
|
+
const { id, method, params } = cmd;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Include port so background.js knows which session owns this command
|
|
51
|
+
const result = await chrome.runtime.sendMessage({
|
|
52
|
+
type: 'mcp_command',
|
|
53
|
+
port,
|
|
54
|
+
method,
|
|
55
|
+
params: params || {},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (result && result.__error) {
|
|
59
|
+
ws.send(JSON.stringify({ id, error: result.__error }));
|
|
60
|
+
} else {
|
|
61
|
+
ws.send(JSON.stringify({ id, result }));
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
ws.send(JSON.stringify({ id, error: err.message || String(err) }));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
ws.onclose = () => {
|
|
69
|
+
clearTimeout(connectTimeout);
|
|
70
|
+
if (connections.get(port) === ws) {
|
|
71
|
+
connections.delete(port);
|
|
72
|
+
console.log(`[Offscreen] Disconnected from port ${port} (${connections.size} remaining)`);
|
|
73
|
+
updateStatus();
|
|
74
|
+
// Notify background to release tabs for this session
|
|
75
|
+
chrome.runtime.sendMessage({ type: 'session_disconnect', port }).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
ws.onerror = () => {
|
|
80
|
+
clearTimeout(connectTimeout);
|
|
81
|
+
ws.close();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function updateStatus() {
|
|
86
|
+
const count = connections.size;
|
|
87
|
+
chrome.runtime.sendMessage({
|
|
88
|
+
type: 'ws_status',
|
|
89
|
+
connected: count > 0,
|
|
90
|
+
count,
|
|
91
|
+
ports: [...connections.keys()],
|
|
92
|
+
}).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Initial scan + frequent rescan for new servers
|
|
96
|
+
scanPorts();
|
|
97
|
+
setInterval(scanPorts, 2000);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
7
|
+
body { width: 320px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 16px; }
|
|
8
|
+
h1 { font-size: 13px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
|
9
|
+
h1 span { color: #3b82f6; }
|
|
10
|
+
.status { display: flex; align-items: center; gap: 8px; padding: 10px; border-radius: 8px; background: #1e293b; margin-bottom: 8px; }
|
|
11
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
12
|
+
.dot.on { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
|
|
13
|
+
.dot.off { background: #ef4444; }
|
|
14
|
+
.label { font-size: 12px; }
|
|
15
|
+
.section-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #475569; margin: 10px 0 4px; font-weight: 600; }
|
|
16
|
+
.sessions { display: flex; flex-direction: column; gap: 6px; }
|
|
17
|
+
.session-card { padding: 8px 10px; background: #1e293b; border-radius: 8px; border-left: 3px solid; }
|
|
18
|
+
.session-header { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
|
|
19
|
+
.session-tabs { font-size: 10px; color: #94a3b8; }
|
|
20
|
+
.session-tabs div { margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
21
|
+
.empty { font-size: 11px; color: #475569; text-align: center; padding: 12px; background: #1e293b; border-radius: 8px; }
|
|
22
|
+
.log { max-height: 120px; overflow-y: auto; background: #1e293b; border-radius: 8px; padding: 6px 10px; }
|
|
23
|
+
.log-entry { font-size: 10px; padding: 2px 0; border-bottom: 1px solid #0f172a; display: flex; gap: 6px; align-items: baseline; }
|
|
24
|
+
.log-entry:last-child { border-bottom: none; }
|
|
25
|
+
.log-time { color: #475569; min-width: 45px; }
|
|
26
|
+
.log-method { font-weight: 500; }
|
|
27
|
+
.log-session { font-size: 9px; color: #64748b; }
|
|
28
|
+
.log-safe { color: #22c55e; }
|
|
29
|
+
.log-sensitive { color: #f59e0b; }
|
|
30
|
+
button { width: 100%; padding: 7px; border: none; border-radius: 6px; background: #1e293b; color: #94a3b8; font-size: 11px; cursor: pointer; margin-top: 4px; }
|
|
31
|
+
button:hover { background: #334155; color: #e2e8f0; }
|
|
32
|
+
.color-blue { border-color: #3b82f6; } .color-blue .session-header { color: #3b82f6; }
|
|
33
|
+
.color-green { border-color: #22c55e; } .color-green .session-header { color: #22c55e; }
|
|
34
|
+
.color-yellow { border-color: #eab308; } .color-yellow .session-header { color: #eab308; }
|
|
35
|
+
.color-red { border-color: #ef4444; } .color-red .session-header { color: #ef4444; }
|
|
36
|
+
.color-pink { border-color: #ec4899; } .color-pink .session-header { color: #ec4899; }
|
|
37
|
+
.color-purple { border-color: #a855f7; } .color-purple .session-header { color: #a855f7; }
|
|
38
|
+
.color-cyan { border-color: #06b6d4; } .color-cyan .session-header { color: #06b6d4; }
|
|
39
|
+
.color-orange { border-color: #f97316; } .color-orange .session-header { color: #f97316; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<h1><span>Agent360</span> Browser MCP</h1>
|
|
44
|
+
|
|
45
|
+
<div class="status">
|
|
46
|
+
<div class="dot off" id="dot"></div>
|
|
47
|
+
<span class="label" id="label">Tjekker...</span>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="section-title">Sessions</div>
|
|
51
|
+
<div id="sessions" class="sessions">
|
|
52
|
+
<div class="empty">Ingen aktive sessions</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="section-title">Action Log</div>
|
|
56
|
+
<div class="log" id="log">
|
|
57
|
+
<div class="empty">Ingen actions endnu</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<button id="reconnect">Reconnect</button>
|
|
61
|
+
<button id="clearLog" style="margin-top:4px">Ryd log</button>
|
|
62
|
+
|
|
63
|
+
<script src="popup.js"></script>
|
|
64
|
+
</body>
|
|
65
|
+
</html>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ── Status ──────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
chrome.storage.local.get(['mcpConnected', 'mcpCount', 'mcpPorts'], (result) => {
|
|
4
|
+
const connected = result.mcpConnected === true;
|
|
5
|
+
const count = result.mcpCount || 0;
|
|
6
|
+
document.getElementById('dot').className = `dot ${connected ? 'on' : 'off'}`;
|
|
7
|
+
document.getElementById('label').textContent = connected
|
|
8
|
+
? `Forbundet til ${count} session${count > 1 ? 's' : ''}`
|
|
9
|
+
: 'Ikke forbundet';
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// ── Sessions with tabs ─────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function renderSessions() {
|
|
15
|
+
chrome.storage.local.get({ sessions: {} }, ({ sessions }) => {
|
|
16
|
+
const container = document.getElementById('sessions');
|
|
17
|
+
const entries = Object.entries(sessions);
|
|
18
|
+
|
|
19
|
+
if (!entries.length) {
|
|
20
|
+
container.innerHTML = '<div class="empty">Ingen aktive sessions</div>';
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fetch tab info for each session
|
|
25
|
+
const promises = entries.map(async ([port, session]) => {
|
|
26
|
+
const tabInfos = [];
|
|
27
|
+
for (const tabId of session.tabIds || []) {
|
|
28
|
+
try {
|
|
29
|
+
const tab = await chrome.tabs.get(tabId);
|
|
30
|
+
const url = tab.url || '';
|
|
31
|
+
const display = url.length > 40 ? url.slice(0, 40) + '…' : url;
|
|
32
|
+
tabInfos.push(display);
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
return { port, session, tabInfos };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
Promise.all(promises).then(results => {
|
|
39
|
+
container.innerHTML = results.map(({ port, session, tabInfos }) => {
|
|
40
|
+
const color = session.color || 'blue';
|
|
41
|
+
const tabHtml = tabInfos.length
|
|
42
|
+
? tabInfos.map(u => `<div>• ${u}</div>`).join('')
|
|
43
|
+
: '<div>Ingen tabs</div>';
|
|
44
|
+
return `
|
|
45
|
+
<div class="session-card color-${color}">
|
|
46
|
+
<div class="session-header">${session.label} <span style="font-weight:normal;font-size:10px;color:#64748b">port ${port}</span></div>
|
|
47
|
+
<div class="session-tabs">${tabHtml}</div>
|
|
48
|
+
</div>`;
|
|
49
|
+
}).join('');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
renderSessions();
|
|
55
|
+
|
|
56
|
+
// ── Action Log ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function renderLog() {
|
|
59
|
+
chrome.storage.local.get({ actionLog: [] }, ({ actionLog }) => {
|
|
60
|
+
const container = document.getElementById('log');
|
|
61
|
+
if (!actionLog.length) {
|
|
62
|
+
container.innerHTML = '<div class="empty">Ingen actions endnu</div>';
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
container.innerHTML = actionLog.slice(0, 30).map(entry => {
|
|
66
|
+
const time = new Date(entry.time).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
67
|
+
const cat = entry.category || 'safe';
|
|
68
|
+
const cls = cat === 'sensitive' ? 'log-sensitive' : 'log-safe';
|
|
69
|
+
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>`;
|
|
70
|
+
}).join('');
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
renderLog();
|
|
75
|
+
|
|
76
|
+
// ── Buttons ─────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
document.getElementById('reconnect').addEventListener('click', () => {
|
|
79
|
+
chrome.runtime.sendMessage({ type: 'reconnect' });
|
|
80
|
+
document.getElementById('label').textContent = 'Reconnecting...';
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
chrome.storage.local.get(['mcpConnected', 'mcpCount'], (result) => {
|
|
83
|
+
const connected = result.mcpConnected === true;
|
|
84
|
+
const count = result.mcpCount || 0;
|
|
85
|
+
document.getElementById('dot').className = `dot ${connected ? 'on' : 'off'}`;
|
|
86
|
+
document.getElementById('label').textContent = connected
|
|
87
|
+
? `Forbundet til ${count} session${count > 1 ? 's' : ''}`
|
|
88
|
+
: 'Ikke forbundet';
|
|
89
|
+
renderSessions();
|
|
90
|
+
});
|
|
91
|
+
}, 3000);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
document.getElementById('clearLog').addEventListener('click', () => {
|
|
95
|
+
chrome.storage.local.set({ actionLog: [] }, renderLog);
|
|
96
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Agent360 Browser MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Bridges Claude Code (stdio MCP) to Chrome Extension (WebSocket).
|
|
6
|
+
* Auto-selects first available port in range 9876-9885 for multi-session support.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* Claude Code ←(stdio)→ this process ←(WS :port)→ Offscreen Doc ←(sendMessage)→ Service Worker → Chrome APIs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import { WebSocketServer } from 'ws';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import { dirname } from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { TOOLS, PROVIDER_PAGES } from './tools.js';
|
|
20
|
+
|
|
21
|
+
// ── Auto-update on startup ─────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const repoDir = dirname(__dirname); // parent of mcp-server/
|
|
25
|
+
|
|
26
|
+
let extensionUpdated = false;
|
|
27
|
+
try {
|
|
28
|
+
const before = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
|
|
29
|
+
execSync('git pull --ff-only 2>/dev/null', { cwd: repoDir, timeout: 10000 });
|
|
30
|
+
const after = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
|
|
31
|
+
if (before !== after) {
|
|
32
|
+
extensionUpdated = true;
|
|
33
|
+
process.stderr.write(`[MCP] Updated to ${after.slice(0, 8)} — extension reload recommended\n`);
|
|
34
|
+
// Check if npm deps changed
|
|
35
|
+
try {
|
|
36
|
+
const diff = execSync(`git diff ${before} ${after} -- mcp-server/package.json`, { cwd: repoDir }).toString();
|
|
37
|
+
if (diff) {
|
|
38
|
+
execSync('npm install --silent', { cwd: `${repoDir}/mcp-server`, timeout: 30000 });
|
|
39
|
+
process.stderr.write('[MCP] Dependencies updated\n');
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
} else {
|
|
43
|
+
process.stderr.write('[MCP] Already up to date\n');
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
process.stderr.write(`[MCP] Auto-update skipped: ${e.message?.split('\n')[0]}\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const BASE_PORT = 9876;
|
|
50
|
+
const MAX_PORT = 9885;
|
|
51
|
+
let extensionSocket = null;
|
|
52
|
+
let activePort = null;
|
|
53
|
+
let wss = null; // Track WSS for graceful shutdown
|
|
54
|
+
let cmdId = 0;
|
|
55
|
+
let lastActivity = Date.now();
|
|
56
|
+
const pending = new Map();
|
|
57
|
+
|
|
58
|
+
// ── WebSocket Server ───────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function createWSS(port = BASE_PORT) {
|
|
61
|
+
const server = new WebSocketServer({ host: '127.0.0.1', port });
|
|
62
|
+
wss = server;
|
|
63
|
+
|
|
64
|
+
server.on('error', (err) => {
|
|
65
|
+
if (err.code === 'EADDRINUSE') {
|
|
66
|
+
if (port < MAX_PORT) {
|
|
67
|
+
process.stderr.write(`[MCP] Port ${port} in use, trying ${port + 1}...\n`);
|
|
68
|
+
createWSS(port + 1);
|
|
69
|
+
} else {
|
|
70
|
+
process.stderr.write(`[MCP] All ports ${BASE_PORT}-${MAX_PORT} in use. Cannot start.\n`);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
process.stderr.write(`[MCP] WebSocket error: ${err.message}\n`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
server.on('connection', (ws) => {
|
|
78
|
+
extensionSocket = ws;
|
|
79
|
+
process.stderr.write(`[MCP] Chrome extension connected on port ${port}\n`);
|
|
80
|
+
|
|
81
|
+
ws.on('message', (data) => {
|
|
82
|
+
let msg;
|
|
83
|
+
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
84
|
+
const { id, result, error } = msg;
|
|
85
|
+
const p = pending.get(id);
|
|
86
|
+
if (!p) return;
|
|
87
|
+
pending.delete(id);
|
|
88
|
+
clearTimeout(p.timer);
|
|
89
|
+
if (error) p.reject(new Error(error));
|
|
90
|
+
else p.resolve(result);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
ws.on('close', () => {
|
|
94
|
+
if (extensionSocket === ws) {
|
|
95
|
+
extensionSocket = null;
|
|
96
|
+
process.stderr.write(`[MCP] Chrome extension disconnected\n`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server.on('listening', () => {
|
|
102
|
+
activePort = port;
|
|
103
|
+
process.stderr.write(`[MCP] WebSocket server listening on ws://127.0.0.1:${port}\n`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Heartbeat + idle timeout (4 hours)
|
|
107
|
+
setInterval(() => {
|
|
108
|
+
if (extensionSocket && extensionSocket.readyState === 1) {
|
|
109
|
+
extensionSocket.ping();
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() - lastActivity > 4 * 60 * 60 * 1000) {
|
|
112
|
+
process.stderr.write('[MCP] Idle timeout (4h) — shutting down\n');
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
}, 20000);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
createWSS();
|
|
119
|
+
|
|
120
|
+
// ── Send command to extension ───────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function sendToExtension(method, params = {}, timeoutMs = 30000) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
if (!extensionSocket || extensionSocket.readyState !== 1) {
|
|
125
|
+
reject(new Error('Chrome extension not connected. Open Chrome and ensure Agent360 Browser MCP extension is installed.'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const id = ++cmdId;
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
pending.delete(id);
|
|
131
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${method}`));
|
|
132
|
+
}, timeoutMs);
|
|
133
|
+
pending.set(id, { resolve, reject, timer });
|
|
134
|
+
extensionSocket.send(JSON.stringify({ id, method, params }));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── MCP Server ──────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
const INSTRUCTIONS = `You control the user's real Chrome browser via this MCP server. Each session gets its own color-coded Chrome Tab Group.
|
|
141
|
+
|
|
142
|
+
## Key behaviors
|
|
143
|
+
- **Always use browser_ask_user** when you need credentials, 2FA codes, CAPTCHA help, or any user input. Never guess passwords or tokens.
|
|
144
|
+
- **ALWAYS close tabs when done** with browser_close_tab after completing each task. Don't leave tabs open — close them immediately after extracting the data you need. Use browser_list_tabs to find and close all session tabs when a task is complete.
|
|
145
|
+
- **Check existing tabs first** with browser_list_tabs before navigating — reuse tabs instead of opening duplicates.
|
|
146
|
+
- **One task per tab** — navigate to a URL, do your work, then close or move on.
|
|
147
|
+
- **Tell the user what you're doing** in the browser. "I'm navigating to Stripe to find the API key" not just silently calling tools.
|
|
148
|
+
|
|
149
|
+
## Tab management
|
|
150
|
+
- navigate creates tabs in your session's tab group (visible in Chrome as colored groups)
|
|
151
|
+
- list_tabs only shows YOUR session's tabs — other Claude sessions have their own
|
|
152
|
+
- switch_tab lets you jump between your tabs
|
|
153
|
+
- close_tab cleans up when you're done
|
|
154
|
+
|
|
155
|
+
## Authentication flows
|
|
156
|
+
1. Navigate to login page
|
|
157
|
+
2. Use browser_ask_user with fields for email/password
|
|
158
|
+
3. Fill credentials with browser_fill
|
|
159
|
+
4. Click submit with browser_click
|
|
160
|
+
5. If 2FA required, use browser_ask_user again: "Please enter the 2FA code shown in your authenticator app"
|
|
161
|
+
6. After success, extract what you need with browser_get_page_content
|
|
162
|
+
|
|
163
|
+
## Screenshots
|
|
164
|
+
- browser_screenshot captures the visible tab — useful for visual verification
|
|
165
|
+
- The tab is auto-activated before capture, so it always shows the right page
|
|
166
|
+
|
|
167
|
+
## Text-based selectors (preferred for dynamic sites)
|
|
168
|
+
- browser_click("text=Get started") — clicks any element containing "Get started"
|
|
169
|
+
- browser_click("button:text(Submit)") — clicks a button containing "Submit"
|
|
170
|
+
- browser_fill("text=Email", "user@example.com") — fills input near "Email" label
|
|
171
|
+
- browser_wait("text=Success") — waits for text to appear
|
|
172
|
+
- These work on ALL sites including Google Cloud, Stripe, Slack (CSP-strict)
|
|
173
|
+
|
|
174
|
+
## Keyboard
|
|
175
|
+
- browser_press_key("Enter") — submit forms
|
|
176
|
+
- browser_press_key("Tab") — navigate between fields
|
|
177
|
+
- browser_press_key("Escape") — close dialogs
|
|
178
|
+
- browser_press_key("ArrowDown") — navigate dropdowns
|
|
179
|
+
- browser_press_key("a", ctrl=true) — select all
|
|
180
|
+
|
|
181
|
+
## CAPTCHA handling
|
|
182
|
+
- After navigate, check the response for captcha_detected field
|
|
183
|
+
- If captcha_detected is set, use browser_ask_user: "A CAPTCHA was detected on this page. Please solve it in the browser, then click Done."
|
|
184
|
+
- After user solves it, retry the navigation or continue with the page
|
|
185
|
+
|
|
186
|
+
## OAuth popups
|
|
187
|
+
- OAuth popups (Google, Microsoft, GitHub, Slack, HubSpot) are automatically intercepted and added to your session's tab group
|
|
188
|
+
- Use browser_get_new_tab to access them, or they'll become your active tab automatically
|
|
189
|
+
|
|
190
|
+
## Shadow DOM (Shopify, Salesforce, etc.)
|
|
191
|
+
- CSS selectors automatically search inside shadow DOM
|
|
192
|
+
- If a standard selector fails, the extension recursively searches shadow roots
|
|
193
|
+
- Text-based selectors ("text=Submit") also traverse shadow DOM
|
|
194
|
+
|
|
195
|
+
## When things fail
|
|
196
|
+
- Element not found → try text-based selector instead of CSS
|
|
197
|
+
- Screenshot fails → debugger fallback is automatic
|
|
198
|
+
- Click doesn't work on SPA → debugger mouse events are used automatically
|
|
199
|
+
- CAPTCHA blocks page → use browser_ask_user, let human solve it
|
|
200
|
+
|
|
201
|
+
## Extension updates
|
|
202
|
+
The MCP server auto-pulls the latest code from git on every new session startup.
|
|
203
|
+
If the extension files were updated, ask the user to reload it:
|
|
204
|
+
"The Browser MCP extension was updated. Please go to chrome://extensions, find 'Agent360 Browser MCP', and click the reload icon (🔄) to apply the update."
|
|
205
|
+
You cannot navigate to chrome:// pages — the user must do this manually.`;
|
|
206
|
+
|
|
207
|
+
const mcpServer = new Server(
|
|
208
|
+
{ name: 'agent360-browser', version: '1.12.0' },
|
|
209
|
+
{ capabilities: { tools: {} } },
|
|
210
|
+
{ instructions: INSTRUCTIONS },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
214
|
+
tools: TOOLS,
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
218
|
+
const { name, arguments: args } = request.params;
|
|
219
|
+
lastActivity = Date.now();
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const methodMap = {
|
|
223
|
+
browser_navigate: 'navigate',
|
|
224
|
+
browser_get_page_content: 'get_page_content',
|
|
225
|
+
browser_screenshot: 'screenshot',
|
|
226
|
+
browser_execute_script: 'execute_script',
|
|
227
|
+
browser_click: 'click',
|
|
228
|
+
browser_fill: 'fill',
|
|
229
|
+
browser_wait: 'wait',
|
|
230
|
+
browser_press_key: 'press_key',
|
|
231
|
+
browser_scroll: 'scroll',
|
|
232
|
+
browser_hover: 'hover',
|
|
233
|
+
browser_fetch: 'fetch',
|
|
234
|
+
browser_select_option: 'select_option',
|
|
235
|
+
browser_handle_dialog: 'handle_dialog',
|
|
236
|
+
browser_wait_for_network: 'wait_for_network',
|
|
237
|
+
browser_list_tabs: 'list_tabs',
|
|
238
|
+
browser_get_cookies: 'get_cookies',
|
|
239
|
+
browser_get_local_storage: 'get_local_storage',
|
|
240
|
+
browser_ask_user: 'ask_user',
|
|
241
|
+
browser_select_frame: 'select_frame',
|
|
242
|
+
browser_list_frames: 'list_frames',
|
|
243
|
+
browser_get_new_tab: 'get_new_tab',
|
|
244
|
+
browser_switch_tab: 'switch_tab',
|
|
245
|
+
browser_close_tab: 'close_tab',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (name === 'browser_extract_token') {
|
|
249
|
+
return await handleExtractToken(args);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const method = methodMap[name];
|
|
253
|
+
if (!method) {
|
|
254
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const timeout = method === 'ask_user' ? (args?.timeout || 120000) + 5000 : 30000;
|
|
258
|
+
const result = await sendToExtension(method, args || {}, timeout);
|
|
259
|
+
|
|
260
|
+
if (name === 'browser_screenshot' && result?.image) {
|
|
261
|
+
const isJpeg = result.image.startsWith('data:image/jpeg');
|
|
262
|
+
const prefix = isJpeg ? /^data:image\/jpeg;base64,/ : /^data:image\/png;base64,/;
|
|
263
|
+
const mimeType = isJpeg ? 'image/jpeg' : 'image/png';
|
|
264
|
+
const base64 = result.image.replace(prefix, '');
|
|
265
|
+
return { content: [{ type: 'image', data: base64, mimeType }] };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const response = {
|
|
269
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Notify on first call if extension was updated
|
|
273
|
+
if (extensionUpdated) {
|
|
274
|
+
extensionUpdated = false;
|
|
275
|
+
response.content.push({
|
|
276
|
+
type: 'text',
|
|
277
|
+
text: '\n⚠️ Extension was updated on startup. Ask the user to reload the extension in chrome://extensions (click 🔄 on Agent360 Browser MCP).',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return response;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
return {
|
|
284
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
285
|
+
isError: true,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
async function handleExtractToken(args) {
|
|
291
|
+
const { provider, store_in_vault } = args;
|
|
292
|
+
const info = PROVIDER_PAGES[provider];
|
|
293
|
+
|
|
294
|
+
if (!info) {
|
|
295
|
+
return {
|
|
296
|
+
content: [{
|
|
297
|
+
type: 'text',
|
|
298
|
+
text: `Unknown provider: ${provider}. Known: ${Object.keys(PROVIDER_PAGES).join(', ')}\n\nYou can still use browser_navigate + browser_get_page_content to extract tokens from any provider manually.`,
|
|
299
|
+
}],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const nav = await sendToExtension('navigate', { url: info.url });
|
|
304
|
+
const content = [
|
|
305
|
+
{ type: 'text', text: `Navigated to ${info.url} (${nav.title})\n\nInstructions: ${info.instructions}\n\nUse browser_get_page_content or browser_screenshot to find the token, then use browser_execute_script to extract it.` },
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
if (store_in_vault) {
|
|
309
|
+
content.push({
|
|
310
|
+
type: 'text',
|
|
311
|
+
text: `\nWhen you have the token, POST it to the vault:\ncurl -X POST http://localhost:8000/v1/vault/connect -H "Authorization: Bearer {jwt}" -d '{"provider":"${provider}","token":"{extracted_token}"}'`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { content };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Start ───────────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
// Clean shutdown — release port so next session can use it
|
|
321
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
322
|
+
process.on('SIGINT', () => process.exit(0));
|
|
323
|
+
process.on('exit', () => {
|
|
324
|
+
if (wss) try { wss.close(); } catch {}
|
|
325
|
+
if (extensionSocket) try { extensionSocket.close(); } catch {}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Detect Claude Code exit (stdin closes when conversation ends)
|
|
329
|
+
process.stdin.on('end', () => {
|
|
330
|
+
process.stderr.write('[MCP] stdin closed — shutting down\n');
|
|
331
|
+
process.exit(0);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const transport = new StdioServerTransport();
|
|
335
|
+
await mcpServer.connect(transport);
|
|
336
|
+
process.stderr.write(`[MCP] Agent360 Browser MCP server running (stdio)\n`);
|