poltertab 1.3.0 → 1.5.0-beta.1

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.
@@ -8,34 +8,16 @@ const {
8
8
  CallToolRequestSchema,
9
9
  ListToolsRequestSchema,
10
10
  } = require("@modelcontextprotocol/sdk/types.js");
11
- const WebSocket = require("ws");
12
- const crypto = require("crypto");
13
- const fs = require("fs");
14
- const os = require("os");
15
- const path = require("path");
16
-
17
- // Anything the user accumulates lives outside the package. Under a global npm
18
- // install __dirname resolves inside node_modules/poltertab/, so site memory
19
- // written next to the code is destroyed by the next `npm update -g` — the
20
- // upgrade would read as amnesia. Downloads have it worse: output_file exists to
21
- // keep large payloads out of the context window, and burying them in
22
- // node_modules makes them hard to find and just as easy to lose.
23
- //
24
- // POLTERTAB_HOME exists so the test suite can point this somewhere disposable
25
- // instead of writing into the real one.
26
- const POLTERTAB_HOME =
27
- process.env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
28
- const MEMORY_DIR = path.join(POLTERTAB_HOME, "navigation_memory");
29
- const DOWNLOADS_DIR = path.join(POLTERTAB_HOME, "downloads");
30
-
31
- fs.mkdirSync(MEMORY_DIR, { recursive: true });
11
+ const { SMART_SCROLL_SETTLE_MS, POLTERTAB_HOME } = require("./config.js");
32
12
 
33
13
  const updates = require("./update-check.js");
14
+ const { BROWSER_TOOLS } = require("./tools.js");
15
+ const { writeOutput, summarizeOutput, rowsOf } = require("./output.js");
16
+ const { readMemory, saveMemory } = require("./memory.js");
17
+ const { extractAll } = require("./extract-all.js");
18
+ const bridge = require("./bridge.js");
34
19
  const OWN_VERSION = require("./../package.json").version;
35
20
 
36
- // Populated when the extension connects; stays null until then, which is itself
37
- // worth reporting — a skew warning must not fire on "no extension yet".
38
- let extensionVersion = null;
39
21
  let updateState = { latest: null, updateAvailable: false };
40
22
  let noticeDelivered = false;
41
23
 
@@ -50,547 +32,6 @@ if (!updates.disabled()) {
50
32
  .catch(() => {});
51
33
  }
52
34
 
