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