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/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; // env override is a test seam; the extension uses 9876
31
- const PEER_PATH = '/peer'; // followers connect here; the extension connects to '/'
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; // absorb brief owner↔follower failover before erroring
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
- 'Chrome extension is not connected. Load the 2ManyTabs MCP extension in your browser ' +
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
- // OWNER state -------------------------------------------------------
54
- let extensionSocket = null; // the browser extension's socket
55
- const peerClients = new Set(); // connected follower sockets
56
- let wireId = 0; // id for requests we send to the extension
57
- const inflight = new Map(); // wireId → delivery descriptor (local or peer)
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
- // FOLLOWER state ----------------------------------------------------
60
- let peerSocket = null; // our client connection to the owner
61
- let proxyId = 0; // id for requests we send to the owner
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') { // PNA preflight before the WS upgrade
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
- // Extension connections must originate from a chrome-extension:// URI.
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; // shed any stale follower state from a prior life
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
- extensionSocket = socket;
151
- log('Extension connected');
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
- deliver(d, msg.result, msg.error);
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
- if (extensionSocket === socket) {
166
- extensionSocket = null;
167
- log('Extension disconnected');
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
- socket.on('error', () => { /* close handles cleanup */ });
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
- forwardToExtension(msg.action, msg.params, { kind: 'peer', socket, peerReqId: msg.peerReqId });
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', () => { peerClients.delete(socket); });
185
- socket.on('error', () => { /* close handles cleanup */ });
198
+ socket.on('close', () => {
199
+ peerClients.delete(socket);
200
+ broadcastStatus();
201
+ });
202
+ socket.on('error', () => { });
186
203
  }
187
204
 
188
- // Send one request to the extension on behalf of a local caller or a follower.
189
- function forwardToExtension(action, params, descriptor) {
190
- if (!extensionSocket || extensionSocket.readyState !== WebSocket.OPEN) {
191
- deliver(descriptor, undefined, EXT_NOT_CONNECTED_MSG);
192
- return;
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
- // Deliver an extension result back to wherever the request came from.
204
- function deliver(descriptor, result, error) {
205
- if (descriptor.kind === 'local') {
206
- if (error) descriptor.reject(new Error(error));
207
- else descriptor.resolve(result);
208
- } else { // 'peer'
209
- if (descriptor.socket.readyState === WebSocket.OPEN) {
210
- descriptor.socket.send(JSON.stringify({ type: 'reply', peerReqId: descriptor.peerReqId, result, error }));
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
- // FOLLOWER: proxy calls to the owner; re-elect if the owner vanishes.
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(); // owner may have died — try to take over
432
+ scheduleReElection();
256
433
  });
257
- socket.on('error', () => { /* close follows */ });
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') return !!extensionSocket && extensionSocket.readyState === WebSocket.OPEN;
303
- if (role === 'follower') return !!peerSocket && peerSocket.readyState === WebSocket.OPEN;
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(); // throws the clear error after the grace window
504
+ const activeRole = await waitForRoute();
336
505
  if (activeRole === 'owner') {
337
- return new Promise((resolve, reject) => {
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: !!extensionSocket && extensionSocket.readyState === WebSocket.OPEN,
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
- // 2ManyTabs MCP – Host (MCP server over stdio)
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 } from './bridge.js';
16
- import { toolRegistry } from './tools/index.js';
4
+ import { startBridge } from './bridge.js';
5
+ import { toolRegistry } from './tools/index.js';
17
6
 
18
- const server = new McpServer({ name: '2manytabs-mcp', version: '2.0.0' });
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: tool.title,
13
+ title: tool.title,
26
14
  description: tool.description,
27
- inputSchema: tool.inputSchema, // raw Zod shape — SDK derives JSON Schema + validates
28
- annotations: tool.annotations, // readOnly/destructive hints (new in modern MCP)
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);