53
- // Installs predating the move kept memory beside the code. Copy it forward once
54
- // so an upgrade does not look like the agent forgot everything it learned.
55
- // Never overwrite: if both sides have a note for a domain, the one already in
56
- // the new location is the live one.
57
- (() => {
58
- const legacy = path.join(__dirname, "navigation_memory");
59
- if (legacy === MEMORY_DIR || !fs.existsSync(legacy)) return;
60
- let copied = 0;
61
- for (const name of fs.readdirSync(legacy)) {
62
- const to = path.join(MEMORY_DIR, name);
63
- if (!name.endsWith(".json") || fs.existsSync(to)) continue;
64
- try {
65
- fs.copyFileSync(path.join(legacy, name), to);
66
- copied++;
67
- } catch (_) {
68
- // A read-only or half-removed legacy dir is not worth failing startup.
69
- }
70
- }
71
- if (copied) {
72
- console.error(
73
- `[PolterTab MCP] Migrated ${copied} site memory file(s) to ${MEMORY_DIR}`,
74
- );
75
- }
76
- })();
77
-
78
- // Port configuration: use MCP_BROWSER_WS_PORT env, or --port CLI arg, or 7822 fallback
79
- let WS_PORT = process.env.MCP_BROWSER_WS_PORT
80
- ? parseInt(process.env.MCP_BROWSER_WS_PORT, 10)
81
- : 7822;
82
- const portArgIndex = process.argv.indexOf("--port");
83
- if (portArgIndex !== -1 && process.argv[portArgIndex + 1]) {
84
- WS_PORT = parseInt(process.argv[portArgIndex + 1], 10);
85
- }
86
-
87
- // Global state
88
- let extensionSocket = null;
89
- const pendingCommands = new Map();
90
- const networkState = new Map(); // tabId -> { lastUpdated: number, requests: array }
91
- const secondaryClients = new Map();
92
- let primaryLastTabId = null;
93
-
94
- let isSecondary = false;
95
- let nodeId = null;
96
- let wss = null;
97
- let httpServer = null;
98
-
99
- function broadcastSessionState() {
100
- if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
101
- const sessions = [];
102
- sessions.push({
103
- id: "Primary Agent",
104
- nodeId: "primary",
105
- lastTabId: primaryLastTabId || null,
106
- });
107
- for (const [nodeId, clientWs] of secondaryClients.entries()) {
108
- sessions.push({
109
- id: "Secondary Agent",
110
- nodeId: nodeId,
111
- lastTabId: clientWs.lastTabId || null,
112
- });
113
- }
114
- extensionSocket.send(
115
- JSON.stringify({
116
- type: "state_update",
117
- sessions,
118
- }),
119
- );
120
- }
121
- }
122
-
123
- function setupPrimaryServer() {
124
- const http = require("http");
125
- httpServer = http.createServer();
126
-
127
- httpServer.on("error", (e) => {
128
- if (e.code === "EADDRINUSE") {
129
- console.error(
130
- `[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
131
- );
132
- startSecondaryMode();
133
- } else {
134
- console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
135
- }
136
- });
137
-
138
- httpServer.listen(WS_PORT, () => {
139
- console.error(
140
- `[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
141
- );
142
- wss = new WebSocket.WebSocketServer({
143
- server: httpServer,
144
- perMessageDeflate: {
145
- zlibDeflateOptions: {
146
- chunkSize: 1024,
147
- memLevel: 7,
148
- level: 1,
149
- },
150
- zlibInflateOptions: {
151
- chunkSize: 10 * 1024,
152
- },
153
- clientNoContextTakeover: true,
154
- serverNoContextTakeover: true,
155
- serverMaxWindowBits: 10,
156
- concurrencyLimit: 10,
157
- threshold: 1024,
158
- },
159
- });
160
-
161
- // Heartbeat mechanism to detect dead connections
162
- const interval = setInterval(() => {
163
- wss.clients.forEach((ws) => {
164
- if (ws.isAlive === false) {
165
- console.error(
166
- `[PolterTab MCP] Terminating dead connection: ${ws.nodeId || "Extension/Unknown"}`,
167
- );
168
- return ws.terminate();
169
- }
170
- ws.isAlive = false;
171
- ws.ping();
172
- });
173
- }, 60000); // 60 seconds
174
-
175
- wss.on("close", () => {
176
- clearInterval(interval);
177
- });
178
-
179
- // Network State Garbage Collector (TTL: 5 minutes)
180
- setInterval(() => {
181
- const now = Date.now();
182
- for (const [tabId, state] of networkState.entries()) {
183
- if (now - state.lastUpdated > 5 * 60 * 1000) {
184
- console.error(
185
- `[PolterTab MCP] Garbage collecting network state for tab ${tabId}`,
186
- );
187
- networkState.delete(tabId);
188
- }
189
- }
190
- }, 60000);
191
-
192
- setupPrimaryWss(wss);
193
- });
194
- }
195
-
196
- function startSecondaryMode() {
197
- isSecondary = true;
198
- nodeId = crypto.randomUUID();
199
- console.error(
200
- `[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
201
- );
202
-
203
- connectToPrimary();
204
- }
205
-
206
- function connectToPrimary() {
207
- extensionSocket = new WebSocket(`ws://localhost:${WS_PORT}`, {
208
- perMessageDeflate: true,
209
- });
210
-
211
- extensionSocket.on("open", () => {
212
- console.error("[PolterTab MCP] Connected to Primary MCP Server.");
213
- extensionSocket.send(JSON.stringify({ type: "secondary_mcp", nodeId }));
214
- });
215
-
216
- extensionSocket.on("message", (message) => {
217
- try {
218
- const msg = JSON.parse(message);
219
- if (msg.id && pendingCommands.has(msg.id)) {
220
- const { resolve, reject, timer } = pendingCommands.get(msg.id);
221
- clearTimeout(timer);
222
- pendingCommands.delete(msg.id);
223
-
224
- if (msg.success) {
225
- resolve(msg.data);
226
- } else {
227
- reject(new Error(msg.error || "Unknown extension error"));
228
- }
229
- }
230
- } catch (err) {
231
- console.error(
232
- "[PolterTab MCP] Failed to parse message from Primary",
233
- err,
234
- );
235
- }
236
- });
237
-
238
- extensionSocket.on("close", () => {
239
- console.error(
240
- "[PolterTab MCP] Connection to Primary lost. Attempting to become Primary...",
241
- );
242
- extensionSocket = null;
243
-
244
- // Reject all pending commands
245
- for (const [id, { reject, timer }] of pendingCommands) {
246
- clearTimeout(timer);
247
- reject(new Error("Primary node disconnected. Command aborted."));
248
- pendingCommands.delete(id);
249
- }
250
-
251
- // Try to become primary with exponential backoff & jitter
252
- const jitter = Math.floor(Math.random() * 2000);
253
- const delay = 500 + jitter;
254
-
255
- setTimeout(() => {
256
- isSecondary = false;
257
- setupPrimaryServer();
258
- }, delay);
259
- });
260
-
261
- extensionSocket.on("error", (err) => {
262
- console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
263
- });
264
- }
265
-
266
- function setupPrimaryWss(wss) {
267
- wss.on("connection", (ws) => {
268
- ws.isAlive = true;
269
- ws.on("pong", () => {
270
- ws.isAlive = true;
271
- });
272
-
273
- ws.on("message", (message) => {
274
- try {
275
- const msg = JSON.parse(message);
276
-
277
- if (msg.type === "secondary_mcp") {
278
- if (secondaryClients.size >= 5) {
279
- console.error(
280
- `[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
281
- );
282
- ws.close();
283
- return;
284
- }
285
- secondaryClients.set(msg.nodeId, ws);
286
- ws.nodeId = msg.nodeId;
287
- console.error(
288
- `[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
289
- );
290
- broadcastSessionState();
291
- return;
292
- }
293
-
294
- if (msg.type === "request_full_state") {
295
- broadcastSessionState();
296
- return;
297
- }
298
-
299
- if (msg.type === "proxy_command") {
300
- const { id, action, params } = msg;
301
- if (params && params.tabId) {
302
- ws.lastTabId = params.tabId;
303
- broadcastSessionState();
304
- }
305
-
306
- if (action === "get_network_state") {
307
- (async () => {
308
- try {
309
- let tabId = params.tabId;
310
- if (!tabId) {
311
- const tabInfo = await sendCommand("get_url", params);
312
- tabId = tabInfo.tabId;
313
- }
314
- let data = [];
315
- if (tabId && networkState.has(tabId)) {
316
- data = networkState.get(tabId).requests;
317
- if (params.clear !== false) {
318
- networkState.get(tabId).requests = [];
319
- }
320
- }
321
- if (ws.readyState === WebSocket.OPEN) {
322
- ws.send(
323
- JSON.stringify({
324
- id,
325
- success: true,
326
- data: { tabId, capturedRequests: data.length, data },
327
- }),
328
- );
329
- }
330
- } catch (err) {
331
- if (ws.readyState === WebSocket.OPEN) {
332
- ws.send(
333
- JSON.stringify({ id, success: false, error: err.message }),
334
- );
335
- }
336
- }
337
- })();
338
- return;
339
- }
340
-
341
- if (
342
- !extensionSocket ||
343
- extensionSocket.readyState !== WebSocket.OPEN
344
- ) {
345
- if (ws.readyState === WebSocket.OPEN) {
346
- ws.send(
347
- JSON.stringify({
348
- id,
349
- success: false,
350
- error: "Browser extension not connected to Primary.",
351
- }),
352
- );
353
- }
354
- return;
355
- }
356
-
357
- const timer = setTimeout(() => {
358
- pendingCommands.delete(id);
359
- if (ws.readyState === WebSocket.OPEN) {
360
- ws.send(
361
- JSON.stringify({
362
- id,
363
- success: false,
364
- error: `Command '${action}' timed out.`,
365
- }),
366
- );
367
- }
368
- }, 35000);
369
-
370
- pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
371
- extensionSocket.send(JSON.stringify({ id, action, ...params }));
372
- return;
373
- }
374
-
375
- // Handle keepalive pings
376
- if (msg.type === "ping" || msg.type === "extension_ready") {
377
- if (!ws.nodeId) {
378
- // if it's the extension
379
- if (
380
- extensionSocket &&
381
- extensionSocket !== ws &&
382
- extensionSocket.readyState === WebSocket.OPEN
383
- ) {
384
- console.error(
385
- "[PolterTab MCP] Another extension trying to connect. Dropping old connection.",
386
- );
387
- extensionSocket.close();
388
- }
389
- if (extensionSocket !== ws) {
390
- extensionSocket = ws;
391
- console.error("[PolterTab MCP] Chrome extension connected.");
392
- }
393
- // The extension has always sent its version here and we always
394
- // dropped it, so the one mismatch that actually breaks commands was
395
- // invisible to both halves.
396
- if (msg.version && msg.version !== extensionVersion) {
397
- extensionVersion = msg.version;
398
- updates.recordExtension(POLTERTAB_HOME, msg.version);
399
- const sk = updates.skew(OWN_VERSION, msg.version);
400
- console.error(
401
- `[PolterTab MCP] Extension v${msg.version} (server v${OWN_VERSION})` +
402
- (sk ? ` — VERSION SKEW: ${sk.kind}` : ""),
403
- );
404
- // Let the extension show the mismatch in its popup; it cannot
405
- // know the server's version otherwise.
406
- try {
407
- ws.send(
408
- JSON.stringify({ type: "server_version", version: OWN_VERSION }),
409
- );
410
- } catch (_) {
411
- // A socket that died mid-handshake reconnects on its own.
412
- }
413
- }
414
- }
415
- return;
416
- }
417
-
418
- // Handle captured network data
419
- if (msg.type === "network_data") {
420
- const { tabId, url, body } = msg;
421
- if (!networkState.has(tabId)) {
422
- networkState.set(tabId, { lastUpdated: Date.now(), requests: [] });
423
- }
424
- const state = networkState.get(tabId);
425
- state.lastUpdated = Date.now();
426
-
427
- try {
428
- // Truncate bodies over 1MB
429
- let processedBody = body;
430
- if (body && body.length > 1024 * 1024) {
431
- processedBody =
432
- '{"error": "Payload exceeded 1MB limit and was truncated"}';
433
- }
434
-
435
- const jsonBody = JSON.parse(processedBody);
436
- state.requests.push({ url, timestamp: Date.now(), data: jsonBody });
437
- } catch (e) {
438
- state.requests.push({ url, timestamp: Date.now(), data: body });
439
- }
440
-
441
- // Cap at 500 requests
442
- if (state.requests.length > 500) {
443
- state.requests.shift();
444
- }
445
- return;
446
- }
447
-
448
- // Handle tab closed
449
- if (msg.type === "tab_closed") {
450
- const { tabId } = msg;
451
- if (networkState.has(tabId)) {
452
- console.error(
453
- `[PolterTab MCP] Clearing network state for closed tab ${tabId}`,
454
- );
455
- networkState.delete(tabId);
456
- }
457
- return;
458
- }
459
-
460
- // Response from Extension
461
- if (msg.id && pendingCommands.has(msg.id)) {
462
- const cmd = pendingCommands.get(msg.id);
463
- clearTimeout(cmd.timer);
464
- pendingCommands.delete(msg.id);
465
-
466
- if (cmd.isProxy) {
467
- if (cmd.sourceWs.readyState === WebSocket.OPEN) {
468
- cmd.sourceWs.send(JSON.stringify(msg));
469
- }
470
- } else if (msg.success) {
471
- cmd.resolve(msg.data);
472
- } else {
473
- cmd.reject(new Error(msg.error || "Unknown extension error"));
474
- }
475
- }
476
- } catch (err) {
477
- console.error("[PolterTab MCP] Failed to parse message", err);
478
- }
479
- });
480
-
481
- ws.on("close", () => {
482
- if (ws.nodeId) {
483
- console.error(
484
- `[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
485
- );
486
- secondaryClients.delete(ws.nodeId);
487
- broadcastSessionState();
488
- } else if (extensionSocket === ws) {
489
- console.error("[PolterTab MCP] Chrome extension disconnected.");
490
- extensionSocket = null;
491
- }
492
- });
493
-
494
- ws.on("error", (err) => {
495
- console.error(`[PolterTab MCP] WebSocket error: ${err.message}`);
496
- });
497
- });
498
- }
499
-
500
- // Start the server
501
- setupPrimaryServer();
502
-
503
- function shutdown() {
504
- console.error("[PolterTab MCP] Shutting down...");
505
- if (wss) wss.close();
506
- if (httpServer) httpServer.close();
507
- process.exit(0);
508
- }
509
- process.on("SIGINT", shutdown);
510
- process.on("SIGTERM", shutdown);
511
-
512
- // Helper to wait for WebSocket connection
513
- async function waitForConnection(timeoutMs = 5000) {
514
- const start = Date.now();
515
- while (Date.now() - start < timeoutMs) {
516
- if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
517
- return true;
518
- }
519
- await new Promise((resolve) => setTimeout(resolve, 100));
520
- }
521
- return false;
522
- }
523
-
524
- // Helper to send command to the extension
525
- async function sendCommand(action, params) {
526
- const isConnected = await waitForConnection(5000);
527
-
528
- if (isSecondary) {
529
- if (!isConnected) {
530
- throw new Error("Secondary node not connected to Primary MCP server.");
531
- }
532
-
533
- // Auto-inject a unique session to prevent collisions over shared tabs
534
- if (!params.session) {
535
- params.session = "agent_" + nodeId;
536
- }
537
-
538
- return new Promise((resolve, reject) => {
539
- const id = crypto.randomUUID();
540
- const timer = setTimeout(() => {
541
- pendingCommands.delete(id);
542
- reject(
543
- new Error(`Proxy Command '${action}' timed out after 35 seconds.`),
544
- );
545
- }, 35000);
546
-
547
- pendingCommands.set(id, { resolve, reject, timer });
548
-
549
- try {
550
- extensionSocket.send(
551
- JSON.stringify({ type: "proxy_command", id, action, params }),
552
- );
553
- } catch (err) {
554
- clearTimeout(timer);
555
- pendingCommands.delete(id);
556
- reject(
557
- new Error(
558
- `Failed to proxy command to Primary over WebSocket: ${err.message}`,
559
- ),
560
- );
561
- }
562
- });
563
- }
564
-
565
- if (!isConnected) {
566
- throw new Error(
567
- "Browser extension not connected. Please ensure the extension is installed, enabled, and pointing to the correct port (default: 7822).",
568
- );
569
- }
570
-
571
- return new Promise((resolve, reject) => {
572
- const id = crypto.randomUUID();
573
-
574
- // Strict timeout (35 seconds) to prevent hanging
575
- const timer = setTimeout(() => {
576
- pendingCommands.delete(id);
577
- reject(new Error(`Command '${action}' timed out after 35 seconds.`));
578
- }, 35000);
579
-
580
- pendingCommands.set(id, { resolve, reject, timer });
581
-
582
- try {
583
- extensionSocket.send(JSON.stringify({ id, action, ...params }));
584
- } catch (err) {
585
- clearTimeout(timer);
586
- pendingCommands.delete(id);
587
- reject(
588
- new Error(`Failed to send command over WebSocket: ${err.message}`),
589
- );
590
- }
591
- });
592
- }
593
-
594
35
  // Create MCP Server
595
36
  const server = new Server(
596
37
  {
@@ -606,304 +47,6 @@ const server = new Server(
606
47
  },
607
48
  );
608
49
 
609
- // Define tools
610
- const BROWSER_TOOLS = [
611
- {
612
- name: "browser_navigate",
613
- description: "Navigate to any URL in the browser",
614
- inputSchema: {
615
- type: "object",
616
- properties: {
617
- url: { type: "string" },
618
- tabId: { type: "number" },
619
- session: {
620
- type: "string",
621
- description: "Optional session name to track this tab",
622
- },
623
- },
624
- required: ["url"],
625
- },
626
- },
627
- {
628
- name: "browser_click",
629
- description: "Click an element on the page",
630
- inputSchema: {
631
- type: "object",
632
- properties: {
633
- selector: { type: "string" },
634
- tabId: { type: "number" },
635
- session: { type: "string" },
636
- },
637
- required: ["selector"],
638
- },
639
- },
640
- {
641
- name: "browser_fill",
642
- description: "Fill an input field with text",
643
- inputSchema: {
644
- type: "object",
645
- properties: {
646
- selector: { type: "string" },
647
- value: { type: "string" },
648
- submit: { type: "boolean" },
649
- tabId: { type: "number" },
650
- session: { type: "string" },
651
- },
652
- required: ["selector", "value"],
653
- },
654
- },
655
- {
656
- name: "browser_scrape",
657
- description: "Scrape the page or specific elements",
658
- inputSchema: {
659
- type: "object",
660
- properties: {
661
- selector: { type: "string" },
662
- attribute: { type: "string" },
663
- multiple: { type: "boolean" },
664
- tabId: { type: "number" },
665
- session: { type: "string" },
666
- },
667
- required: [],
668
- },
669
- },
670
- {
671
- name: "browser_screenshot",
672
- description:
673
- "Capture visible tab as base64 PNG. This action will focus the target tab.",
674
- inputSchema: {
675
- type: "object",
676
- properties: {
677
- tabId: { type: "number" },
678
- session: { type: "string" },
679
- },
680
- required: [],
681
- },
682
- },
683
- {
684
- name: "browser_scroll",
685
- description: "Scroll the page",
686
- inputSchema: {
687
- type: "object",
688
- properties: {
689
- direction: {
690
- type: "string",
691
- enum: ["up", "down", "left", "right", "top", "bottom"],
692
- },
693
- amount: { type: "number" },
694
- selector: { type: "string" },
695
- tabId: { type: "number" },
696
- session: { type: "string" },
697
- },
698
- required: ["direction"],
699
- },
700
- },
701
- {
702
- name: "browser_hover",
703
- description: "Hover over an element",
704
- inputSchema: {
705
- type: "object",
706
- properties: {
707
- selector: { type: "string" },
708
- tabId: { type: "number" },
709
- session: { type: "string" },
710
- },
711
- required: ["selector"],
712
- },
713
- },
714
- {
715
- name: "browser_get_text",
716
- description: "Get text content of an element",
717
- inputSchema: {
718
- type: "object",
719
- properties: {
720
- selector: { type: "string" },
721
- tabId: { type: "number" },
722
- session: { type: "string" },
723
- },
724
- required: ["selector"],
725
- },
726
- },
727
- {
728
- name: "browser_get_title",
729
- description: "Get page title and URL",
730
- inputSchema: {
731
- type: "object",
732
- properties: {
733
- tabId: { type: "number" },
734
- session: { type: "string" },
735
- },
736
- required: [],
737
- },
738
- },
739
- {
740
- name: "browser_get_url",
741
- description: "Get page URL",
742
- inputSchema: {
743
- type: "object",
744
- properties: {
745
- tabId: { type: "number" },
746
- session: { type: "string" },
747
- },
748
- required: [],
749
- },
750
- },
751
- {
752
- name: "browser_snapshot",
753
- description: "Get a snapshot of the DOM",
754
- inputSchema: {
755
- type: "object",
756
- properties: {
757
- tabId: { type: "number" },
758
- session: { type: "string" },
759
- },
760
- required: [],
761
- },
762
- },
763
- {
764
- name: "browser_get_network_state",
765
- description:
766
- "Get captured raw JSON data (GraphQL/XHR) for the current tab, bypassing DOM virtualization",
767
- inputSchema: {
768
- type: "object",
769
- properties: {
770
- tabId: { type: "number" },
771
- session: { type: "string" },
772
- clear: {
773
- type: "boolean",
774
- description: "Clear the buffer after reading (default: true)",
775
- },
776
- output_file: {
777
- type: "string",
778
- description:
779
- "Optional filename to save the data directly to disk to avoid flooding the context window (e.g., 'data.json')",
780
- },
781
- },
782
- required: [],
783
- },
784
- },
785
- {
786
- name: "browser_set_intercept_patterns",
787
- description:
788
- "Set URL substrings to determine which network requests are captured in the MAIN world.",
789
- inputSchema: {
790
- type: "object",
791
- properties: {
792
- patterns: {
793
- type: "array",
794
- items: { type: "string" },
795
- description:
796
- "Array of substrings (e.g. ['graphql', '/api/v1/comments'])",
797
- },
798
- tabId: { type: "number" },
799
- session: { type: "string" },
800
- },
801
- required: ["patterns"],
802
- },
803
- },
804
- {
805
- name: "browser_smart_scroll",
806
- description:
807
- "Scroll the page and wait for new network data to load (handles lazy-loading)",
808
- inputSchema: {
809
- type: "object",
810
- properties: {
811
- direction: { type: "string", enum: ["up", "down"] },
812
- amount: { type: "number" },
813
- tabId: { type: "number" },
814
- session: { type: "string" },
815
- },
816
- required: ["direction"],
817
- },
818
- },
819
- {
820
- name: "browser_session_create",
821
- description: "Create or track a named browser session (tab)",
822
- inputSchema: {
823
- type: "object",
824
- properties: {
825
- name: { type: "string" },
826
- url: { type: "string" },
827
- },
828
- required: ["name"],
829
- },
830
- },
831
- {
832
- name: "browser_session_switch",
833
- description: "Switch to a different tracked session",
834
- inputSchema: {
835
- type: "object",
836
- properties: {
837
- name: { type: "string" },
838
- },
839
- required: ["name"],
840
- },
841
- },
842
- {
843
- name: "browser_session_list",
844
- description: "List all tracked sessions",
845
- inputSchema: {
846
- type: "object",
847
- properties: {},
848
- required: [],
849
- },
850
- },
851
- {
852
- name: "browser_session_close",
853
- description: "Close a tracked session",
854
- inputSchema: {
855
- type: "object",
856
- properties: {
857
- name: { type: "string" },
858
- },
859
- required: ["name"],
860
- },
861
- },
862
- {
863
- name: "browser_session_context",
864
- description: "Get context info for the current active session",
865
- inputSchema: {
866
- type: "object",
867
- properties: {},
868
- required: [],
869
- },
870
- },
871
- {
872
- name: "browser_get_site_memory",
873
- description:
874
- "Get navigation memory, obstacles, and fixes for a specific website domain",
875
- inputSchema: {
876
- type: "object",
877
- properties: {
878
- hostname: {
879
- type: "string",
880
- description: "e.g., 'www.linkedin.com' or 'x.com'",
881
- },
882
- domain: { type: "string", description: "Alias for hostname" },
883
- },
884
- required: [],
885
- },
886
- },
887
- {
888
- name: "browser_save_site_memory",
889
- description:
890
- "Save a new navigation memory, obstacle, or fix for a specific website domain",
891
- inputSchema: {
892
- type: "object",
893
- properties: {
894
- hostname: { type: "string", description: "e.g., 'www.linkedin.com'" },
895
- domain: { type: "string", description: "Alias for hostname" },
896
- obstacle: {
897
- type: "string",
898
- description: "What broke or was difficult?",
899
- },
900
- solution: { type: "string", description: "How did you solve it?" },
901
- },
902
- required: ["obstacle", "solution"],
903
- },
904
- },
905
- ];
906
-
907
50
  server.setRequestHandler(ListToolsRequestSchema, async () => {
908
51
  return {
909
52
  tools: BROWSER_TOOLS,
@@ -925,50 +68,24 @@ const handleToolCall = async (request) => {
925
68
  const opts = args || {};
926
69
  let responsePayload;
927
70
 
928
- if (isSecondary) {
929
- // Proxy it to primary! Primary handles resolving the networkState map
930
- responsePayload = await sendCommand("get_network_state", opts);
71
+ if (bridge.isSecondary()) {
72
+ // Proxy it to the Primary — it is the one holding the capture buffer.
73
+ responsePayload = await bridge.sendCommand("get_network_state", opts);
931
74
  } else {
932
- // First, we need to resolve the active tab ID. We can do a dummy 'get_url' command to ask the extension what the current tabId is.
933
- const tabInfo = await sendCommand("get_url", opts);
934
- const tabId = tabInfo.tabId;
935
-
936
- primaryLastTabId = tabId;
937
- broadcastSessionState();
938
-
939
- let data = [];
940
- if (tabId && networkState.has(tabId)) {
941
- data = networkState.get(tabId).requests;
942
- if (opts.clear !== false) {
943
- networkState.get(tabId).requests = [];
944
- }
945
- }
946
-
947
- responsePayload = { tabId, capturedRequests: data.length, data };
75
+ responsePayload = await bridge.readNetworkState(opts);
76
+ bridge.noteActiveTab(responsePayload.tabId);
948
77
  }
949
78
 
950
79
  // Must be honoured in BOTH roles. A Secondary that returned the raw
951
80
  // payload would flood the very context window this parameter exists to
952
81
  // protect.
953
82
  if (opts.output_file) {
954
- // Sanitize the file name to prevent Path Traversal
955
- const safeName = path.basename(opts.output_file);
956
- // Add timestamp to prevent collisions
957
- const parts = safeName.split(".");
958
- const ext = parts.length > 1 ? `.${parts.pop()}` : "";
959
- const base = parts.join(".");
960
- const finalName = `${base}_${Date.now()}${ext}`;
961
-
962
- fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
963
-
964
- const safePath = path.join(DOWNLOADS_DIR, finalName);
965
- fs.writeFileSync(safePath, JSON.stringify(responsePayload, null, 2));
966
-
83
+ const written = writeOutput(opts.output_file, responsePayload);
967
84
  return {
968
85
  content: [
969
86
  {
970
87
  type: "text",
971
- text: `Data successfully written to ${safePath}. Captured ${responsePayload.capturedRequests} requests.`,
88
+ text: `Data successfully written to ${written.file}. Captured ${responsePayload.capturedRequests} requests.`,
972
89
  },
973
90
  ],
974
91
  };
@@ -986,15 +103,11 @@ const handleToolCall = async (request) => {
986
103
 
987
104
  // Custom handling for smart scroll
988
105
  if (action === "smart_scroll") {
989
- const scrollResult = await sendCommand("scroll", args || {});
990
-
991
- if (!isSecondary && scrollResult && scrollResult.tabId) {
992
- primaryLastTabId = scrollResult.tabId;
993
- broadcastSessionState();
994
- }
106
+ const scrollResult = await bridge.sendCommand("scroll", args || {});
107
+ bridge.noteActiveTab(scrollResult && scrollResult.tabId);
995
108
 
996
109
  // Wait for network requests to arrive (lazy loading)
997
- await new Promise((r) => setTimeout(r, 2000));
110
+ await new Promise((r) => setTimeout(r, SMART_SCROLL_SETTLE_MS));
998
111
 
999
112
  return {
1000
113
  content: [
@@ -1015,7 +128,7 @@ const handleToolCall = async (request) => {
1015
128
 
1016
129
  // Custom handling for setting intercept patterns globally via storage, then updating current tab
1017
130
  if (action === "set_intercept_patterns") {
1018
- const result = await sendCommand("set_intercept_patterns", args);
131
+ const result = await bridge.sendCommand("set_intercept_patterns", args);
1019
132
  return {
1020
133
  content: [
1021
134
  {
@@ -1029,46 +142,68 @@ const handleToolCall = async (request) => {
1029
142
  if (action === "get_site_memory") {
1030
143
  const host = args.hostname || args.domain || args.url;
1031
144
  if (!host) throw new Error("Missing 'hostname' parameter");
1032
- const file = path.join(MEMORY_DIR, `${host}.json`);
1033
- let data = [];
1034
- if (fs.existsSync(file)) {
1035
- data = JSON.parse(fs.readFileSync(file, "utf8"));
1036
- }
1037
145
  return {
1038
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
146
+ content: [
147
+ { type: "text", text: JSON.stringify(readMemory(host), null, 2) },
148
+ ],
1039
149
  };
1040
150
  }
1041
151
 
1042
152
  if (action === "save_site_memory") {
1043
153
  const host = args.hostname || args.domain || args.url;
1044
154
  if (!host) throw new Error("Missing 'hostname' parameter");
1045
- const file = path.join(MEMORY_DIR, `${host}.json`);
1046
- let data = [];
1047
- if (fs.existsSync(file)) {
1048
- data = JSON.parse(fs.readFileSync(file, "utf8"));
1049
- }
1050
- data.push({
1051
- obstacle: args.obstacle,
1052
- solution: args.solution,
1053
- timestamp: Date.now(),
1054
- });
1055
- fs.writeFileSync(file, JSON.stringify(data, null, 2));
155
+ saveMemory(host, args.obstacle, args.solution);
1056
156
  return {
1057
157
  content: [{ type: "text", text: "Memory successfully saved." }],
1058
158
  };
1059
159
  }
1060
160
 
1061
- const result = await sendCommand(action, args || {});
161
+ // Loops in the server, not in the model. One tool call covers every page.
162
+ if (action === "extract_all") {
163
+ const payload = await extractAll(bridge.sendCommand, args || {});
164
+ const opts = args || {};
1062
165
 
1063
- if (!isSecondary) {
1064
- // Track primary's active tab
1065
- if (result && result.tabId) {
1066
- primaryLastTabId = result.tabId;
1067
- broadcastSessionState();
1068
- } else if (args && args.tabId) {
1069
- primaryLastTabId = args.tabId;
1070
- broadcastSessionState();
166
+ if (opts.output_file) {
167
+ const written = writeOutput(opts.output_file, payload, payload.rows);
168
+ const { rows, pages, ...rest } = payload;
169
+ const summary = {
170
+ ...rest,
171
+ ...written,
172
+ fields: rows.length ? Object.keys(rows[0]) : [],
173
+ sample: rows.slice(0, 2),
174
+ };
175
+ return {
176
+ content: [
177
+ { type: "text", text: JSON.stringify(summary, null, 2) },
178
+ ],
179
+ };
1071
180
  }
181
+
182
+ return {
183
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
184
+ };
185
+ }
186
+
187
+ const result = await bridge.sendCommand(action, args || {});
188
+ bridge.noteActiveTab((result && result.tabId) || (args && args.tabId));
189
+
190
+ // Any read tool can send its payload to disk. Placed after tab tracking so
191
+ // taking the file path does not cost the session its tab bookkeeping.
192
+ if (args && args.output_file && result && typeof result === "object") {
193
+ const rows = rowsOf(result);
194
+ const written = writeOutput(args.output_file, result, rows);
195
+ return {
196
+ content: [
197
+ {
198
+ type: "text",
199
+ text: JSON.stringify(
200
+ summarizeOutput(result, rows, written),
201
+ null,
202
+ 2,
203
+ ),
204
+ },
205
+ ],
206
+ };
1072
207
  }
1073
208
 
1074
209
  // Check if error result string (graceful error handling)
@@ -1122,7 +257,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1122
257
  current: OWN_VERSION,
1123
258
  latest: updateState.latest,
1124
259
  updateAvailable: updateState.updateAvailable,
1125
- skew: updates.skew(OWN_VERSION, extensionVersion),
260
+ skew: updates.skew(OWN_VERSION, bridge.extensionVersion()),
1126
261
  });
1127
262
  if (!text) return result;
1128
263
 
@@ -1133,6 +268,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1133
268
 
1134
269
  // Start the server
1135
270
  async function startMcp() {
271
+ bridge.start();
272
+
1136
273
  const transport = new StdioServerTransport();
1137
274
  await server.connect(transport);
1138
275