2manytabs-mcp-host 2.0.0 → 3.0.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 +158 -0
- package/bridge.js +288 -120
- package/host.js +7 -21
- package/lib/tabs.js +29 -21
- package/package.json +34 -4
- package/tools/activate-tab.tool.js +30 -0
- package/tools/close-tabs.tool.js +40 -13
- package/tools/execute-script.tool.js +54 -0
- package/tools/get-tab-text.tool.js +31 -0
- package/tools/group-tabs.tool.js +36 -0
- package/tools/index.js +16 -4
- package/tools/list-tab-groups.tool.js +45 -0
- package/tools/list-tabs.tool.js +34 -74
- package/tools/open-tabs.tool.js +6 -4
- package/tools/present.js +131 -0
- package/tools/rendering.js +40 -0
- package/tools/ungroup-tabs.tool.js +28 -0
- package/tools/update-tab-group.tool.js +36 -0
- package/tools/update-tab.tool.js +36 -0
package/bridge.js
CHANGED
|
@@ -1,41 +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;
|
|
8
|
+
|
|
9
|
+
const ALLOWED_EXTENSION_ORIGIN_PREFIXES = ['chrome-extension://', 'moz-extension://'];
|
|
34
10
|
|
|
35
11
|
const EXT_NOT_CONNECTED_MSG =
|
|
36
|
-
'
|
|
12
|
+
'Browser extension is not connected. Load the 2ManyTabs MCP extension in Chrome or Firefox ' +
|
|
37
13
|
'and confirm its popup shows "Connected".';
|
|
38
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
|
+
|
|
39
19
|
const PNA_HEADERS = {
|
|
40
20
|
'Access-Control-Allow-Origin': '*',
|
|
41
21
|
'Access-Control-Allow-Private-Network': 'true',
|
|
@@ -43,50 +23,36 @@ const PNA_HEADERS = {
|
|
|
43
23
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
44
24
|
};
|
|
45
25
|
|
|
46
|
-
|
|
47
|
-
// Module state
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
let role = 'starting'; // 'starting' | 'owner' | 'follower'
|
|
26
|
+
let role = 'starting';
|
|
51
27
|
let bindInProgress = false;
|
|
52
28
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
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();
|
|
58
35
|
|
|
59
|
-
|
|
60
|
-
let
|
|
61
|
-
|
|
62
|
-
const proxyPending = new Map(); // proxyId → { resolve, reject, timer }
|
|
36
|
+
let peerSocket = null;
|
|
37
|
+
let proxyId = 0;
|
|
38
|
+
const proxyPending = new Map();
|
|
63
39
|
|
|
64
|
-
// Calls parked until a usable route appears (covers cold start / failover).
|
|
65
40
|
const readyWaiters = [];
|
|
66
41
|
|
|
67
42
|
function log(msg) {
|
|
68
|
-
// stderr only — stdout is reserved for the MCP stdio transport.
|
|
69
43
|
process.stderr.write(`[2manytabs-mcp] ${msg}\n`);
|
|
70
44
|
}
|
|
71
45
|
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
// Entry point
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
|
|
76
46
|
export function startBridge() {
|
|
77
47
|
attemptBind();
|
|
78
48
|
}
|
|
79
49
|
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// OWNER: try to bind 9876. Win → own the extension. Lose (EADDRINUSE) → follow.
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
|
|
84
50
|
function attemptBind() {
|
|
85
51
|
if (bindInProgress || role === 'owner') return;
|
|
86
52
|
bindInProgress = true;
|
|
87
53
|
|
|
88
54
|
const httpServer = http.createServer((req, res) => {
|
|
89
|
-
if (req.method === 'OPTIONS') {
|
|
55
|
+
if (req.method === 'OPTIONS') {
|
|
90
56
|
res.writeHead(204, PNA_HEADERS);
|
|
91
57
|
res.end();
|
|
92
58
|
return;
|
|
@@ -98,7 +64,6 @@ function attemptBind() {
|
|
|
98
64
|
httpServer.on('error', (err) => {
|
|
99
65
|
bindInProgress = false;
|
|
100
66
|
if (err.code === 'EADDRINUSE') {
|
|
101
|
-
// Another host already owns the bridge — expected with multiple sessions. Follow it.
|
|
102
67
|
log(`Port ${WS_PORT} already owned by another host — joining as a follower.`);
|
|
103
68
|
becomeFollower();
|
|
104
69
|
} else {
|
|
@@ -118,8 +83,6 @@ function attemptBind() {
|
|
|
118
83
|
const origin = req.headers.origin || '';
|
|
119
84
|
|
|
120
85
|
if (req.url === PEER_PATH) {
|
|
121
|
-
// Follower connections must be node-to-node and should not have a browser origin.
|
|
122
|
-
// Standard browsers will always send an Origin header for web-based requests.
|
|
123
86
|
if (origin) {
|
|
124
87
|
log(`Rejected peer connection from non-node origin: ${origin}`);
|
|
125
88
|
socket.close(4003, 'Forbidden origin');
|
|
@@ -127,29 +90,58 @@ function attemptBind() {
|
|
|
127
90
|
}
|
|
128
91
|
handlePeerConnection(socket);
|
|
129
92
|
} else {
|
|
130
|
-
|
|
131
|
-
if (!origin.startsWith('chrome-extension://')) {
|
|
93
|
+
if (!ALLOWED_EXTENSION_ORIGIN_PREFIXES.some(prefix => origin.startsWith(prefix))) {
|
|
132
94
|
log(`Rejected extension connection from unauthorized origin: ${origin}`);
|
|
133
95
|
socket.close(4003, 'Unauthorized origin');
|
|
134
96
|
return;
|
|
135
97
|
}
|
|
136
|
-
handleExtensionConnection(socket);
|
|
98
|
+
handleExtensionConnection(socket, req);
|
|
137
99
|
}
|
|
138
100
|
});
|
|
139
101
|
|
|
140
102
|
httpServer.listen(WS_PORT, '127.0.0.1', () => {
|
|
141
103
|
bindInProgress = false;
|
|
142
104
|
role = 'owner';
|
|
143
|
-
peerSocket = null;
|
|
105
|
+
peerSocket = null;
|
|
144
106
|
log(`Bridge OWNER listening on ws://127.0.0.1:${WS_PORT}`);
|
|
145
107
|
log('Waiting for browser extension...');
|
|
146
108
|
});
|
|
147
109
|
}
|
|
148
110
|
|
|
149
|
-
function handleExtensionConnection(socket) {
|
|
150
|
-
|
|
151
|
-
|
|
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}`);
|
|
152
143
|
flushReady();
|
|
144
|
+
broadcastStatus();
|
|
153
145
|
|
|
154
146
|
socket.on('message', (raw) => {
|
|
155
147
|
let msg;
|
|
@@ -158,63 +150,248 @@ function handleExtensionConnection(socket) {
|
|
|
158
150
|
if (!d) return;
|
|
159
151
|
clearTimeout(d.timer);
|
|
160
152
|
inflight.delete(msg.id);
|
|
161
|
-
|
|
153
|
+
if (msg.error) d.reject(new Error(msg.error));
|
|
154
|
+
else d.resolve(msg.result);
|
|
162
155
|
});
|
|
163
156
|
|
|
164
157
|
socket.on('close', () => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
}
|
|
168
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();
|
|
169
172
|
});
|
|
170
|
-
|
|
173
|
+
|
|
174
|
+
socket.on('error', () => { });
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
function handlePeerConnection(socket) {
|
|
174
178
|
peerClients.add(socket);
|
|
175
179
|
log(`Follower host connected (${peerClients.size} active)`);
|
|
180
|
+
broadcastStatus();
|
|
176
181
|
|
|
177
|
-
socket.on('message', (raw) => {
|
|
182
|
+
socket.on('message', async (raw) => {
|
|
178
183
|
let msg;
|
|
179
184
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
180
185
|
if (msg.type !== 'call') return;
|
|
181
|
-
|
|
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
|
+
}
|
|
182
196
|
});
|
|
183
197
|
|
|
184
|
-
socket.on('close', () => {
|
|
185
|
-
|
|
198
|
+
socket.on('close', () => {
|
|
199
|
+
peerClients.delete(socket);
|
|
200
|
+
broadcastStatus();
|
|
201
|
+
});
|
|
202
|
+
socket.on('error', () => { });
|
|
186
203
|
}
|
|
187
204
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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);
|
|
193
243
|
}
|
|
194
|
-
const id = ++wireId;
|
|
195
|
-
descriptor.timer = setTimeout(() => {
|
|
196
|
-
inflight.delete(id);
|
|
197
|
-
deliver(descriptor, undefined, 'Timed out waiting for browser extension response (10s).');
|
|
198
|
-
}, CALL_TIMEOUT_MS);
|
|
199
|
-
inflight.set(id, descriptor);
|
|
200
|
-
extensionSocket.send(JSON.stringify({ id, action, ...(params || {}) }));
|
|
201
244
|
}
|
|
202
245
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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(', ')}.`);
|
|
211
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;
|
|
212
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;
|
|
213
321
|
}
|
|
214
322
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
}
|
|
218
395
|
|
|
219
396
|
function becomeFollower() {
|
|
220
397
|
role = 'follower';
|
|
@@ -252,14 +429,11 @@ function connectPeer() {
|
|
|
252
429
|
socket.on('close', () => {
|
|
253
430
|
if (peerSocket === socket) peerSocket = null;
|
|
254
431
|
failProxyPending();
|
|
255
|
-
scheduleReElection();
|
|
432
|
+
scheduleReElection();
|
|
256
433
|
});
|
|
257
|
-
socket.on('error', () => {
|
|
434
|
+
socket.on('error', () => { });
|
|
258
435
|
}
|
|
259
436
|
|
|
260
|
-
// The owner connection dropped. After a little jitter (to avoid a thundering
|
|
261
|
-
// herd of followers), try to become the owner. Whoever wins the port wins;
|
|
262
|
-
// the rest will EADDRINUSE and fall back to following the new owner.
|
|
263
437
|
function scheduleReElection() {
|
|
264
438
|
const delay = 200 + Math.floor(Math.random() * 300);
|
|
265
439
|
setTimeout(() => {
|
|
@@ -292,15 +466,14 @@ function failProxyPending() {
|
|
|
292
466
|
proxyPending.clear();
|
|
293
467
|
}
|
|
294
468
|
|
|
295
|
-
// ---------------------------------------------------------------------------
|
|
296
|
-
// Routing readiness — a call needs a live route (owner+extension, or
|
|
297
|
-
// follower+owner). In steady state this resolves instantly; during a cold
|
|
298
|
-
// start or a brief failover it parks the call up to ROUTE_GRACE_MS.
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
|
|
301
469
|
function hasRoute() {
|
|
302
|
-
if (role === 'owner')
|
|
303
|
-
|
|
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;
|
|
304
477
|
return false;
|
|
305
478
|
}
|
|
306
479
|
|
|
@@ -327,16 +500,10 @@ function waitForRoute() {
|
|
|
327
500
|
});
|
|
328
501
|
}
|
|
329
502
|
|
|
330
|
-
// ---------------------------------------------------------------------------
|
|
331
|
-
// Public primitive used by every tool.
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
|
|
334
503
|
export async function callExtension(action, params = {}) {
|
|
335
|
-
const activeRole = await waitForRoute();
|
|
504
|
+
const activeRole = await waitForRoute();
|
|
336
505
|
if (activeRole === 'owner') {
|
|
337
|
-
return
|
|
338
|
-
forwardToExtension(action, params, { kind: 'local', resolve, reject });
|
|
339
|
-
});
|
|
506
|
+
return routeCall(action, params);
|
|
340
507
|
}
|
|
341
508
|
return followerCall(action, params);
|
|
342
509
|
}
|
|
@@ -345,11 +512,12 @@ export function isExtensionConnected() {
|
|
|
345
512
|
return hasRoute();
|
|
346
513
|
}
|
|
347
514
|
|
|
348
|
-
// Lightweight introspection for logging / a future status tool.
|
|
349
515
|
export function bridgeStatus() {
|
|
516
|
+
const clients = getActiveClients();
|
|
350
517
|
return {
|
|
351
518
|
role,
|
|
352
|
-
extension_connected:
|
|
519
|
+
extension_connected: clients.length > 0,
|
|
520
|
+
clients: clients.map(c => ({ id: c.id, browser: c.browser, name: c.name })),
|
|
353
521
|
followers: peerClients.size,
|
|
354
522
|
owner_reachable: role === 'follower' ? (!!peerSocket && peerSocket.readyState === WebSocket.OPEN) : undefined,
|
|
355
523
|
};
|
package/host.js
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
// Hermes ─stdio(JSON-RPC)─▶ host.js ─ws/127.0.0.1:9876─▶ background.js ─▶ chrome.tabs
|
|
5
|
-
//
|
|
6
|
-
// Architecture mirrors gemini-mcp-tool's "unified tool" pattern: one self-contained
|
|
7
|
-
// file per tool, collected in tools/index.js. The difference — and the point of this
|
|
8
|
-
// rewrite — is that SDK 1.x's McpServer.registerTool() absorbs the hand-rolled
|
|
9
|
-
// registry.ts (zod-to-json-schema, getToolDefinitions, executeTool, manual Zod parsing)
|
|
10
|
-
// that the 0.5-era SDK forced us to write. We keep the modular philosophy; the SDK
|
|
11
|
-
// keeps the boilerplate.
|
|
12
|
-
|
|
13
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
14
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
|
-
import { startBridge }
|
|
16
|
-
import { toolRegistry }
|
|
4
|
+
import { startBridge } from './bridge.js';
|
|
5
|
+
import { toolRegistry } from './tools/index.js';
|
|
17
6
|
|
|
18
|
-
const server = new McpServer({ name: '2manytabs-mcp', version: '
|
|
7
|
+
const server = new McpServer({ name: '2manytabs-mcp', version: '3.0.0' });
|
|
19
8
|
|
|
20
|
-
// Register every tool generically. Adding a tool requires zero changes here.
|
|
21
9
|
for (const tool of toolRegistry) {
|
|
22
10
|
server.registerTool(
|
|
23
11
|
tool.name,
|
|
24
12
|
{
|
|
25
|
-
title:
|
|
13
|
+
title: tool.title,
|
|
26
14
|
description: tool.description,
|
|
27
|
-
inputSchema: tool.inputSchema,
|
|
28
|
-
annotations: tool.annotations,
|
|
15
|
+
inputSchema: tool.inputSchema,
|
|
16
|
+
annotations: tool.annotations,
|
|
29
17
|
},
|
|
30
18
|
async (args) => {
|
|
31
19
|
try {
|
|
@@ -41,14 +29,12 @@ for (const tool of toolRegistry) {
|
|
|
41
29
|
);
|
|
42
30
|
}
|
|
43
31
|
|
|
44
|
-
// Bring up the extension bridge, then connect MCP over stdio.
|
|
45
32
|
startBridge();
|
|
46
33
|
|
|
47
34
|
const transport = new StdioServerTransport();
|
|
48
35
|
await server.connect(transport);
|
|
49
36
|
process.stderr.write(`[2manytabs-mcp] MCP server ready (${toolRegistry.length} tools) on stdio\n`);
|
|
50
37
|
|
|
51
|
-
// Ensure clean exit when the parent process disconnects or terminates
|
|
52
38
|
const cleanup = () => process.exit(0);
|
|
53
39
|
process.stdin.on('close', cleanup);
|
|
54
40
|
process.stdin.on('end', cleanup);
|