iframer-toolkit 0.9.0 → 0.10.5
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 +65 -16
- package/dist/cli.js +9060 -2950
- package/dist/local-server.cjs +2420 -1135
- package/dist/mcp-server.cjs +1296 -828
- package/extension/README.md +134 -0
- package/extension/background.js +639 -0
- package/extension/manifest.json +32 -0
- package/extension/native-host.cjs +91 -0
- package/extension/popup.html +72 -0
- package/extension/popup.js +108 -0
- package/package.json +12 -6
package/dist/mcp-server.cjs
CHANGED
|
@@ -1,34 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
const importMetaUrl = require('node:url').pathToFileURL(__filename).href;
|
|
3
|
+
"use strict";
|
|
2
4
|
var __create = Object.create;
|
|
3
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
4
5
|
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
7
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
9
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
var __toESM = (mod, isNodeMode, target) => {
|
|
13
|
-
var canCache = mod != null && typeof mod === "object";
|
|
14
|
-
if (canCache) {
|
|
15
|
-
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
16
|
-
var cached = cache.get(mod);
|
|
17
|
-
if (cached)
|
|
18
|
-
return cached;
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
19
15
|
}
|
|
20
|
-
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
21
|
-
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
22
|
-
for (let key of __getOwnPropNames(mod))
|
|
23
|
-
if (!__hasOwnProp.call(to, key))
|
|
24
|
-
__defProp(to, key, {
|
|
25
|
-
get: __accessProp.bind(mod, key),
|
|
26
|
-
enumerable: true
|
|
27
|
-
});
|
|
28
|
-
if (canCache)
|
|
29
|
-
cache.set(mod, to);
|
|
30
16
|
return to;
|
|
31
17
|
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
19
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
20
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
21
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
22
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
23
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
24
|
+
mod
|
|
25
|
+
));
|
|
32
26
|
|
|
33
27
|
// src/mcp/server.ts
|
|
34
28
|
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
@@ -45,20 +39,16 @@ function createLogger(tag) {
|
|
|
45
39
|
const prefix = `[${tag}]`;
|
|
46
40
|
return {
|
|
47
41
|
debug: (...args) => {
|
|
48
|
-
if (LEVELS[currentLevel] <= 0)
|
|
49
|
-
console.log(prefix, ...args);
|
|
42
|
+
if (LEVELS[currentLevel] <= 0) console.log(prefix, ...args);
|
|
50
43
|
},
|
|
51
44
|
info: (...args) => {
|
|
52
|
-
if (LEVELS[currentLevel] <= 1)
|
|
53
|
-
console.log(prefix, ...args);
|
|
45
|
+
if (LEVELS[currentLevel] <= 1) console.log(prefix, ...args);
|
|
54
46
|
},
|
|
55
47
|
warn: (...args) => {
|
|
56
|
-
if (LEVELS[currentLevel] <= 2)
|
|
57
|
-
console.warn(prefix, ...args);
|
|
48
|
+
if (LEVELS[currentLevel] <= 2) console.warn(prefix, ...args);
|
|
58
49
|
},
|
|
59
50
|
error: (...args) => {
|
|
60
|
-
if (LEVELS[currentLevel] <= 3)
|
|
61
|
-
console.error(prefix, ...args);
|
|
51
|
+
if (LEVELS[currentLevel] <= 3) console.error(prefix, ...args);
|
|
62
52
|
}
|
|
63
53
|
};
|
|
64
54
|
}
|
|
@@ -78,8 +68,7 @@ function getDataDir() {
|
|
|
78
68
|
|
|
79
69
|
// src/lib/auth/crypto.ts
|
|
80
70
|
function getLocalToken() {
|
|
81
|
-
if (process.env.IFRAMER_SECRET)
|
|
82
|
-
return process.env.IFRAMER_SECRET;
|
|
71
|
+
if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
|
|
83
72
|
const candidates = [
|
|
84
73
|
import_path2.default.join(getDataDir(), "secret"),
|
|
85
74
|
import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
|
|
@@ -87,9 +76,9 @@ function getLocalToken() {
|
|
|
87
76
|
for (const file of candidates) {
|
|
88
77
|
try {
|
|
89
78
|
const existing = import_fs.default.readFileSync(file, "utf8").trim();
|
|
90
|
-
if (existing)
|
|
91
|
-
|
|
92
|
-
}
|
|
79
|
+
if (existing) return existing;
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
93
82
|
}
|
|
94
83
|
for (const file of candidates) {
|
|
95
84
|
try {
|
|
@@ -97,9 +86,12 @@ function getLocalToken() {
|
|
|
97
86
|
const secret = import_crypto.default.randomBytes(32).toString("hex");
|
|
98
87
|
import_fs.default.writeFileSync(file, secret, { mode: 384 });
|
|
99
88
|
return secret;
|
|
100
|
-
} catch {
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
101
91
|
}
|
|
102
|
-
throw new Error(
|
|
92
|
+
throw new Error(
|
|
93
|
+
`iframer: could not read or create a persistent encryption secret in any writable location (${candidates.join(", ")}). Set IFRAMER_SECRET to a stable value shared between the MCP server and CLI (openssl rand -hex 32).`
|
|
94
|
+
);
|
|
103
95
|
}
|
|
104
96
|
|
|
105
97
|
// src/mcp/local-server.ts
|
|
@@ -116,8 +108,7 @@ function serverInfoPath() {
|
|
|
116
108
|
return import_path3.default.join(getDataDir(), "server.json");
|
|
117
109
|
}
|
|
118
110
|
function isPidAlive(pid) {
|
|
119
|
-
if (!Number.isInteger(pid) || pid <= 1)
|
|
120
|
-
return false;
|
|
111
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
121
112
|
try {
|
|
122
113
|
process.kill(pid, 0);
|
|
123
114
|
return true;
|
|
@@ -128,8 +119,7 @@ function isPidAlive(pid) {
|
|
|
128
119
|
function readServerInfo() {
|
|
129
120
|
try {
|
|
130
121
|
const info = JSON.parse(import_fs2.default.readFileSync(serverInfoPath(), "utf8"));
|
|
131
|
-
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port))
|
|
132
|
-
return null;
|
|
122
|
+
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
|
|
133
123
|
return info;
|
|
134
124
|
} catch {
|
|
135
125
|
return null;
|
|
@@ -137,14 +127,12 @@ function readServerInfo() {
|
|
|
137
127
|
}
|
|
138
128
|
|
|
139
129
|
// src/mcp/local-server.ts
|
|
140
|
-
var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
|
|
141
130
|
var BASE_PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
|
|
142
|
-
var PORT_SCAN_ATTEMPTS =
|
|
143
|
-
var STARTUP_TIMEOUT_MS =
|
|
131
|
+
var PORT_SCAN_ATTEMPTS = 21;
|
|
132
|
+
var STARTUP_TIMEOUT_MS = 15e3;
|
|
144
133
|
var HEALTH_POLL_MS = 300;
|
|
145
|
-
var SPAWN_LOCK_STALE_MS =
|
|
146
|
-
|
|
147
|
-
class LocalServerManager {
|
|
134
|
+
var SPAWN_LOCK_STALE_MS = 2e4;
|
|
135
|
+
var LocalServerManager = class {
|
|
148
136
|
startingPromise = null;
|
|
149
137
|
baseUrl = "";
|
|
150
138
|
logPath;
|
|
@@ -153,24 +141,24 @@ class LocalServerManager {
|
|
|
153
141
|
}
|
|
154
142
|
getBaseUrl() {
|
|
155
143
|
if (!this.baseUrl) {
|
|
156
|
-
throw new Error("Local server not started yet
|
|
144
|
+
throw new Error("Local server not started yet \u2014 call ensureRunning() first.");
|
|
157
145
|
}
|
|
158
146
|
return this.baseUrl;
|
|
159
147
|
}
|
|
148
|
+
/** Ensure a shared local server is reachable. Idempotent — concurrent
|
|
149
|
+
* callers await the same startup promise. */
|
|
160
150
|
async ensureRunning() {
|
|
161
|
-
if (this.startingPromise)
|
|
162
|
-
|
|
163
|
-
if (await this.adoptExisting())
|
|
164
|
-
return;
|
|
151
|
+
if (this.startingPromise) return this.startingPromise;
|
|
152
|
+
if (await this.adoptExisting()) return;
|
|
165
153
|
this.startingPromise = this.startShared().finally(() => {
|
|
166
154
|
this.startingPromise = null;
|
|
167
155
|
});
|
|
168
156
|
return this.startingPromise;
|
|
169
157
|
}
|
|
158
|
+
/** Adopt the advertised shared server if its process is alive and healthy. */
|
|
170
159
|
async adoptExisting() {
|
|
171
160
|
const info = readServerInfo();
|
|
172
|
-
if (!info || !isPidAlive(info.pid))
|
|
173
|
-
return false;
|
|
161
|
+
if (!info || !isPidAlive(info.pid)) return false;
|
|
174
162
|
const url = `http://127.0.0.1:${info.port}`;
|
|
175
163
|
if (await healthCheck(url)) {
|
|
176
164
|
this.baseUrl = url;
|
|
@@ -199,21 +187,20 @@ class LocalServerManager {
|
|
|
199
187
|
import_fs3.default.rmdirSync(lockDir);
|
|
200
188
|
import_fs3.default.mkdirSync(lockDir);
|
|
201
189
|
holdingLock = true;
|
|
202
|
-
} catch {
|
|
190
|
+
} catch {
|
|
191
|
+
}
|
|
203
192
|
}
|
|
204
193
|
}
|
|
205
194
|
try {
|
|
206
195
|
if (!holdingLock) {
|
|
207
196
|
const deadline2 = Date.now() + STARTUP_TIMEOUT_MS;
|
|
208
197
|
while (Date.now() < deadline2) {
|
|
209
|
-
if (await this.adoptExisting())
|
|
210
|
-
return;
|
|
198
|
+
if (await this.adoptExisting()) return;
|
|
211
199
|
await sleep(HEALTH_POLL_MS);
|
|
212
200
|
}
|
|
213
201
|
throw new Error("Timed out waiting for another session to start the shared iframer server.");
|
|
214
202
|
}
|
|
215
|
-
if (await this.adoptExisting())
|
|
216
|
-
return;
|
|
203
|
+
if (await this.adoptExisting()) return;
|
|
217
204
|
const port = await findFreePort(BASE_PORT, PORT_SCAN_ATTEMPTS);
|
|
218
205
|
const url = `http://127.0.0.1:${port}`;
|
|
219
206
|
const { command, args } = this.resolveRuntime();
|
|
@@ -229,11 +216,11 @@ class LocalServerManager {
|
|
|
229
216
|
} else {
|
|
230
217
|
try {
|
|
231
218
|
const secret = import_fs3.default.readFileSync(import_path4.default.join(dataDir, "secret"), "utf8").trim();
|
|
232
|
-
if (secret)
|
|
233
|
-
|
|
234
|
-
}
|
|
219
|
+
if (secret) env.IFRAMER_SECRET = secret;
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
235
222
|
}
|
|
236
|
-
const child = import_child_process.spawn(command, args, {
|
|
223
|
+
const child = (0, import_child_process.spawn)(command, args, {
|
|
237
224
|
env,
|
|
238
225
|
stdio: ["ignore", logFd, logFd],
|
|
239
226
|
detached: true
|
|
@@ -250,71 +237,109 @@ class LocalServerManager {
|
|
|
250
237
|
}
|
|
251
238
|
try {
|
|
252
239
|
child.kill("SIGKILL");
|
|
253
|
-
} catch {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
${
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Local iframer server failed to start on port ${port} within ${STARTUP_TIMEOUT_MS}ms.
|
|
244
|
+
Last log lines:
|
|
245
|
+
${this.readLogTail()}`
|
|
246
|
+
);
|
|
257
247
|
} finally {
|
|
258
248
|
if (holdingLock) {
|
|
259
249
|
try {
|
|
260
250
|
import_fs3.default.rmdirSync(lockDir);
|
|
261
|
-
} catch {
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
262
253
|
}
|
|
263
254
|
}
|
|
264
255
|
}
|
|
256
|
+
/** Package root, found by walking up for iframer-toolkit's package.json.
|
|
257
|
+
* Robust to WHERE this code runs from: src/mcp/ (source), dist/ (bundled
|
|
258
|
+
* CLI under node), or a global npm install — a fixed __dirname/../.. guess
|
|
259
|
+
* breaks in the bundle (that's why the warm CLI silently ran in-process). */
|
|
260
|
+
packageRoot() {
|
|
261
|
+
let dir = __dirname;
|
|
262
|
+
for (let i = 0; i < 8; i++) {
|
|
263
|
+
try {
|
|
264
|
+
const pkg = JSON.parse(import_fs3.default.readFileSync(import_path4.default.join(dir, "package.json"), "utf8"));
|
|
265
|
+
if (pkg.name === "iframer-toolkit") return dir;
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
const parent = import_path4.default.dirname(dir);
|
|
269
|
+
if (parent === dir) break;
|
|
270
|
+
dir = parent;
|
|
271
|
+
}
|
|
272
|
+
return import_path4.default.join(__dirname, "..", "..");
|
|
273
|
+
}
|
|
265
274
|
resolveRuntime() {
|
|
275
|
+
const root = this.packageRoot();
|
|
276
|
+
const serverTs = import_path4.default.join(root, "index.ts");
|
|
277
|
+
const serverCjs = import_path4.default.join(root, "dist", "local-server.cjs");
|
|
278
|
+
if (import_fs3.default.existsSync(serverCjs)) {
|
|
279
|
+
return { command: "node", args: [serverCjs] };
|
|
280
|
+
}
|
|
281
|
+
if (import_fs3.default.existsSync(serverTs)) {
|
|
282
|
+
try {
|
|
283
|
+
require.resolve("tsx");
|
|
284
|
+
return { command: "node", args: ["--import", "tsx", serverTs] };
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
287
|
+
}
|
|
266
288
|
try {
|
|
267
289
|
const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return { command: bunPath, args: ["run",
|
|
290
|
+
if (bunPath && import_fs3.default.existsSync(serverTs)) {
|
|
291
|
+
console.error("[iframer] running local server under bun \u2014 extension mode (connectOverCDP) will not work; run `bun run build` to get the node bundle");
|
|
292
|
+
return { command: bunPath, args: ["run", serverTs] };
|
|
271
293
|
}
|
|
272
|
-
} catch {
|
|
273
|
-
const serverCjs = import_path4.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
|
|
274
|
-
if (import_fs3.default.existsSync(serverCjs)) {
|
|
275
|
-
return { command: "node", args: [serverCjs] };
|
|
294
|
+
} catch {
|
|
276
295
|
}
|
|
277
|
-
|
|
278
|
-
|
|
296
|
+
throw new Error(
|
|
297
|
+
"No runnable iframer server entry found: dist/local-server.cjs is missing, tsx is not installed, and bun is unavailable. Run `bun install && bun run build` in the iframer repo."
|
|
298
|
+
);
|
|
279
299
|
}
|
|
300
|
+
/** Ask the shared server to exit (it kills its browsers with a hard
|
|
301
|
+
* deadline), then start a fresh one. Used for crash recovery. */
|
|
280
302
|
async restart() {
|
|
281
303
|
const info = readServerInfo();
|
|
282
304
|
if (info && this.baseUrl) {
|
|
283
305
|
try {
|
|
284
|
-
await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(
|
|
285
|
-
} catch {
|
|
286
|
-
|
|
306
|
+
await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3e3) });
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
const deadline = Date.now() + 12e3;
|
|
287
310
|
while (Date.now() < deadline && isPidAlive(info.pid)) {
|
|
288
311
|
await sleep(200);
|
|
289
312
|
}
|
|
290
313
|
if (isPidAlive(info.pid)) {
|
|
291
314
|
try {
|
|
292
315
|
process.kill(info.pid, "SIGKILL");
|
|
293
|
-
} catch {
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
294
318
|
}
|
|
295
319
|
}
|
|
296
320
|
this.baseUrl = "";
|
|
297
321
|
await this.ensureRunning();
|
|
298
322
|
}
|
|
299
|
-
|
|
323
|
+
/** MCP process exiting. The shared server intentionally stays up —
|
|
324
|
+
* other sessions may be using it; idle-exit + reaper govern its life. */
|
|
325
|
+
shutdown() {
|
|
326
|
+
}
|
|
300
327
|
readLogTail() {
|
|
301
328
|
try {
|
|
302
329
|
const content = import_fs3.default.readFileSync(this.logPath, "utf8");
|
|
303
|
-
const lines = content.trim().split(
|
|
304
|
-
|
|
305
|
-
return lines.slice(-10).join(`
|
|
306
|
-
`);
|
|
330
|
+
const lines = content.trim().split("\n");
|
|
331
|
+
return lines.slice(-10).join("\n");
|
|
307
332
|
} catch {
|
|
308
333
|
return "(no log file)";
|
|
309
334
|
}
|
|
310
335
|
}
|
|
311
|
-
}
|
|
336
|
+
};
|
|
312
337
|
function sleep(ms) {
|
|
313
338
|
return new Promise((r) => setTimeout(r, ms));
|
|
314
339
|
}
|
|
315
340
|
async function healthCheck(baseUrl) {
|
|
316
341
|
try {
|
|
317
|
-
const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(
|
|
342
|
+
const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2e3) });
|
|
318
343
|
const data = await res.json();
|
|
319
344
|
return data.ok === true;
|
|
320
345
|
} catch {
|
|
@@ -343,8 +368,8 @@ function findFreePort(start, attempts) {
|
|
|
343
368
|
}
|
|
344
369
|
|
|
345
370
|
// src/lib/errors.ts
|
|
346
|
-
function getErrorMessage(
|
|
347
|
-
return
|
|
371
|
+
function getErrorMessage(err2) {
|
|
372
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
348
373
|
}
|
|
349
374
|
|
|
350
375
|
// src/mcp/helpers.ts
|
|
@@ -353,56 +378,55 @@ var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
|
|
|
353
378
|
var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
|
|
354
379
|
var IFRAMER_MODE = process.env.IFRAMER_MODE;
|
|
355
380
|
var LOCAL_TOKEN = getLocalToken();
|
|
356
|
-
var localServer = new LocalServerManager;
|
|
381
|
+
var localServer = new LocalServerManager();
|
|
357
382
|
async function ensureLocalServer() {
|
|
358
383
|
await localServer.ensureRunning();
|
|
359
384
|
}
|
|
360
385
|
function authHeaders(secret) {
|
|
361
386
|
const headers = { "Content-Type": "application/json" };
|
|
362
387
|
const key = secret || IFRAMER_SECRET;
|
|
363
|
-
if (key)
|
|
364
|
-
headers["x-api-key"] = key;
|
|
388
|
+
if (key) headers["x-api-key"] = key;
|
|
365
389
|
return headers;
|
|
366
390
|
}
|
|
367
391
|
async function apiPost(endpoint, body) {
|
|
368
392
|
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
|
369
393
|
method: "POST",
|
|
370
394
|
headers: authHeaders(),
|
|
371
|
-
body: body ? JSON.stringify(body) :
|
|
372
|
-
signal: AbortSignal.timeout(
|
|
395
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
396
|
+
signal: AbortSignal.timeout(18e4)
|
|
373
397
|
});
|
|
374
398
|
return res.json();
|
|
375
399
|
}
|
|
376
400
|
async function apiGet(endpoint) {
|
|
377
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders() });
|
|
401
|
+
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(3e4) });
|
|
378
402
|
return res.json();
|
|
379
403
|
}
|
|
380
|
-
async function localApiPost(endpoint, body) {
|
|
404
|
+
async function localApiPost(endpoint, body, timeoutMs = 18e4) {
|
|
381
405
|
await ensureLocalServer();
|
|
382
406
|
const url = localServer.getBaseUrl();
|
|
383
407
|
const res = await fetch(`${url}${endpoint}`, {
|
|
384
408
|
method: "POST",
|
|
385
409
|
headers: authHeaders(LOCAL_TOKEN),
|
|
386
|
-
body: body ? JSON.stringify(body) :
|
|
387
|
-
signal: AbortSignal.timeout(
|
|
410
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
411
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
388
412
|
});
|
|
389
413
|
return res.json();
|
|
390
414
|
}
|
|
391
415
|
async function localApiGet(endpoint) {
|
|
392
416
|
await ensureLocalServer();
|
|
393
417
|
const url = localServer.getBaseUrl();
|
|
394
|
-
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN) });
|
|
418
|
+
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
|
|
395
419
|
return res.json();
|
|
396
420
|
}
|
|
397
421
|
async function localApiDelete(endpoint) {
|
|
398
422
|
await ensureLocalServer();
|
|
399
423
|
const url = localServer.getBaseUrl();
|
|
400
|
-
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN) });
|
|
424
|
+
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
|
|
401
425
|
return res.json();
|
|
402
426
|
}
|
|
403
427
|
async function isDockerRunning() {
|
|
404
428
|
try {
|
|
405
|
-
const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(
|
|
429
|
+
const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3e3) });
|
|
406
430
|
const data = await res.json();
|
|
407
431
|
return data.ok === true;
|
|
408
432
|
} catch {
|
|
@@ -414,20 +438,18 @@ async function resolveScreenshotPath(url) {
|
|
|
414
438
|
if (url.startsWith("file://")) {
|
|
415
439
|
const { fileURLToPath } = await import("url");
|
|
416
440
|
const filePath2 = fileURLToPath(url);
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
419
|
-
return filePath2;
|
|
441
|
+
const fs11 = await import("fs");
|
|
442
|
+
if (fs11.existsSync(filePath2)) return filePath2;
|
|
420
443
|
return null;
|
|
421
444
|
}
|
|
422
445
|
const res = await fetch(url);
|
|
423
|
-
if (!res.ok)
|
|
424
|
-
return null;
|
|
446
|
+
if (!res.ok) return null;
|
|
425
447
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
426
|
-
const
|
|
448
|
+
const fs10 = await import("fs");
|
|
427
449
|
const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
|
|
428
|
-
|
|
450
|
+
fs10.mkdirSync(dir, { recursive: true });
|
|
429
451
|
const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
|
|
430
|
-
|
|
452
|
+
fs10.writeFileSync(filePath, buf);
|
|
431
453
|
return filePath;
|
|
432
454
|
} catch {
|
|
433
455
|
return null;
|
|
@@ -446,24 +468,23 @@ function defaultFile() {
|
|
|
446
468
|
}
|
|
447
469
|
var TTL_DAYS = 14;
|
|
448
470
|
var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
|
|
449
|
-
|
|
450
|
-
class DomainModeStore {
|
|
471
|
+
var DomainModeStore = class {
|
|
451
472
|
data = {};
|
|
452
473
|
filePath;
|
|
453
474
|
constructor(filePath = defaultFile()) {
|
|
454
475
|
this.filePath = filePath;
|
|
455
476
|
this.load();
|
|
456
477
|
}
|
|
478
|
+
/** Get the recommended mode for a domain, or null if unknown/expired */
|
|
457
479
|
getMode(domain) {
|
|
458
480
|
const entry = this.data[domain];
|
|
459
|
-
if (!entry)
|
|
460
|
-
|
|
461
|
-
if (this.isExpired(entry))
|
|
462
|
-
return null;
|
|
481
|
+
if (!entry) return null;
|
|
482
|
+
if (this.isExpired(entry)) return null;
|
|
463
483
|
return entry.mode;
|
|
464
484
|
}
|
|
485
|
+
/** Record a successful access */
|
|
465
486
|
recordSuccess(domain, mode) {
|
|
466
|
-
const now = new Date().toISOString();
|
|
487
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
467
488
|
const existing = this.data[domain];
|
|
468
489
|
this.data[domain] = {
|
|
469
490
|
mode,
|
|
@@ -475,8 +496,9 @@ class DomainModeStore {
|
|
|
475
496
|
};
|
|
476
497
|
this.save();
|
|
477
498
|
}
|
|
499
|
+
/** Record a blocked attempt */
|
|
478
500
|
recordFailure(domain, mode, reason) {
|
|
479
|
-
const now = new Date().toISOString();
|
|
501
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
480
502
|
const existing = this.data[domain];
|
|
481
503
|
this.data[domain] = {
|
|
482
504
|
mode: existing?.mode || mode,
|
|
@@ -488,26 +510,28 @@ class DomainModeStore {
|
|
|
488
510
|
};
|
|
489
511
|
this.save();
|
|
490
512
|
}
|
|
513
|
+
/** Get the next mode to try after a failure, or null if exhausted */
|
|
491
514
|
getNextMode(failedMode, availableModes) {
|
|
492
515
|
const idx = ESCALATION_LADDER.indexOf(failedMode);
|
|
493
|
-
for (let i = idx + 1;i < ESCALATION_LADDER.length; i++) {
|
|
516
|
+
for (let i = idx + 1; i < ESCALATION_LADDER.length; i++) {
|
|
494
517
|
if (availableModes.includes(ESCALATION_LADDER[i])) {
|
|
495
518
|
return ESCALATION_LADDER[i];
|
|
496
519
|
}
|
|
497
520
|
}
|
|
498
521
|
return null;
|
|
499
522
|
}
|
|
523
|
+
/** Get the best starting mode: domain memory > first available in ladder */
|
|
500
524
|
getBestMode(domain, availableModes) {
|
|
501
525
|
const remembered = this.getMode(domain);
|
|
502
526
|
if (remembered && availableModes.includes(remembered)) {
|
|
503
527
|
return remembered;
|
|
504
528
|
}
|
|
505
529
|
for (const mode of ESCALATION_LADDER) {
|
|
506
|
-
if (availableModes.includes(mode))
|
|
507
|
-
return mode;
|
|
530
|
+
if (availableModes.includes(mode)) return mode;
|
|
508
531
|
}
|
|
509
532
|
return "headless";
|
|
510
533
|
}
|
|
534
|
+
/** Get summary stats for the status tool */
|
|
511
535
|
getSummary() {
|
|
512
536
|
const entries = Object.entries(this.data).filter(([, e]) => !this.isExpired(e)).sort(([, a], [, b]) => b.lastSuccess.localeCompare(a.lastSuccess));
|
|
513
537
|
return {
|
|
@@ -516,10 +540,9 @@ class DomainModeStore {
|
|
|
516
540
|
};
|
|
517
541
|
}
|
|
518
542
|
isExpired(entry) {
|
|
519
|
-
if (!entry.lastSuccess)
|
|
520
|
-
return true;
|
|
543
|
+
if (!entry.lastSuccess) return true;
|
|
521
544
|
const age = Date.now() - new Date(entry.lastSuccess).getTime();
|
|
522
|
-
return age > TTL_DAYS * 24 * 60 * 60 *
|
|
545
|
+
return age > TTL_DAYS * 24 * 60 * 60 * 1e3;
|
|
523
546
|
}
|
|
524
547
|
load() {
|
|
525
548
|
try {
|
|
@@ -538,81 +561,95 @@ class DomainModeStore {
|
|
|
538
561
|
log3.error("Failed to save:", err2);
|
|
539
562
|
}
|
|
540
563
|
}
|
|
541
|
-
}
|
|
564
|
+
};
|
|
542
565
|
|
|
543
566
|
// src/mcp/tools/status.ts
|
|
544
|
-
function registerStatusTool(
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
567
|
+
function registerStatusTool(server2) {
|
|
568
|
+
server2.tool(
|
|
569
|
+
"status",
|
|
570
|
+
`Get the full state of iframer in one call. Call this first. Returns: available browser modes, API health, active session, stored credentials, and domain memory.`,
|
|
571
|
+
{},
|
|
572
|
+
async () => {
|
|
550
573
|
try {
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
status.localServer = false;
|
|
555
|
-
}
|
|
556
|
-
try {
|
|
557
|
-
const browserHealth = await localApiGet("/browser/health");
|
|
558
|
-
status.browserAlive = browserHealth.alive;
|
|
559
|
-
status.runningModes = browserHealth.modes;
|
|
560
|
-
} catch {}
|
|
561
|
-
if (dockerRunning) {
|
|
574
|
+
const status = {};
|
|
575
|
+
const dockerRunning = await isDockerRunning();
|
|
576
|
+
status.dockerApi = dockerRunning;
|
|
562
577
|
try {
|
|
563
|
-
const
|
|
564
|
-
status.
|
|
565
|
-
} catch {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
578
|
+
const localHealth = await localApiGet("/health");
|
|
579
|
+
status.localServer = localHealth.ok;
|
|
580
|
+
} catch {
|
|
581
|
+
status.localServer = false;
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const browserHealth = await localApiGet("/browser/health");
|
|
585
|
+
status.browserAlive = browserHealth.alive;
|
|
586
|
+
status.runningModes = browserHealth.modes;
|
|
587
|
+
} catch {
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const inst = await localApiGet("/instances");
|
|
591
|
+
if (inst.instances && inst.instances.length) status.liveInstances = inst.instances;
|
|
592
|
+
} catch {
|
|
593
|
+
}
|
|
594
|
+
if (dockerRunning) {
|
|
595
|
+
try {
|
|
596
|
+
const sessionData = await apiGet("/interactive/status");
|
|
597
|
+
status.dockerSession = sessionData.active ? { active: true, noVncUrl: sessionData.noVncUrl } : { active: false };
|
|
598
|
+
} catch {
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
const credData = await localApiGet("/credentials");
|
|
603
|
+
status.credentials = credData.domains || [];
|
|
604
|
+
} catch {
|
|
605
|
+
status.credentials = [];
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
const domainModes = new DomainModeStore();
|
|
609
|
+
status.domainMemory = domainModes.getSummary();
|
|
610
|
+
} catch {
|
|
611
|
+
}
|
|
612
|
+
return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
|
|
613
|
+
} catch (e) {
|
|
614
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
572
615
|
}
|
|
573
|
-
try {
|
|
574
|
-
const domainModes = new DomainModeStore;
|
|
575
|
-
status.domainMemory = domainModes.getSummary();
|
|
576
|
-
} catch {}
|
|
577
|
-
return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
|
|
578
|
-
} catch (e) {
|
|
579
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
580
616
|
}
|
|
581
|
-
|
|
617
|
+
);
|
|
582
618
|
}
|
|
583
619
|
|
|
584
620
|
// src/mcp/tools/browse.ts
|
|
585
621
|
var import_zod = require("zod");
|
|
586
|
-
function registerBrowseTool(
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
622
|
+
function registerBrowseTool(server2) {
|
|
623
|
+
server2.tool(
|
|
624
|
+
"browse",
|
|
625
|
+
`Fetch a web page with a headless browser. Use for pages that need JavaScript rendering but don't have bot detection walls. Session cookies and stored credentials persist across calls via the single local SQLite store.
|
|
626
|
+
|
|
627
|
+
PRE-FLIGHT: Call \`knowledge get <domain>\` first. If the cache shows a direct-API path for the data you need, skip this tool and hit the endpoints directly \u2014 it's orders of magnitude faster.`,
|
|
628
|
+
{
|
|
629
|
+
url: import_zod.z.string().describe("URL to navigate to"),
|
|
630
|
+
extract: import_zod.z.string().optional().describe("JavaScript expression to evaluate (e.g. 'document.title')"),
|
|
631
|
+
actions: import_zod.z.array(import_zod.z.object({
|
|
632
|
+
type: import_zod.z.enum(["click", "fill", "wait", "scroll", "human-click", "human-type"]),
|
|
633
|
+
selector: import_zod.z.string().optional(),
|
|
634
|
+
value: import_zod.z.string().optional(),
|
|
635
|
+
ms: import_zod.z.number().optional()
|
|
636
|
+
})).optional().describe("Actions to execute before extracting"),
|
|
637
|
+
returnHtml: import_zod.z.boolean().optional().describe("Return full page HTML"),
|
|
638
|
+
waitForSelector: import_zod.z.string().optional().describe("Wait for this CSS selector before proceeding"),
|
|
639
|
+
sessionless: import_zod.z.boolean().optional().describe("Skip session persistence")
|
|
640
|
+
},
|
|
641
|
+
async (params) => {
|
|
642
|
+
try {
|
|
643
|
+
const fetchResult = await localApiPost("/fetch", params);
|
|
644
|
+
if (!fetchResult.ok) return err(`Error: ${fetchResult.error}`);
|
|
645
|
+
const { html, ...rest } = fetchResult;
|
|
646
|
+
const text = html ? JSON.stringify(rest, null, 2) + "\n\n--- HTML ---\n" + html : JSON.stringify(rest, null, 2);
|
|
647
|
+
return { content: [{ type: "text", text }] };
|
|
648
|
+
} catch (e) {
|
|
649
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
650
|
+
}
|
|
614
651
|
}
|
|
615
|
-
|
|
652
|
+
);
|
|
616
653
|
}
|
|
617
654
|
|
|
618
655
|
// src/mcp/tools/execute.ts
|
|
@@ -623,184 +660,79 @@ var import_zod2 = require("zod");
|
|
|
623
660
|
var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
624
661
|
import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
|
|
625
662
|
import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
|
|
626
|
-
import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
|
|
663
|
+
import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string().describe("Sets an input/textarea's value. Framework-aware: fires the React-safe native setter + input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) register the value AND mark the field touched. This is the fix for 'I filled the field but submit says it's still empty' \u2014 always use fill for form fields, not evaluate.") }),
|
|
627
664
|
import_zod2.z.object({ type: import_zod2.z.literal("human-click"), selector: import_zod2.z.string().optional(), x: import_zod2.z.number().optional(), y: import_zod2.z.number().optional() }),
|
|
628
665
|
import_zod2.z.object({ type: import_zod2.z.literal("right-click"), selector: import_zod2.z.string().optional(), x: import_zod2.z.number().optional(), y: import_zod2.z.number().optional() }),
|
|
629
|
-
import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
|
|
666
|
+
import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string(), skipClick: import_zod2.z.boolean().optional().describe("Skip the click-to-focus and type into the already-focused element. Use for editors that blur on a synthetic click (e.g. Draft.js). Either way, typing aborts safely if the target isn't actually focused."), speed: import_zod2.z.enum(["slow", "normal", "fast"]).optional().describe("Typing speed. 'normal' (~130ms/char, realistic, default), 'fast' (~45ms/char) for long non-sensitive text, 'slow' for extra realism.") }),
|
|
630
667
|
import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
|
|
631
668
|
import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
|
|
632
669
|
import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
|
|
633
670
|
import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
|
|
634
|
-
import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional() }),
|
|
635
|
-
import_zod2.z.object({ type: import_zod2.z.literal("
|
|
671
|
+
import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional(), selector: import_zod2.z.string().optional().describe("Scroll within this element instead of the window"), human: import_zod2.z.boolean().optional().describe("Real eased wheel events instead of an instant jump (slower, less bot-obvious)") }),
|
|
672
|
+
import_zod2.z.object({ type: import_zod2.z.literal("select"), selector: import_zod2.z.string(), value: import_zod2.z.string().describe("Option value to pick in a <select> \u2014 native selection, real change events (never set .value via evaluate)") }),
|
|
673
|
+
import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string(), meta: import_zod2.z.boolean().optional(), ctrl: import_zod2.z.boolean().optional(), shift: import_zod2.z.boolean().optional(), alt: import_zod2.z.boolean().optional() }),
|
|
674
|
+
import_zod2.z.object({ type: import_zod2.z.literal("read"), selector: import_zod2.z.string().optional().describe("Element to read text from (CSS or @e ref); omit for the whole body"), maxChars: import_zod2.z.number().optional().describe("Cap returned text length (default 6000)") }),
|
|
675
|
+
import_zod2.z.object({ type: import_zod2.z.literal("upload"), selector: import_zod2.z.string().describe("The <input type=file> (CSS, @e ref, or @a anchor)"), files: import_zod2.z.array(import_zod2.z.string()).describe("Absolute local file path(s) on this machine") }),
|
|
676
|
+
import_zod2.z.object({ type: import_zod2.z.literal("paste"), selector: import_zod2.z.string().optional().describe("Field to paste the OS clipboard into; omit for the focused element. Reliable via CDP insertText where a \u2318V keyboard step isn't.") }),
|
|
677
|
+
import_zod2.z.object({ type: import_zod2.z.literal("download"), url: import_zod2.z.string().describe("File URL \u2014 fetched with the browser's cookies (auth'd downloads work), written to disk server-side, no Save-As dialog"), path: import_zod2.z.string().optional().describe("Absolute save path (default: ~/.iframer/downloads/<name>)") }),
|
|
636
678
|
import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
|
|
637
679
|
import_zod2.z.object({ type: import_zod2.z.literal("login"), domain: import_zod2.z.string(), usernameSelector: import_zod2.z.string().optional(), passwordSelector: import_zod2.z.string().optional(), submitSelector: import_zod2.z.string().optional(), totpSelector: import_zod2.z.string().optional() }),
|
|
638
680
|
import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
|
|
639
|
-
import_zod2.z.object({ type: import_zod2.z.literal("screenshot"), annotate: import_zod2.z.boolean().optional().describe("Overlay numbered badges on interactive elements
|
|
640
|
-
import_zod2.z.object({ type: import_zod2.z.literal("snapshot"), interactiveOnly: import_zod2.z.boolean().optional().describe("Only
|
|
641
|
-
import_zod2.z.object({ type: import_zod2.z.literal("find"), role: import_zod2.z.string().optional().describe("ARIA role: button, link, textbox
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha
|
|
645
|
-
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-info") }),
|
|
646
|
-
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-solve") }),
|
|
647
|
-
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-answer"), tiles: import_zod2.z.array(import_zod2.z.number()) })
|
|
681
|
+
import_zod2.z.object({ type: import_zod2.z.literal("screenshot"), annotate: import_zod2.z.boolean().optional().describe("Overlay numbered badges on interactive elements; returns @e refs") }),
|
|
682
|
+
import_zod2.z.object({ type: import_zod2.z.literal("snapshot"), interactiveOnly: import_zod2.z.boolean().optional().describe("Only interactive elements (default: true)"), maxElements: import_zod2.z.number().optional().describe("Max elements (default: 80)") }),
|
|
683
|
+
import_zod2.z.object({ type: import_zod2.z.literal("find"), role: import_zod2.z.string().optional().describe("ARIA role: button, link, textbox\u2026"), name: import_zod2.z.string().optional().describe("Accessible name / aria-label"), text: import_zod2.z.string().optional().describe("Visible text content"), placeholder: import_zod2.z.string().optional(), label: import_zod2.z.string().optional(), exact: import_zod2.z.boolean().optional().describe("Exact match vs substring (default: substring)") }),
|
|
684
|
+
// Captcha interaction, collapsed to one step. The full workflow manual is
|
|
685
|
+
// injected into the execute RESULT whenever a captcha blocks a run.
|
|
686
|
+
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha"), action: import_zod2.z.enum(["info", "click", "select", "verify", "solve", "answer"]).describe("Captcha interaction (manual arrives in the result when a captcha blocks a run): info=state+grid screenshot, click=checkbox, answer=select tiles+verify+recheck, select/verify=manual control, solve=auto vision solve"), tiles: import_zod2.z.array(import_zod2.z.number()).optional().describe("Tile numbers for select/answer") })
|
|
648
687
|
]);
|
|
688
|
+
function normalizeSteps(steps) {
|
|
689
|
+
return (steps || []).map((s) => {
|
|
690
|
+
if (s && s.type === "recaptcha") {
|
|
691
|
+
const { action, ...rest } = s;
|
|
692
|
+
return { ...rest, type: `recaptcha-${action || "info"}` };
|
|
693
|
+
}
|
|
694
|
+
return s;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
649
697
|
|
|
650
|
-
// src/
|
|
698
|
+
// src/lib/format-result.ts
|
|
651
699
|
function resultOf(r, _type) {
|
|
652
700
|
return r.result;
|
|
653
701
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
|
|
671
|
-
|
|
672
|
-
Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
|
|
673
|
-
steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
|
|
674
|
-
options: import_zod3.z.object({
|
|
675
|
-
staleTimeoutMs: import_zod3.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
676
|
-
screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
|
|
677
|
-
continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
678
|
-
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
679
|
-
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
|
|
680
|
-
mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates."),
|
|
681
|
-
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
682
|
-
instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state.")
|
|
683
|
-
}).optional()
|
|
684
|
-
}, async (params) => {
|
|
685
|
-
try {
|
|
686
|
-
const dockerRunning = await isDockerRunning();
|
|
687
|
-
async function runWithMode(mode) {
|
|
688
|
-
if (mode === "docker-headful") {
|
|
689
|
-
if (!dockerRunning) {
|
|
690
|
-
return {
|
|
691
|
-
ok: false,
|
|
692
|
-
completedSteps: 0,
|
|
693
|
-
totalSteps: params.steps.length,
|
|
694
|
-
results: [],
|
|
695
|
-
finalState: { url: "", title: "" },
|
|
696
|
-
obstacles: [],
|
|
697
|
-
durationMs: 0,
|
|
698
|
-
modeUsed: "docker-headful",
|
|
699
|
-
error: {
|
|
700
|
-
failedAtStep: 0,
|
|
701
|
-
failedStep: params.steps[0],
|
|
702
|
-
errorType: "action-failed",
|
|
703
|
-
message: "docker-headful mode was requested but the Docker API is not reachable.",
|
|
704
|
-
pageState: { url: "", title: "" },
|
|
705
|
-
suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
|
|
706
|
-
retryable: false
|
|
707
|
-
}
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
return apiPost("/execute", {
|
|
711
|
-
steps: params.steps,
|
|
712
|
-
options: { ...params.options, mode: "docker-headful", autoEscalate: false }
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
return localApiPost("/execute", {
|
|
716
|
-
steps: params.steps,
|
|
717
|
-
options: { ...params.options, mode: mode || undefined }
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
let execResult;
|
|
721
|
-
try {
|
|
722
|
-
execResult = await runWithMode(params.options?.mode);
|
|
723
|
-
} catch (execErr) {
|
|
724
|
-
const msg = execErr instanceof Error ? execErr.message : String(execErr);
|
|
725
|
-
const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
|
|
726
|
-
if (isCrash) {
|
|
727
|
-
log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
|
|
728
|
-
try {
|
|
729
|
-
await localServer.restart();
|
|
730
|
-
} catch {}
|
|
731
|
-
try {
|
|
732
|
-
execResult = await runWithMode(params.options?.mode);
|
|
733
|
-
} catch (retryErr) {
|
|
734
|
-
return err(`Browser server crashed and retry also failed.
|
|
735
|
-
` + `Original: ${msg}
|
|
736
|
-
Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
|
|
737
|
-
|
|
738
|
-
` + `Call \`session restart\` to reset, then retry.`);
|
|
739
|
-
}
|
|
740
|
-
} else {
|
|
741
|
-
throw execErr;
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
const requestedMode = params.options?.mode;
|
|
745
|
-
if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
|
|
746
|
-
const escalation = ["docker-headful", "binary-headful"];
|
|
747
|
-
for (const nextMode of escalation) {
|
|
748
|
-
if (nextMode === "docker-headful" && !dockerRunning)
|
|
749
|
-
continue;
|
|
750
|
-
log2.info(`Auto-escalating to ${nextMode}`);
|
|
751
|
-
execResult = await runWithMode(nextMode);
|
|
752
|
-
if (execResult.ok)
|
|
753
|
-
break;
|
|
754
|
-
if (execResult.error?.errorType !== "bot-blocked")
|
|
755
|
-
break;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
const lines = formatExecuteResult(execResult);
|
|
759
|
-
let screenshotUrl = null;
|
|
760
|
-
if (execResult.error) {
|
|
761
|
-
screenshotUrl = execResult.error.pageState?.screenshotUrl ?? null;
|
|
762
|
-
} else {
|
|
763
|
-
screenshotUrl = execResult.finalState?.screenshotUrl ?? null;
|
|
764
|
-
}
|
|
765
|
-
if (screenshotUrl) {
|
|
766
|
-
const filePath = await resolveScreenshotPath(screenshotUrl);
|
|
767
|
-
if (filePath) {
|
|
768
|
-
lines.push(`
|
|
769
|
-
Screenshot saved: ${filePath}`);
|
|
770
|
-
lines.push("Use the Read tool on the path above to view the screenshot.");
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
const content = [{ type: "text", text: lines.join(`
|
|
774
|
-
`) }];
|
|
775
|
-
if (!execResult.ok)
|
|
776
|
-
return { content, isError: true };
|
|
777
|
-
return { content };
|
|
778
|
-
} catch (e) {
|
|
779
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
780
|
-
}
|
|
781
|
-
});
|
|
702
|
+
var RECAPTCHA_MANUAL = `
|
|
703
|
+
--- Captcha workflow ---
|
|
704
|
+
A captcha is blocking this run. Use the "recaptcha" step with an action:
|
|
705
|
+
{type:"recaptcha", action:"info"} \u2192 state + instruction + tile-grid screenshot
|
|
706
|
+
{type:"recaptcha", action:"click"} \u2192 click the "I'm not a robot" checkbox
|
|
707
|
+
{type:"recaptcha", action:"answer", tiles:[...]} \u2192 select tiles + verify + re-check (handles refreshing grids)
|
|
708
|
+
{type:"recaptcha", action:"select"|"verify"} \u2192 manual tile-select / submit, if you need finer control
|
|
709
|
+
{type:"recaptcha", action:"solve"} \u2192 automatic vision solve (docker-headful)
|
|
710
|
+
Or {type:"solve-captcha"} for one-shot auto-detect + solve.
|
|
711
|
+
In binary-headful mode, prefer asking the user to solve it in the visible window.`;
|
|
712
|
+
function captchaBlocked(data) {
|
|
713
|
+
if (data.error?.errorType === "captcha-unsolvable") return true;
|
|
714
|
+
return (data.obstacles || []).some(
|
|
715
|
+
(o) => (o.type === "captcha" || o.type === "hcaptcha") && !o.resolved
|
|
716
|
+
);
|
|
782
717
|
}
|
|
783
718
|
function formatExecuteResult(data) {
|
|
784
719
|
const lines = [];
|
|
785
720
|
lines.push(`ok: ${data.ok}`);
|
|
786
721
|
lines.push(`steps: ${data.completedSteps}/${data.totalSteps}`);
|
|
787
|
-
if (data.durationMs)
|
|
788
|
-
|
|
789
|
-
if (data.modeUsed)
|
|
790
|
-
lines.push(`mode: ${data.modeUsed}${data.modeEscalated ? " (auto-escalated)" : ""}`);
|
|
722
|
+
if (data.durationMs) lines.push(`duration: ${data.durationMs}ms`);
|
|
723
|
+
if (data.modeUsed) lines.push(`mode: ${data.modeUsed}${data.modeEscalated ? " (auto-escalated)" : ""}`);
|
|
791
724
|
if (data.finalState) {
|
|
792
725
|
lines.push(`
|
|
793
726
|
Final page: ${data.finalState.title}`);
|
|
794
727
|
lines.push(`URL: ${data.finalState.url}`);
|
|
795
728
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const domain = new URL(data.finalState.url).hostname.replace(/^www\./, "");
|
|
729
|
+
for (const r of data.results || []) {
|
|
730
|
+
if (r.tabSwitchedTo) {
|
|
799
731
|
lines.push(`
|
|
800
|
-
|
|
801
|
-
}
|
|
732
|
+
\u21B3 step ${r.stepIndex} opened a new tab \u2014 pipeline is now on: ${r.tabSwitchedTo}`);
|
|
733
|
+
}
|
|
802
734
|
}
|
|
803
|
-
const meaningful = (data.results || []).filter((r) => r.ok && r.result !==
|
|
735
|
+
const meaningful = (data.results || []).filter((r) => r.ok && r.result !== void 0 && r.result !== null);
|
|
804
736
|
for (const r of meaningful) {
|
|
805
737
|
if (r.step.type === "snapshot") {
|
|
806
738
|
const res = resultOf(r, "snapshot");
|
|
@@ -822,21 +754,26 @@ Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchC
|
|
|
822
754
|
--- Annotated screenshot refs ---`);
|
|
823
755
|
lines.push(res.refs);
|
|
824
756
|
}
|
|
825
|
-
} else if (r.
|
|
757
|
+
} else if (r.step.type === "read") {
|
|
758
|
+
const res = r.result;
|
|
759
|
+
if (res?.text !== void 0) {
|
|
760
|
+
lines.push(`
|
|
761
|
+
--- Read (step ${r.stepIndex}${res.truncated ? ", truncated" : ""}) ---`);
|
|
762
|
+
lines.push(res.text);
|
|
763
|
+
}
|
|
764
|
+
} else if (r.step.type === "extract" || r.step.type === "evaluate") {
|
|
826
765
|
lines.push(`
|
|
827
|
-
step ${r.stepIndex}: ${JSON.stringify(r.result)}`);
|
|
766
|
+
step ${r.stepIndex} (${r.step.type}): ${JSON.stringify(r.result)}`);
|
|
828
767
|
}
|
|
829
768
|
}
|
|
830
769
|
if (data.obstacles && data.obstacles.length > 0) {
|
|
831
|
-
lines.push(
|
|
832
|
-
Obstacles handled:`);
|
|
770
|
+
lines.push("\nObstacles handled:");
|
|
833
771
|
for (const o of data.obstacles) {
|
|
834
772
|
lines.push(` [step ${o.detectedAtStep}] ${o.type}: ${o.resolved ? o.resolution : "UNRESOLVED - " + (o.resolution || "unknown")}`);
|
|
835
773
|
}
|
|
836
774
|
}
|
|
837
775
|
if (data.capturedApi && data.capturedApi.length > 0) {
|
|
838
|
-
lines.push(
|
|
839
|
-
--- Captured API ---`);
|
|
776
|
+
lines.push("\n--- Captured API ---");
|
|
840
777
|
for (const api of data.capturedApi) {
|
|
841
778
|
lines.push(`
|
|
842
779
|
${api.domain} (${api.baseUrl})`);
|
|
@@ -847,8 +784,7 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
847
784
|
}
|
|
848
785
|
}
|
|
849
786
|
if (data.error) {
|
|
850
|
-
lines.push(
|
|
851
|
-
--- Failure ---`);
|
|
787
|
+
lines.push("\n--- Failure ---");
|
|
852
788
|
if (typeof data.error === "string") {
|
|
853
789
|
lines.push(`Error: ${data.error}`);
|
|
854
790
|
} else {
|
|
@@ -856,15 +792,173 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
856
792
|
lines.push(`Error type: ${data.error.errorType}`);
|
|
857
793
|
lines.push(`Message: ${data.error.message}`);
|
|
858
794
|
lines.push(`Retryable: ${data.error.retryable}`);
|
|
859
|
-
if (data.error.suggestion)
|
|
860
|
-
|
|
861
|
-
if (data.error.pageState?.url)
|
|
862
|
-
lines.push(`URL at failure: ${data.error.pageState.url}`);
|
|
795
|
+
if (data.error.suggestion) lines.push(`Suggestion: ${data.error.suggestion}`);
|
|
796
|
+
if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
|
|
863
797
|
}
|
|
864
798
|
}
|
|
799
|
+
if (captchaBlocked(data)) {
|
|
800
|
+
lines.push(RECAPTCHA_MANUAL);
|
|
801
|
+
}
|
|
865
802
|
return lines;
|
|
866
803
|
}
|
|
867
804
|
|
|
805
|
+
// src/mcp/tools/execute.ts
|
|
806
|
+
function registerExecuteTool(server2) {
|
|
807
|
+
server2.tool(
|
|
808
|
+
"execute",
|
|
809
|
+
`Execute a pipeline of browser steps. Auto-starts a session; handles obstacles (captcha, cookie banners) automatically.
|
|
810
|
+
|
|
811
|
+
MANDATORY PRE-FLIGHT: call \`knowledge get <domain>\` first. If the cache shows a direct-API path that satisfies the request, skip the browser entirely \u2014 direct API calls are orders of magnitude cheaper. Every successful run updates the cache silently.
|
|
812
|
+
|
|
813
|
+
Steps run sequentially (20s stale-state timeout per step; failures return errorType + suggestion).
|
|
814
|
+
|
|
815
|
+
Seeing the page: \`snapshot\` lists interactive elements with refs (@e1\u2026), \`find\` locates one element by role/name/text, \`screenshot\` with annotate=true overlays numbered badges, \`extract\` evaluates JS and returns the result. \`login\` fills login forms from stored credentials (never exposes passwords; handles email-first flows).
|
|
816
|
+
|
|
817
|
+
Selectors: every selector field accepts @e refs (PREFER them over CSS) and persisted per-domain @a:<name> anchors from the \`remember\` tool. \`remember get <domain>\` before a UI task \u2014 an existing anchor can be targeted directly with no snapshot. When a newly found selector works, \`remember save\` it (@e refs reset each snapshot; @a: anchors persist across runs).
|
|
818
|
+
|
|
819
|
+
FORMS: use \`fill\` for text inputs \u2014 never evaluate-set .value (fill fires the framework-aware events; see its description). If a submit still claims fields are empty/required, re-run fill on the flagged field rather than assuming the value didn't land.
|
|
820
|
+
|
|
821
|
+
RESUMING A TASK: name a multi-step or human-in-the-loop task's browser with a stable \`options.instanceId\` (e.g. "otp-login") \u2014 the browser and its page PERSIST between calls and survive an interrupt. To resume (after a stop, an interrupt, or a wait for the user to enter a code), call execute again with the SAME instanceId and act on the CURRENT page (snapshot/read/find) \u2014 do NOT navigate again, which reloads and discards the state (e.g. an OTP screen). \`status\` lists live windows (instanceId \u2192 current url) so you can find the right one.
|
|
822
|
+
|
|
823
|
+
Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstacles, capturedApi, and on failure a screenshot path + errorType + suggestion + retryable.`,
|
|
824
|
+
{
|
|
825
|
+
steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
|
|
826
|
+
options: import_zod3.z.object({
|
|
827
|
+
staleTimeoutMs: import_zod3.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
828
|
+
screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
|
|
829
|
+
continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
830
|
+
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
831
|
+
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
|
|
832
|
+
mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("DO NOT SET unless the user explicitly requests a mode \u2014 iframer auto-selects and auto-escalates. 'extension' drives a real-Chrome tab (requires options.tabId from the `tabs` tool)."),
|
|
833
|
+
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
834
|
+
instanceId: import_zod3.z.string().optional().describe("Named parallel browser (default 'default') \u2014 distinct ids drive several browsers at once, each with its own session state."),
|
|
835
|
+
tabId: import_zod3.z.number().optional().describe("mode='extension': the real-Chrome tab id to drive (from the `tabs` tool)."),
|
|
836
|
+
clientId: import_zod3.z.string().optional().describe("mode='extension': owning profile's clientId, only when several browsers are connected and the tab is ambiguous."),
|
|
837
|
+
focus: import_zod3.z.boolean().optional().describe("mode='extension': raise the window to the foreground while driving (default false \u2014 background drive with focus emulation). Only if a site ignores background input.")
|
|
838
|
+
}).optional()
|
|
839
|
+
},
|
|
840
|
+
async (params) => {
|
|
841
|
+
try {
|
|
842
|
+
const steps = normalizeSteps(params.steps);
|
|
843
|
+
if (params.options?.mode === "extension") {
|
|
844
|
+
const tabId = params.options?.tabId;
|
|
845
|
+
if (typeof tabId !== "number") {
|
|
846
|
+
return err(
|
|
847
|
+
"mode='extension' requires options.tabId. Call the `tabs` tool first to find the id of the tab the user wants to drive."
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const typeChars = (steps || []).reduce((n, s) => {
|
|
851
|
+
return (s.type === "human-type" || s.type === "type-code") && typeof s.value === "string" ? n + s.value.length : n;
|
|
852
|
+
}, 0);
|
|
853
|
+
const timeoutMs = Math.min(6e4 + (steps.length || 0) * 15e3 + typeChars * 250, 12e5) + 3e4;
|
|
854
|
+
const extResult = await localApiPost("/extension/execute", {
|
|
855
|
+
tabId,
|
|
856
|
+
clientId: params.options?.clientId,
|
|
857
|
+
steps,
|
|
858
|
+
options: params.options
|
|
859
|
+
}, timeoutMs);
|
|
860
|
+
const extLines = formatExecuteResult(extResult);
|
|
861
|
+
const content2 = [{ type: "text", text: extLines.join("\n") }];
|
|
862
|
+
if (!extResult.ok) return { content: content2, isError: true };
|
|
863
|
+
return { content: content2 };
|
|
864
|
+
}
|
|
865
|
+
const dockerRunning = await isDockerRunning();
|
|
866
|
+
async function runWithMode(mode) {
|
|
867
|
+
if (mode === "docker-headful") {
|
|
868
|
+
if (!dockerRunning) {
|
|
869
|
+
return {
|
|
870
|
+
ok: false,
|
|
871
|
+
completedSteps: 0,
|
|
872
|
+
totalSteps: steps.length,
|
|
873
|
+
results: [],
|
|
874
|
+
finalState: { url: "", title: "" },
|
|
875
|
+
obstacles: [],
|
|
876
|
+
durationMs: 0,
|
|
877
|
+
modeUsed: "docker-headful",
|
|
878
|
+
error: {
|
|
879
|
+
failedAtStep: 0,
|
|
880
|
+
failedStep: steps[0],
|
|
881
|
+
errorType: "action-failed",
|
|
882
|
+
message: "docker-headful mode was requested but the Docker API is not reachable.",
|
|
883
|
+
pageState: { url: "", title: "" },
|
|
884
|
+
suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
|
|
885
|
+
retryable: false
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
return apiPost("/execute", {
|
|
890
|
+
steps,
|
|
891
|
+
options: { ...params.options, mode: "docker-headful", autoEscalate: false }
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
return localApiPost("/execute", {
|
|
895
|
+
steps,
|
|
896
|
+
options: { ...params.options, mode: mode || void 0 }
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
let execResult;
|
|
900
|
+
try {
|
|
901
|
+
execResult = await runWithMode(params.options?.mode);
|
|
902
|
+
} catch (execErr) {
|
|
903
|
+
const msg = execErr instanceof Error ? execErr.message : String(execErr);
|
|
904
|
+
const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
|
|
905
|
+
if (isCrash) {
|
|
906
|
+
log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
|
|
907
|
+
try {
|
|
908
|
+
await localServer.restart();
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
execResult = await runWithMode(params.options?.mode);
|
|
913
|
+
} catch (retryErr) {
|
|
914
|
+
return err(
|
|
915
|
+
`Browser server crashed and retry also failed.
|
|
916
|
+
Original: ${msg}
|
|
917
|
+
Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
|
|
918
|
+
|
|
919
|
+
Call \`session restart\` to reset, then retry.`
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
} else {
|
|
923
|
+
throw execErr;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const requestedMode = params.options?.mode;
|
|
927
|
+
if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
|
|
928
|
+
const escalation = ["docker-headful", "binary-headful"];
|
|
929
|
+
for (const nextMode of escalation) {
|
|
930
|
+
if (nextMode === "docker-headful" && !dockerRunning) continue;
|
|
931
|
+
log2.info(`Auto-escalating to ${nextMode}`);
|
|
932
|
+
execResult = await runWithMode(nextMode);
|
|
933
|
+
if (execResult.ok) break;
|
|
934
|
+
if (execResult.error?.errorType !== "bot-blocked") break;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const lines = formatExecuteResult(execResult);
|
|
938
|
+
let screenshotUrl = null;
|
|
939
|
+
if (execResult.error) {
|
|
940
|
+
screenshotUrl = execResult.error.pageState?.screenshotUrl ?? null;
|
|
941
|
+
} else {
|
|
942
|
+
screenshotUrl = execResult.finalState?.screenshotUrl ?? null;
|
|
943
|
+
}
|
|
944
|
+
if (screenshotUrl) {
|
|
945
|
+
const filePath = await resolveScreenshotPath(screenshotUrl);
|
|
946
|
+
if (filePath) {
|
|
947
|
+
lines.push(`
|
|
948
|
+
Screenshot saved: ${filePath}`);
|
|
949
|
+
lines.push("Use the Read tool on the path above to view the screenshot.");
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
953
|
+
if (!execResult.ok) return { content, isError: true };
|
|
954
|
+
return { content };
|
|
955
|
+
} catch (e) {
|
|
956
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
868
962
|
// src/mcp/tools/session.ts
|
|
869
963
|
var import_zod5 = require("zod");
|
|
870
964
|
var import_path7 = __toESM(require("path"));
|
|
@@ -872,148 +966,121 @@ var import_fs5 = __toESM(require("fs"));
|
|
|
872
966
|
|
|
873
967
|
// src/mcp/tools/reverse-engineer.ts
|
|
874
968
|
var import_zod4 = require("zod");
|
|
875
|
-
function registerReverseEngineerTool(
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
Use
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
Use capturedApi[i].endpoints[j].protocol, .action, .verb, .functionName directly — iframer already classified them. Put queries (verb=read|list) under queries/, mutations (verb=create|update|delete|action) under mutations/ for GraphQL. For REST, group by verb dir.
|
|
917
|
-
|
|
918
|
-
The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`, {
|
|
919
|
-
steps: import_zod4.z.array(stepSchema).describe("Pipeline steps to execute while capturing API calls"),
|
|
920
|
-
outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
|
|
921
|
-
typed: import_zod4.z.boolean().optional().describe("Save as .ts with inferred types instead of .js. Set to true when the user asks for types, typescript, or type inference."),
|
|
922
|
-
options: import_zod4.z.object({
|
|
923
|
-
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
924
|
-
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
925
|
-
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
926
|
-
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
|
|
927
|
-
}).optional()
|
|
928
|
-
}, async (params) => {
|
|
929
|
-
try {
|
|
930
|
-
const execParams = {
|
|
931
|
-
steps: params.steps,
|
|
932
|
-
options: { ...params.options, captureApi: true }
|
|
933
|
-
};
|
|
934
|
-
const mode = params.options?.mode;
|
|
935
|
-
const dockerRunning = await isDockerRunning();
|
|
936
|
-
const captureResult = mode === "docker-headful" && dockerRunning ? await apiPost("/execute", execParams) : await localApiPost("/execute", execParams);
|
|
937
|
-
const lines = [];
|
|
938
|
-
lines.push(`ok: ${captureResult.ok}`);
|
|
939
|
-
lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
|
|
940
|
-
if (captureResult.durationMs)
|
|
941
|
-
lines.push(`duration: ${captureResult.durationMs}ms`);
|
|
942
|
-
if (captureResult.finalState) {
|
|
943
|
-
lines.push(`
|
|
944
|
-
Final page: ${captureResult.finalState.title}`);
|
|
945
|
-
lines.push(`URL: ${captureResult.finalState.url}`);
|
|
946
|
-
}
|
|
947
|
-
if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
|
|
948
|
-
formatCapturedApi(lines, captureResult.capturedApi, params);
|
|
949
|
-
} else {
|
|
950
|
-
lines.push(`
|
|
951
|
-
No API calls were captured. The page may not have made any XHR/fetch requests during the steps, or the steps may not have triggered the expected behavior.`);
|
|
952
|
-
}
|
|
953
|
-
if (captureResult.error) {
|
|
954
|
-
lines.push(`
|
|
955
|
-
--- Failure ---`);
|
|
956
|
-
if (typeof captureResult.error === "string") {
|
|
957
|
-
lines.push(`Error: ${captureResult.error}`);
|
|
969
|
+
function registerReverseEngineerTool(server2) {
|
|
970
|
+
server2.tool(
|
|
971
|
+
"reverse-engineer",
|
|
972
|
+
`Reverse-engineer a website's API: runs the steps you provide (SAME format as the \`execute\` tool's steps) while recording every XHR/fetch the page makes \u2014 auth tokens, cookies, headers, request/response bodies, ready-to-use curl. Use when the user asks to "reverse engineer", "map", or "capture" a site's API so it can be replayed later.
|
|
973
|
+
|
|
974
|
+
During capture, drive the site like a user (open the inbox, send a message\u2026) \u2014 the actions are what trigger the calls worth recording. Endpoint classification, output layout, and code-generation rules arrive WITH the captured results.`,
|
|
975
|
+
{
|
|
976
|
+
steps: import_zod4.z.array(import_zod4.z.record(import_zod4.z.string(), import_zod4.z.unknown())).describe("Pipeline steps to run while capturing \u2014 same step format as the execute tool"),
|
|
977
|
+
outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
|
|
978
|
+
typed: import_zod4.z.boolean().optional().describe("Save as .ts with inferred types instead of .js. Set to true when the user asks for types, typescript, or type inference."),
|
|
979
|
+
options: import_zod4.z.object({
|
|
980
|
+
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
981
|
+
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
982
|
+
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
983
|
+
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("Mode override. 'extension' captures a tab already open in the user's real Chrome (requires options.tabId from `tabs`)."),
|
|
984
|
+
tabId: import_zod4.z.number().optional().describe("mode='extension': the real-Chrome tab id (from `tabs`)."),
|
|
985
|
+
clientId: import_zod4.z.string().optional().describe("mode='extension': owning profile's clientId when the tab is ambiguous.")
|
|
986
|
+
}).optional()
|
|
987
|
+
},
|
|
988
|
+
async (params) => {
|
|
989
|
+
try {
|
|
990
|
+
const steps = normalizeSteps(params.steps);
|
|
991
|
+
const execParams = {
|
|
992
|
+
steps,
|
|
993
|
+
options: { ...params.options, captureApi: true }
|
|
994
|
+
};
|
|
995
|
+
const mode = params.options?.mode;
|
|
996
|
+
const dockerRunning = await isDockerRunning();
|
|
997
|
+
let captureResult;
|
|
998
|
+
if (mode === "extension") {
|
|
999
|
+
if (typeof params.options?.tabId !== "number") {
|
|
1000
|
+
return err("mode='extension' requires options.tabId. Call the `tabs` tool first to find the tab to reverse-engineer.");
|
|
1001
|
+
}
|
|
1002
|
+
captureResult = await localApiPost("/extension/execute", {
|
|
1003
|
+
tabId: params.options.tabId,
|
|
1004
|
+
clientId: params.options.clientId,
|
|
1005
|
+
steps,
|
|
1006
|
+
options: { ...params.options, captureApi: true }
|
|
1007
|
+
});
|
|
1008
|
+
} else if (mode === "docker-headful" && dockerRunning) {
|
|
1009
|
+
captureResult = await apiPost("/execute", execParams);
|
|
958
1010
|
} else {
|
|
959
|
-
|
|
960
|
-
lines.push(`Error type: ${captureResult.error.errorType}`);
|
|
961
|
-
lines.push(`Message: ${captureResult.error.message}`);
|
|
962
|
-
if (captureResult.error.suggestion)
|
|
963
|
-
lines.push(`Suggestion: ${captureResult.error.suggestion}`);
|
|
1011
|
+
captureResult = await localApiPost("/execute", execParams);
|
|
964
1012
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
if (
|
|
1013
|
+
const lines = [];
|
|
1014
|
+
lines.push(`ok: ${captureResult.ok}`);
|
|
1015
|
+
lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
|
|
1016
|
+
if (captureResult.durationMs) lines.push(`duration: ${captureResult.durationMs}ms`);
|
|
1017
|
+
if (captureResult.finalState) {
|
|
970
1018
|
lines.push(`
|
|
1019
|
+
Final page: ${captureResult.finalState.title}`);
|
|
1020
|
+
lines.push(`URL: ${captureResult.finalState.url}`);
|
|
1021
|
+
}
|
|
1022
|
+
if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
|
|
1023
|
+
formatCapturedApi(lines, captureResult.capturedApi, params);
|
|
1024
|
+
} else {
|
|
1025
|
+
lines.push("\nNo API calls were captured. The page may not have made any XHR/fetch requests during the steps, or the steps may not have triggered the expected behavior.");
|
|
1026
|
+
}
|
|
1027
|
+
if (captureResult.error) {
|
|
1028
|
+
lines.push("\n--- Failure ---");
|
|
1029
|
+
if (typeof captureResult.error === "string") {
|
|
1030
|
+
lines.push(`Error: ${captureResult.error}`);
|
|
1031
|
+
} else {
|
|
1032
|
+
lines.push(`Failed at step ${captureResult.error.failedAtStep}: ${JSON.stringify(captureResult.error.failedStep)}`);
|
|
1033
|
+
lines.push(`Error type: ${captureResult.error.errorType}`);
|
|
1034
|
+
lines.push(`Message: ${captureResult.error.message}`);
|
|
1035
|
+
if (captureResult.error.suggestion) lines.push(`Suggestion: ${captureResult.error.suggestion}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const screenshotUrl = captureResult.error?.pageState?.screenshotUrl ?? captureResult.finalState?.screenshotUrl;
|
|
1039
|
+
if (screenshotUrl) {
|
|
1040
|
+
const filePath = await resolveScreenshotPath(screenshotUrl);
|
|
1041
|
+
if (filePath) {
|
|
1042
|
+
lines.push(`
|
|
971
1043
|
Screenshot saved: ${filePath}`);
|
|
972
|
-
|
|
1044
|
+
lines.push("Use the Read tool on the path above to view the screenshot.");
|
|
1045
|
+
}
|
|
973
1046
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
lines.push(`
|
|
1047
|
+
if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
|
|
1048
|
+
const mainDomain = captureResult.capturedApi[0]?.domain || "api";
|
|
1049
|
+
const outDir = params.outputDir || `./${mainDomain}`;
|
|
1050
|
+
try {
|
|
1051
|
+
const fs10 = await import("fs");
|
|
1052
|
+
const path12 = await import("path");
|
|
1053
|
+
fs10.mkdirSync(outDir, { recursive: true });
|
|
1054
|
+
const jsonPath = path12.join(outDir, "captured-api.json");
|
|
1055
|
+
fs10.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
|
|
1056
|
+
lines.push(`
|
|
985
1057
|
Full captured data saved to: ${jsonPath}`);
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1058
|
+
lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
|
|
1059
|
+
} catch (writeErr) {
|
|
1060
|
+
lines.push(`
|
|
989
1061
|
(Could not save captured JSON: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
|
|
1062
|
+
}
|
|
990
1063
|
}
|
|
1064
|
+
let text = lines.join("\n");
|
|
1065
|
+
if (text.length > 3e4) {
|
|
1066
|
+
text = text.slice(0, 3e4) + "\n\n[... index truncated \u2014 read captured-api.json for the full endpoint list and all detail]";
|
|
1067
|
+
}
|
|
1068
|
+
const content = [{ type: "text", text }];
|
|
1069
|
+
if (!captureResult.ok) return { content, isError: true };
|
|
1070
|
+
return { content };
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
|
|
991
1073
|
}
|
|
992
|
-
let text = lines.join(`
|
|
993
|
-
`);
|
|
994
|
-
if (text.length > 80000) {
|
|
995
|
-
text = text.slice(0, 80000) + `
|
|
996
|
-
|
|
997
|
-
[... response truncated — read captured-api.json for full data]`;
|
|
998
|
-
}
|
|
999
|
-
const content = [{ type: "text", text }];
|
|
1000
|
-
if (!captureResult.ok)
|
|
1001
|
-
return { content, isError: true };
|
|
1002
|
-
return { content };
|
|
1003
|
-
} catch (e) {
|
|
1004
|
-
return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
|
|
1005
1074
|
}
|
|
1006
|
-
|
|
1075
|
+
);
|
|
1007
1076
|
}
|
|
1008
1077
|
function formatCapturedApi(lines, capturedApi, params) {
|
|
1009
1078
|
for (const api of capturedApi) {
|
|
1010
1079
|
lines.push(`
|
|
1011
|
-
|
|
1080
|
+
\u2501\u2501\u2501 ${api.domain} (${api.baseUrl}) \u2501\u2501\u2501`);
|
|
1012
1081
|
const authParts = [];
|
|
1013
|
-
if (api.auth?.authorization)
|
|
1014
|
-
|
|
1015
|
-
if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0)
|
|
1016
|
-
authParts.push(`${Object.keys(api.auth.cookies).length} cookies`);
|
|
1082
|
+
if (api.auth?.authorization) authParts.push(`Authorization: ${api.auth.authorization.slice(0, 30)}...`);
|
|
1083
|
+
if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0) authParts.push(`${Object.keys(api.auth.cookies).length} cookies`);
|
|
1017
1084
|
if (api.auth?.tokens && Object.keys(api.auth.tokens).length > 0) {
|
|
1018
1085
|
for (const [k, v] of Object.entries(api.auth.tokens)) {
|
|
1019
1086
|
authParts.push(`${k}: ${String(v).slice(0, 30)}...`);
|
|
@@ -1022,76 +1089,62 @@ function formatCapturedApi(lines, capturedApi, params) {
|
|
|
1022
1089
|
if (authParts.length > 0) {
|
|
1023
1090
|
lines.push(`
|
|
1024
1091
|
Auth:`);
|
|
1025
|
-
for (const part of authParts)
|
|
1026
|
-
lines.push(` ${part}`);
|
|
1092
|
+
for (const part of authParts) lines.push(` ${part}`);
|
|
1027
1093
|
}
|
|
1028
|
-
const byProtocol = new Map;
|
|
1094
|
+
const byProtocol = /* @__PURE__ */ new Map();
|
|
1029
1095
|
for (const ep of api.endpoints) {
|
|
1030
1096
|
const p = ep.protocol || "rest";
|
|
1031
|
-
if (!byProtocol.has(p))
|
|
1032
|
-
byProtocol.set(p, []);
|
|
1097
|
+
if (!byProtocol.has(p)) byProtocol.set(p, []);
|
|
1033
1098
|
byProtocol.get(p).push(ep);
|
|
1034
1099
|
}
|
|
1035
1100
|
const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
|
|
1036
1101
|
lines.push(`
|
|
1037
|
-
Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
|
|
1102
|
+
Endpoints (${api.endpoints.length}) [${protocolSummary}] \u2014 full detail in captured-api.json:`);
|
|
1038
1103
|
for (const ep of api.endpoints) {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
lines.push(`
|
|
1042
|
-
lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
|
|
1043
|
-
if (ep.requestBody) {
|
|
1044
|
-
const body = ep.requestBody;
|
|
1045
|
-
const signalKeys = extractSignalKeys(body);
|
|
1046
|
-
if (signalKeys)
|
|
1047
|
-
lines.push(` Params: ${signalKeys}`);
|
|
1048
|
-
}
|
|
1104
|
+
const params2 = ep.requestBody ? extractSignalKeys(ep.requestBody) : null;
|
|
1105
|
+
const tail = params2 ? ` {${params2}}` : "";
|
|
1106
|
+
lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path} \u2192 ${ep.functionName}${tail}`);
|
|
1049
1107
|
}
|
|
1050
1108
|
}
|
|
1051
1109
|
const mainDomain = capturedApi[0]?.domain || "api";
|
|
1052
1110
|
const dir = params.outputDir || `./${mainDomain}`;
|
|
1053
1111
|
const ext = params.typed ? ".ts" : ".js";
|
|
1054
1112
|
lines.push(`
|
|
1055
|
-
|
|
1113
|
+
\u2501\u2501\u2501 Save instructions \u2501\u2501\u2501`);
|
|
1056
1114
|
lines.push(`Save to: ${dir}`);
|
|
1057
1115
|
lines.push(`Format: ${ext} files`);
|
|
1058
|
-
const protocolsSeen = new Set;
|
|
1059
|
-
for (const api of capturedApi)
|
|
1060
|
-
for (const ep of api.endpoints)
|
|
1061
|
-
protocolsSeen.add(ep.protocol);
|
|
1116
|
+
const protocolsSeen = /* @__PURE__ */ new Set();
|
|
1117
|
+
for (const api of capturedApi) for (const ep of api.endpoints) protocolsSeen.add(ep.protocol);
|
|
1062
1118
|
lines.push(`
|
|
1063
1119
|
Layout (protocols present: ${Array.from(protocolsSeen).join(", ")}):`);
|
|
1064
1120
|
lines.push(` auth${ext}`);
|
|
1065
|
-
if (protocolsSeen.has("rest"))
|
|
1066
|
-
|
|
1067
|
-
if (protocolsSeen.has("
|
|
1068
|
-
|
|
1069
|
-
if (protocolsSeen.has("
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
lines.push(` transport/grpc${ext} — shared gRPC-web client`);
|
|
1073
|
-
if (protocolsSeen.has("form-rpc"))
|
|
1074
|
-
lines.push(` transport/formRpc${ext} — shared urlencoded RPC client`);
|
|
1075
|
-
if (protocolsSeen.has("soap"))
|
|
1076
|
-
lines.push(` transport/soap${ext} — shared SOAP client`);
|
|
1077
|
-
lines.push(` <protocol>/<verb>/<functionName>${ext} — one file per endpoint, uses shared transport`);
|
|
1121
|
+
if (protocolsSeen.has("rest")) lines.push(` transport/rest${ext} \u2014 shared fetch wrapper with auth headers`);
|
|
1122
|
+
if (protocolsSeen.has("graphql")) lines.push(` transport/graphql${ext} \u2014 post(operationName|docId, variables) \u2192 shared GraphQL client`);
|
|
1123
|
+
if (protocolsSeen.has("json-rpc")) lines.push(` transport/jsonRpc${ext} \u2014 shared JSON-RPC client`);
|
|
1124
|
+
if (protocolsSeen.has("grpc-web")) lines.push(` transport/grpc${ext} \u2014 shared gRPC-web client`);
|
|
1125
|
+
if (protocolsSeen.has("form-rpc")) lines.push(` transport/formRpc${ext} \u2014 shared urlencoded RPC client`);
|
|
1126
|
+
if (protocolsSeen.has("soap")) lines.push(` transport/soap${ext} \u2014 shared SOAP client`);
|
|
1127
|
+
lines.push(` <protocol>/<verb>/<functionName>${ext} \u2014 one file per endpoint, uses shared transport`);
|
|
1078
1128
|
lines.push(` e.g. graphql/queries/<fn>${ext}, graphql/mutations/<fn>${ext}, rest/read/<fn>${ext}, rest/create/<fn>${ext}`);
|
|
1079
|
-
lines.push(` index${ext}
|
|
1080
|
-
if (params.typed)
|
|
1081
|
-
|
|
1082
|
-
lines.push(`
|
|
1129
|
+
lines.push(` index${ext} \u2014 re-export all endpoint functions`);
|
|
1130
|
+
if (params.typed) lines.push(` types.ts \u2014 interfaces inferred from response bodies`);
|
|
1131
|
+
lines.push(` README.md \u2014 endpoints grouped by protocol + verb, auth expiry warning`);
|
|
1132
|
+
lines.push(`
|
|
1133
|
+
How to read the endpoint list \u2014 one request \u2260 one endpoint; an endpoint is (protocol, action):`);
|
|
1134
|
+
lines.push(` REST: action = "METHOD /parameterized/path" \xB7 GraphQL: action = operationName (or doc_id for persisted queries) \u2014 many ops share one /graphql URL, EACH is its own endpoint \xB7 JSON-RPC: action = body.method \xB7 gRPC-web: action = request path \xB7 Form-RPC (FB-style urlencoded): action = fb_api_req_friendly_name / doc_id \xB7 SOAP: action = SOAPAction header.`);
|
|
1135
|
+
lines.push(` iframer already classified everything \u2014 use .protocol, .action, .verb, .functionName directly.`);
|
|
1083
1136
|
lines.push(`
|
|
1084
1137
|
Rules:`);
|
|
1085
|
-
lines.push(` - One function per (protocol, action). Use the functionName field verbatim for the file + export name.`);
|
|
1086
|
-
lines.push(` - verb=read|list
|
|
1138
|
+
lines.push(` - One function per (protocol, action) \u2014 NEVER merge GraphQL operations just because they share the URL. Use the functionName field verbatim for the file + export name.`);
|
|
1139
|
+
lines.push(` - verb=read|list \u2192 queries/ (GraphQL) or read/ (REST). verb=create|update|delete|action \u2192 mutations/ (GraphQL) or verb dir (REST).`);
|
|
1087
1140
|
lines.push(` - Each endpoint file is minimal: import transport + auth, call transport with the action id, pass variables, return typed result.`);
|
|
1088
1141
|
lines.push(` - GraphQL transport signature: post(opNameOrDocId: string, variables: object). Pick doc_id when present in captured body, else operationName.`);
|
|
1089
|
-
lines.push(` - Do NOT inline auth headers per endpoint
|
|
1142
|
+
lines.push(` - Do NOT inline auth headers per endpoint \u2014 they live in auth${ext} and the transport reads them.`);
|
|
1090
1143
|
lines.push(`
|
|
1091
|
-
IMPORTANT: Auth data contains real tokens/cookies
|
|
1144
|
+
IMPORTANT: Auth data contains real tokens/cookies \u2014 they expire. Remind the user.`);
|
|
1092
1145
|
}
|
|
1093
1146
|
function extractSignalKeys(body) {
|
|
1094
|
-
const NOISE = new Set([
|
|
1147
|
+
const NOISE = /* @__PURE__ */ new Set([
|
|
1095
1148
|
"__csr",
|
|
1096
1149
|
"__hsdp",
|
|
1097
1150
|
"__hblp",
|
|
@@ -1118,10 +1171,12 @@ function extractSignalKeys(body) {
|
|
|
1118
1171
|
]);
|
|
1119
1172
|
const signalEntries = [];
|
|
1120
1173
|
for (const [k, v] of Object.entries(body)) {
|
|
1121
|
-
if (NOISE.has(k))
|
|
1122
|
-
continue;
|
|
1174
|
+
if (NOISE.has(k)) continue;
|
|
1123
1175
|
if (typeof v === "string" && v.length > 80) {
|
|
1124
1176
|
signalEntries.push(`${k}=${v.slice(0, 40)}...`);
|
|
1177
|
+
} else if (Array.isArray(v)) {
|
|
1178
|
+
const items = v.slice(0, 8).map((x) => String(x)).join(",");
|
|
1179
|
+
signalEntries.push(`${k}=[${items}${v.length > 8 ? ",\u2026" : ""}]`);
|
|
1125
1180
|
} else if (typeof v === "object" && v !== null) {
|
|
1126
1181
|
const keys = Object.keys(v).slice(0, 5).join(",");
|
|
1127
1182
|
signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
|
|
@@ -1133,135 +1188,123 @@ function extractSignalKeys(body) {
|
|
|
1133
1188
|
}
|
|
1134
1189
|
|
|
1135
1190
|
// src/mcp/tools/session.ts
|
|
1136
|
-
function registerSessionTool(
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
}, async ({ action, mode, urls, instanceId, outputDir }) => {
|
|
1155
|
-
try {
|
|
1156
|
-
if (action === "stop") {
|
|
1157
|
-
const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
|
|
1158
|
-
return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
|
|
1159
|
-
}
|
|
1160
|
-
if (action === "clear") {
|
|
1161
|
-
await localApiDelete("/session").catch(() => {});
|
|
1162
|
-
return { content: [{ type: "text", text: "Session data cleared from database." }] };
|
|
1163
|
-
}
|
|
1164
|
-
if (action === "restart") {
|
|
1165
|
-
const parts = [];
|
|
1166
|
-
try {
|
|
1167
|
-
await localApiPost("/browser/restart");
|
|
1168
|
-
parts.push("Local browser restarted.");
|
|
1169
|
-
} catch {
|
|
1170
|
-
try {
|
|
1171
|
-
await localServer.restart();
|
|
1172
|
-
parts.push("Local server respawned.");
|
|
1173
|
-
} catch (e) {
|
|
1174
|
-
parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1175
|
-
}
|
|
1191
|
+
function registerSessionTool(server2) {
|
|
1192
|
+
server2.tool(
|
|
1193
|
+
"session",
|
|
1194
|
+
`Manage the browser session/lifecycle.
|
|
1195
|
+
|
|
1196
|
+
stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when browser work is done. clear: wipe stored session data (doesn't kill browsers). restart: kill all browser instances and reset \u2014 use when frozen/crashed/bad state (credentials + knowledge unaffected). capture-start / capture-stop: persistent XHR/fetch recorder not tied to a pipeline \u2014 start it, trigger the action you want captured, stop to return + save endpoints. get-cookies: all cookies via CDP incl. HttpOnly (pass urls to scope). get-auth: cookies + localStorage + sessionStorage in one shot, for replaying authed requests.`,
|
|
1197
|
+
{
|
|
1198
|
+
action: import_zod5.z.enum(["stop", "clear", "restart", "capture-start", "capture-stop", "get-cookies", "get-auth"]).describe("stop | clear | restart | capture-start | capture-stop | get-cookies | get-auth"),
|
|
1199
|
+
mode: import_zod5.z.enum(["headless", "binary-headful"]).optional().describe("Browser mode (default: binary-headful)"),
|
|
1200
|
+
urls: import_zod5.z.array(import_zod5.z.string()).optional().describe("URLs to scope cookie extraction (get-cookies/get-auth). Omit for all cookies."),
|
|
1201
|
+
instanceId: import_zod5.z.string().optional().describe("Target a named parallel browser within this session (default: 'default'). Match the instanceId used in execute."),
|
|
1202
|
+
outputDir: import_zod5.z.string().optional().describe("Where to save captured-api.json for capture-stop")
|
|
1203
|
+
},
|
|
1204
|
+
async ({ action, mode, urls, instanceId, outputDir }) => {
|
|
1205
|
+
try {
|
|
1206
|
+
if (action === "stop") {
|
|
1207
|
+
const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
|
|
1208
|
+
return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
|
|
1176
1209
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1210
|
+
if (action === "clear") {
|
|
1211
|
+
await localApiDelete("/session").catch(() => {
|
|
1212
|
+
});
|
|
1213
|
+
return { content: [{ type: "text", text: "Session data cleared from database." }] };
|
|
1214
|
+
}
|
|
1215
|
+
if (action === "restart") {
|
|
1216
|
+
const parts = [];
|
|
1217
|
+
try {
|
|
1218
|
+
await localApiPost("/browser/restart");
|
|
1219
|
+
parts.push("Local browser restarted.");
|
|
1220
|
+
} catch {
|
|
1221
|
+
try {
|
|
1222
|
+
await localServer.restart();
|
|
1223
|
+
parts.push("Local server respawned.");
|
|
1224
|
+
} catch (e) {
|
|
1225
|
+
parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1226
|
+
}
|
|
1181
1227
|
}
|
|
1182
|
-
} catch {}
|
|
1183
|
-
parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
|
|
1184
|
-
return { content: [{ type: "text", text: parts.join(" ") }] };
|
|
1185
|
-
}
|
|
1186
|
-
if (action === "capture-start") {
|
|
1187
|
-
const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
|
|
1188
|
-
return { content: [{ type: "text", text: result.message }] };
|
|
1189
|
-
}
|
|
1190
|
-
if (action === "capture-stop") {
|
|
1191
|
-
const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
|
|
1192
|
-
const lines = [result.message];
|
|
1193
|
-
if (result.capturedApi && result.capturedApi.length > 0) {
|
|
1194
|
-
formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
|
|
1195
|
-
const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
|
|
1196
1228
|
try {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1229
|
+
if (await isDockerRunning()) {
|
|
1230
|
+
await apiPost("/browser/restart");
|
|
1231
|
+
parts.push("Docker browser restarted.");
|
|
1232
|
+
}
|
|
1233
|
+
} catch {
|
|
1234
|
+
}
|
|
1235
|
+
parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
|
|
1236
|
+
return { content: [{ type: "text", text: parts.join(" ") }] };
|
|
1237
|
+
}
|
|
1238
|
+
if (action === "capture-start") {
|
|
1239
|
+
const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
|
|
1240
|
+
return { content: [{ type: "text", text: result.message }] };
|
|
1241
|
+
}
|
|
1242
|
+
if (action === "capture-stop") {
|
|
1243
|
+
const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
|
|
1244
|
+
const lines = [result.message];
|
|
1245
|
+
if (result.capturedApi && result.capturedApi.length > 0) {
|
|
1246
|
+
formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
|
|
1247
|
+
const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
|
|
1248
|
+
try {
|
|
1249
|
+
import_fs5.default.mkdirSync(outDir, { recursive: true });
|
|
1250
|
+
const jsonPath = import_path7.default.join(outDir, "captured-api.json");
|
|
1251
|
+
import_fs5.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
|
|
1252
|
+
lines.push(`
|
|
1201
1253
|
Full data saved to: ${jsonPath}`);
|
|
1202
|
-
|
|
1203
|
-
|
|
1254
|
+
} catch (writeErr) {
|
|
1255
|
+
lines.push(`
|
|
1204
1256
|
(Could not save: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
|
|
1257
|
+
}
|
|
1205
1258
|
}
|
|
1259
|
+
let text = lines.join("\n");
|
|
1260
|
+
if (text.length > 8e4) text = text.slice(0, 8e4) + "\n\n[truncated \u2014 read captured-api.json]";
|
|
1261
|
+
return { content: [{ type: "text", text }] };
|
|
1206
1262
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
}
|
|
1215
|
-
if (action === "get-cookies") {
|
|
1216
|
-
const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1217
|
-
const lines = [result.message, ""];
|
|
1218
|
-
for (const c of result.cookies) {
|
|
1219
|
-
lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
|
|
1220
|
-
}
|
|
1221
|
-
return { content: [{ type: "text", text: lines.join(`
|
|
1222
|
-
`) }] };
|
|
1223
|
-
}
|
|
1224
|
-
if (action === "get-auth") {
|
|
1225
|
-
const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1226
|
-
const lines = [result.message, ""];
|
|
1227
|
-
lines.push("=== Cookies ===");
|
|
1228
|
-
for (const c of result.cookies) {
|
|
1229
|
-
lines.push(`${c.name}=${c.value} [domain=${c.domain} httpOnly=${c.httpOnly}]`);
|
|
1263
|
+
if (action === "get-cookies") {
|
|
1264
|
+
const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1265
|
+
const lines = [result.message, ""];
|
|
1266
|
+
for (const c of result.cookies) {
|
|
1267
|
+
lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
|
|
1268
|
+
}
|
|
1269
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1230
1270
|
}
|
|
1231
|
-
if (
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1271
|
+
if (action === "get-auth") {
|
|
1272
|
+
const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1273
|
+
const lines = [result.message, ""];
|
|
1274
|
+
lines.push("=== Cookies ===");
|
|
1275
|
+
for (const c of result.cookies) {
|
|
1276
|
+
lines.push(`${c.name}=${c.value} [domain=${c.domain} httpOnly=${c.httpOnly}]`);
|
|
1277
|
+
}
|
|
1278
|
+
if (Object.keys(result.localStorage || {}).length > 0) {
|
|
1279
|
+
lines.push("\n=== localStorage ===");
|
|
1280
|
+
for (const [origin, store] of Object.entries(result.localStorage)) {
|
|
1281
|
+
lines.push(`[${origin}]`);
|
|
1282
|
+
for (const [k, v] of Object.entries(store)) {
|
|
1283
|
+
const val = String(v);
|
|
1284
|
+
lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "\u2026" : val}`);
|
|
1285
|
+
}
|
|
1239
1286
|
}
|
|
1240
1287
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "…" : val}`);
|
|
1288
|
+
if (Object.keys(result.sessionStorage || {}).length > 0) {
|
|
1289
|
+
lines.push("\n=== sessionStorage ===");
|
|
1290
|
+
for (const [origin, store] of Object.entries(result.sessionStorage)) {
|
|
1291
|
+
lines.push(`[${origin}]`);
|
|
1292
|
+
for (const [k, v] of Object.entries(store)) {
|
|
1293
|
+
const val = String(v);
|
|
1294
|
+
lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "\u2026" : val}`);
|
|
1295
|
+
}
|
|
1250
1296
|
}
|
|
1251
1297
|
}
|
|
1298
|
+
let text = lines.join("\n");
|
|
1299
|
+
if (text.length > 8e4) text = text.slice(0, 8e4) + "\n[truncated]";
|
|
1300
|
+
return { content: [{ type: "text", text }] };
|
|
1252
1301
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
text = text.slice(0, 80000) + `
|
|
1257
|
-
[truncated]`;
|
|
1258
|
-
return { content: [{ type: "text", text }] };
|
|
1302
|
+
return err("Unknown action");
|
|
1303
|
+
} catch (e) {
|
|
1304
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
1259
1305
|
}
|
|
1260
|
-
return err("Unknown action");
|
|
1261
|
-
} catch (e) {
|
|
1262
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
1263
1306
|
}
|
|
1264
|
-
|
|
1307
|
+
);
|
|
1265
1308
|
}
|
|
1266
1309
|
|
|
1267
1310
|
// src/mcp/tools/credentials.ts
|
|
@@ -1282,15 +1325,15 @@ function getKnowledgePath(domain) {
|
|
|
1282
1325
|
}
|
|
1283
1326
|
function normalizeDomain(input) {
|
|
1284
1327
|
let d = (input || "").trim().toLowerCase();
|
|
1285
|
-
if (!d)
|
|
1286
|
-
return "";
|
|
1328
|
+
if (!d) return "";
|
|
1287
1329
|
try {
|
|
1288
1330
|
if (d.includes("://")) {
|
|
1289
1331
|
d = new URL(d).hostname;
|
|
1290
1332
|
} else if (d.includes("/")) {
|
|
1291
1333
|
d = new URL(`https://${d}`).hostname;
|
|
1292
1334
|
}
|
|
1293
|
-
} catch {
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1294
1337
|
d = d.replace(/:\d+$/, "");
|
|
1295
1338
|
d = d.replace(/^www\./, "");
|
|
1296
1339
|
return d;
|
|
@@ -1317,8 +1360,7 @@ function listKnowledge() {
|
|
|
1317
1360
|
}
|
|
1318
1361
|
const results = [];
|
|
1319
1362
|
for (const file of entries) {
|
|
1320
|
-
if (!file.endsWith(".md"))
|
|
1321
|
-
continue;
|
|
1363
|
+
if (!file.endsWith(".md")) continue;
|
|
1322
1364
|
const full = import_path8.default.join(dir, file);
|
|
1323
1365
|
try {
|
|
1324
1366
|
const stat = import_fs6.default.statSync(full);
|
|
@@ -1330,7 +1372,8 @@ function listKnowledge() {
|
|
|
1330
1372
|
lastMode: parsed?.lastMode ?? "unknown",
|
|
1331
1373
|
sizeBytes: stat.size
|
|
1332
1374
|
});
|
|
1333
|
-
} catch {
|
|
1375
|
+
} catch {
|
|
1376
|
+
}
|
|
1334
1377
|
}
|
|
1335
1378
|
results.sort((a, b) => a.lastVerified < b.lastVerified ? 1 : -1);
|
|
1336
1379
|
return results;
|
|
@@ -1354,22 +1397,21 @@ function clearKnowledge(domain) {
|
|
|
1354
1397
|
try {
|
|
1355
1398
|
import_fs6.default.unlinkSync(import_path8.default.join(dir, f));
|
|
1356
1399
|
removed++;
|
|
1357
|
-
} catch {
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1358
1402
|
}
|
|
1359
1403
|
}
|
|
1360
|
-
} catch {
|
|
1404
|
+
} catch {
|
|
1405
|
+
}
|
|
1361
1406
|
return { removed };
|
|
1362
1407
|
}
|
|
1363
1408
|
function parseMarkdown(raw) {
|
|
1364
1409
|
const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
1365
|
-
if (!frontmatterMatch)
|
|
1366
|
-
return null;
|
|
1410
|
+
if (!frontmatterMatch) return null;
|
|
1367
1411
|
const fm = {};
|
|
1368
|
-
for (const line of frontmatterMatch[1].split(
|
|
1369
|
-
`)) {
|
|
1412
|
+
for (const line of frontmatterMatch[1].split("\n")) {
|
|
1370
1413
|
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
1371
|
-
if (m)
|
|
1372
|
-
fm[m[1]] = m[2].trim();
|
|
1414
|
+
if (m) fm[m[1]] = m[2].trim();
|
|
1373
1415
|
}
|
|
1374
1416
|
const body = raw.slice(frontmatterMatch[0].length);
|
|
1375
1417
|
const auth = { type: fm.authType ?? "unknown" };
|
|
@@ -1392,11 +1434,9 @@ function parseMarkdown(raw) {
|
|
|
1392
1434
|
const notes = [];
|
|
1393
1435
|
const notesSection = extractSection(body, "Notes");
|
|
1394
1436
|
if (notesSection) {
|
|
1395
|
-
for (const line of notesSection.split(
|
|
1396
|
-
`)) {
|
|
1437
|
+
for (const line of notesSection.split("\n")) {
|
|
1397
1438
|
const m = line.match(/^-\s+(.+)$/);
|
|
1398
|
-
if (m)
|
|
1399
|
-
notes.push(m[1].trim());
|
|
1439
|
+
if (m) notes.push(m[1].trim());
|
|
1400
1440
|
}
|
|
1401
1441
|
}
|
|
1402
1442
|
return {
|
|
@@ -1412,8 +1452,7 @@ function parseMarkdown(raw) {
|
|
|
1412
1452
|
function extractSection(body, heading) {
|
|
1413
1453
|
const re = new RegExp(`^##\\s+${heading}\\s*$`, "m");
|
|
1414
1454
|
const match = body.match(re);
|
|
1415
|
-
if (!match || match.index ===
|
|
1416
|
-
return null;
|
|
1455
|
+
if (!match || match.index === void 0) return null;
|
|
1417
1456
|
const start = match.index + match[0].length;
|
|
1418
1457
|
const nextSection = body.slice(start).match(/^##\s+/m);
|
|
1419
1458
|
const end = nextSection?.index != null ? start + nextSection.index : body.length;
|
|
@@ -1421,221 +1460,611 @@ function extractSection(body, heading) {
|
|
|
1421
1460
|
}
|
|
1422
1461
|
function extractBackticks(re, text) {
|
|
1423
1462
|
const m = text.match(re);
|
|
1424
|
-
if (!m)
|
|
1425
|
-
return;
|
|
1463
|
+
if (!m) return void 0;
|
|
1426
1464
|
const items = [...m[1].matchAll(/`([^`]+)`/g)].map((x) => x[1]);
|
|
1427
|
-
return items.length > 0 ? items :
|
|
1465
|
+
return items.length > 0 ? items : void 0;
|
|
1428
1466
|
}
|
|
1429
1467
|
|
|
1430
1468
|
// src/mcp/tools/credentials.ts
|
|
1431
|
-
var ELICIT_TIMEOUT_MS =
|
|
1469
|
+
var ELICIT_TIMEOUT_MS = 45e3;
|
|
1432
1470
|
function mcpLog(event, data) {
|
|
1433
1471
|
try {
|
|
1434
1472
|
const dir = getDataDir();
|
|
1435
1473
|
import_fs7.default.mkdirSync(dir, { recursive: true });
|
|
1436
|
-
import_fs7.default.appendFileSync(
|
|
1437
|
-
|
|
1438
|
-
|
|
1474
|
+
import_fs7.default.appendFileSync(
|
|
1475
|
+
import_path9.default.join(dir, "mcp.log"),
|
|
1476
|
+
JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), event, ...data }) + "\n"
|
|
1477
|
+
);
|
|
1478
|
+
} catch {
|
|
1479
|
+
}
|
|
1439
1480
|
}
|
|
1440
1481
|
function domainMatches(normalized, stored) {
|
|
1441
|
-
return stored.some(
|
|
1482
|
+
return stored.some(
|
|
1483
|
+
(d) => d === normalized || normalized.endsWith("." + d) || d.endsWith("." + normalized)
|
|
1484
|
+
);
|
|
1442
1485
|
}
|
|
1443
|
-
function registerCredentialsTool(
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
2. **NEVER ask the user "do you have credentials?"** — call action=list and read the response.
|
|
1459
|
-
|
|
1460
|
-
3. **NEVER confabulate.** If action=list returns "No credentials stored", the database is empty.
|
|
1461
|
-
|
|
1462
|
-
4. **NEVER pretend a store call succeeded if the response was an error.**
|
|
1463
|
-
|
|
1464
|
-
5. **NEVER ask the user to paste their password in chat.**
|
|
1465
|
-
|
|
1466
|
-
6. **\`force: true\` on store is ONLY for explicit password changes.**`, {
|
|
1467
|
-
action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
|
|
1468
|
-
domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
|
|
1469
|
-
force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
|
|
1470
|
-
}, async ({ action, domain, force }) => {
|
|
1471
|
-
try {
|
|
1472
|
-
if (action === "list") {
|
|
1473
|
-
const credData2 = await localApiGet("/credentials");
|
|
1474
|
-
const domains = credData2.domains || [];
|
|
1475
|
-
if (!domains.length) {
|
|
1476
|
-
return { content: [{ type: "text", text: "No credentials stored. Call this tool again with action=store and the domain to prompt the user for credentials now." }] };
|
|
1477
|
-
}
|
|
1478
|
-
return { content: [{ type: "text", text: `Stored credentials for:
|
|
1479
|
-
${domains.map((d) => ` - ${d}`).join(`
|
|
1480
|
-
`)}` }] };
|
|
1481
|
-
}
|
|
1482
|
-
if (action !== "store")
|
|
1483
|
-
return err("Unknown action");
|
|
1484
|
-
if (!domain)
|
|
1485
|
-
return err("domain is required for action=store");
|
|
1486
|
-
const normalized = normalizeDomain(domain);
|
|
1487
|
-
const credData = await localApiGet("/credentials");
|
|
1488
|
-
const stored = credData.domains || [];
|
|
1489
|
-
if (domainMatches(normalized, stored) && !force) {
|
|
1490
|
-
mcpLog("credentials.store.rejected_already_exists", { domain: normalized });
|
|
1491
|
-
return err(`REFUSING TO RE-STORE: credentials for "${normalized}" already exist. ` + `Login failures are NOT credential problems — retry with a stronger browser mode.
|
|
1492
|
-
|
|
1493
|
-
` + `Use \`force: true\` ONLY if the user explicitly says their password changed.`);
|
|
1494
|
-
}
|
|
1495
|
-
mcpLog("credentials.store.attempt", { domain: normalized, force: !!force });
|
|
1496
|
-
let result;
|
|
1486
|
+
function registerCredentialsTool(server2) {
|
|
1487
|
+
server2.tool(
|
|
1488
|
+
"credentials",
|
|
1489
|
+
`Store/list login credentials (one local SQLite store shared by all browser modes). This tool never logs in \u2014 logins run via \`execute\`'s \`login\` step.
|
|
1490
|
+
|
|
1491
|
+
WORKFLOW: (1) action=list and read the response LITERALLY \u2014 never ask the user whether credentials exist, never confabulate. (2) Domain missing \u2192 action=store pops a secure form in the user's UI; if the response is an elicitation-unsupported error, relay its CLI instructions verbatim and STOP (never pretend it succeeded). (3) Then \`execute\` [{type:"navigate",url:"https://<site>/login"},{type:"login",domain:"<site>"}] \u2014 auto-detects the form, fills stored credentials, handles 2FA, escalates modes if blocked.
|
|
1492
|
+
|
|
1493
|
+
RULES: NEVER re-store after a failed login (that's a browser/bot problem; the store is rejected anyway). NEVER ask for passwords in chat. force:true only for an explicit password change.`,
|
|
1494
|
+
{
|
|
1495
|
+
action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
|
|
1496
|
+
domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
|
|
1497
|
+
force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
|
|
1498
|
+
},
|
|
1499
|
+
async ({ action, domain, force }) => {
|
|
1497
1500
|
try {
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
type: "
|
|
1503
|
-
properties: {
|
|
1504
|
-
username: { type: "string", title: "Username / Email" },
|
|
1505
|
-
password: { type: "string", title: "Password" },
|
|
1506
|
-
totp_secret: { type: "string", title: "TOTP Secret (leave empty if no 2FA)" }
|
|
1507
|
-
},
|
|
1508
|
-
required: ["username", "password"]
|
|
1501
|
+
if (action === "list") {
|
|
1502
|
+
const credData2 = await localApiGet("/credentials");
|
|
1503
|
+
const domains = credData2.domains || [];
|
|
1504
|
+
if (!domains.length) {
|
|
1505
|
+
return { content: [{ type: "text", text: "No credentials stored. Call this tool again with action=store and the domain to prompt the user for credentials now." }] };
|
|
1509
1506
|
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1507
|
+
return { content: [{ type: "text", text: `Stored credentials for:
|
|
1508
|
+
${domains.map((d) => ` - ${d}`).join("\n")}` }] };
|
|
1509
|
+
}
|
|
1510
|
+
if (action !== "store") return err("Unknown action");
|
|
1511
|
+
if (!domain) return err("domain is required for action=store");
|
|
1512
|
+
const normalized = normalizeDomain(domain);
|
|
1513
|
+
const credData = await localApiGet("/credentials");
|
|
1514
|
+
const stored = credData.domains || [];
|
|
1515
|
+
if (domainMatches(normalized, stored) && !force) {
|
|
1516
|
+
mcpLog("credentials.store.rejected_already_exists", { domain: normalized });
|
|
1517
|
+
return err(
|
|
1518
|
+
`REFUSING TO RE-STORE: credentials for "${normalized}" already exist. Login failures are NOT credential problems \u2014 retry with a stronger browser mode.
|
|
1519
|
+
|
|
1520
|
+
Use \`force: true\` ONLY if the user explicitly says their password changed.`
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
mcpLog("credentials.store.attempt", { domain: normalized, force: !!force });
|
|
1524
|
+
let result;
|
|
1525
|
+
try {
|
|
1526
|
+
const elicitPromise = server2.server.elicitInput({
|
|
1527
|
+
mode: "form",
|
|
1528
|
+
message: `Enter your login credentials for ${normalized}. These are encrypted and stored locally \u2014 the agent never sees them.`,
|
|
1529
|
+
requestedSchema: {
|
|
1530
|
+
type: "object",
|
|
1531
|
+
properties: {
|
|
1532
|
+
username: { type: "string", title: "Username / Email" },
|
|
1533
|
+
password: { type: "string", title: "Password" },
|
|
1534
|
+
totp_secret: { type: "string", title: "TOTP Secret (leave empty if no 2FA)" }
|
|
1535
|
+
},
|
|
1536
|
+
required: ["username", "password"]
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
const timeoutPromise = new Promise(
|
|
1540
|
+
(_, reject) => setTimeout(() => reject(new Error(`elicitation timed out after ${ELICIT_TIMEOUT_MS}ms`)), ELICIT_TIMEOUT_MS)
|
|
1541
|
+
);
|
|
1542
|
+
result = await Promise.race([elicitPromise, timeoutPromise]);
|
|
1543
|
+
} catch (elicitErr) {
|
|
1544
|
+
const msg = elicitErr instanceof Error ? elicitErr.message : String(elicitErr);
|
|
1545
|
+
mcpLog("credentials.store.elicit_failed", { domain: normalized, error: msg });
|
|
1546
|
+
return err(
|
|
1547
|
+
`FAILED TO STORE CREDENTIALS for ${normalized}: ${msg}
|
|
1548
|
+
|
|
1549
|
+
The user MUST run this command in their terminal:
|
|
1550
|
+
|
|
1551
|
+
iframer-toolkit credentials add ${normalized}
|
|
1552
|
+
|
|
1553
|
+
After they run it, retry the login. DO NOT pretend credentials were stored.`
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
mcpLog("credentials.store.elicit_result", { domain: normalized, action: result.action, hasContent: !!result.content });
|
|
1557
|
+
if (result.action !== "accept" || !result.content) {
|
|
1558
|
+
return err(`Credential form was ${result.action || "dismissed"}. No credentials saved for ${normalized}.`);
|
|
1559
|
+
}
|
|
1560
|
+
const { username, password, totp_secret } = result.content;
|
|
1561
|
+
if (!username || !password) {
|
|
1562
|
+
return err(`Form submitted but username or password was empty. No credentials saved.`);
|
|
1563
|
+
}
|
|
1564
|
+
try {
|
|
1565
|
+
const storeResult = await localApiPost("/credentials", {
|
|
1566
|
+
domain: normalized,
|
|
1567
|
+
username,
|
|
1568
|
+
password,
|
|
1569
|
+
totp_secret: totp_secret || void 0
|
|
1570
|
+
});
|
|
1571
|
+
if (!storeResult.ok) return err(`Failed to store: ${storeResult.error}`);
|
|
1572
|
+
} catch (storeErr) {
|
|
1573
|
+
const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
|
|
1574
|
+
mcpLog("credentials.store.write_failed", { domain: normalized, error: msg });
|
|
1575
|
+
return err(`Failed to write credentials: ${msg}`);
|
|
1576
|
+
}
|
|
1577
|
+
mcpLog("credentials.store.success", { domain: normalized });
|
|
1578
|
+
return { content: [{ type: "text", text: `Credentials stored for ${normalized}. Shared across all browser modes.` }] };
|
|
1579
|
+
} catch (e) {
|
|
1580
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
1545
1581
|
}
|
|
1546
|
-
mcpLog("credentials.store.success", { domain: normalized });
|
|
1547
|
-
return { content: [{ type: "text", text: `Credentials stored for ${normalized}. Shared across all browser modes.` }] };
|
|
1548
|
-
} catch (e) {
|
|
1549
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
1550
1582
|
}
|
|
1551
|
-
|
|
1583
|
+
);
|
|
1552
1584
|
}
|
|
1553
1585
|
|
|
1554
1586
|
// src/mcp/tools/knowledge.ts
|
|
1555
1587
|
var import_zod7 = require("zod");
|
|
1556
|
-
function registerKnowledgeTool(
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
- Auth mechanism (which cookies / localStorage keys / headers are load-bearing)
|
|
1561
|
-
- Known API endpoints the site uses (captured from real browser runs)
|
|
1562
|
-
- Notes about captchas, bot detection, session behavior
|
|
1563
|
-
- Which browser mode last worked
|
|
1588
|
+
function registerKnowledgeTool(server2) {
|
|
1589
|
+
server2.tool(
|
|
1590
|
+
"knowledge",
|
|
1591
|
+
`Per-domain knowledge cache (markdown at ~/.iframer/knowledge/<domain>.md): auth mechanism (load-bearing cookies/headers), captured API endpoints, captcha/bot notes, last working browser mode.
|
|
1564
1592
|
|
|
1565
|
-
MANDATORY
|
|
1593
|
+
MANDATORY: \`knowledge get <domain>\` BEFORE any execute/browse on a site. If it shows a direct-API path that satisfies the request, call the endpoints directly and SKIP the browser entirely \u2014 orders of magnitude cheaper. Empty/stale \u2192 fall through to \`execute\` (successful runs update the cache automatically). Cached endpoints returning 401/403 \u2192 stale session: run an execute pipeline with a \`login\` step.
|
|
1566
1594
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1595
|
+
Actions: get <domain> \xB7 list \xB7 clear [domain].`,
|
|
1596
|
+
{
|
|
1597
|
+
action: import_zod7.z.enum(["get", "list", "clear"]).describe("get: return cache for a domain | list: all cached domains | clear: delete cache"),
|
|
1598
|
+
domain: import_zod7.z.string().optional().describe("Domain (required for get; optional for clear \u2014 omit to clear everything)")
|
|
1599
|
+
},
|
|
1600
|
+
async ({ action, domain }) => {
|
|
1601
|
+
try {
|
|
1602
|
+
if (action === "list") {
|
|
1603
|
+
const entries = listKnowledge();
|
|
1604
|
+
if (entries.length === 0) {
|
|
1605
|
+
return {
|
|
1606
|
+
content: [
|
|
1607
|
+
{
|
|
1608
|
+
type: "text",
|
|
1609
|
+
text: "No cached knowledge yet. Every successful `execute` run populates the cache for its target domain automatically."
|
|
1610
|
+
}
|
|
1611
|
+
]
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
const lines = [`${entries.length} domain${entries.length === 1 ? "" : "s"} cached:
|
|
1615
|
+
`];
|
|
1616
|
+
for (const e of entries) {
|
|
1617
|
+
lines.push(` ${e.domain} (${e.lastMode}, verified ${e.lastVerified}, ${e.sizeBytes}B)`);
|
|
1618
|
+
}
|
|
1619
|
+
lines.push("\nCall `knowledge get <domain>` for the full cache contents.");
|
|
1620
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1621
|
+
}
|
|
1622
|
+
if (action === "get") {
|
|
1623
|
+
if (!domain) return err("domain is required for action=get");
|
|
1624
|
+
const md = readKnowledge(domain);
|
|
1625
|
+
if (!md) {
|
|
1626
|
+
return {
|
|
1627
|
+
content: [
|
|
1628
|
+
{
|
|
1629
|
+
type: "text",
|
|
1630
|
+
text: `No cache for ${sanitizeDomain(domain)}. Run \`execute\` with a pipeline that navigates to this domain (and optionally enables captureApi) \u2014 the cache will be populated automatically on success.`
|
|
1631
|
+
}
|
|
1632
|
+
]
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
return { content: [{ type: "text", text: md }] };
|
|
1636
|
+
}
|
|
1637
|
+
if (action === "clear") {
|
|
1638
|
+
const { removed } = clearKnowledge(domain);
|
|
1639
|
+
const scope = domain ? `for ${sanitizeDomain(domain)}` : "(all domains)";
|
|
1640
|
+
return {
|
|
1641
|
+
content: [
|
|
1642
|
+
{
|
|
1643
|
+
type: "text",
|
|
1644
|
+
text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
|
|
1645
|
+
}
|
|
1646
|
+
]
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
return err("Unknown action");
|
|
1650
|
+
} catch (e) {
|
|
1651
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1571
1656
|
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1657
|
+
// src/mcp/tools/tabs.ts
|
|
1658
|
+
var import_zod8 = require("zod");
|
|
1659
|
+
function registerTabsTool(server2) {
|
|
1660
|
+
server2.tool(
|
|
1661
|
+
"tabs",
|
|
1662
|
+
`List/control tabs in the user's REAL Chrome via the iframer extension (must be installed + paired; returns a connect hint otherwise).
|
|
1663
|
+
|
|
1664
|
+
Use when the user references an open tab ("my Gmail tab"). Match by url/title from the returned tabs, then drive that tab with \`execute\` options.mode="extension", options.tabId=<id> (Chrome shows its debug bar during runs). Several matches \u2192 ask, don't guess.
|
|
1665
|
+
|
|
1666
|
+
Actions: list (default) \u2192 {connected, tabs:[{id,title,url,active,windowId}]} \xB7 open \u2192 new native tab at options.url, returns its id \xB7 group / ungroup / update-group / groups \u2192 native Chrome tab-group management (title/color/collapsed; groups lists ids).`,
|
|
1667
|
+
{
|
|
1668
|
+
action: import_zod8.z.enum(["list", "open", "group", "ungroup", "update-group", "groups"]).optional().describe("'list' (default) lists open tabs; 'open' opens a new tab; 'group'/'ungroup' add/remove tabIds to a group; 'update-group' renames/recolors an existing group by groupId; 'groups' lists all groups."),
|
|
1669
|
+
url: import_zod8.z.string().optional().describe("With action='open': the URL to open (omit for a blank tab)."),
|
|
1670
|
+
active: import_zod8.z.boolean().optional().describe("With action='open': focus the new tab (default true)."),
|
|
1671
|
+
tabIds: import_zod8.z.array(import_zod8.z.number()).optional().describe("With action='group': the tab ids to group (must be in the same window)."),
|
|
1672
|
+
title: import_zod8.z.string().optional().describe("With action='group': the group's label."),
|
|
1673
|
+
color: import_zod8.z.enum(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]).optional().describe("With action='group': the group's color."),
|
|
1674
|
+
collapsed: import_zod8.z.boolean().optional().describe("With action='group': collapse the group."),
|
|
1675
|
+
groupId: import_zod8.z.number().optional().describe("With action='group': add tabs to this existing group instead of creating a new one."),
|
|
1676
|
+
clientId: import_zod8.z.string().optional().describe("With action='open'/'group' and multiple profiles connected: which profile to act in."),
|
|
1677
|
+
filter: import_zod8.z.string().optional().describe("With action='list': case-insensitive substring to match against tab url or title (e.g. 'gmail', 'github.com'). Omit to list every open tab.")
|
|
1678
|
+
},
|
|
1679
|
+
async ({ action, url, active, tabIds, title, color, collapsed, groupId, clientId, filter }) => {
|
|
1680
|
+
try {
|
|
1681
|
+
if (action === "open") {
|
|
1682
|
+
const res = await localApiPost("/extension/tab/create", { url, active, clientId });
|
|
1683
|
+
if (!res.ok || !res.tab) return err(res.error || "Failed to open tab.");
|
|
1684
|
+
const t = res.tab;
|
|
1583
1685
|
return {
|
|
1584
1686
|
content: [
|
|
1585
1687
|
{
|
|
1586
1688
|
type: "text",
|
|
1587
|
-
text:
|
|
1689
|
+
text: `Opened tab [id ${t.id}] ${t.title || t.url}
|
|
1690
|
+
${t.url}
|
|
1691
|
+
Drive it with: execute mode="extension", tabId=${t.id}.`
|
|
1588
1692
|
}
|
|
1589
1693
|
]
|
|
1590
1694
|
};
|
|
1591
1695
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1696
|
+
if (action === "group") {
|
|
1697
|
+
if (!tabIds || tabIds.length === 0) return err("action='group' requires tabIds (array of tab ids).");
|
|
1698
|
+
const res = await localApiPost("/extension/tab/group", { tabIds, title, color, collapsed, groupId, clientId });
|
|
1699
|
+
if (!res.ok || !res.group) return err(res.error || "Failed to group tabs.");
|
|
1700
|
+
const g = res.group;
|
|
1701
|
+
return {
|
|
1702
|
+
content: [
|
|
1703
|
+
{
|
|
1704
|
+
type: "text",
|
|
1705
|
+
text: `Grouped ${tabIds.length} tab(s) into group ${g.groupId}${g.title ? ` "${g.title}"` : ""}${g.color ? ` (${g.color})` : ""}${g.collapsed ? ", collapsed" : ""}.`
|
|
1706
|
+
}
|
|
1707
|
+
]
|
|
1708
|
+
};
|
|
1596
1709
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1710
|
+
if (action === "ungroup") {
|
|
1711
|
+
if (!tabIds || tabIds.length === 0) return err("action='ungroup' requires tabIds.");
|
|
1712
|
+
const res = await localApiPost("/extension/tab/ungroup", { tabIds, clientId });
|
|
1713
|
+
if (!res.ok) return err(res.error || "Failed to ungroup tabs.");
|
|
1714
|
+
return { content: [{ type: "text", text: `Ungrouped ${tabIds.length} tab(s).` }] };
|
|
1715
|
+
}
|
|
1716
|
+
if (action === "update-group") {
|
|
1717
|
+
if (typeof groupId !== "number") return err("action='update-group' requires groupId (from action='groups'). Pass any of title, color, collapsed.");
|
|
1718
|
+
const res = await localApiPost("/extension/group/update", { groupId, title, color, collapsed, clientId });
|
|
1719
|
+
if (!res.ok || !res.group) return err(res.error || "Failed to update group.");
|
|
1720
|
+
const g = res.group;
|
|
1721
|
+
return { content: [{ type: "text", text: `Updated group ${g.groupId} \u2192 title "${g.title}", color ${g.color}${g.collapsed ? ", collapsed" : ""}.` }] };
|
|
1722
|
+
}
|
|
1723
|
+
if (action === "groups") {
|
|
1724
|
+
const res = await localApiPost("/extension/groups", { clientId });
|
|
1725
|
+
if (!res.ok) return err(res.error || "Failed to list groups.");
|
|
1726
|
+
const groups = res.groups || [];
|
|
1727
|
+
if (groups.length === 0) return { content: [{ type: "text", text: "No tab groups open." }] };
|
|
1728
|
+
const lines2 = [`${groups.length} tab group(s):`, ""];
|
|
1729
|
+
for (const g of groups) lines2.push(` [group ${g.groupId}] "${g.title || "(untitled)"}" \u2014 ${g.color}${g.collapsed ? ", collapsed" : ""} (window ${g.windowId})`);
|
|
1730
|
+
return { content: [{ type: "text", text: lines2.join("\n") }] };
|
|
1731
|
+
}
|
|
1732
|
+
const status = await localApiGet("/extension/status");
|
|
1733
|
+
if (!status.connected) {
|
|
1734
|
+
return err(
|
|
1735
|
+
"No iframer extension is connected.\n\nTo use your real Chrome tabs:\n1. Install the iframer extension (chrome://extensions \u2192 Load unpacked \u2192 the `extension/` folder).\n2. Run `iframer install extension` in a terminal, then restart the browser \u2014 the extension pairs itself.\n (Manual fallback: click the iframer icon and paste the token from `cat ~/.iframer/secret`.)\nOnce the dot is green, run `tabs` again \u2014 iframer can then see and drive any open tab."
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
const data = await localApiPost("/extension/tabs", {});
|
|
1739
|
+
let tabs = data.tabs || [];
|
|
1740
|
+
const clients = data.clients || [];
|
|
1741
|
+
const multiProfile = clients.length > 1;
|
|
1742
|
+
if (filter) {
|
|
1743
|
+
const f = filter.toLowerCase();
|
|
1744
|
+
tabs = tabs.filter(
|
|
1745
|
+
(t) => t.url.toLowerCase().includes(f) || (t.title || "").toLowerCase().includes(f)
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
if (tabs.length === 0) {
|
|
1606
1749
|
return {
|
|
1607
1750
|
content: [
|
|
1608
1751
|
{
|
|
1609
1752
|
type: "text",
|
|
1610
|
-
text:
|
|
1753
|
+
text: filter ? `Extension connected, but no open tab matches "${filter}".` : "Extension connected, but no tabs were reported."
|
|
1611
1754
|
}
|
|
1612
1755
|
]
|
|
1613
1756
|
};
|
|
1614
1757
|
}
|
|
1615
|
-
|
|
1758
|
+
const profiles = clients.map((c) => c.profileName || c.clientId.slice(0, 8)).join(", ");
|
|
1759
|
+
const ver = clients[0]?.extVersion ? ` [ext v${clients[0].extVersion}]` : "";
|
|
1760
|
+
const lines = [
|
|
1761
|
+
`Connected: ${clients.length} profile${clients.length > 1 ? "s" : ""}${profiles ? ` (${profiles})` : ""}${ver}.`,
|
|
1762
|
+
`${tabs.length} tab${tabs.length > 1 ? "s" : ""}${filter ? ` matching "${filter}"` : ""}:`,
|
|
1763
|
+
""
|
|
1764
|
+
];
|
|
1765
|
+
for (const t of tabs) {
|
|
1766
|
+
const prof = multiProfile ? ` \xAB${t.profileName || t.clientId.slice(0, 8)}\xBB` : "";
|
|
1767
|
+
lines.push(` [id ${t.id}]${t.active ? " (active)" : ""}${prof} ${t.title}`);
|
|
1768
|
+
lines.push(` ${t.url}`);
|
|
1769
|
+
if (multiProfile) lines.push(` clientId: ${t.clientId}`);
|
|
1770
|
+
}
|
|
1771
|
+
lines.push("");
|
|
1772
|
+
lines.push('To drive one: execute with options.mode="extension", options.tabId=<id>.');
|
|
1773
|
+
if (multiProfile) {
|
|
1774
|
+
lines.push(
|
|
1775
|
+
"Multiple profiles are connected \u2014 if two tabs share a title, also pass options.clientId (shown as \xABprofile\xBB above maps to a clientId) so the right profile is driven."
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1779
|
+
} catch (e) {
|
|
1780
|
+
return err(`Error listing tabs: ${getErrorMessage(e)}`);
|
|
1616
1781
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1782
|
+
}
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// src/mcp/tools/anchors.ts
|
|
1787
|
+
var import_zod9 = require("zod");
|
|
1788
|
+
|
|
1789
|
+
// src/lib/knowledge/component-map.ts
|
|
1790
|
+
var import_fs8 = __toESM(require("fs"));
|
|
1791
|
+
var import_path10 = __toESM(require("path"));
|
|
1792
|
+
function anchorsPath(domain) {
|
|
1793
|
+
return import_path10.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
|
|
1794
|
+
}
|
|
1795
|
+
function loadComponentMap(domain) {
|
|
1796
|
+
const norm = normalizeDomain(domain);
|
|
1797
|
+
try {
|
|
1798
|
+
const raw = import_fs8.default.readFileSync(anchorsPath(norm), "utf8");
|
|
1799
|
+
const parsed = JSON.parse(raw);
|
|
1800
|
+
return {
|
|
1801
|
+
domain: parsed.domain || norm,
|
|
1802
|
+
anchors: parsed.anchors || {},
|
|
1803
|
+
quirks: Array.isArray(parsed.quirks) ? parsed.quirks : []
|
|
1804
|
+
};
|
|
1805
|
+
} catch {
|
|
1806
|
+
return { domain: norm, anchors: {}, quirks: [] };
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
function write(cm) {
|
|
1810
|
+
import_fs8.default.mkdirSync(getKnowledgeDir(), { recursive: true });
|
|
1811
|
+
import_fs8.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
|
|
1812
|
+
}
|
|
1813
|
+
function saveAnchor(domain, input, now) {
|
|
1814
|
+
const cm = loadComponentMap(domain);
|
|
1815
|
+
cm.anchors[input.name] = {
|
|
1816
|
+
name: input.name,
|
|
1817
|
+
selector: input.selector,
|
|
1818
|
+
role: input.role,
|
|
1819
|
+
description: input.description,
|
|
1820
|
+
quirks: input.quirks && input.quirks.length ? input.quirks : void 0,
|
|
1821
|
+
uses: 0,
|
|
1822
|
+
fails: 0,
|
|
1823
|
+
lastVerified: now
|
|
1824
|
+
};
|
|
1825
|
+
write(cm);
|
|
1826
|
+
}
|
|
1827
|
+
function removeAnchor(domain, name) {
|
|
1828
|
+
const cm = loadComponentMap(domain);
|
|
1829
|
+
if (!(name in cm.anchors)) return false;
|
|
1830
|
+
delete cm.anchors[name];
|
|
1831
|
+
write(cm);
|
|
1832
|
+
return true;
|
|
1833
|
+
}
|
|
1834
|
+
function setDomainQuirks(domain, quirks) {
|
|
1835
|
+
const cm = loadComponentMap(domain);
|
|
1836
|
+
cm.quirks = Array.from(/* @__PURE__ */ new Set([...cm.quirks, ...quirks]));
|
|
1837
|
+
write(cm);
|
|
1838
|
+
}
|
|
1839
|
+
function listAnchorDomains() {
|
|
1840
|
+
try {
|
|
1841
|
+
return import_fs8.default.readdirSync(getKnowledgeDir()).filter((f) => f.endsWith(".anchors.json")).map((f) => f.replace(/\.anchors\.json$/, ""));
|
|
1842
|
+
} catch {
|
|
1843
|
+
return [];
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// src/mcp/tools/anchors.ts
|
|
1848
|
+
function registerRememberTool(server2) {
|
|
1849
|
+
server2.tool(
|
|
1850
|
+
"remember",
|
|
1851
|
+
`Persisted per-domain map of a site's UI elements ("anchors") \u2014 recall where things are instead of re-exploring the DOM each run.
|
|
1852
|
+
|
|
1853
|
+
(1) Before a UI task: \`remember get <domain>\`; an existing anchor is targeted as @a:<name> in any execute selector \u2014 no snapshot needed. (2) A newly discovered selector that worked \u2192 \`remember save\` it (prefer stable selectors: aria-label, data-qa, role+name). (3) SELF-HEAL: if @a:<name> fails, the page changed \u2014 re-discover with snapshot/find and \`remember save\` the new selector; do NOT retry the stale one. (4) \`remember quirk\` records site-wide gotchas ("synthetic clicks ignored \u2014 use trusted"); \`get\` surfaces them.`,
|
|
1854
|
+
{
|
|
1855
|
+
action: import_zod9.z.enum(["get", "save", "forget", "list", "quirk"]).describe("get: show a domain's anchors+quirks | save: create/overwrite an anchor | forget: delete an anchor | list: all domains with anchors | quirk: add site-wide quirk note(s)"),
|
|
1856
|
+
domain: import_zod9.z.string().optional().describe("Site domain, e.g. 'slack.com' or 'app.slack.com' (required for all actions except list)."),
|
|
1857
|
+
name: import_zod9.z.string().optional().describe("Anchor name for save/forget, e.g. 'composer', 'send-button', 'search'. Short, stable, kebab-case."),
|
|
1858
|
+
selector: import_zod9.z.string().optional().describe("With save: the CSS selector that locates the element. Prefer stable attributes: [aria-label=...], [data-qa=...], role+name. Avoid brittle generated class chains."),
|
|
1859
|
+
role: import_zod9.z.string().optional().describe("With save (optional): the element role, e.g. textbox, button, link."),
|
|
1860
|
+
description: import_zod9.z.string().optional().describe("With save (optional): a short human note about the element."),
|
|
1861
|
+
quirks: import_zod9.z.array(import_zod9.z.string()).optional().describe("With save: element-specific gotchas. With quirk: site-wide gotchas to append.")
|
|
1862
|
+
},
|
|
1863
|
+
async ({ action, domain, name, selector, role, description, quirks }) => {
|
|
1864
|
+
try {
|
|
1865
|
+
if (action === "list") {
|
|
1866
|
+
const domains = listAnchorDomains();
|
|
1867
|
+
if (domains.length === 0) {
|
|
1868
|
+
return { content: [{ type: "text", text: "No anchors saved yet. Use `remember save` after locating an element with snapshot/find." }] };
|
|
1869
|
+
}
|
|
1870
|
+
const lines = ["Domains with saved anchors:\n"];
|
|
1871
|
+
for (const d of domains) {
|
|
1872
|
+
const cm = loadComponentMap(d);
|
|
1873
|
+
lines.push(` ${d} \u2014 ${Object.keys(cm.anchors).length} anchor(s)${cm.quirks.length ? `, ${cm.quirks.length} quirk(s)` : ""}`);
|
|
1874
|
+
}
|
|
1875
|
+
lines.push("\nCall `remember get <domain>` for details.");
|
|
1876
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1877
|
+
}
|
|
1878
|
+
if (!domain) return err(`\`${action}\` requires a domain (e.g. 'app.slack.com').`);
|
|
1879
|
+
if (action === "get") {
|
|
1880
|
+
const cm = loadComponentMap(domain);
|
|
1881
|
+
const names = Object.keys(cm.anchors);
|
|
1882
|
+
if (names.length === 0 && cm.quirks.length === 0) {
|
|
1883
|
+
return { content: [{ type: "text", text: `No anchors saved for ${domain} yet. Discover elements with snapshot/find, then \`remember save\` them.` }] };
|
|
1884
|
+
}
|
|
1885
|
+
const lines = [`Component map for ${domain}:
|
|
1886
|
+
`];
|
|
1887
|
+
if (cm.quirks.length) {
|
|
1888
|
+
lines.push("Site quirks:");
|
|
1889
|
+
for (const q of cm.quirks) lines.push(` - ${q}`);
|
|
1890
|
+
lines.push("");
|
|
1891
|
+
}
|
|
1892
|
+
lines.push(`Anchors (use as @a:<name> in any selector):`);
|
|
1893
|
+
for (const a of Object.values(cm.anchors)) {
|
|
1894
|
+
const health = a.fails > 0 ? ` [${a.uses}\u2713/${a.fails}\u2717${a.fails >= a.uses && a.fails >= 2 ? " \u2014 likely STALE, re-verify" : ""}]` : a.uses > 0 ? ` [${a.uses}\u2713]` : "";
|
|
1895
|
+
lines.push(` @a:${a.name}${a.role ? ` (${a.role})` : ""} \u2192 ${a.selector}${health}`);
|
|
1896
|
+
if (a.description) lines.push(` ${a.description}`);
|
|
1897
|
+
if (a.quirks?.length) for (const q of a.quirks) lines.push(` \u2022 ${q}`);
|
|
1898
|
+
}
|
|
1899
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1900
|
+
}
|
|
1901
|
+
if (action === "quirk") {
|
|
1902
|
+
if (!quirks || quirks.length === 0) return err("`quirk` requires a non-empty `quirks` array.");
|
|
1903
|
+
setDomainQuirks(domain, quirks);
|
|
1904
|
+
return { content: [{ type: "text", text: `Added ${quirks.length} quirk(s) to ${domain}.` }] };
|
|
1905
|
+
}
|
|
1906
|
+
if (action === "forget") {
|
|
1907
|
+
if (!name) return err("`forget` requires the anchor `name`.");
|
|
1908
|
+
const removed = removeAnchor(domain, name);
|
|
1909
|
+
return { content: [{ type: "text", text: removed ? `Removed anchor @a:${name} from ${domain}.` : `No anchor named '${name}' for ${domain}.` }] };
|
|
1910
|
+
}
|
|
1911
|
+
if (!name) return err("`save` requires an anchor `name`.");
|
|
1912
|
+
if (!selector) return err("`save` requires a `selector` (the CSS that locates the element).");
|
|
1913
|
+
saveAnchor(domain, { name, selector, role, description, quirks }, (/* @__PURE__ */ new Date()).toISOString());
|
|
1620
1914
|
return {
|
|
1621
1915
|
content: [
|
|
1622
1916
|
{
|
|
1623
1917
|
type: "text",
|
|
1624
|
-
text: `
|
|
1918
|
+
text: `Saved anchor @a:${name} for ${domain} \u2192 ${selector}
|
|
1919
|
+
Use it in any selector field: e.g. {"type":"click","selector":"@a:${name}"}.`
|
|
1625
1920
|
}
|
|
1626
1921
|
]
|
|
1627
1922
|
};
|
|
1923
|
+
} catch (e) {
|
|
1924
|
+
return err(`remember failed: ${getErrorMessage(e)}`);
|
|
1628
1925
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1926
|
+
}
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// src/mcp/tools/clipboard.ts
|
|
1931
|
+
var import_zod10 = require("zod");
|
|
1932
|
+
|
|
1933
|
+
// src/lib/clipboard.ts
|
|
1934
|
+
var import_child_process2 = require("child_process");
|
|
1935
|
+
function platformTools(mode) {
|
|
1936
|
+
if (process.platform === "darwin") return [[mode === "read" ? "pbpaste" : "pbcopy"]];
|
|
1937
|
+
if (mode === "read") return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
|
|
1938
|
+
return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
|
|
1939
|
+
}
|
|
1940
|
+
function run(cmd, input) {
|
|
1941
|
+
return new Promise((resolve) => {
|
|
1942
|
+
let child;
|
|
1943
|
+
try {
|
|
1944
|
+
child = (0, import_child_process2.spawn)(cmd[0], cmd.slice(1));
|
|
1945
|
+
} catch {
|
|
1946
|
+
resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
let out = "";
|
|
1950
|
+
let errOut = "";
|
|
1951
|
+
child.stdout?.on("data", (d) => out += d);
|
|
1952
|
+
child.stderr?.on("data", (d) => errOut += d);
|
|
1953
|
+
child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
|
|
1954
|
+
child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
|
|
1955
|
+
if (input !== void 0) {
|
|
1956
|
+
child.stdin?.write(input);
|
|
1957
|
+
child.stdin?.end();
|
|
1632
1958
|
}
|
|
1633
1959
|
});
|
|
1634
1960
|
}
|
|
1961
|
+
async function clipboardWrite(text) {
|
|
1962
|
+
const tools = platformTools("write");
|
|
1963
|
+
for (const cmd of tools) {
|
|
1964
|
+
if ((await run(cmd, text)).ok) return;
|
|
1965
|
+
}
|
|
1966
|
+
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}).`);
|
|
1967
|
+
}
|
|
1968
|
+
async function clipboardRead() {
|
|
1969
|
+
const tools = platformTools("read");
|
|
1970
|
+
let lastErr = "";
|
|
1971
|
+
for (const cmd of tools) {
|
|
1972
|
+
const r = await run(cmd);
|
|
1973
|
+
if (r.ok) return r.out;
|
|
1974
|
+
lastErr = r.err;
|
|
1975
|
+
}
|
|
1976
|
+
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// src/mcp/tools/clipboard.ts
|
|
1980
|
+
function registerClipboardTool(server2) {
|
|
1981
|
+
server2.tool(
|
|
1982
|
+
"clipboard",
|
|
1983
|
+
`Read/write the machine's clipboard (the same one the user's Chrome uses). get \u2192 read what a site copied (codes, links); set <text> \u2192 write. To paste INTO a page field use the \`paste\` execute step, not a \u2318V keyboard step (the extension relay ignores modifier keys).`,
|
|
1984
|
+
{
|
|
1985
|
+
action: import_zod10.z.enum(["get", "set"]).describe("get: read clipboard text | set: write text to clipboard"),
|
|
1986
|
+
text: import_zod10.z.string().optional().describe("With action='set': the text to put on the clipboard.")
|
|
1987
|
+
},
|
|
1988
|
+
async ({ action, text }) => {
|
|
1989
|
+
try {
|
|
1990
|
+
if (action === "set") {
|
|
1991
|
+
if (text === void 0) return err("action='set' requires `text`.");
|
|
1992
|
+
await clipboardWrite(text);
|
|
1993
|
+
return { content: [{ type: "text", text: `Clipboard set (${text.length} chars). To paste into a field, use the \`paste\` execute step.` }] };
|
|
1994
|
+
}
|
|
1995
|
+
const value = await clipboardRead();
|
|
1996
|
+
return { content: [{ type: "text", text: value.length ? value : "(clipboard is empty)" }] };
|
|
1997
|
+
} catch (e) {
|
|
1998
|
+
return err(`clipboard failed: ${getErrorMessage(e)}`);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// src/mcp/telemetry.ts
|
|
2005
|
+
var import_fs9 = __toESM(require("fs"));
|
|
2006
|
+
var import_path11 = __toESM(require("path"));
|
|
2007
|
+
var CHARS_PER_TOKEN = 4;
|
|
2008
|
+
var est = (chars) => Math.round(chars / CHARS_PER_TOKEN);
|
|
2009
|
+
var enabled = process.env.IFRAMER_TELEMETRY !== "0";
|
|
2010
|
+
var sessionId = `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${process.pid}`;
|
|
2011
|
+
function telemetryPath() {
|
|
2012
|
+
return import_path11.default.join(getDataDir(), "telemetry.jsonl");
|
|
2013
|
+
}
|
|
2014
|
+
var sessionCalls = 0;
|
|
2015
|
+
var sessionChars = 0;
|
|
2016
|
+
function write2(rec) {
|
|
2017
|
+
if (!enabled) return;
|
|
2018
|
+
try {
|
|
2019
|
+
import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), session: sessionId, ...rec }) + "\n");
|
|
2020
|
+
} catch {
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
function recordDefinitions(toolCount, defChars2, instructionChars) {
|
|
2024
|
+
write2({
|
|
2025
|
+
kind: "definitions",
|
|
2026
|
+
toolCount,
|
|
2027
|
+
defChars: defChars2,
|
|
2028
|
+
instructionChars,
|
|
2029
|
+
estTokens: est(defChars2 + instructionChars)
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
function recordCall(tool, inChars, outChars, ms, isError) {
|
|
2033
|
+
sessionCalls++;
|
|
2034
|
+
sessionChars += inChars + outChars;
|
|
2035
|
+
write2({
|
|
2036
|
+
kind: "call",
|
|
2037
|
+
tool,
|
|
2038
|
+
inChars,
|
|
2039
|
+
outChars,
|
|
2040
|
+
estTokens: est(inChars + outChars),
|
|
2041
|
+
ms,
|
|
2042
|
+
isError,
|
|
2043
|
+
sessionCalls,
|
|
2044
|
+
sessionEstTokens: est(sessionChars)
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
function contentChars(res) {
|
|
2048
|
+
const r = res;
|
|
2049
|
+
if (!r || !Array.isArray(r.content)) return 0;
|
|
2050
|
+
let n = 0;
|
|
2051
|
+
for (const c of r.content) {
|
|
2052
|
+
if (typeof c?.text === "string") n += c.text.length;
|
|
2053
|
+
else if (typeof c?.data === "string") n += c.data.length;
|
|
2054
|
+
}
|
|
2055
|
+
return n;
|
|
2056
|
+
}
|
|
2057
|
+
function safeJsonLen(v) {
|
|
2058
|
+
try {
|
|
2059
|
+
return v === void 0 ? 0 : JSON.stringify(v).length;
|
|
2060
|
+
} catch {
|
|
2061
|
+
return 0;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
1635
2064
|
|
|
1636
2065
|
// src/mcp/server.ts
|
|
1637
2066
|
var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
|
|
1638
|
-
var INSTRUCTIONS = IS_DEV ? `iframer-dev
|
|
2067
|
+
var INSTRUCTIONS = IS_DEV ? `iframer-dev \u2014 local development instance of iframer (connects to ${BASE_URL}).
|
|
1639
2068
|
|
|
1640
2069
|
CRITICAL RULES:
|
|
1641
2070
|
1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
|
|
@@ -1644,8 +2073,8 @@ CRITICAL RULES:
|
|
|
1644
2073
|
4. If execute fails, read the error. It tells you exactly what happened and where.
|
|
1645
2074
|
5. Call "session" action=stop when done to save session state.
|
|
1646
2075
|
|
|
1647
|
-
CAPTCHA: Use the "solve-captcha" step
|
|
1648
|
-
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map APIs.` : `iframer
|
|
2076
|
+
CAPTCHA: Use the "solve-captcha" step \u2014 auto-detects reCAPTCHA/hCaptcha and solves with vision AI.
|
|
2077
|
+
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map APIs.` : `iframer \u2014 browser access for AI agents when normal methods fail.
|
|
1649
2078
|
|
|
1650
2079
|
CRITICAL RULES:
|
|
1651
2080
|
1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
|
|
@@ -1653,16 +2082,42 @@ CRITICAL RULES:
|
|
|
1653
2082
|
3. DO NOT present options when you can just act. Check status, credentials, execute.
|
|
1654
2083
|
4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
|
|
1655
2084
|
5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
|
|
1656
|
-
6.
|
|
2085
|
+
6. For UI tasks on a site, check "remember get <domain>" first \u2014 saved anchors let you target elements as @a:<name> (in any selector) instead of re-finding them. After you locate a new element with snapshot/find and it works, "remember save" it. If an @a: anchor fails, the page changed: re-discover and "remember save" the new selector (don't retry the stale one).
|
|
2086
|
+
7. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
|
|
1657
2087
|
7. If the browser crashes, call "session restart" and retry. Don't panic.
|
|
1658
2088
|
8. ALWAYS call "session" action=stop when you are done with browser work. It saves session state and frees the browser. Idle browsers are auto-reclaimed, but don't rely on that.
|
|
1659
2089
|
|
|
1660
|
-
BROWSER MODES: Don't specify options.mode
|
|
2090
|
+
BROWSER MODES: Don't specify options.mode \u2014 iframer auto-selects and auto-escalates (headless \u2192 binary-headful). Only set a mode if the user explicitly asks.
|
|
1661
2091
|
|
|
1662
2092
|
CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. In docker-headful mode, use the "solve-captcha" step.
|
|
1663
2093
|
|
|
1664
2094
|
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
|
|
1665
|
-
var server = new import_mcp.McpServer(
|
|
2095
|
+
var server = new import_mcp.McpServer(
|
|
2096
|
+
{ name: "iframer", version: "3.0.0" },
|
|
2097
|
+
{ instructions: INSTRUCTIONS }
|
|
2098
|
+
);
|
|
2099
|
+
var defChars = 0;
|
|
2100
|
+
var defCount = 0;
|
|
2101
|
+
{
|
|
2102
|
+
const origTool = server.tool.bind(server);
|
|
2103
|
+
server.tool = (...a) => {
|
|
2104
|
+
const name = String(a[0]);
|
|
2105
|
+
defCount++;
|
|
2106
|
+
defChars += name.length + (typeof a[1] === "string" ? a[1].length : 0);
|
|
2107
|
+
const hIdx = a.length - 1;
|
|
2108
|
+
const handler = a[hIdx];
|
|
2109
|
+
if (typeof handler === "function") {
|
|
2110
|
+
a[hIdx] = async (...ha) => {
|
|
2111
|
+
const started = Date.now();
|
|
2112
|
+
const res = await handler(...ha);
|
|
2113
|
+
const r = res;
|
|
2114
|
+
recordCall(name, safeJsonLen(ha[0]), contentChars(res), Date.now() - started, !!r?.isError);
|
|
2115
|
+
return res;
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
return origTool(...a);
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
1666
2121
|
registerStatusTool(server);
|
|
1667
2122
|
registerBrowseTool(server);
|
|
1668
2123
|
registerExecuteTool(server);
|
|
@@ -1670,19 +2125,32 @@ registerSessionTool(server);
|
|
|
1670
2125
|
registerCredentialsTool(server);
|
|
1671
2126
|
registerReverseEngineerTool(server);
|
|
1672
2127
|
registerKnowledgeTool(server);
|
|
2128
|
+
registerTabsTool(server);
|
|
2129
|
+
registerRememberTool(server);
|
|
2130
|
+
registerClipboardTool(server);
|
|
2131
|
+
recordDefinitions(defCount, defChars, INSTRUCTIONS.length);
|
|
1673
2132
|
process.on("SIGTERM", () => process.exit(0));
|
|
1674
2133
|
process.on("SIGINT", () => process.exit(0));
|
|
2134
|
+
process.stdin.on("end", () => process.exit(0));
|
|
2135
|
+
process.stdin.on("close", () => process.exit(0));
|
|
2136
|
+
var originalPpid = process.ppid;
|
|
2137
|
+
var orphanCheck = setInterval(() => {
|
|
2138
|
+
if (process.ppid !== originalPpid || process.ppid === 1) process.exit(0);
|
|
2139
|
+
}, 3e4);
|
|
2140
|
+
orphanCheck.unref?.();
|
|
1675
2141
|
process.on("uncaughtException", (err2) => {
|
|
1676
2142
|
try {
|
|
1677
2143
|
console.error(`[mcp] uncaughtException: ${err2?.message}`);
|
|
1678
|
-
} catch {
|
|
2144
|
+
} catch {
|
|
2145
|
+
}
|
|
1679
2146
|
});
|
|
1680
2147
|
process.on("unhandledRejection", (reason) => {
|
|
1681
2148
|
try {
|
|
1682
2149
|
console.error(`[mcp] unhandledRejection: ${reason}`);
|
|
1683
|
-
} catch {
|
|
2150
|
+
} catch {
|
|
2151
|
+
}
|
|
1684
2152
|
});
|
|
1685
|
-
var transport = new import_stdio.StdioServerTransport;
|
|
2153
|
+
var transport = new import_stdio.StdioServerTransport();
|
|
1686
2154
|
(async () => {
|
|
1687
2155
|
await server.connect(transport);
|
|
1688
2156
|
})();
|