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.
- package/README.md +77 -23
- package/mcp-server/bridge.js +593 -0
- package/mcp-server/config.js +85 -0
- package/mcp-server/extract-all.js +159 -0
- package/mcp-server/index.js +68 -931
- package/mcp-server/memory.js +81 -0
- package/mcp-server/output.js +103 -0
- package/mcp-server/tools.js +439 -0
- package/package.json +1 -2
- package/skills/browser-navigation-strategy/SKILL.md +162 -34
package/mcp-server/index.js
CHANGED
|
@@ -8,34 +8,16 @@ const {
|
|
|
8
8
|
CallToolRequestSchema,
|
|
9
9
|
ListToolsRequestSchema,
|
|
10
10
|
} = require("@modelcontextprotocol/sdk/types.js");
|
|
11
|
-
const
|
|
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
|
|
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
|
-
|
|
933
|
-
|
|
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
|
-
|
|
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 ${
|
|
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,
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
|