2manytabs-mcp-host 2.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 +356 -0
- package/host.js +56 -0
- package/lib/tabs.js +94 -0
- package/package.json +27 -0
- package/tools/close-tabs.tool.js +97 -0
- package/tools/index.js +13 -0
- package/tools/list-tabs.tool.js +111 -0
- package/tools/open-tabs.tool.js +35 -0
package/bridge.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
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
|
+
import http from 'http';
|
|
28
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
29
|
+
|
|
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 '/'
|
|
32
|
+
const CALL_TIMEOUT_MS = 10_000;
|
|
33
|
+
const ROUTE_GRACE_MS = 3_500; // absorb brief owner↔follower failover before erroring
|
|
34
|
+
|
|
35
|
+
const EXT_NOT_CONNECTED_MSG =
|
|
36
|
+
'Chrome extension is not connected. Load the 2ManyTabs MCP extension in your browser ' +
|
|
37
|
+
'and confirm its popup shows "Connected".';
|
|
38
|
+
|
|
39
|
+
const PNA_HEADERS = {
|
|
40
|
+
'Access-Control-Allow-Origin': '*',
|
|
41
|
+
'Access-Control-Allow-Private-Network': 'true',
|
|
42
|
+
'Access-Control-Allow-Headers': 'content-type',
|
|
43
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Module state
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
let role = 'starting'; // 'starting' | 'owner' | 'follower'
|
|
51
|
+
let bindInProgress = false;
|
|
52
|
+
|
|
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)
|
|
58
|
+
|
|
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 }
|
|
63
|
+
|
|
64
|
+
// Calls parked until a usable route appears (covers cold start / failover).
|
|
65
|
+
const readyWaiters = [];
|
|
66
|
+
|
|
67
|
+
function log(msg) {
|
|
68
|
+
// stderr only — stdout is reserved for the MCP stdio transport.
|
|
69
|
+
process.stderr.write(`[2manytabs-mcp] ${msg}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Entry point
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export function startBridge() {
|
|
77
|
+
attemptBind();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// OWNER: try to bind 9876. Win → own the extension. Lose (EADDRINUSE) → follow.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function attemptBind() {
|
|
85
|
+
if (bindInProgress || role === 'owner') return;
|
|
86
|
+
bindInProgress = true;
|
|
87
|
+
|
|
88
|
+
const httpServer = http.createServer((req, res) => {
|
|
89
|
+
if (req.method === 'OPTIONS') { // PNA preflight before the WS upgrade
|
|
90
|
+
res.writeHead(204, PNA_HEADERS);
|
|
91
|
+
res.end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
95
|
+
res.end('2ManyTabs MCP host running\n');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
httpServer.on('error', (err) => {
|
|
99
|
+
bindInProgress = false;
|
|
100
|
+
if (err.code === 'EADDRINUSE') {
|
|
101
|
+
// Another host already owns the bridge — expected with multiple sessions. Follow it.
|
|
102
|
+
log(`Port ${WS_PORT} already owned by another host — joining as a follower.`);
|
|
103
|
+
becomeFollower();
|
|
104
|
+
} else {
|
|
105
|
+
log(`HTTP server error: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
110
|
+
|
|
111
|
+
wss.on('headers', (headers) => {
|
|
112
|
+
headers.push('Access-Control-Allow-Origin: *');
|
|
113
|
+
headers.push('Access-Control-Allow-Private-Network: true');
|
|
114
|
+
});
|
|
115
|
+
wss.on('error', (err) => { if (err.code !== 'EADDRINUSE') log(`WebSocket error: ${err.message}`); });
|
|
116
|
+
|
|
117
|
+
wss.on('connection', (socket, req) => {
|
|
118
|
+
const origin = req.headers.origin || '';
|
|
119
|
+
|
|
120
|
+
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
|
+
if (origin) {
|
|
124
|
+
log(`Rejected peer connection from non-node origin: ${origin}`);
|
|
125
|
+
socket.close(4003, 'Forbidden origin');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
handlePeerConnection(socket);
|
|
129
|
+
} else {
|
|
130
|
+
// Extension connections must originate from a chrome-extension:// URI.
|
|
131
|
+
if (!origin.startsWith('chrome-extension://')) {
|
|
132
|
+
log(`Rejected extension connection from unauthorized origin: ${origin}`);
|
|
133
|
+
socket.close(4003, 'Unauthorized origin');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
handleExtensionConnection(socket);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
httpServer.listen(WS_PORT, '127.0.0.1', () => {
|
|
141
|
+
bindInProgress = false;
|
|
142
|
+
role = 'owner';
|
|
143
|
+
peerSocket = null; // shed any stale follower state from a prior life
|
|
144
|
+
log(`Bridge OWNER listening on ws://127.0.0.1:${WS_PORT}`);
|
|
145
|
+
log('Waiting for browser extension...');
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleExtensionConnection(socket) {
|
|
150
|
+
extensionSocket = socket;
|
|
151
|
+
log('Extension connected');
|
|
152
|
+
flushReady();
|
|
153
|
+
|
|
154
|
+
socket.on('message', (raw) => {
|
|
155
|
+
let msg;
|
|
156
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
157
|
+
const d = inflight.get(msg.id);
|
|
158
|
+
if (!d) return;
|
|
159
|
+
clearTimeout(d.timer);
|
|
160
|
+
inflight.delete(msg.id);
|
|
161
|
+
deliver(d, msg.result, msg.error);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
socket.on('close', () => {
|
|
165
|
+
if (extensionSocket === socket) {
|
|
166
|
+
extensionSocket = null;
|
|
167
|
+
log('Extension disconnected');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
socket.on('error', () => { /* close handles cleanup */ });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function handlePeerConnection(socket) {
|
|
174
|
+
peerClients.add(socket);
|
|
175
|
+
log(`Follower host connected (${peerClients.size} active)`);
|
|
176
|
+
|
|
177
|
+
socket.on('message', (raw) => {
|
|
178
|
+
let msg;
|
|
179
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
180
|
+
if (msg.type !== 'call') return;
|
|
181
|
+
forwardToExtension(msg.action, msg.params, { kind: 'peer', socket, peerReqId: msg.peerReqId });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
socket.on('close', () => { peerClients.delete(socket); });
|
|
185
|
+
socket.on('error', () => { /* close handles cleanup */ });
|
|
186
|
+
}
|
|
187
|
+
|
|
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;
|
|
193
|
+
}
|
|
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
|
+
}
|
|
202
|
+
|
|
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 }));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// FOLLOWER: proxy calls to the owner; re-elect if the owner vanishes.
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
function becomeFollower() {
|
|
220
|
+
role = 'follower';
|
|
221
|
+
if (peerSocket && peerSocket.readyState === WebSocket.OPEN) return;
|
|
222
|
+
connectPeer();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function connectPeer() {
|
|
226
|
+
let socket;
|
|
227
|
+
try {
|
|
228
|
+
socket = new WebSocket(`ws://127.0.0.1:${WS_PORT}${PEER_PATH}`);
|
|
229
|
+
} catch {
|
|
230
|
+
scheduleReElection();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
peerSocket = socket;
|
|
234
|
+
|
|
235
|
+
socket.on('open', () => {
|
|
236
|
+
log('Bridge FOLLOWER connected to owner');
|
|
237
|
+
flushReady();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
socket.on('message', (raw) => {
|
|
241
|
+
let msg;
|
|
242
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
243
|
+
if (msg.type !== 'reply') return;
|
|
244
|
+
const p = proxyPending.get(msg.peerReqId);
|
|
245
|
+
if (!p) return;
|
|
246
|
+
clearTimeout(p.timer);
|
|
247
|
+
proxyPending.delete(msg.peerReqId);
|
|
248
|
+
if (msg.error) p.reject(new Error(msg.error));
|
|
249
|
+
else p.resolve(msg.result);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
socket.on('close', () => {
|
|
253
|
+
if (peerSocket === socket) peerSocket = null;
|
|
254
|
+
failProxyPending();
|
|
255
|
+
scheduleReElection(); // owner may have died — try to take over
|
|
256
|
+
});
|
|
257
|
+
socket.on('error', () => { /* close follows */ });
|
|
258
|
+
}
|
|
259
|
+
|
|
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
|
+
function scheduleReElection() {
|
|
264
|
+
const delay = 200 + Math.floor(Math.random() * 300);
|
|
265
|
+
setTimeout(() => {
|
|
266
|
+
if (role === 'follower' && (!peerSocket || peerSocket.readyState !== WebSocket.OPEN)) {
|
|
267
|
+
attemptBind();
|
|
268
|
+
}
|
|
269
|
+
}, delay);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function followerCall(action, params) {
|
|
273
|
+
if (!peerSocket || peerSocket.readyState !== WebSocket.OPEN) {
|
|
274
|
+
return Promise.reject(new Error(EXT_NOT_CONNECTED_MSG));
|
|
275
|
+
}
|
|
276
|
+
const peerReqId = ++proxyId;
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
proxyPending.delete(peerReqId);
|
|
280
|
+
reject(new Error('Timed out waiting for browser extension response (10s).'));
|
|
281
|
+
}, CALL_TIMEOUT_MS);
|
|
282
|
+
proxyPending.set(peerReqId, { resolve, reject, timer });
|
|
283
|
+
peerSocket.send(JSON.stringify({ type: 'call', peerReqId, action, params }));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function failProxyPending() {
|
|
288
|
+
for (const [, p] of proxyPending) {
|
|
289
|
+
clearTimeout(p.timer);
|
|
290
|
+
p.reject(new Error('Bridge owner connection lost; please retry.'));
|
|
291
|
+
}
|
|
292
|
+
proxyPending.clear();
|
|
293
|
+
}
|
|
294
|
+
|
|
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
|
+
function hasRoute() {
|
|
302
|
+
if (role === 'owner') return !!extensionSocket && extensionSocket.readyState === WebSocket.OPEN;
|
|
303
|
+
if (role === 'follower') return !!peerSocket && peerSocket.readyState === WebSocket.OPEN;
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function flushReady() {
|
|
308
|
+
if (!hasRoute()) return;
|
|
309
|
+
while (readyWaiters.length) readyWaiters.shift()();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function waitForRoute() {
|
|
313
|
+
if (hasRoute()) return Promise.resolve(role);
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const onReady = () => {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
const i = readyWaiters.indexOf(onReady);
|
|
318
|
+
if (i >= 0) readyWaiters.splice(i, 1);
|
|
319
|
+
resolve(role);
|
|
320
|
+
};
|
|
321
|
+
const timer = setTimeout(() => {
|
|
322
|
+
const i = readyWaiters.indexOf(onReady);
|
|
323
|
+
if (i >= 0) readyWaiters.splice(i, 1);
|
|
324
|
+
reject(new Error(EXT_NOT_CONNECTED_MSG));
|
|
325
|
+
}, ROUTE_GRACE_MS);
|
|
326
|
+
readyWaiters.push(onReady);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Public primitive used by every tool.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
export async function callExtension(action, params = {}) {
|
|
335
|
+
const activeRole = await waitForRoute(); // throws the clear error after the grace window
|
|
336
|
+
if (activeRole === 'owner') {
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
forwardToExtension(action, params, { kind: 'local', resolve, reject });
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return followerCall(action, params);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function isExtensionConnected() {
|
|
345
|
+
return hasRoute();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Lightweight introspection for logging / a future status tool.
|
|
349
|
+
export function bridgeStatus() {
|
|
350
|
+
return {
|
|
351
|
+
role,
|
|
352
|
+
extension_connected: !!extensionSocket && extensionSocket.readyState === WebSocket.OPEN,
|
|
353
|
+
followers: peerClients.size,
|
|
354
|
+
owner_reachable: role === 'follower' ? (!!peerSocket && peerSocket.readyState === WebSocket.OPEN) : undefined,
|
|
355
|
+
};
|
|
356
|
+
}
|
package/host.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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';
|
|
14
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
|
+
import { startBridge } from './bridge.js';
|
|
16
|
+
import { toolRegistry } from './tools/index.js';
|
|
17
|
+
|
|
18
|
+
const server = new McpServer({ name: '2manytabs-mcp', version: '2.0.0' });
|
|
19
|
+
|
|
20
|
+
// Register every tool generically. Adding a tool requires zero changes here.
|
|
21
|
+
for (const tool of toolRegistry) {
|
|
22
|
+
server.registerTool(
|
|
23
|
+
tool.name,
|
|
24
|
+
{
|
|
25
|
+
title: tool.title,
|
|
26
|
+
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)
|
|
29
|
+
},
|
|
30
|
+
async (args) => {
|
|
31
|
+
try {
|
|
32
|
+
const text = await tool.execute(args);
|
|
33
|
+
return { content: [{ type: 'text', text }] };
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: 'text', text: `Error in ${tool.name}: ${err.message}` }],
|
|
37
|
+
isError: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Bring up the extension bridge, then connect MCP over stdio.
|
|
45
|
+
startBridge();
|
|
46
|
+
|
|
47
|
+
const transport = new StdioServerTransport();
|
|
48
|
+
await server.connect(transport);
|
|
49
|
+
process.stderr.write(`[2manytabs-mcp] MCP server ready (${toolRegistry.length} tools) on stdio\n`);
|
|
50
|
+
|
|
51
|
+
// Ensure clean exit when the parent process disconnects or terminates
|
|
52
|
+
const cleanup = () => process.exit(0);
|
|
53
|
+
process.stdin.on('close', cleanup);
|
|
54
|
+
process.stdin.on('end', cleanup);
|
|
55
|
+
process.on('SIGINT', cleanup);
|
|
56
|
+
process.on('SIGTERM', cleanup);
|
package/lib/tabs.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Pure tab-selection helpers — shared by the list_tabs and close_tabs tools.
|
|
2
|
+
// Keeping these side-effect-free makes the tools easy to reason about and test.
|
|
3
|
+
|
|
4
|
+
/** Extract a display domain from a tab URL, tolerating chrome://, file://, blank. */
|
|
5
|
+
export function domainOf(tab) {
|
|
6
|
+
try {
|
|
7
|
+
const u = new URL(tab.url);
|
|
8
|
+
if (u.protocol === 'chrome:' || u.protocol === 'chrome-extension:') {
|
|
9
|
+
return `${u.protocol}//${u.hostname || u.pathname.split('/')[0] || ''}`.replace(/\/$/, '');
|
|
10
|
+
}
|
|
11
|
+
return u.hostname || '(local)';
|
|
12
|
+
} catch {
|
|
13
|
+
return '(unknown)';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Case-insensitive substring match against title OR url. */
|
|
18
|
+
export function matchesQuery(tab, q) {
|
|
19
|
+
const needle = q.toLowerCase();
|
|
20
|
+
return (tab.title ?? '').toLowerCase().includes(needle) ||
|
|
21
|
+
(tab.url ?? '').toLowerCase().includes(needle);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Return the ids of duplicate tabs — every tab sharing a URL with an earlier
|
|
26
|
+
* tab. The first occurrence of each URL is kept; the rest are returned.
|
|
27
|
+
*/
|
|
28
|
+
export function findDuplicateIds(tabs) {
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const dupes = [];
|
|
31
|
+
for (const t of tabs) {
|
|
32
|
+
if (seen.has(t.url)) dupes.push(t.id);
|
|
33
|
+
else seen.add(t.url);
|
|
34
|
+
}
|
|
35
|
+
return dupes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build a { domain -> count } histogram, sorted by count descending. */
|
|
39
|
+
export function domainHistogram(tabs, limit = 15) {
|
|
40
|
+
const counts = {};
|
|
41
|
+
for (const t of tabs) {
|
|
42
|
+
const d = domainOf(t);
|
|
43
|
+
counts[d] = (counts[d] ?? 0) + 1;
|
|
44
|
+
}
|
|
45
|
+
return Object.entries(counts)
|
|
46
|
+
.sort((a, b) => b[1] - a[1])
|
|
47
|
+
.slice(0, limit)
|
|
48
|
+
.map(([domain, count]) => ({ domain, count }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Group tabs by domain → { domain: { count, tab_ids, sample_titles } }, sorted by count. */
|
|
52
|
+
export function groupByDomain(tabs) {
|
|
53
|
+
const groups = {};
|
|
54
|
+
for (const t of tabs) {
|
|
55
|
+
const d = domainOf(t);
|
|
56
|
+
(groups[d] ??= []).push(t);
|
|
57
|
+
}
|
|
58
|
+
return Object.fromEntries(
|
|
59
|
+
Object.entries(groups)
|
|
60
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
61
|
+
.map(([domain, list]) => [domain, {
|
|
62
|
+
count: list.length,
|
|
63
|
+
tab_ids: list.map(t => t.id),
|
|
64
|
+
sample_titles: list.slice(0, 3).map(t => t.title || '(untitled)'),
|
|
65
|
+
}])
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Group tabs by windowId → { windowId: { count, tab_ids } }. */
|
|
70
|
+
export function groupByWindow(tabs) {
|
|
71
|
+
const groups = {};
|
|
72
|
+
for (const t of tabs) {
|
|
73
|
+
(groups[t.windowId] ??= []).push(t);
|
|
74
|
+
}
|
|
75
|
+
return Object.fromEntries(
|
|
76
|
+
Object.entries(groups).map(([win, list]) => [win, {
|
|
77
|
+
count: list.length,
|
|
78
|
+
tab_ids: list.map(t => t.id),
|
|
79
|
+
}])
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Trim a tab to a compact shape for listing (keeps token cost sane at 1000 tabs). */
|
|
84
|
+
export function compact(tab) {
|
|
85
|
+
const title = tab.title ?? '';
|
|
86
|
+
return {
|
|
87
|
+
id: tab.id,
|
|
88
|
+
window: tab.windowId,
|
|
89
|
+
title: title.length > 80 ? title.slice(0, 77) + '…' : title,
|
|
90
|
+
url: tab.url ?? '',
|
|
91
|
+
pinned: tab.pinned || undefined,
|
|
92
|
+
audible: tab.audible || undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "2manytabs-mcp-host",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "MCP server that exposes Chrome tabs via the 2ManyTabs MCP extension",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "host.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"2manytabs-mcp": "host.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"host.js",
|
|
12
|
+
"bridge.js",
|
|
13
|
+
"lib/",
|
|
14
|
+
"tools/"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node host.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
21
|
+
"ws": "^8.18.0",
|
|
22
|
+
"zod": "^4.4.3"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { callExtension } from '../bridge.js';
|
|
3
|
+
import { matchesQuery, findDuplicateIds, domainOf } from '../lib/tabs.js';
|
|
4
|
+
|
|
5
|
+
// ACT tool. Absorbs the old close_tabs + close_tabs_matching + close_duplicate_tabs.
|
|
6
|
+
// Exactly one selection mode must be supplied. `dry_run` previews without closing —
|
|
7
|
+
// the safe way to verify a bulk close of hundreds of tabs before committing.
|
|
8
|
+
export const closeTabsTool = {
|
|
9
|
+
name: 'close_tabs',
|
|
10
|
+
title: 'Close Tabs',
|
|
11
|
+
description:
|
|
12
|
+
'Close Chrome tabs by one selection mode: `tab_ids` (explicit), `match` (substring of ' +
|
|
13
|
+
'title/URL), or `duplicates` (every tab sharing a URL with an earlier one). Set `dry_run` ' +
|
|
14
|
+
'to preview the exact tabs that would close without touching them. Destructive — closed ' +
|
|
15
|
+
'tabs cannot be recovered through this tool.\n\n' +
|
|
16
|
+
'CRITICAL: You MUST explicitly ask the user for confirmation before running this tool with ' +
|
|
17
|
+
'dry_run=false, unless they already asked you to.\n\n' +
|
|
18
|
+
'Examples:\n' +
|
|
19
|
+
'- {"tab_ids": [123, 456], "dry_run": true}\n' +
|
|
20
|
+
'- {"match": "github.com", "dry_run": false}\n' +
|
|
21
|
+
'- {"duplicates": true, "dry_run": true}\n\n' +
|
|
22
|
+
'Returns:\n' +
|
|
23
|
+
' JSON object containing:\n' +
|
|
24
|
+
' - dry_run (boolean): Whether this was a preview\n' +
|
|
25
|
+
' - reason (string): Description of selection criteria\n' +
|
|
26
|
+
' - count/closed (int): Number of tabs matched/closed\n' +
|
|
27
|
+
' - by_domain (object): Map of domain to count\n' +
|
|
28
|
+
' - sample (array): First 10 tabs with {id, title}',
|
|
29
|
+
annotations: {
|
|
30
|
+
readOnlyHint: false,
|
|
31
|
+
destructiveHint: true,
|
|
32
|
+
idempotentHint: false,
|
|
33
|
+
openWorldHint: true,
|
|
34
|
+
},
|
|
35
|
+
inputSchema: {
|
|
36
|
+
tab_ids: z.array(z.number().int()).optional()
|
|
37
|
+
.describe('Explicit tab IDs to close (from list_tabs).'),
|
|
38
|
+
match: z.string().optional()
|
|
39
|
+
.describe('Close every tab whose title or URL contains this substring (case-insensitive).'),
|
|
40
|
+
duplicates: z.boolean().default(false)
|
|
41
|
+
.describe('Close duplicate tabs, keeping the first occurrence of each URL.'),
|
|
42
|
+
dry_run: z.boolean().default(false)
|
|
43
|
+
.describe('Preview the tabs that would close — does not close anything. Recommended for bulk closes.'),
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
execute: async ({ tab_ids, match, duplicates, dry_run }) => {
|
|
47
|
+
// Enforce exactly one selection mode.
|
|
48
|
+
const modes = [tab_ids?.length ? 'tab_ids' : null, match ? 'match' : null, duplicates ? 'duplicates' : null]
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
if (modes.length === 0) {
|
|
51
|
+
throw new Error('Provide one selection mode: tab_ids, match, or duplicates:true.');
|
|
52
|
+
}
|
|
53
|
+
if (modes.length > 1) {
|
|
54
|
+
throw new Error(`Use only one selection mode at a time (got: ${modes.join(', ')}).`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const all = await callExtension('query_tabs');
|
|
58
|
+
const byId = new Map(all.map((t) => [t.id, t]));
|
|
59
|
+
|
|
60
|
+
let targets, reason;
|
|
61
|
+
if (tab_ids?.length) {
|
|
62
|
+
targets = tab_ids.filter((id) => byId.has(id)).map((id) => byId.get(id));
|
|
63
|
+
reason = `${targets.length} tab(s) by id`;
|
|
64
|
+
const missing = tab_ids.filter((id) => !byId.has(id));
|
|
65
|
+
if (missing.length) reason += ` (${missing.length} id(s) no longer exist, skipped)`;
|
|
66
|
+
} else if (duplicates) {
|
|
67
|
+
const dupIds = new Set(findDuplicateIds(all));
|
|
68
|
+
targets = all.filter((t) => dupIds.has(t.id));
|
|
69
|
+
reason = 'duplicate tabs';
|
|
70
|
+
} else {
|
|
71
|
+
targets = all.filter((t) => matchesQuery(t, match));
|
|
72
|
+
reason = `tabs matching "${match}"`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (targets.length === 0) {
|
|
76
|
+
return `No tabs matched (${reason}). Nothing to close.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const preview = {
|
|
80
|
+
reason,
|
|
81
|
+
count: targets.length,
|
|
82
|
+
by_domain: targets.reduce((acc, t) => {
|
|
83
|
+
const d = domainOf(t);
|
|
84
|
+
acc[d] = (acc[d] ?? 0) + 1;
|
|
85
|
+
return acc;
|
|
86
|
+
}, {}),
|
|
87
|
+
sample: targets.slice(0, 10).map((t) => ({ id: t.id, title: t.title || '(untitled)' })),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (dry_run) {
|
|
91
|
+
return JSON.stringify({ dry_run: true, ...preview }, null, 2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const res = await callExtension('close_tabs', { tab_ids: targets.map((t) => t.id) });
|
|
95
|
+
return JSON.stringify({ dry_run: false, closed: res.closed, reason, by_domain: preview.by_domain, sample: preview.sample }, null, 2);
|
|
96
|
+
},
|
|
97
|
+
};
|
package/tools/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Tool registry — the single place that knows which tools exist.
|
|
2
|
+
// To add a tool: create ./my-thing.tool.js exporting a tool object, then add it here.
|
|
3
|
+
// host.js handles all SDK wiring (schema generation, validation, dispatch) generically.
|
|
4
|
+
|
|
5
|
+
import { listTabsTool } from './list-tabs.tool.js';
|
|
6
|
+
import { closeTabsTool } from './close-tabs.tool.js';
|
|
7
|
+
import { openTabsTool } from './open-tabs.tool.js';
|
|
8
|
+
|
|
9
|
+
export const toolRegistry = [
|
|
10
|
+
listTabsTool,
|
|
11
|
+
closeTabsTool,
|
|
12
|
+
openTabsTool,
|
|
13
|
+
];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { callExtension } from '../bridge.js';
|
|
3
|
+
import {
|
|
4
|
+
matchesQuery, findDuplicateIds, domainHistogram,
|
|
5
|
+
groupByDomain, groupByWindow, compact,
|
|
6
|
+
} from '../lib/tabs.js';
|
|
7
|
+
|
|
8
|
+
// READ tool. Absorbs the old list_tabs + get_tab_groups.
|
|
9
|
+
// Always returns a domain histogram up top so the agent gets an instant map of
|
|
10
|
+
// 1000 tabs before deciding what to close.
|
|
11
|
+
export const listTabsTool = {
|
|
12
|
+
name: 'list_tabs',
|
|
13
|
+
title: 'List Tabs',
|
|
14
|
+
description:
|
|
15
|
+
'Read open Chrome tabs. Returns a domain histogram (the fastest way to understand a large ' +
|
|
16
|
+
'tab set) plus a detailed view. When summarizing the results for the user, you should ' +
|
|
17
|
+
'include the histogram bar graph in your response. Filter with `query`, reshape with ' +
|
|
18
|
+
'`group_by`, or isolate redundant tabs with `duplicates_only`. Read-only — never closes anything.',
|
|
19
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
20
|
+
inputSchema: {
|
|
21
|
+
query: z.string().optional()
|
|
22
|
+
.describe('Case-insensitive substring; keep only tabs whose title or URL contains it.'),
|
|
23
|
+
group_by: z.enum(['none', 'domain', 'window']).default('domain')
|
|
24
|
+
.describe("Shape of the detailed view. 'domain' (default) is best for triaging many tabs; " +
|
|
25
|
+
"'window' groups by browser window; 'none' returns a flat list."),
|
|
26
|
+
duplicates_only: z.boolean().default(false)
|
|
27
|
+
.describe('Only include tabs that are duplicates (share a URL with an earlier tab).'),
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
execute: async ({ query, group_by, duplicates_only }) => {
|
|
31
|
+
const all = await callExtension('query_tabs');
|
|
32
|
+
|
|
33
|
+
let tabs = all;
|
|
34
|
+
if (query) tabs = tabs.filter((t) => matchesQuery(t, query));
|
|
35
|
+
if (duplicates_only) {
|
|
36
|
+
const dupIds = new Set(findDuplicateIds(tabs));
|
|
37
|
+
tabs = tabs.filter((t) => dupIds.has(t.id));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const windows = new Set(tabs.map((t) => t.windowId)).size;
|
|
41
|
+
const dupCount = findDuplicateIds(tabs).length;
|
|
42
|
+
|
|
43
|
+
const histogram = domainHistogram(tabs);
|
|
44
|
+
|
|
45
|
+
// Build a human-readable grouped summary that naturally guides the agent
|
|
46
|
+
// to present tabs in a logical way.
|
|
47
|
+
const lines = [];
|
|
48
|
+
|
|
49
|
+
// Header line
|
|
50
|
+
lines.push(`📊 ${tabs.length} tab(s) across ${windows} window(s)` +
|
|
51
|
+
(query ? ` matching "${query}"` : '') +
|
|
52
|
+
(duplicates_only ? ' (duplicates only)' : '') +
|
|
53
|
+
(dupCount > 0 ? ` · ${dupCount} duplicate(s) found` : '') +
|
|
54
|
+
'\n');
|
|
55
|
+
|
|
56
|
+
// Domain summary bar
|
|
57
|
+
lines.push('Domains:');
|
|
58
|
+
for (const d of histogram) {
|
|
59
|
+
const bar = '▇'.repeat(Math.max(1, Math.round(d.count / Math.max(...histogram.map(x => x.count)) * 20)));
|
|
60
|
+
lines.push(` ${bar} ${d.domain.padEnd(28)} ${d.count} tab(s)`);
|
|
61
|
+
}
|
|
62
|
+
lines.push('');
|
|
63
|
+
|
|
64
|
+
// Detailed grouped view
|
|
65
|
+
if (group_by === 'domain') {
|
|
66
|
+
const groups = groupByDomain(tabs);
|
|
67
|
+
for (const [domain, info] of Object.entries(groups)) {
|
|
68
|
+
lines.push(`📁 ${domain} — ${info.count} tab(s)`);
|
|
69
|
+
for (const id of info.tab_ids) {
|
|
70
|
+
const tab = tabs.find(t => t.id === id);
|
|
71
|
+
if (!tab) continue;
|
|
72
|
+
const label = (tab.title || '(untitled)').length > 90
|
|
73
|
+
? (tab.title || '(untitled)').slice(0, 87) + '…'
|
|
74
|
+
: (tab.title || '(untitled)');
|
|
75
|
+
const flags = [];
|
|
76
|
+
if (tab.pinned) flags.push('📌');
|
|
77
|
+
if (tab.audible) flags.push('🔊');
|
|
78
|
+
const flagStr = flags.length ? ' ' + flags.join('') : '';
|
|
79
|
+
lines.push(` · ${label}${flagStr}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
}
|
|
83
|
+
} else if (group_by === 'window') {
|
|
84
|
+
const groups = groupByWindow(tabs);
|
|
85
|
+
for (const [winId, info] of Object.entries(groups)) {
|
|
86
|
+
lines.push(`🪟 Window ${winId} — ${info.count} tab(s)`);
|
|
87
|
+
for (const id of info.tab_ids) {
|
|
88
|
+
const tab = tabs.find(t => t.id === id);
|
|
89
|
+
if (!tab) continue;
|
|
90
|
+
const label = (tab.title || '(untitled)').length > 90
|
|
91
|
+
? (tab.title || '(untitled)').slice(0, 87) + '…'
|
|
92
|
+
: (tab.title || '(untitled)');
|
|
93
|
+
lines.push(` · ${label}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
for (const t of tabs) {
|
|
99
|
+
const c = compact(t);
|
|
100
|
+
const label = c.title.length > 90 ? c.title.slice(0, 87) + '…' : c.title;
|
|
101
|
+
const flags = [];
|
|
102
|
+
if (c.pinned) flags.push('📌');
|
|
103
|
+
if (c.audible) flags.push('🔊');
|
|
104
|
+
const flagStr = flags.length ? ' ' + flags.join('') : '';
|
|
105
|
+
lines.push(` · ${label}${flagStr}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { callExtension } from '../bridge.js';
|
|
3
|
+
|
|
4
|
+
export const openTabsTool = {
|
|
5
|
+
name: 'open_tabs',
|
|
6
|
+
title: 'Open Tabs',
|
|
7
|
+
description:
|
|
8
|
+
'Open one or more URLs in new Chrome tabs. This tool allows you to navigate the user to ' +
|
|
9
|
+
'specific web pages or search queries.\n\n' +
|
|
10
|
+
'Examples:\n' +
|
|
11
|
+
'- {"urls": ["https://github.com", "https://youtube.com"]}\n' +
|
|
12
|
+
'- {"urls": ["en.wikipedia.org/wiki/Palantir"]}\n\n' +
|
|
13
|
+
'Returns:\n' +
|
|
14
|
+
' JSON object containing:\n' +
|
|
15
|
+
' - opened (int): Number of tabs successfully opened',
|
|
16
|
+
annotations: {
|
|
17
|
+
readOnlyHint: false,
|
|
18
|
+
destructiveHint: false, // opening tabs is non-destructive
|
|
19
|
+
idempotentHint: false,
|
|
20
|
+
openWorldHint: true,
|
|
21
|
+
},
|
|
22
|
+
inputSchema: {
|
|
23
|
+
urls: z.array(z.string()).min(1)
|
|
24
|
+
.describe('An array of URLs to open. If no protocol is provided, https:// will be prepended automatically.'),
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
execute: async ({ urls }) => {
|
|
28
|
+
if (!urls || urls.length === 0) {
|
|
29
|
+
throw new Error('Provide at least one URL to open.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const res = await callExtension('open_tabs', { urls });
|
|
33
|
+
return JSON.stringify({ opened: res.opened }, null, 2);
|
|
34
|
+
},
|
|
35
|
+
};
|