2manytabs-mcp-host 2.1.0 → 3.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 +193 -0
- package/bridge.js +285 -120
- package/host.js +29 -1
- package/lib/clusters.js +250 -0
- package/lib/guide.js +48 -0
- package/lib/snapshots.js +99 -0
- package/lib/tabs.js +75 -21
- package/package.json +33 -5
- package/skills/closing-tab-loops/SKILL.md +117 -0
- package/skills/closing-tab-loops/references/evidence.md +75 -0
- package/skills/closing-tab-loops/references/parking.md +43 -0
- package/skills/closing-tab-loops/references/tool-notes.md +70 -0
- package/skills/closing-tab-loops/references/triage.md +55 -0
- package/tools/activate-tab.tool.js +6 -4
- package/tools/close-tabs.tool.js +43 -42
- package/tools/cluster-tabs.tool.js +47 -0
- package/tools/execute-script.tool.js +54 -0
- package/tools/get-tab-text.tool.js +6 -4
- package/tools/group-tabs.tool.js +23 -10
- package/tools/index.js +10 -4
- package/tools/list-tab-groups.tool.js +2 -3
- package/tools/list-tabs.tool.js +24 -13
- package/tools/live.js +17 -0
- package/tools/open-tabs.tool.js +6 -4
- package/tools/present.js +30 -13
- package/tools/rendering.js +7 -15
- package/tools/reopen-tabs.tool.js +56 -0
- package/tools/snapshot-tabs.tool.js +36 -0
- package/tools/start-tab-cleanup.tool.js +16 -0
- package/tools/ungroup-tabs.tool.js +7 -5
- package/tools/update-tab-group.tool.js +10 -6
- package/tools/update-tab.tool.js +6 -4
package/bridge.js
CHANGED
|
@@ -1,44 +1,21 @@
|
|
|
1
|
-
// 2ManyTabs MCP – Extension Bridge (self-organizing, multi-host)
|
|
2
|
-
//
|
|
3
|
-
// Why this exists
|
|
4
|
-
// ----------------
|
|
5
|
-
// MCP-over-stdio is 1:1 by design: every client (each Hermes instance, the
|
|
6
|
-
// mcpjam inspector) spawns its OWN host.js and owns that process's stdio pipes.
|
|
7
|
-
// That part is correct. The catch is one layer down — all those independent
|
|
8
|
-
// hosts reach for a SINGLE shared resource: there is one browser, one extension,
|
|
9
|
-
// one port (9876), one set of tabs. N MCP servers, a rank-1 resource.
|
|
10
|
-
//
|
|
11
|
-
// Previously the first host to start bound 9876 and every other host's tab tools
|
|
12
|
-
// failed with "extension not connected". Worse, the winner was often a stale
|
|
13
|
-
// session's host, so the session you were actually using couldn't see the tabs.
|
|
14
|
-
//
|
|
15
|
-
// Fix: the hosts self-organize onto the shared resource instead of fighting over it.
|
|
16
|
-
// • Exactly one host binds 9876 and owns the extension socket → the OWNER.
|
|
17
|
-
// • Every other host connects to the owner and proxies its calls → a FOLLOWER.
|
|
18
|
-
// • If the owner dies, followers race to re-bind; one becomes the new owner and
|
|
19
|
-
// the extension reconnects to it. No external daemon, no config, and the
|
|
20
|
-
// extension never knows the difference.
|
|
21
|
-
//
|
|
22
|
-
// Hermes A ─stdio▶ host(OWNER) ─ws:9876──────▶ extension ─▶ browser tabs
|
|
23
|
-
// Hermes B ─stdio▶ host(FOLLOWER) ─ws:9876/peer─▶ OWNER ─────┘
|
|
24
|
-
//
|
|
25
|
-
// Public API is unchanged: startBridge(), callExtension(), isExtensionConnected().
|
|
26
|
-
|
|
27
1
|
import http from 'http';
|
|
28
2
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
29
3
|
|
|
30
|
-
const WS_PORT = Number(process.env.MANYTABS_BRIDGE_PORT) || 9876;
|
|
31
|
-
const PEER_PATH = '/peer';
|
|
4
|
+
const WS_PORT = Number(process.env.MANYTABS_BRIDGE_PORT) || 9876;
|
|
5
|
+
const PEER_PATH = '/peer';
|
|
32
6
|
const CALL_TIMEOUT_MS = 10_000;
|
|
33
|
-
const ROUTE_GRACE_MS = 3_500;
|
|
7
|
+
const ROUTE_GRACE_MS = 3_500;
|
|
34
8
|
|
|
35
|
-
// chrome-extension:// for Chromium builds, moz-extension:// for Firefox.
|
|
36
9
|
const ALLOWED_EXTENSION_ORIGIN_PREFIXES = ['chrome-extension://', 'moz-extension://'];
|
|
37
10
|
|
|
38
11
|
const EXT_NOT_CONNECTED_MSG =
|
|
39
|
-
'
|
|
12
|
+
'Browser extension is not connected. Load the 2ManyTabs MCP extension in Chrome or Firefox ' +
|
|
40
13
|
'and confirm its popup shows "Connected".';
|
|
41
14
|
|
|
15
|
+
const CHROME_NO_SCRIPTING_MSG =
|
|
16
|
+
'Sorry, scripting is not available in Chrome: its extension rules block running script text. ' +
|
|
17
|
+
'Open the page in Firefox and use its firefox: tab id instead.';
|
|
18
|
+
|
|
42
19
|
const PNA_HEADERS = {
|
|
43
20
|
'Access-Control-Allow-Origin': '*',
|
|
44
21
|
'Access-Control-Allow-Private-Network': 'true',
|
|
@@ -46,50 +23,36 @@ const PNA_HEADERS = {
|
|
|
46
23
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
47
24
|
};
|
|
48
25
|
|
|
49
|
-
|
|
50
|
-
// Module state
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
|
|
53
|
-
let role = 'starting'; // 'starting' | 'owner' | 'follower'
|
|
26
|
+
let role = 'starting';
|
|
54
27
|
let bindInProgress = false;
|
|
55
28
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
29
|
+
const extensionClients = new Map();
|
|
30
|
+
const peerClients = new Set();
|
|
31
|
+
let wireId = 0;
|
|
32
|
+
const inflight = new Map();
|
|
33
|
+
const tabClientIndex = new Map();
|
|
34
|
+
const groupClientIndex = new Map();
|
|
61
35
|
|
|
62
|
-
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
const proxyPending = new Map(); // proxyId → { resolve, reject, timer }
|
|
36
|
+
let peerSocket = null;
|
|
37
|
+
let proxyId = 0;
|
|
38
|
+
const proxyPending = new Map();
|
|
66
39
|
|
|
67
|
-
// Calls parked until a usable route appears (covers cold start / failover).
|
|
68
40
|
const readyWaiters = [];
|
|
69
41
|
|
|
70
42
|
function log(msg) {
|
|
71
|
-
// stderr only — stdout is reserved for the MCP stdio transport.
|
|
72
43
|
process.stderr.write(`[2manytabs-mcp] ${msg}\n`);
|
|
73
44
|
}
|
|
74
45
|
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Entry point
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
46
|
export function startBridge() {
|
|
80
47
|
attemptBind();
|
|
81
48
|
}
|
|
82
49
|
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// OWNER: try to bind 9876. Win → own the extension. Lose (EADDRINUSE) → follow.
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
50
|
function attemptBind() {
|
|
88
51
|
if (bindInProgress || role === 'owner') return;
|
|
89
52
|
bindInProgress = true;
|
|
90
53
|
|
|
91
54
|
const httpServer = http.createServer((req, res) => {
|
|
92
|
-
if (req.method === 'OPTIONS') {
|
|
55
|
+
if (req.method === 'OPTIONS') {
|
|
93
56
|
res.writeHead(204, PNA_HEADERS);
|
|
94
57
|
res.end();
|
|
95
58
|
return;
|
|
@@ -101,7 +64,6 @@ function attemptBind() {
|
|
|
101
64
|
httpServer.on('error', (err) => {
|
|
102
65
|
bindInProgress = false;
|
|
103
66
|
if (err.code === 'EADDRINUSE') {
|
|
104
|
-
// Another host already owns the bridge — expected with multiple sessions. Follow it.
|
|
105
67
|
log(`Port ${WS_PORT} already owned by another host — joining as a follower.`);
|
|
106
68
|
becomeFollower();
|
|
107
69
|
} else {
|
|
@@ -121,8 +83,6 @@ function attemptBind() {
|
|
|
121
83
|
const origin = req.headers.origin || '';
|
|
122
84
|
|
|
123
85
|
if (req.url === PEER_PATH) {
|
|
124
|
-
// Follower connections must be node-to-node and should not have a browser origin.
|
|
125
|
-
// Standard browsers will always send an Origin header for web-based requests.
|
|
126
86
|
if (origin) {
|
|
127
87
|
log(`Rejected peer connection from non-node origin: ${origin}`);
|
|
128
88
|
socket.close(4003, 'Forbidden origin');
|
|
@@ -130,29 +90,58 @@ function attemptBind() {
|
|
|
130
90
|
}
|
|
131
91
|
handlePeerConnection(socket);
|
|
132
92
|
} else {
|
|
133
|
-
// Extension connections must originate from a browser-extension URI.
|
|
134
93
|
if (!ALLOWED_EXTENSION_ORIGIN_PREFIXES.some(prefix => origin.startsWith(prefix))) {
|
|
135
94
|
log(`Rejected extension connection from unauthorized origin: ${origin}`);
|
|
136
95
|
socket.close(4003, 'Unauthorized origin');
|
|
137
96
|
return;
|
|
138
97
|
}
|
|
139
|
-
handleExtensionConnection(socket);
|
|
98
|
+
handleExtensionConnection(socket, req);
|
|
140
99
|
}
|
|
141
100
|
});
|
|
142
101
|
|
|
143
102
|
httpServer.listen(WS_PORT, '127.0.0.1', () => {
|
|
144
103
|
bindInProgress = false;
|
|
145
104
|
role = 'owner';
|
|
146
|
-
peerSocket = null;
|
|
105
|
+
peerSocket = null;
|
|
147
106
|
log(`Bridge OWNER listening on ws://127.0.0.1:${WS_PORT}`);
|
|
148
107
|
log('Waiting for browser extension...');
|
|
149
108
|
});
|
|
150
109
|
}
|
|
151
110
|
|
|
152
|
-
function handleExtensionConnection(socket) {
|
|
153
|
-
|
|
154
|
-
|
|
111
|
+
function handleExtensionConnection(socket, req) {
|
|
112
|
+
const origin = req.headers.origin || '';
|
|
113
|
+
let browser = origin.startsWith('moz-extension://') ? 'firefox' : 'chrome';
|
|
114
|
+
let name = browser === 'firefox' ? 'Firefox' : 'Chrome';
|
|
115
|
+
let clientId = `${browser}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const parsedUrl = new URL(req.url, 'http://127.0.0.1');
|
|
119
|
+
const browserQuery = parsedUrl.searchParams.get('browser');
|
|
120
|
+
const nameQuery = parsedUrl.searchParams.get('name');
|
|
121
|
+
const instanceQuery = parsedUrl.searchParams.get('instance');
|
|
122
|
+
|
|
123
|
+
if (browserQuery) browser = browserQuery.toLowerCase();
|
|
124
|
+
if (nameQuery) name = nameQuery;
|
|
125
|
+
else if (browser === 'firefox') name = 'Firefox';
|
|
126
|
+
else if (browser === 'chrome') name = 'Chrome';
|
|
127
|
+
|
|
128
|
+
if (instanceQuery) clientId = instanceQuery;
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const client = {
|
|
133
|
+
id: clientId,
|
|
134
|
+
socket,
|
|
135
|
+
browser,
|
|
136
|
+
name,
|
|
137
|
+
origin,
|
|
138
|
+
connectedAt: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
extensionClients.set(clientId, client);
|
|
142
|
+
log(`Extension connected: ${name} (${browser}) [${clientId}]. Total active: ${extensionClients.size}`);
|
|
155
143
|
flushReady();
|
|
144
|
+
broadcastStatus();
|
|
156
145
|
|
|
157
146
|
socket.on('message', (raw) => {
|
|
158
147
|
let msg;
|
|
@@ -161,63 +150,248 @@ function handleExtensionConnection(socket) {
|
|
|
161
150
|
if (!d) return;
|
|
162
151
|
clearTimeout(d.timer);
|
|
163
152
|
inflight.delete(msg.id);
|
|
164
|
-
|
|
153
|
+
if (msg.error) d.reject(new Error(msg.error));
|
|
154
|
+
else d.resolve(msg.result);
|
|
165
155
|
});
|
|
166
156
|
|
|
167
157
|
socket.on('close', () => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
158
|
+
const replaced = extensionClients.has(clientId) && extensionClients.get(clientId).socket !== socket;
|
|
159
|
+
if (!replaced) extensionClients.delete(clientId);
|
|
160
|
+
for (const [id, d] of inflight.entries()) {
|
|
161
|
+
if (d.socket === socket) {
|
|
162
|
+
clearTimeout(d.timer);
|
|
163
|
+
inflight.delete(id);
|
|
164
|
+
d.reject(new Error(`Extension connection lost: ${name}`));
|
|
165
|
+
}
|
|
171
166
|
}
|
|
167
|
+
if (!replaced) {
|
|
168
|
+
for (const index of [tabClientIndex, groupClientIndex]) forgetClient(index, clientId);
|
|
169
|
+
}
|
|
170
|
+
log(`Extension disconnected: ${name} [${clientId}]. Remaining: ${extensionClients.size}`);
|
|
171
|
+
broadcastStatus();
|
|
172
172
|
});
|
|
173
|
-
|
|
173
|
+
|
|
174
|
+
socket.on('error', () => { });
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
function handlePeerConnection(socket) {
|
|
177
178
|
peerClients.add(socket);
|
|
178
179
|
log(`Follower host connected (${peerClients.size} active)`);
|
|
180
|
+
broadcastStatus();
|
|
179
181
|
|
|
180
|
-
socket.on('message', (raw) => {
|
|
182
|
+
socket.on('message', async (raw) => {
|
|
181
183
|
let msg;
|
|
182
184
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
183
185
|
if (msg.type !== 'call') return;
|
|
184
|
-
|
|
186
|
+
try {
|
|
187
|
+
const result = await routeCall(msg.action, msg.params, [...peerClients].indexOf(socket) + 1);
|
|
188
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
189
|
+
socket.send(JSON.stringify({ type: 'reply', peerReqId: msg.peerReqId, result }));
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
193
|
+
socket.send(JSON.stringify({ type: 'reply', peerReqId: msg.peerReqId, error: err.message }));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
185
196
|
});
|
|
186
197
|
|
|
187
|
-
socket.on('close', () => {
|
|
188
|
-
|
|
198
|
+
socket.on('close', () => {
|
|
199
|
+
peerClients.delete(socket);
|
|
200
|
+
broadcastStatus();
|
|
201
|
+
});
|
|
202
|
+
socket.on('error', () => { });
|
|
189
203
|
}
|
|
190
204
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
function deliver(client, action, params = {}, agent = 0) {
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
if (!client?.socket || client.socket.readyState !== WebSocket.OPEN) {
|
|
208
|
+
return reject(new Error(EXT_NOT_CONNECTED_MSG));
|
|
209
|
+
}
|
|
210
|
+
const id = ++wireId;
|
|
211
|
+
const timer = setTimeout(() => {
|
|
212
|
+
inflight.delete(id);
|
|
213
|
+
reject(new Error('Timed out waiting for browser extension response (10s).'));
|
|
214
|
+
}, CALL_TIMEOUT_MS);
|
|
215
|
+
inflight.set(id, { socket: client.socket, resolve, reject, timer });
|
|
216
|
+
client.socket.send(JSON.stringify({ id, action, ...params }));
|
|
217
|
+
broadcastActivity(client.id, action, agent);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function broadcastActivity(target, action, agent) {
|
|
222
|
+
const msg = JSON.stringify({ type: 'activity', target, action, agent, at: Date.now() });
|
|
223
|
+
for (const c of getActiveClients()) c.socket.send(msg);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function broadcastStatus() {
|
|
227
|
+
const clients = getActiveClients();
|
|
228
|
+
const browsers = clients.map((c) => ({ id: c.id, browser: c.browser, name: c.name }));
|
|
229
|
+
const agents = 1 + peerClients.size;
|
|
230
|
+
for (const c of clients) {
|
|
231
|
+
c.socket.send(JSON.stringify({ type: 'status', self: c.id, agents, browsers }));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getActiveClients() {
|
|
236
|
+
return Array.from(extensionClients.values()).filter(c => c.socket.readyState === WebSocket.OPEN);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function forgetClient(index, clientId) {
|
|
240
|
+
for (const [key, owners] of index) {
|
|
241
|
+
owners.delete(clientId);
|
|
242
|
+
if (owners.size === 0) index.delete(key);
|
|
196
243
|
}
|
|
197
|
-
const id = ++wireId;
|
|
198
|
-
descriptor.timer = setTimeout(() => {
|
|
199
|
-
inflight.delete(id);
|
|
200
|
-
deliver(descriptor, undefined, 'Timed out waiting for browser extension response (10s).');
|
|
201
|
-
}, CALL_TIMEOUT_MS);
|
|
202
|
-
inflight.set(id, descriptor);
|
|
203
|
-
extensionSocket.send(JSON.stringify({ id, action, ...(params || {}) }));
|
|
204
244
|
}
|
|
205
245
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
246
|
+
function noteOwner(index, id, clientId) {
|
|
247
|
+
if (!index.has(id)) index.set(id, new Set());
|
|
248
|
+
index.get(id).add(clientId);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function resolveTarget(rawId, browserHint, index, kind = 'tab') {
|
|
252
|
+
const clients = getActiveClients();
|
|
253
|
+
if (clients.length === 0) throw new Error(EXT_NOT_CONNECTED_MSG);
|
|
254
|
+
|
|
255
|
+
let prefix = null;
|
|
256
|
+
let idPart = rawId;
|
|
257
|
+
if (typeof rawId === 'string' && rawId.includes(':')) {
|
|
258
|
+
const at = rawId.lastIndexOf(':');
|
|
259
|
+
prefix = rawId.slice(0, at);
|
|
260
|
+
idPart = rawId.slice(at + 1);
|
|
261
|
+
}
|
|
262
|
+
const cleanId = Number(idPart);
|
|
263
|
+
if (!Number.isInteger(cleanId)) throw new Error(`Invalid ${kind} id: ${rawId}`);
|
|
264
|
+
|
|
265
|
+
const want = prefix ?? browserHint;
|
|
266
|
+
if (want) {
|
|
267
|
+
const w = want.toLowerCase();
|
|
268
|
+
const matches = clients.filter((c) => c.id === want || c.browser === w);
|
|
269
|
+
if (matches.length === 1) return { client: matches[0], cleanId };
|
|
270
|
+
if (matches.length === 0) {
|
|
271
|
+
throw new Error(`No connected browser matches "${want}" for ${kind} ${rawId}. Connected: ${clients.map((c) => c.browser).join(', ')}.`);
|
|
214
272
|
}
|
|
273
|
+
const owners = [...(index.get(cleanId) ?? [])].filter((id) => matches.some((c) => c.id === id));
|
|
274
|
+
if (owners.length === 1) return { client: extensionClients.get(owners[0]), cleanId };
|
|
275
|
+
throw new Error(`${kind} ${rawId} is ambiguous across ${matches.length} ${w} instances; use "<instance>:${cleanId}".`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (clients.length === 1) return { client: clients[0], cleanId };
|
|
279
|
+
|
|
280
|
+
const owners = [...(index.get(cleanId) ?? [])].filter((id) => extensionClients.get(id)?.socket.readyState === WebSocket.OPEN);
|
|
281
|
+
if (owners.length === 1) return { client: extensionClients.get(owners[0]), cleanId };
|
|
282
|
+
const hint = clients.map((c) => `"${c.browser}:${cleanId}"`).join(' or ');
|
|
283
|
+
throw new Error(owners.length > 1
|
|
284
|
+
? `${kind} id ${cleanId} exists in more than one browser; use ${hint}.`
|
|
285
|
+
: `Unknown ${kind} id ${cleanId} with several browsers connected; run list_tabs first or use ${hint}.`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function batchByClient(rawIds, browserHint) {
|
|
289
|
+
const batches = new Map();
|
|
290
|
+
for (const rawId of rawIds) {
|
|
291
|
+
const { client, cleanId } = resolveTarget(rawId, browserHint, tabClientIndex);
|
|
292
|
+
if (!batches.has(client)) batches.set(client, []);
|
|
293
|
+
batches.get(client).push(cleanId);
|
|
294
|
+
}
|
|
295
|
+
return batches;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function fanOut(action, params, clients, onItem, onClient, agent = 0) {
|
|
299
|
+
const results = await Promise.allSettled(clients.map((c) => deliver(c, action, params, agent)));
|
|
300
|
+
if (clients.length === 1 && results[0].status === 'fulfilled' && !Array.isArray(results[0].value)) {
|
|
301
|
+
return results[0].value;
|
|
215
302
|
}
|
|
303
|
+
const failed = results.filter((res) => res.status === 'rejected');
|
|
304
|
+
if (failed.length === results.length) throw failed[0].reason;
|
|
305
|
+
const all = [];
|
|
306
|
+
results.forEach((res, i) => {
|
|
307
|
+
if (res.status !== 'fulfilled' || !Array.isArray(res.value)) return;
|
|
308
|
+
onClient?.(clients[i]);
|
|
309
|
+
for (const item of res.value) {
|
|
310
|
+
onItem(item, clients[i]);
|
|
311
|
+
all.push(item);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
return all;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function withoutBrowser(params, extra) {
|
|
318
|
+
const out = { ...params, ...extra };
|
|
319
|
+
delete out.browser;
|
|
320
|
+
return out;
|
|
216
321
|
}
|
|
217
322
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
323
|
+
async function routeCall(action, params = {}, agent = 0) {
|
|
324
|
+
const sendToClient = (c, a, p) => deliver(c, a, p, agent);
|
|
325
|
+
const clients = getActiveClients();
|
|
326
|
+
if (clients.length === 0) {
|
|
327
|
+
throw new Error(EXT_NOT_CONNECTED_MSG);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (action === 'query_tabs') {
|
|
331
|
+
return fanOut(action, params, clients, (tab, client) => {
|
|
332
|
+
tab.browser = client.browser;
|
|
333
|
+
tab.browserInstance = client.id;
|
|
334
|
+
tab.browserName = client.name;
|
|
335
|
+
noteOwner(tabClientIndex, tab.id, client.id);
|
|
336
|
+
}, (client) => forgetClient(tabClientIndex, client.id), agent);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (action === 'query_groups') {
|
|
340
|
+
return fanOut(action, params, clients, (grp, client) => {
|
|
341
|
+
grp.browser = client.browser;
|
|
342
|
+
grp.browserInstance = client.id;
|
|
343
|
+
noteOwner(groupClientIndex, grp.id, client.id);
|
|
344
|
+
}, (client) => forgetClient(groupClientIndex, client.id), agent);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (action === 'group_tabs') {
|
|
348
|
+
const batches = batchByClient(params.tab_ids, params.browser);
|
|
349
|
+
let groupTarget = null;
|
|
350
|
+
if (params.group_id !== undefined) {
|
|
351
|
+
groupTarget = resolveTarget(params.group_id, params.browser, groupClientIndex, 'group');
|
|
352
|
+
batches.set(groupTarget.client, batches.get(groupTarget.client) ?? []);
|
|
353
|
+
}
|
|
354
|
+
if (batches.size > 1) throw new Error('Cannot group tabs from different browsers into one group.');
|
|
355
|
+
const [[client, ids]] = batches;
|
|
356
|
+
const res = await sendToClient(client, action, withoutBrowser(params, { tab_ids: ids, group_id: groupTarget?.cleanId }));
|
|
357
|
+
if (res && typeof res.groupId === 'number') noteOwner(groupClientIndex, res.groupId, client.id);
|
|
358
|
+
return res;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (action === 'update_group') {
|
|
362
|
+
const { client, cleanId } = resolveTarget(params.group_id, params.browser, groupClientIndex, 'group');
|
|
363
|
+
return sendToClient(client, action, withoutBrowser(params, { group_id: cleanId }));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (Array.isArray(params.tab_ids)) {
|
|
367
|
+
const sumKey = action === 'close_tabs' ? 'closed' : action === 'ungroup_tabs' ? 'ungrouped' : null;
|
|
368
|
+
const batches = batchByClient(params.tab_ids, params.browser);
|
|
369
|
+
if (!sumKey && batches.size > 1) throw new Error(`${action} cannot span browsers.`);
|
|
370
|
+
let total = 0;
|
|
371
|
+
let last;
|
|
372
|
+
for (const [client, ids] of batches) {
|
|
373
|
+
last = await sendToClient(client, action, withoutBrowser(params, { tab_ids: ids }));
|
|
374
|
+
if (sumKey && typeof last?.[sumKey] === 'number') total += last[sumKey];
|
|
375
|
+
}
|
|
376
|
+
return sumKey ? { ...last, [sumKey]: total } : last;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (params.tab_id !== undefined) {
|
|
380
|
+
const { client, cleanId } = resolveTarget(params.tab_id, params.browser, tabClientIndex);
|
|
381
|
+
if (action === 'execute_script' && client.browser === 'chrome') {
|
|
382
|
+
throw new Error(CHROME_NO_SCRIPTING_MSG);
|
|
383
|
+
}
|
|
384
|
+
return sendToClient(client, action, withoutBrowser(params, { tab_id: cleanId }));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (params.browser) {
|
|
388
|
+
const match = clients.find(c => c.browser === params.browser.toLowerCase() || c.id === params.browser);
|
|
389
|
+
if (!match) throw new Error(`No connected browser matches "${params.browser}".`);
|
|
390
|
+
return sendToClient(match, action, withoutBrowser(params));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return sendToClient(clients[0], action, params);
|
|
394
|
+
}
|
|
221
395
|
|
|
222
396
|
function becomeFollower() {
|
|
223
397
|
role = 'follower';
|
|
@@ -255,14 +429,11 @@ function connectPeer() {
|
|
|
255
429
|
socket.on('close', () => {
|
|
256
430
|
if (peerSocket === socket) peerSocket = null;
|
|
257
431
|
failProxyPending();
|
|
258
|
-
scheduleReElection();
|
|
432
|
+
scheduleReElection();
|
|
259
433
|
});
|
|
260
|
-
socket.on('error', () => {
|
|
434
|
+
socket.on('error', () => { });
|
|
261
435
|
}
|
|
262
436
|
|
|
263
|
-
// The owner connection dropped. After a little jitter (to avoid a thundering
|
|
264
|
-
// herd of followers), try to become the owner. Whoever wins the port wins;
|
|
265
|
-
// the rest will EADDRINUSE and fall back to following the new owner.
|
|
266
437
|
function scheduleReElection() {
|
|
267
438
|
const delay = 200 + Math.floor(Math.random() * 300);
|
|
268
439
|
setTimeout(() => {
|
|
@@ -295,15 +466,14 @@ function failProxyPending() {
|
|
|
295
466
|
proxyPending.clear();
|
|
296
467
|
}
|
|
297
468
|
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
// Routing readiness — a call needs a live route (owner+extension, or
|
|
300
|
-
// follower+owner). In steady state this resolves instantly; during a cold
|
|
301
|
-
// start or a brief failover it parks the call up to ROUTE_GRACE_MS.
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
|
|
304
469
|
function hasRoute() {
|
|
305
|
-
if (role === 'owner')
|
|
306
|
-
|
|
470
|
+
if (role === 'owner') {
|
|
471
|
+
for (const client of extensionClients.values()) {
|
|
472
|
+
if (client.socket.readyState === WebSocket.OPEN) return true;
|
|
473
|
+
}
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
if (role === 'follower') return !!peerSocket && peerSocket.readyState === WebSocket.OPEN;
|
|
307
477
|
return false;
|
|
308
478
|
}
|
|
309
479
|
|
|
@@ -330,16 +500,10 @@ function waitForRoute() {
|
|
|
330
500
|
});
|
|
331
501
|
}
|
|
332
502
|
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
// Public primitive used by every tool.
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
|
|
337
503
|
export async function callExtension(action, params = {}) {
|
|
338
|
-
const activeRole = await waitForRoute();
|
|
504
|
+
const activeRole = await waitForRoute();
|
|
339
505
|
if (activeRole === 'owner') {
|
|
340
|
-
return
|
|
341
|
-
forwardToExtension(action, params, { kind: 'local', resolve, reject });
|
|
342
|
-
});
|
|
506
|
+
return routeCall(action, params);
|
|
343
507
|
}
|
|
344
508
|
return followerCall(action, params);
|
|
345
509
|
}
|
|
@@ -348,11 +512,12 @@ export function isExtensionConnected() {
|
|
|
348
512
|
return hasRoute();
|
|
349
513
|
}
|
|
350
514
|
|
|
351
|
-
// Lightweight introspection for logging / a future status tool.
|
|
352
515
|
export function bridgeStatus() {
|
|
516
|
+
const clients = getActiveClients();
|
|
353
517
|
return {
|
|
354
518
|
role,
|
|
355
|
-
extension_connected:
|
|
519
|
+
extension_connected: clients.length > 0,
|
|
520
|
+
clients: clients.map(c => ({ id: c.id, browser: c.browser, name: c.name })),
|
|
356
521
|
followers: peerClients.size,
|
|
357
522
|
owner_reachable: role === 'follower' ? (!!peerSocket && peerSocket.readyState === WebSocket.OPEN) : undefined,
|
|
358
523
|
};
|
package/host.js
CHANGED
|
@@ -2,9 +2,37 @@
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { startBridge } from './bridge.js';
|
|
5
|
+
import { z } from 'zod';
|
|
5
6
|
import { toolRegistry } from './tools/index.js';
|
|
7
|
+
import { SERVER_INSTRUCTIONS, cleanupGuide } from './lib/guide.js';
|
|
6
8
|
|
|
7
|
-
const server = new McpServer(
|
|
9
|
+
const server = new McpServer(
|
|
10
|
+
{ name: '2manytabs-mcp', version: '3.0.0' },
|
|
11
|
+
{ instructions: SERVER_INSTRUCTIONS },
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
// The same method as start_tab_cleanup, for clients that list MCP prompts as commands.
|
|
15
|
+
server.registerPrompt(
|
|
16
|
+
'close-the-loops',
|
|
17
|
+
{
|
|
18
|
+
title: 'Clean up my tabs',
|
|
19
|
+
description: 'Sort your open tabs, close what you no longer need, and keep the few real to-dos.',
|
|
20
|
+
argsSchema: {
|
|
21
|
+
focus: z.string().optional()
|
|
22
|
+
.describe('Optional: only part of the job, e.g. "my YouTube tabs" or "duplicates".'),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
({ focus }) => ({
|
|
26
|
+
messages: [{
|
|
27
|
+
role: 'user',
|
|
28
|
+
content: {
|
|
29
|
+
type: 'text',
|
|
30
|
+
text: `I have too many browser tabs open. Help me with ${focus ? `this: ${focus}` : 'them'}.\n\n` +
|
|
31
|
+
'Follow this method, and start with step 1:\n\n' + cleanupGuide(),
|
|
32
|
+
},
|
|
33
|
+
}],
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
8
36
|
|
|
9
37
|
for (const tool of toolRegistry) {
|
|
10
38
|
server.registerTool(
|