poltertab 1.4.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 +7 -16
- 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 +25 -1333
- 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/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,722 +47,12 @@ 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:
|
|
658
|
-
"Scrape the page or specific elements. For repeating records (cards, rows, listings) use browser_extract instead — it keeps fields grouped per record.",
|
|
659
|
-
inputSchema: {
|
|
660
|
-
type: "object",
|
|
661
|
-
properties: {
|
|
662
|
-
selector: { type: "string" },
|
|
663
|
-
attribute: {
|
|
664
|
-
type: "string",
|
|
665
|
-
description:
|
|
666
|
-
"Attribute to read, or 'text' for the element's text. 'href'/'src' come back absolute.",
|
|
667
|
-
},
|
|
668
|
-
multiple: { type: "boolean" },
|
|
669
|
-
fields: {
|
|
670
|
-
type: "array",
|
|
671
|
-
items: {
|
|
672
|
-
type: "string",
|
|
673
|
-
enum: ["meta", "jsonld", "links", "headings", "bodyText"],
|
|
674
|
-
},
|
|
675
|
-
description:
|
|
676
|
-
"Full-page scrape only (no selector): which parts to return. title and url are always included. ['meta','jsonld'] is the cheap structured-data path — og: tags and schema.org blobs without 50KB of body text. Omit for everything.",
|
|
677
|
-
},
|
|
678
|
-
max_text: {
|
|
679
|
-
type: "number",
|
|
680
|
-
description: "Max characters of text per element (default 500)",
|
|
681
|
-
},
|
|
682
|
-
output_file: {
|
|
683
|
-
type: "string",
|
|
684
|
-
description:
|
|
685
|
-
"Filename to write the payload under ~/.poltertab/downloads/, returning only a summary. A path is reduced to its basename — output cannot be written elsewhere.",
|
|
686
|
-
},
|
|
687
|
-
tabId: { type: "number" },
|
|
688
|
-
session: { type: "string" },
|
|
689
|
-
},
|
|
690
|
-
required: [],
|
|
691
|
-
},
|
|
692
|
-
},
|
|
693
|
-
{
|
|
694
|
-
name: "browser_extract",
|
|
695
|
-
description:
|
|
696
|
-
"Extract repeating records (cards, rows, listings) with fields grouped per record. Fields resolve INSIDE each record root and a missing field yields null instead of shifting later records' values. Returns fill rates and warns when a field is empty inside the record scope but matches page-wide (record boundary too narrow).",
|
|
697
|
-
inputSchema: {
|
|
698
|
-
type: "object",
|
|
699
|
-
properties: {
|
|
700
|
-
record: {
|
|
701
|
-
type: "string",
|
|
702
|
-
description:
|
|
703
|
-
"Selector for the repeating container, e.g. '.agent-card'. Verify with browser_snapshot that it encloses ALL the fields you want — the element that looks like the card often excludes siblings holding some of them.",
|
|
704
|
-
},
|
|
705
|
-
fields: {
|
|
706
|
-
type: "object",
|
|
707
|
-
description:
|
|
708
|
-
"Map of field name -> {sel, get, many, strip}. sel is relative to the record root; omit it (or use '.') for the root itself. get: 'text' (default) | 'href' | 'src' | any attribute name. many: true collects all matches into an array. strip removes a leading prefix, e.g. 'tel:'.",
|
|
709
|
-
additionalProperties: {
|
|
710
|
-
type: "object",
|
|
711
|
-
properties: {
|
|
712
|
-
sel: { type: "string" },
|
|
713
|
-
get: { type: "string" },
|
|
714
|
-
many: { type: "boolean" },
|
|
715
|
-
strip: { type: "string" },
|
|
716
|
-
},
|
|
717
|
-
},
|
|
718
|
-
},
|
|
719
|
-
anchor: {
|
|
720
|
-
type: "string",
|
|
721
|
-
description:
|
|
722
|
-
"Name of the field that is always present on a real record (usually the detail-page link). Records missing it are dropped as placeholders and counted in 'dropped'.",
|
|
723
|
-
},
|
|
724
|
-
max_text: {
|
|
725
|
-
type: "number",
|
|
726
|
-
description: "Max characters per text field (default 500)",
|
|
727
|
-
},
|
|
728
|
-
probe: {
|
|
729
|
-
type: "boolean",
|
|
730
|
-
description:
|
|
731
|
-
"Page-wide re-check of any field that came back entirely empty (default true)",
|
|
732
|
-
},
|
|
733
|
-
output_file: {
|
|
734
|
-
type: "string",
|
|
735
|
-
description:
|
|
736
|
-
"Filename to write rows under ~/.poltertab/downloads/, returning only a summary. .jsonl and .csv are written in those formats, anything else as JSON. A path is reduced to its basename — output cannot be written elsewhere.",
|
|
737
|
-
},
|
|
738
|
-
tabId: { type: "number" },
|
|
739
|
-
session: { type: "string" },
|
|
740
|
-
},
|
|
741
|
-
required: ["record", "fields"],
|
|
742
|
-
},
|
|
743
|
-
},
|
|
744
|
-
{
|
|
745
|
-
name: "browser_extract_all",
|
|
746
|
-
description:
|
|
747
|
-
"Paginate and extract in one call, with no model round-trip per page. Takes browser_extract's spec plus a URL template, walks pages, dedups on a key, and halts on: limit reached, empty page, a page whose records repeat an earlier page's (the trap where ignored page-size params silently return page 1 again), fill rates collapsing against page 1's baseline, or max_pages. Always reports which condition fired and returns everything collected so far.",
|
|
748
|
-
inputSchema: {
|
|
749
|
-
type: "object",
|
|
750
|
-
properties: {
|
|
751
|
-
url_template: {
|
|
752
|
-
type: "string",
|
|
753
|
-
description:
|
|
754
|
-
"URL with a {page} placeholder, e.g. 'https://www.kw.com/agents?page={page}'",
|
|
755
|
-
},
|
|
756
|
-
record: { type: "string" },
|
|
757
|
-
fields: { type: "object" },
|
|
758
|
-
anchor: { type: "string" },
|
|
759
|
-
key: {
|
|
760
|
-
type: "string",
|
|
761
|
-
description:
|
|
762
|
-
"Field name to dedup on — use a stable per-record identifier such as the detail URL. Falls back to whole-row equality.",
|
|
763
|
-
},
|
|
764
|
-
limit: {
|
|
765
|
-
type: "number",
|
|
766
|
-
description: "Stop after this many records (default 200)",
|
|
767
|
-
},
|
|
768
|
-
offset: {
|
|
769
|
-
type: "number",
|
|
770
|
-
description:
|
|
771
|
-
"Skip this many records from the start of the stream. Pages are re-fetched to reach the offset; pass start_page to skip cheaply.",
|
|
772
|
-
},
|
|
773
|
-
start_page: { type: "number", description: "First page (default 1)" },
|
|
774
|
-
max_pages: {
|
|
775
|
-
type: "number",
|
|
776
|
-
description: "Hard guard on pages fetched (default 50)",
|
|
777
|
-
},
|
|
778
|
-
fill_tolerance: {
|
|
779
|
-
type: "number",
|
|
780
|
-
description:
|
|
781
|
-
"Halt when a field well-populated on the baseline page falls below this fraction of it (default 0.5). 0 disables the check.",
|
|
782
|
-
},
|
|
783
|
-
max_text: { type: "number" },
|
|
784
|
-
output_file: { type: "string" },
|
|
785
|
-
session: { type: "string" },
|
|
786
|
-
},
|
|
787
|
-
required: ["url_template", "record", "fields"],
|
|
788
|
-
},
|
|
789
|
-
},
|
|
790
|
-
{
|
|
791
|
-
name: "browser_screenshot",
|
|
792
|
-
description:
|
|
793
|
-
"Capture visible tab as base64 PNG. This action will focus the target tab.",
|
|
794
|
-
inputSchema: {
|
|
795
|
-
type: "object",
|
|
796
|
-
properties: {
|
|
797
|
-
tabId: { type: "number" },
|
|
798
|
-
session: { type: "string" },
|
|
799
|
-
},
|
|
800
|
-
required: [],
|
|
801
|
-
},
|
|
802
|
-
},
|
|
803
|
-
{
|
|
804
|
-
name: "browser_scroll",
|
|
805
|
-
description: "Scroll the page",
|
|
806
|
-
inputSchema: {
|
|
807
|
-
type: "object",
|
|
808
|
-
properties: {
|
|
809
|
-
direction: {
|
|
810
|
-
type: "string",
|
|
811
|
-
enum: ["up", "down", "left", "right", "top", "bottom"],
|
|
812
|
-
},
|
|
813
|
-
amount: { type: "number" },
|
|
814
|
-
selector: { type: "string" },
|
|
815
|
-
tabId: { type: "number" },
|
|
816
|
-
session: { type: "string" },
|
|
817
|
-
},
|
|
818
|
-
required: ["direction"],
|
|
819
|
-
},
|
|
820
|
-
},
|
|
821
|
-
{
|
|
822
|
-
name: "browser_hover",
|
|
823
|
-
description: "Hover over an element",
|
|
824
|
-
inputSchema: {
|
|
825
|
-
type: "object",
|
|
826
|
-
properties: {
|
|
827
|
-
selector: { type: "string" },
|
|
828
|
-
tabId: { type: "number" },
|
|
829
|
-
session: { type: "string" },
|
|
830
|
-
},
|
|
831
|
-
required: ["selector"],
|
|
832
|
-
},
|
|
833
|
-
},
|
|
834
|
-
{
|
|
835
|
-
name: "browser_get_text",
|
|
836
|
-
description: "Get text content of an element",
|
|
837
|
-
inputSchema: {
|
|
838
|
-
type: "object",
|
|
839
|
-
properties: {
|
|
840
|
-
selector: { type: "string" },
|
|
841
|
-
max_text: {
|
|
842
|
-
type: "number",
|
|
843
|
-
description:
|
|
844
|
-
"Max characters (default 10000). The result flags it when text was cut.",
|
|
845
|
-
},
|
|
846
|
-
output_file: { type: "string" },
|
|
847
|
-
tabId: { type: "number" },
|
|
848
|
-
session: { type: "string" },
|
|
849
|
-
},
|
|
850
|
-
required: ["selector"],
|
|
851
|
-
},
|
|
852
|
-
},
|
|
853
|
-
{
|
|
854
|
-
name: "browser_get_title",
|
|
855
|
-
description: "Get page title and URL",
|
|
856
|
-
inputSchema: {
|
|
857
|
-
type: "object",
|
|
858
|
-
properties: {
|
|
859
|
-
tabId: { type: "number" },
|
|
860
|
-
session: { type: "string" },
|
|
861
|
-
},
|
|
862
|
-
required: [],
|
|
863
|
-
},
|
|
864
|
-
},
|
|
865
|
-
{
|
|
866
|
-
name: "browser_get_url",
|
|
867
|
-
description: "Get page URL",
|
|
868
|
-
inputSchema: {
|
|
869
|
-
type: "object",
|
|
870
|
-
properties: {
|
|
871
|
-
tabId: { type: "number" },
|
|
872
|
-
session: { type: "string" },
|
|
873
|
-
},
|
|
874
|
-
required: [],
|
|
875
|
-
},
|
|
876
|
-
},
|
|
877
|
-
{
|
|
878
|
-
name: "browser_snapshot",
|
|
879
|
-
description:
|
|
880
|
-
"Get a snapshot of the DOM. Large pages run to tens of KB — narrow it with interactive_only/max_nodes, or send it to output_file, before spending the context on it.",
|
|
881
|
-
inputSchema: {
|
|
882
|
-
type: "object",
|
|
883
|
-
properties: {
|
|
884
|
-
interactive_only: {
|
|
885
|
-
type: "boolean",
|
|
886
|
-
description: "Only clickable/typable elements",
|
|
887
|
-
},
|
|
888
|
-
max_nodes: {
|
|
889
|
-
type: "number",
|
|
890
|
-
description: "Cap on nodes returned (default 400)",
|
|
891
|
-
},
|
|
892
|
-
max_depth: { type: "number" },
|
|
893
|
-
output_file: { type: "string" },
|
|
894
|
-
tabId: { type: "number" },
|
|
895
|
-
session: { type: "string" },
|
|
896
|
-
},
|
|
897
|
-
required: [],
|
|
898
|
-
},
|
|
899
|
-
},
|
|
900
|
-
{
|
|
901
|
-
name: "browser_get_network_state",
|
|
902
|
-
description:
|
|
903
|
-
"Get captured raw JSON data (GraphQL/XHR) for the current tab, bypassing DOM virtualization",
|
|
904
|
-
inputSchema: {
|
|
905
|
-
type: "object",
|
|
906
|
-
properties: {
|
|
907
|
-
tabId: { type: "number" },
|
|
908
|
-
session: { type: "string" },
|
|
909
|
-
clear: {
|
|
910
|
-
type: "boolean",
|
|
911
|
-
description: "Clear the buffer after reading (default: true)",
|
|
912
|
-
},
|
|
913
|
-
output_file: {
|
|
914
|
-
type: "string",
|
|
915
|
-
description:
|
|
916
|
-
"Optional filename to save the data directly to disk to avoid flooding the context window (e.g., 'data.json')",
|
|
917
|
-
},
|
|
918
|
-
},
|
|
919
|
-
required: [],
|
|
920
|
-
},
|
|
921
|
-
},
|
|
922
|
-
{
|
|
923
|
-
name: "browser_set_intercept_patterns",
|
|
924
|
-
description:
|
|
925
|
-
"Set URL substrings to determine which network requests are captured in the MAIN world.",
|
|
926
|
-
inputSchema: {
|
|
927
|
-
type: "object",
|
|
928
|
-
properties: {
|
|
929
|
-
patterns: {
|
|
930
|
-
type: "array",
|
|
931
|
-
items: { type: "string" },
|
|
932
|
-
description:
|
|
933
|
-
"Array of substrings (e.g. ['graphql', '/api/v1/comments'])",
|
|
934
|
-
},
|
|
935
|
-
tabId: { type: "number" },
|
|
936
|
-
session: { type: "string" },
|
|
937
|
-
},
|
|
938
|
-
required: ["patterns"],
|
|
939
|
-
},
|
|
940
|
-
},
|
|
941
|
-
{
|
|
942
|
-
name: "browser_smart_scroll",
|
|
943
|
-
description:
|
|
944
|
-
"Scroll the page and wait for new network data to load (handles lazy-loading)",
|
|
945
|
-
inputSchema: {
|
|
946
|
-
type: "object",
|
|
947
|
-
properties: {
|
|
948
|
-
direction: { type: "string", enum: ["up", "down"] },
|
|
949
|
-
amount: { type: "number" },
|
|
950
|
-
tabId: { type: "number" },
|
|
951
|
-
session: { type: "string" },
|
|
952
|
-
},
|
|
953
|
-
required: ["direction"],
|
|
954
|
-
},
|
|
955
|
-
},
|
|
956
|
-
{
|
|
957
|
-
name: "browser_session_create",
|
|
958
|
-
description: "Create or track a named browser session (tab)",
|
|
959
|
-
inputSchema: {
|
|
960
|
-
type: "object",
|
|
961
|
-
properties: {
|
|
962
|
-
name: { type: "string" },
|
|
963
|
-
url: { type: "string" },
|
|
964
|
-
},
|
|
965
|
-
required: ["name"],
|
|
966
|
-
},
|
|
967
|
-
},
|
|
968
|
-
{
|
|
969
|
-
name: "browser_session_switch",
|
|
970
|
-
description: "Switch to a different tracked session",
|
|
971
|
-
inputSchema: {
|
|
972
|
-
type: "object",
|
|
973
|
-
properties: {
|
|
974
|
-
name: { type: "string" },
|
|
975
|
-
},
|
|
976
|
-
required: ["name"],
|
|
977
|
-
},
|
|
978
|
-
},
|
|
979
|
-
{
|
|
980
|
-
name: "browser_session_list",
|
|
981
|
-
description: "List all tracked sessions",
|
|
982
|
-
inputSchema: {
|
|
983
|
-
type: "object",
|
|
984
|
-
properties: {},
|
|
985
|
-
required: [],
|
|
986
|
-
},
|
|
987
|
-
},
|
|
988
|
-
{
|
|
989
|
-
name: "browser_session_close",
|
|
990
|
-
description: "Close a tracked session",
|
|
991
|
-
inputSchema: {
|
|
992
|
-
type: "object",
|
|
993
|
-
properties: {
|
|
994
|
-
name: { type: "string" },
|
|
995
|
-
},
|
|
996
|
-
required: ["name"],
|
|
997
|
-
},
|
|
998
|
-
},
|
|
999
|
-
{
|
|
1000
|
-
name: "browser_session_context",
|
|
1001
|
-
description: "Get context info for the current active session",
|
|
1002
|
-
inputSchema: {
|
|
1003
|
-
type: "object",
|
|
1004
|
-
properties: {},
|
|
1005
|
-
required: [],
|
|
1006
|
-
},
|
|
1007
|
-
},
|
|
1008
|
-
{
|
|
1009
|
-
name: "browser_get_site_memory",
|
|
1010
|
-
description:
|
|
1011
|
-
"Get navigation memory, obstacles, and fixes for a specific website domain",
|
|
1012
|
-
inputSchema: {
|
|
1013
|
-
type: "object",
|
|
1014
|
-
properties: {
|
|
1015
|
-
hostname: {
|
|
1016
|
-
type: "string",
|
|
1017
|
-
description: "e.g., 'www.linkedin.com' or 'x.com'",
|
|
1018
|
-
},
|
|
1019
|
-
domain: { type: "string", description: "Alias for hostname" },
|
|
1020
|
-
},
|
|
1021
|
-
required: [],
|
|
1022
|
-
},
|
|
1023
|
-
},
|
|
1024
|
-
{
|
|
1025
|
-
name: "browser_save_site_memory",
|
|
1026
|
-
description:
|
|
1027
|
-
"Save a new navigation memory, obstacle, or fix for a specific website domain",
|
|
1028
|
-
inputSchema: {
|
|
1029
|
-
type: "object",
|
|
1030
|
-
properties: {
|
|
1031
|
-
hostname: { type: "string", description: "e.g., 'www.linkedin.com'" },
|
|
1032
|
-
domain: { type: "string", description: "Alias for hostname" },
|
|
1033
|
-
obstacle: {
|
|
1034
|
-
type: "string",
|
|
1035
|
-
description: "What broke or was difficult?",
|
|
1036
|
-
},
|
|
1037
|
-
solution: { type: "string", description: "How did you solve it?" },
|
|
1038
|
-
},
|
|
1039
|
-
required: ["obstacle", "solution"],
|
|
1040
|
-
},
|
|
1041
|
-
},
|
|
1042
|
-
];
|
|
1043
|
-
|
|
1044
50
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1045
51
|
return {
|
|
1046
52
|
tools: BROWSER_TOOLS,
|
|
1047
53
|
};
|
|
1048
54
|
});
|
|
1049
55
|
|
|
1050
|
-
// --- Output handling ---
|
|
1051
|
-
//
|
|
1052
|
-
// The raw payload must never be the default path into the context window. Two
|
|
1053
|
-
// costs dominated a 100-record scrape and neither was the data: envelopes
|
|
1054
|
-
// several lines of JSON deep to carry one short string, and the agent then
|
|
1055
|
-
// re-typing every record by hand into a file. Writing straight to disk removes
|
|
1056
|
-
// both.
|
|
1057
|
-
|
|
1058
|
-
function toCsv(rows) {
|
|
1059
|
-
if (!rows.length) return "";
|
|
1060
|
-
const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
1061
|
-
const cell = (v) => {
|
|
1062
|
-
if (v === null || v === undefined) return "";
|
|
1063
|
-
const s = Array.isArray(v)
|
|
1064
|
-
? v.join(" | ")
|
|
1065
|
-
: typeof v === "object"
|
|
1066
|
-
? JSON.stringify(v) // beats a column of "[object Object]"
|
|
1067
|
-
: String(v);
|
|
1068
|
-
// Quote only when it would otherwise break the row, and double any
|
|
1069
|
-
// embedded quote — the two mistakes that produce a file which imports
|
|
1070
|
-
// silently and wrongly.
|
|
1071
|
-
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
1072
|
-
};
|
|
1073
|
-
return [
|
|
1074
|
-
cols.join(","),
|
|
1075
|
-
...rows.map((r) => cols.map((c) => cell(r[c])).join(",")),
|
|
1076
|
-
].join("\n");
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// Site memory is keyed by hostname, and that key arrives from a model — so it
|
|
1080
|
-
// is untrusted input rather than a filename. Two failures this closes: a note
|
|
1081
|
-
// saved under kw.com was invisible to a lookup for www.kw.com (the same site),
|
|
1082
|
-
// and the raw value was interpolated straight into a path, so "../.." reached
|
|
1083
|
-
// outside MEMORY_DIR.
|
|
1084
|
-
function memoryFile(rawHost) {
|
|
1085
|
-
let host = String(rawHost).trim().toLowerCase();
|
|
1086
|
-
|
|
1087
|
-
// The parameter is also documented as accepting `url`, so a full URL turning
|
|
1088
|
-
// up here is expected rather than a caller mistake.
|
|
1089
|
-
if (host.includes("/")) {
|
|
1090
|
-
try {
|
|
1091
|
-
host = new URL(host.includes("://") ? host : `https://${host}`).hostname;
|
|
1092
|
-
} catch {
|
|
1093
|
-
host = host.split("/")[0];
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
host = host.replace(/[^a-z0-9.-]/g, "").replace(/^\.+/, "");
|
|
1098
|
-
if (!host) throw new Error(`Not a usable hostname: ${rawHost}`);
|
|
1099
|
-
|
|
1100
|
-
// Existing notes live under whichever spelling first created them — the store
|
|
1101
|
-
// already holds both kw.com.json and www.linkedin.com.json — so try the
|
|
1102
|
-
// variants before concluding this is a new file.
|
|
1103
|
-
const bare = host.replace(/^www\./, "");
|
|
1104
|
-
for (const name of [bare, host, `www.${bare}`]) {
|
|
1105
|
-
const p = path.join(MEMORY_DIR, `${name}.json`);
|
|
1106
|
-
if (fs.existsSync(p)) return p;
|
|
1107
|
-
}
|
|
1108
|
-
return path.join(MEMORY_DIR, `${bare}.json`);
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
const isBlank = (v) =>
|
|
1112
|
-
v === null || v === undefined || v === "" || (Array.isArray(v) && !v.length);
|
|
1113
|
-
|
|
1114
|
-
// `rows` is passed separately when the payload has a records array: .jsonl and
|
|
1115
|
-
// .csv are formats for records, not for envelopes.
|
|
1116
|
-
function writeOutput(name, payload, rows) {
|
|
1117
|
-
const requested = String(name);
|
|
1118
|
-
const safeName = path.basename(requested);
|
|
1119
|
-
const parts = safeName.split(".");
|
|
1120
|
-
const ext = parts.length > 1 ? `.${parts.pop()}` : "";
|
|
1121
|
-
const base = parts.join(".");
|
|
1122
|
-
const finalName = `${base}_${Date.now()}${ext}`;
|
|
1123
|
-
|
|
1124
|
-
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
|
|
1125
|
-
const safePath = path.join(DOWNLOADS_DIR, finalName);
|
|
1126
|
-
|
|
1127
|
-
let body;
|
|
1128
|
-
if (rows && ext === ".jsonl") {
|
|
1129
|
-
body = rows.map((r) => JSON.stringify(r)).join("\n");
|
|
1130
|
-
} else if (rows && ext === ".csv") {
|
|
1131
|
-
body = toCsv(rows);
|
|
1132
|
-
} else {
|
|
1133
|
-
body = JSON.stringify(payload, null, 2);
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
fs.writeFileSync(safePath, body);
|
|
1137
|
-
const out = { file: safePath, bytes: Buffer.byteLength(body) };
|
|
1138
|
-
|
|
1139
|
-
// Output stays inside DOWNLOADS_DIR: this path comes from a model, and a tool
|
|
1140
|
-
// that writes to an arbitrary absolute path is a different capability than
|
|
1141
|
-
// one that saves a scrape. But relocating without a word is how a caller ends
|
|
1142
|
-
// up looking for a file that was never going to be there.
|
|
1143
|
-
if (safeName !== requested) {
|
|
1144
|
-
out.note = `output_file is confined to ${DOWNLOADS_DIR} — "${requested}" was written as ${path.basename(safePath)}, not to the path requested.`;
|
|
1145
|
-
}
|
|
1146
|
-
return out;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// What comes back inline when the payload went to disk: enough to know the call
|
|
1150
|
-
// worked and the shape is right, and nothing more.
|
|
1151
|
-
function summarizeOutput(payload, rows, written) {
|
|
1152
|
-
const summary = { ...written };
|
|
1153
|
-
if (rows) {
|
|
1154
|
-
summary.rows = rows.length;
|
|
1155
|
-
summary.fields = rows.length ? Object.keys(rows[0]) : [];
|
|
1156
|
-
summary.sample = rows.slice(0, 2);
|
|
1157
|
-
for (const k of ["fill_rates", "dropped", "warnings", "stopped_because", "pages_fetched"]) {
|
|
1158
|
-
if (payload && payload[k] !== undefined) summary[k] = payload[k];
|
|
1159
|
-
}
|
|
1160
|
-
} else if (Array.isArray(payload)) {
|
|
1161
|
-
summary.items = payload.length;
|
|
1162
|
-
summary.sample = payload.slice(0, 2);
|
|
1163
|
-
} else if (payload && typeof payload === "object") {
|
|
1164
|
-
summary.keys = Object.keys(payload);
|
|
1165
|
-
}
|
|
1166
|
-
return summary;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
// Payload shapes that carry a records array worth writing as jsonl/csv.
|
|
1170
|
-
function rowsOf(payload) {
|
|
1171
|
-
if (payload && Array.isArray(payload.rows)) return payload.rows;
|
|
1172
|
-
if (Array.isArray(payload)) return payload;
|
|
1173
|
-
return null;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// --- The pagination loop ---
|
|
1177
|
-
//
|
|
1178
|
-
// This exists so the model stops being the for-loop. It supplies the spec once
|
|
1179
|
-
// and receives records; every page in between costs a browser round-trip
|
|
1180
|
-
// instead of a model round-trip.
|
|
1181
|
-
//
|
|
1182
|
-
// Every halt condition below is a case where continuing would produce data
|
|
1183
|
-
// that looks complete and is not, so each one names itself in the result. A
|
|
1184
|
-
// paused run costs far less than a confidently wrong dataset.
|
|
1185
|
-
async function extractAll(args) {
|
|
1186
|
-
const {
|
|
1187
|
-
url_template,
|
|
1188
|
-
record,
|
|
1189
|
-
fields,
|
|
1190
|
-
anchor,
|
|
1191
|
-
key,
|
|
1192
|
-
limit = 200,
|
|
1193
|
-
offset = 0,
|
|
1194
|
-
start_page = 1,
|
|
1195
|
-
max_pages = 50,
|
|
1196
|
-
fill_tolerance = 0.5,
|
|
1197
|
-
max_text,
|
|
1198
|
-
session,
|
|
1199
|
-
} = args;
|
|
1200
|
-
|
|
1201
|
-
if (!url_template.includes("{page}")) {
|
|
1202
|
-
throw new Error("url_template must contain a {page} placeholder");
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
const spec = { record, fields, anchor, max_text, session, probe: true };
|
|
1206
|
-
const target = offset + limit;
|
|
1207
|
-
const rows = [];
|
|
1208
|
-
const seen = new Set();
|
|
1209
|
-
const warnings = [];
|
|
1210
|
-
const pages = [];
|
|
1211
|
-
|
|
1212
|
-
let baseline = null;
|
|
1213
|
-
let stopped_because = "max_pages";
|
|
1214
|
-
let page = start_page;
|
|
1215
|
-
let fetched = 0;
|
|
1216
|
-
|
|
1217
|
-
const keyOf = (row) =>
|
|
1218
|
-
key ? String(row[key] ?? "") : JSON.stringify(row);
|
|
1219
|
-
|
|
1220
|
-
while (fetched < max_pages) {
|
|
1221
|
-
const url = url_template.replace("{page}", String(page));
|
|
1222
|
-
const nav = await sendCommand("navigate", { url, session });
|
|
1223
|
-
fetched++;
|
|
1224
|
-
|
|
1225
|
-
const res = await sendCommand("extract", spec);
|
|
1226
|
-
const pageRows = (res && res.rows) || [];
|
|
1227
|
-
pages.push({
|
|
1228
|
-
page,
|
|
1229
|
-
url: nav && nav.url,
|
|
1230
|
-
found: res ? res.records_found : 0,
|
|
1231
|
-
kept: pageRows.length,
|
|
1232
|
-
dropped: res ? res.dropped : 0,
|
|
1233
|
-
});
|
|
1234
|
-
if (res && res.warnings && res.warnings.length) {
|
|
1235
|
-
warnings.push(`page ${page}: ${res.warnings.join("; ")}`);
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
if (!pageRows.length) {
|
|
1239
|
-
stopped_because = "empty_page";
|
|
1240
|
-
break;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
// A site that ignores an unrecognised page param answers every request with
|
|
1244
|
-
// page 1. Identical content reads as real data, which is how a "next 100"
|
|
1245
|
-
// silently becomes the same 12 records eight times over.
|
|
1246
|
-
const fresh = pageRows.filter((r) => !seen.has(keyOf(r)));
|
|
1247
|
-
if (!fresh.length) {
|
|
1248
|
-
stopped_because = "duplicate_page";
|
|
1249
|
-
warnings.push(
|
|
1250
|
-
`page ${page} returned only records already seen — pagination is not advancing`,
|
|
1251
|
-
);
|
|
1252
|
-
break;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
// Fill rates against the first page. A spec learned on page 1 degrades
|
|
1256
|
-
// quietly later: variant card layouts, a column that stops being populated.
|
|
1257
|
-
// Halting beats emitting rows that are 40% empty.
|
|
1258
|
-
const ratios = {};
|
|
1259
|
-
for (const [name, n] of Object.entries(res.fill_rates || {})) {
|
|
1260
|
-
ratios[name] = n / pageRows.length;
|
|
1261
|
-
}
|
|
1262
|
-
if (!baseline) {
|
|
1263
|
-
baseline = ratios;
|
|
1264
|
-
} else if (fill_tolerance > 0) {
|
|
1265
|
-
const collapsed = Object.keys(baseline).filter(
|
|
1266
|
-
(name) =>
|
|
1267
|
-
baseline[name] >= 0.5 &&
|
|
1268
|
-
ratios[name] < baseline[name] * fill_tolerance,
|
|
1269
|
-
);
|
|
1270
|
-
if (collapsed.length) {
|
|
1271
|
-
stopped_because = "fill_rate_deviation";
|
|
1272
|
-
warnings.push(
|
|
1273
|
-
`page ${page}: ${collapsed
|
|
1274
|
-
.map(
|
|
1275
|
-
(n) =>
|
|
1276
|
-
`${n} ${(ratios[n] * 100).toFixed(0)}% vs baseline ${(baseline[n] * 100).toFixed(0)}%`,
|
|
1277
|
-
)
|
|
1278
|
-
.join(", ")} — page layout likely differs from the learned spec`,
|
|
1279
|
-
);
|
|
1280
|
-
break;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
for (const r of fresh) {
|
|
1285
|
-
seen.add(keyOf(r));
|
|
1286
|
-
rows.push(r);
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
if (rows.length >= target) {
|
|
1290
|
-
stopped_because = "limit_reached";
|
|
1291
|
-
break;
|
|
1292
|
-
}
|
|
1293
|
-
page++;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
const sliced = rows.slice(offset, offset + limit);
|
|
1297
|
-
if (offset && fetched) {
|
|
1298
|
-
warnings.push(
|
|
1299
|
-
`offset ${offset} was reached by fetching from page ${start_page}; pass start_page to skip pages instead of re-reading them`,
|
|
1300
|
-
);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// extract reports fill as counts, so extract_all does too — the per-page
|
|
1304
|
-
// baseline is a fraction because it is compared across pages of differing
|
|
1305
|
-
// size, and carries the unit in its name rather than looking like a count.
|
|
1306
|
-
const fill_rates = {};
|
|
1307
|
-
for (const name of Object.keys(fields)) {
|
|
1308
|
-
fill_rates[name] = sliced.filter((r) => !isBlank(r[name])).length;
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
return {
|
|
1312
|
-
rows: sliced,
|
|
1313
|
-
count: sliced.length,
|
|
1314
|
-
collected: rows.length,
|
|
1315
|
-
pages_fetched: fetched,
|
|
1316
|
-
last_page: page,
|
|
1317
|
-
stopped_because,
|
|
1318
|
-
fill_rates,
|
|
1319
|
-
baseline_fill_ratios: baseline,
|
|
1320
|
-
pages,
|
|
1321
|
-
warnings,
|
|
1322
|
-
};
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
56
|
const handleToolCall = async (request) => {
|
|
1326
57
|
const { name, arguments: args } = request.params;
|
|
1327
58
|
|
|
@@ -1337,26 +68,12 @@ const handleToolCall = async (request) => {
|
|
|
1337
68
|
const opts = args || {};
|
|
1338
69
|
let responsePayload;
|
|
1339
70
|
|
|
1340
|
-
if (isSecondary) {
|
|
1341
|
-
// Proxy it to
|
|
1342
|
-
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);
|
|
1343
74
|
} else {
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const tabId = tabInfo.tabId;
|
|
1347
|
-
|
|
1348
|
-
primaryLastTabId = tabId;
|
|
1349
|
-
broadcastSessionState();
|
|
1350
|
-
|
|
1351
|
-
let data = [];
|
|
1352
|
-
if (tabId && networkState.has(tabId)) {
|
|
1353
|
-
data = networkState.get(tabId).requests;
|
|
1354
|
-
if (opts.clear !== false) {
|
|
1355
|
-
networkState.get(tabId).requests = [];
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
responsePayload = { tabId, capturedRequests: data.length, data };
|
|
75
|
+
responsePayload = await bridge.readNetworkState(opts);
|
|
76
|
+
bridge.noteActiveTab(responsePayload.tabId);
|
|
1360
77
|
}
|
|
1361
78
|
|
|
1362
79
|
// Must be honoured in BOTH roles. A Secondary that returned the raw
|
|
@@ -1386,15 +103,11 @@ const handleToolCall = async (request) => {
|
|
|
1386
103
|
|
|
1387
104
|
// Custom handling for smart scroll
|
|
1388
105
|
if (action === "smart_scroll") {
|
|
1389
|
-
const scrollResult = await sendCommand("scroll", args || {});
|
|
1390
|
-
|
|
1391
|
-
if (!isSecondary && scrollResult && scrollResult.tabId) {
|
|
1392
|
-
primaryLastTabId = scrollResult.tabId;
|
|
1393
|
-
broadcastSessionState();
|
|
1394
|
-
}
|
|
106
|
+
const scrollResult = await bridge.sendCommand("scroll", args || {});
|
|
107
|
+
bridge.noteActiveTab(scrollResult && scrollResult.tabId);
|
|
1395
108
|
|
|
1396
109
|
// Wait for network requests to arrive (lazy loading)
|
|
1397
|
-
await new Promise((r) => setTimeout(r,
|
|
110
|
+
await new Promise((r) => setTimeout(r, SMART_SCROLL_SETTLE_MS));
|
|
1398
111
|
|
|
1399
112
|
return {
|
|
1400
113
|
content: [
|
|
@@ -1415,7 +128,7 @@ const handleToolCall = async (request) => {
|
|
|
1415
128
|
|
|
1416
129
|
// Custom handling for setting intercept patterns globally via storage, then updating current tab
|
|
1417
130
|
if (action === "set_intercept_patterns") {
|
|
1418
|
-
const result = await sendCommand("set_intercept_patterns", args);
|
|
131
|
+
const result = await bridge.sendCommand("set_intercept_patterns", args);
|
|
1419
132
|
return {
|
|
1420
133
|
content: [
|
|
1421
134
|
{
|
|
@@ -1429,30 +142,17 @@ const handleToolCall = async (request) => {
|
|
|
1429
142
|
if (action === "get_site_memory") {
|
|
1430
143
|
const host = args.hostname || args.domain || args.url;
|
|
1431
144
|
if (!host) throw new Error("Missing 'hostname' parameter");
|
|
1432
|
-
const file = memoryFile(host);
|
|
1433
|
-
let data = [];
|
|
1434
|
-
if (fs.existsSync(file)) {
|
|
1435
|
-
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1436
|
-
}
|
|
1437
145
|
return {
|
|
1438
|
-
content: [
|
|
146
|
+
content: [
|
|
147
|
+
{ type: "text", text: JSON.stringify(readMemory(host), null, 2) },
|
|
148
|
+
],
|
|
1439
149
|
};
|
|
1440
150
|
}
|
|
1441
151
|
|
|
1442
152
|
if (action === "save_site_memory") {
|
|
1443
153
|
const host = args.hostname || args.domain || args.url;
|
|
1444
154
|
if (!host) throw new Error("Missing 'hostname' parameter");
|
|
1445
|
-
|
|
1446
|
-
let data = [];
|
|
1447
|
-
if (fs.existsSync(file)) {
|
|
1448
|
-
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1449
|
-
}
|
|
1450
|
-
data.push({
|
|
1451
|
-
obstacle: args.obstacle,
|
|
1452
|
-
solution: args.solution,
|
|
1453
|
-
timestamp: Date.now(),
|
|
1454
|
-
});
|
|
1455
|
-
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
|
155
|
+
saveMemory(host, args.obstacle, args.solution);
|
|
1456
156
|
return {
|
|
1457
157
|
content: [{ type: "text", text: "Memory successfully saved." }],
|
|
1458
158
|
};
|
|
@@ -1460,7 +160,7 @@ const handleToolCall = async (request) => {
|
|
|
1460
160
|
|
|
1461
161
|
// Loops in the server, not in the model. One tool call covers every page.
|
|
1462
162
|
if (action === "extract_all") {
|
|
1463
|
-
const payload = await extractAll(args || {});
|
|
163
|
+
const payload = await extractAll(bridge.sendCommand, args || {});
|
|
1464
164
|
const opts = args || {};
|
|
1465
165
|
|
|
1466
166
|
if (opts.output_file) {
|
|
@@ -1484,18 +184,8 @@ const handleToolCall = async (request) => {
|
|
|
1484
184
|
};
|
|
1485
185
|
}
|
|
1486
186
|
|
|
1487
|
-
const result = await sendCommand(action, args || {});
|
|
1488
|
-
|
|
1489
|
-
if (!isSecondary) {
|
|
1490
|
-
// Track primary's active tab
|
|
1491
|
-
if (result && result.tabId) {
|
|
1492
|
-
primaryLastTabId = result.tabId;
|
|
1493
|
-
broadcastSessionState();
|
|
1494
|
-
} else if (args && args.tabId) {
|
|
1495
|
-
primaryLastTabId = args.tabId;
|
|
1496
|
-
broadcastSessionState();
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
187
|
+
const result = await bridge.sendCommand(action, args || {});
|
|
188
|
+
bridge.noteActiveTab((result && result.tabId) || (args && args.tabId));
|
|
1499
189
|
|
|
1500
190
|
// Any read tool can send its payload to disk. Placed after tab tracking so
|
|
1501
191
|
// taking the file path does not cost the session its tab bookkeeping.
|
|
@@ -1567,7 +257,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1567
257
|
current: OWN_VERSION,
|
|
1568
258
|
latest: updateState.latest,
|
|
1569
259
|
updateAvailable: updateState.updateAvailable,
|
|
1570
|
-
skew: updates.skew(OWN_VERSION, extensionVersion),
|
|
260
|
+
skew: updates.skew(OWN_VERSION, bridge.extensionVersion()),
|
|
1571
261
|
});
|
|
1572
262
|
if (!text) return result;
|
|
1573
263
|
|
|
@@ -1578,6 +268,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1578
268
|
|
|
1579
269
|
// Start the server
|
|
1580
270
|
async function startMcp() {
|
|
271
|
+
bridge.start();
|
|
272
|
+
|
|
1581
273
|
const transport = new StdioServerTransport();
|
|
1582
274
|
await server.connect(transport);
|
|
1583
275
|
|