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/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; // 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;
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
- '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 ' +
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
- // OWNER state -------------------------------------------------------
57
- let extensionSocket = null; // the browser extension's socket
58
- const peerClients = new Set(); // connected follower sockets
59
- let wireId = 0; // id for requests we send to the extension
60
- 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();
61
35
 
62
- // FOLLOWER state ----------------------------------------------------
63
- let peerSocket = null; // our client connection to the owner
64
- let proxyId = 0; // id for requests we send to the owner
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') { // PNA preflight before the WS upgrade
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; // shed any stale follower state from a prior life
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
- extensionSocket = socket;
154
- 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}`);
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
- deliver(d, msg.result, msg.error);
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
- if (extensionSocket === socket) {
169
- extensionSocket = null;
170
- 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
+ }
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
- socket.on('error', () => { /* close handles cleanup */ });
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
- 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
+ }
185
196
  });
186
197
 
187
- socket.on('close', () => { peerClients.delete(socket); });
188
- socket.on('error', () => { /* close handles cleanup */ });
198
+ socket.on('close', () => {
199
+ peerClients.delete(socket);
200
+ broadcastStatus();
201
+ });
202
+ socket.on('error', () => { });
189
203
  }
190
204
 
191
- // Send one request to the extension on behalf of a local caller or a follower.
192
- function forwardToExtension(action, params, descriptor) {
193
- if (!extensionSocket || extensionSocket.readyState !== WebSocket.OPEN) {
194
- deliver(descriptor, undefined, EXT_NOT_CONNECTED_MSG);
195
- 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);
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
- // Deliver an extension result back to wherever the request came from.
207
- function deliver(descriptor, result, error) {
208
- if (descriptor.kind === 'local') {
209
- if (error) descriptor.reject(new Error(error));
210
- else descriptor.resolve(result);
211
- } else { // 'peer'
212
- if (descriptor.socket.readyState === WebSocket.OPEN) {
213
- 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(', ')}.`);
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
- // FOLLOWER: proxy calls to the owner; re-elect if the owner vanishes.
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(); // owner may have died — try to take over
432
+ scheduleReElection();
259
433
  });
260
- socket.on('error', () => { /* close follows */ });
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') return !!extensionSocket && extensionSocket.readyState === WebSocket.OPEN;
306
- 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;
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(); // throws the clear error after the grace window
504
+ const activeRole = await waitForRoute();
339
505
  if (activeRole === 'owner') {
340
- return new Promise((resolve, reject) => {
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: !!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 })),
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({ name: '2manytabs-mcp', version: '2.1.0' });
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(