iframer-toolkit 0.10.1 → 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 +8265 -6499
- package/dist/local-server.cjs +1491 -1322
- package/dist/mcp-server.cjs +1083 -1157
- package/extension/manifest.json +1 -1
- package/package.json +3 -5
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
131
|
var PORT_SCAN_ATTEMPTS = 21;
|
|
143
|
-
var STARTUP_TIMEOUT_MS =
|
|
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,77 +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
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
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 {
|
|
262
267
|
}
|
|
268
|
+
const parent = import_path4.default.dirname(dir);
|
|
269
|
+
if (parent === dir) break;
|
|
270
|
+
dir = parent;
|
|
263
271
|
}
|
|
272
|
+
return import_path4.default.join(__dirname, "..", "..");
|
|
264
273
|
}
|
|
265
274
|
resolveRuntime() {
|
|
266
|
-
const
|
|
267
|
-
const
|
|
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");
|
|
268
278
|
if (import_fs3.default.existsSync(serverCjs)) {
|
|
269
279
|
return { command: "node", args: [serverCjs] };
|
|
270
280
|
}
|
|
271
281
|
if (import_fs3.default.existsSync(serverTs)) {
|
|
272
282
|
try {
|
|
273
|
-
require.resolve("
|
|
283
|
+
require.resolve("tsx");
|
|
274
284
|
return { command: "node", args: ["--import", "tsx", serverTs] };
|
|
275
|
-
} catch {
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
276
287
|
}
|
|
277
288
|
try {
|
|
278
289
|
const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
|
|
279
290
|
if (bunPath && import_fs3.default.existsSync(serverTs)) {
|
|
280
|
-
console.error("[iframer] running local server under bun
|
|
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");
|
|
281
292
|
return { command: bunPath, args: ["run", serverTs] };
|
|
282
293
|
}
|
|
283
|
-
} catch {
|
|
284
|
-
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
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
|
+
);
|
|
285
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. */
|
|
286
302
|
async restart() {
|
|
287
303
|
const info = readServerInfo();
|
|
288
304
|
if (info && this.baseUrl) {
|
|
289
305
|
try {
|
|
290
|
-
await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(
|
|
291
|
-
} catch {
|
|
292
|
-
|
|
306
|
+
await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3e3) });
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
const deadline = Date.now() + 12e3;
|
|
293
310
|
while (Date.now() < deadline && isPidAlive(info.pid)) {
|
|
294
311
|
await sleep(200);
|
|
295
312
|
}
|
|
296
313
|
if (isPidAlive(info.pid)) {
|
|
297
314
|
try {
|
|
298
315
|
process.kill(info.pid, "SIGKILL");
|
|
299
|
-
} catch {
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
300
318
|
}
|
|
301
319
|
}
|
|
302
320
|
this.baseUrl = "";
|
|
303
321
|
await this.ensureRunning();
|
|
304
322
|
}
|
|
305
|
-
|
|
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
|
+
}
|
|
306
327
|
readLogTail() {
|
|
307
328
|
try {
|
|
308
329
|
const content = import_fs3.default.readFileSync(this.logPath, "utf8");
|
|
309
|
-
const lines = content.trim().split(
|
|
310
|
-
|
|
311
|
-
return lines.slice(-10).join(`
|
|
312
|
-
`);
|
|
330
|
+
const lines = content.trim().split("\n");
|
|
331
|
+
return lines.slice(-10).join("\n");
|
|
313
332
|
} catch {
|
|
314
333
|
return "(no log file)";
|
|
315
334
|
}
|
|
316
335
|
}
|
|
317
|
-
}
|
|
336
|
+
};
|
|
318
337
|
function sleep(ms) {
|
|
319
338
|
return new Promise((r) => setTimeout(r, ms));
|
|
320
339
|
}
|
|
321
340
|
async function healthCheck(baseUrl) {
|
|
322
341
|
try {
|
|
323
|
-
const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(
|
|
342
|
+
const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2e3) });
|
|
324
343
|
const data = await res.json();
|
|
325
344
|
return data.ok === true;
|
|
326
345
|
} catch {
|
|
@@ -349,8 +368,8 @@ function findFreePort(start, attempts) {
|
|
|
349
368
|
}
|
|
350
369
|
|
|
351
370
|
// src/lib/errors.ts
|
|
352
|
-
function getErrorMessage(
|
|
353
|
-
return
|
|
371
|
+
function getErrorMessage(err2) {
|
|
372
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
354
373
|
}
|
|
355
374
|
|
|
356
375
|
// src/mcp/helpers.ts
|
|
@@ -359,56 +378,55 @@ var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
|
|
|
359
378
|
var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
|
|
360
379
|
var IFRAMER_MODE = process.env.IFRAMER_MODE;
|
|
361
380
|
var LOCAL_TOKEN = getLocalToken();
|
|
362
|
-
var localServer = new LocalServerManager;
|
|
381
|
+
var localServer = new LocalServerManager();
|
|
363
382
|
async function ensureLocalServer() {
|
|
364
383
|
await localServer.ensureRunning();
|
|
365
384
|
}
|
|
366
385
|
function authHeaders(secret) {
|
|
367
386
|
const headers = { "Content-Type": "application/json" };
|
|
368
387
|
const key = secret || IFRAMER_SECRET;
|
|
369
|
-
if (key)
|
|
370
|
-
headers["x-api-key"] = key;
|
|
388
|
+
if (key) headers["x-api-key"] = key;
|
|
371
389
|
return headers;
|
|
372
390
|
}
|
|
373
391
|
async function apiPost(endpoint, body) {
|
|
374
392
|
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
|
375
393
|
method: "POST",
|
|
376
394
|
headers: authHeaders(),
|
|
377
|
-
body: body ? JSON.stringify(body) :
|
|
378
|
-
signal: AbortSignal.timeout(
|
|
395
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
396
|
+
signal: AbortSignal.timeout(18e4)
|
|
379
397
|
});
|
|
380
398
|
return res.json();
|
|
381
399
|
}
|
|
382
400
|
async function apiGet(endpoint) {
|
|
383
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(
|
|
401
|
+
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(3e4) });
|
|
384
402
|
return res.json();
|
|
385
403
|
}
|
|
386
|
-
async function localApiPost(endpoint, body) {
|
|
404
|
+
async function localApiPost(endpoint, body, timeoutMs = 18e4) {
|
|
387
405
|
await ensureLocalServer();
|
|
388
406
|
const url = localServer.getBaseUrl();
|
|
389
407
|
const res = await fetch(`${url}${endpoint}`, {
|
|
390
408
|
method: "POST",
|
|
391
409
|
headers: authHeaders(LOCAL_TOKEN),
|
|
392
|
-
body: body ? JSON.stringify(body) :
|
|
393
|
-
signal: AbortSignal.timeout(
|
|
410
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
411
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
394
412
|
});
|
|
395
413
|
return res.json();
|
|
396
414
|
}
|
|
397
415
|
async function localApiGet(endpoint) {
|
|
398
416
|
await ensureLocalServer();
|
|
399
417
|
const url = localServer.getBaseUrl();
|
|
400
|
-
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(
|
|
418
|
+
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
|
|
401
419
|
return res.json();
|
|
402
420
|
}
|
|
403
421
|
async function localApiDelete(endpoint) {
|
|
404
422
|
await ensureLocalServer();
|
|
405
423
|
const url = localServer.getBaseUrl();
|
|
406
|
-
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(
|
|
424
|
+
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
|
|
407
425
|
return res.json();
|
|
408
426
|
}
|
|
409
427
|
async function isDockerRunning() {
|
|
410
428
|
try {
|
|
411
|
-
const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(
|
|
429
|
+
const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3e3) });
|
|
412
430
|
const data = await res.json();
|
|
413
431
|
return data.ok === true;
|
|
414
432
|
} catch {
|
|
@@ -420,20 +438,18 @@ async function resolveScreenshotPath(url) {
|
|
|
420
438
|
if (url.startsWith("file://")) {
|
|
421
439
|
const { fileURLToPath } = await import("url");
|
|
422
440
|
const filePath2 = fileURLToPath(url);
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
return filePath2;
|
|
441
|
+
const fs11 = await import("fs");
|
|
442
|
+
if (fs11.existsSync(filePath2)) return filePath2;
|
|
426
443
|
return null;
|
|
427
444
|
}
|
|
428
445
|
const res = await fetch(url);
|
|
429
|
-
if (!res.ok)
|
|
430
|
-
return null;
|
|
446
|
+
if (!res.ok) return null;
|
|
431
447
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
432
|
-
const
|
|
448
|
+
const fs10 = await import("fs");
|
|
433
449
|
const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
|
|
434
|
-
|
|
450
|
+
fs10.mkdirSync(dir, { recursive: true });
|
|
435
451
|
const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
|
|
436
|
-
|
|
452
|
+
fs10.writeFileSync(filePath, buf);
|
|
437
453
|
return filePath;
|
|
438
454
|
} catch {
|
|
439
455
|
return null;
|
|
@@ -452,24 +468,23 @@ function defaultFile() {
|
|
|
452
468
|
}
|
|
453
469
|
var TTL_DAYS = 14;
|
|
454
470
|
var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
|
|
455
|
-
|
|
456
|
-
class DomainModeStore {
|
|
471
|
+
var DomainModeStore = class {
|
|
457
472
|
data = {};
|
|
458
473
|
filePath;
|
|
459
474
|
constructor(filePath = defaultFile()) {
|
|
460
475
|
this.filePath = filePath;
|
|
461
476
|
this.load();
|
|
462
477
|
}
|
|
478
|
+
/** Get the recommended mode for a domain, or null if unknown/expired */
|
|
463
479
|
getMode(domain) {
|
|
464
480
|
const entry = this.data[domain];
|
|
465
|
-
if (!entry)
|
|
466
|
-
|
|
467
|
-
if (this.isExpired(entry))
|
|
468
|
-
return null;
|
|
481
|
+
if (!entry) return null;
|
|
482
|
+
if (this.isExpired(entry)) return null;
|
|
469
483
|
return entry.mode;
|
|
470
484
|
}
|
|
485
|
+
/** Record a successful access */
|
|
471
486
|
recordSuccess(domain, mode) {
|
|
472
|
-
const now = new Date().toISOString();
|
|
487
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
473
488
|
const existing = this.data[domain];
|
|
474
489
|
this.data[domain] = {
|
|
475
490
|
mode,
|
|
@@ -481,8 +496,9 @@ class DomainModeStore {
|
|
|
481
496
|
};
|
|
482
497
|
this.save();
|
|
483
498
|
}
|
|
499
|
+
/** Record a blocked attempt */
|
|
484
500
|
recordFailure(domain, mode, reason) {
|
|
485
|
-
const now = new Date().toISOString();
|
|
501
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
486
502
|
const existing = this.data[domain];
|
|
487
503
|
this.data[domain] = {
|
|
488
504
|
mode: existing?.mode || mode,
|
|
@@ -494,26 +510,28 @@ class DomainModeStore {
|
|
|
494
510
|
};
|
|
495
511
|
this.save();
|
|
496
512
|
}
|
|
513
|
+
/** Get the next mode to try after a failure, or null if exhausted */
|
|
497
514
|
getNextMode(failedMode, availableModes) {
|
|
498
515
|
const idx = ESCALATION_LADDER.indexOf(failedMode);
|
|
499
|
-
for (let i = idx + 1;i < ESCALATION_LADDER.length; i++) {
|
|
516
|
+
for (let i = idx + 1; i < ESCALATION_LADDER.length; i++) {
|
|
500
517
|
if (availableModes.includes(ESCALATION_LADDER[i])) {
|
|
501
518
|
return ESCALATION_LADDER[i];
|
|
502
519
|
}
|
|
503
520
|
}
|
|
504
521
|
return null;
|
|
505
522
|
}
|
|
523
|
+
/** Get the best starting mode: domain memory > first available in ladder */
|
|
506
524
|
getBestMode(domain, availableModes) {
|
|
507
525
|
const remembered = this.getMode(domain);
|
|
508
526
|
if (remembered && availableModes.includes(remembered)) {
|
|
509
527
|
return remembered;
|
|
510
528
|
}
|
|
511
529
|
for (const mode of ESCALATION_LADDER) {
|
|
512
|
-
if (availableModes.includes(mode))
|
|
513
|
-
return mode;
|
|
530
|
+
if (availableModes.includes(mode)) return mode;
|
|
514
531
|
}
|
|
515
532
|
return "headless";
|
|
516
533
|
}
|
|
534
|
+
/** Get summary stats for the status tool */
|
|
517
535
|
getSummary() {
|
|
518
536
|
const entries = Object.entries(this.data).filter(([, e]) => !this.isExpired(e)).sort(([, a], [, b]) => b.lastSuccess.localeCompare(a.lastSuccess));
|
|
519
537
|
return {
|
|
@@ -522,10 +540,9 @@ class DomainModeStore {
|
|
|
522
540
|
};
|
|
523
541
|
}
|
|
524
542
|
isExpired(entry) {
|
|
525
|
-
if (!entry.lastSuccess)
|
|
526
|
-
return true;
|
|
543
|
+
if (!entry.lastSuccess) return true;
|
|
527
544
|
const age = Date.now() - new Date(entry.lastSuccess).getTime();
|
|
528
|
-
return age > TTL_DAYS * 24 * 60 * 60 *
|
|
545
|
+
return age > TTL_DAYS * 24 * 60 * 60 * 1e3;
|
|
529
546
|
}
|
|
530
547
|
load() {
|
|
531
548
|
try {
|
|
@@ -544,81 +561,95 @@ class DomainModeStore {
|
|
|
544
561
|
log3.error("Failed to save:", err2);
|
|
545
562
|
}
|
|
546
563
|
}
|
|
547
|
-
}
|
|
564
|
+
};
|
|
548
565
|
|
|
549
566
|
// src/mcp/tools/status.ts
|
|
550
|
-
function registerStatusTool(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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 () => {
|
|
556
573
|
try {
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
status.localServer = false;
|
|
561
|
-
}
|
|
562
|
-
try {
|
|
563
|
-
const browserHealth = await localApiGet("/browser/health");
|
|
564
|
-
status.browserAlive = browserHealth.alive;
|
|
565
|
-
status.runningModes = browserHealth.modes;
|
|
566
|
-
} catch {}
|
|
567
|
-
if (dockerRunning) {
|
|
574
|
+
const status = {};
|
|
575
|
+
const dockerRunning = await isDockerRunning();
|
|
576
|
+
status.dockerApi = dockerRunning;
|
|
568
577
|
try {
|
|
569
|
-
const
|
|
570
|
-
status.
|
|
571
|
-
} catch {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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)}`);
|
|
578
615
|
}
|
|
579
|
-
try {
|
|
580
|
-
const domainModes = new DomainModeStore;
|
|
581
|
-
status.domainMemory = domainModes.getSummary();
|
|
582
|
-
} catch {}
|
|
583
|
-
return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
|
|
584
|
-
} catch (e) {
|
|
585
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
586
616
|
}
|
|
587
|
-
|
|
617
|
+
);
|
|
588
618
|
}
|
|
589
619
|
|
|
590
620
|
// src/mcp/tools/browse.ts
|
|
591
621
|
var import_zod = require("zod");
|
|
592
|
-
function registerBrowseTool(
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
+
}
|
|
620
651
|
}
|
|
621
|
-
|
|
652
|
+
);
|
|
622
653
|
}
|
|
623
654
|
|
|
624
655
|
// src/mcp/tools/execute.ts
|
|
@@ -629,219 +660,79 @@ var import_zod2 = require("zod");
|
|
|
629
660
|
var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
630
661
|
import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
|
|
631
662
|
import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
|
|
632
|
-
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'
|
|
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.") }),
|
|
633
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() }),
|
|
634
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() }),
|
|
635
|
-
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.") }),
|
|
636
667
|
import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
|
|
637
668
|
import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
|
|
638
669
|
import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
|
|
639
670
|
import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
|
|
640
|
-
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") }),
|
|
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)") }),
|
|
641
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() }),
|
|
642
|
-
import_zod2.z.object({ type: import_zod2.z.literal("read"), selector: import_zod2.z.string().optional().describe("Element to read
|
|
643
|
-
import_zod2.z.object({ type: import_zod2.z.literal("upload"), selector: import_zod2.z.string().describe("The <input type=file>
|
|
644
|
-
import_zod2.z.object({ type: import_zod2.z.literal("paste"), selector: import_zod2.z.string().optional().describe("Field to paste the OS clipboard into
|
|
645
|
-
import_zod2.z.object({ type: import_zod2.z.literal("download"), url: import_zod2.z.string().describe("URL
|
|
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>)") }),
|
|
646
678
|
import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
|
|
647
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() }),
|
|
648
680
|
import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
|
|
649
|
-
import_zod2.z.object({ type: import_zod2.z.literal("screenshot"), annotate: import_zod2.z.boolean().optional().describe("Overlay numbered badges on interactive elements
|
|
650
|
-
import_zod2.z.object({ type: import_zod2.z.literal("snapshot"), interactiveOnly: import_zod2.z.boolean().optional().describe("Only
|
|
651
|
-
import_zod2.z.object({ type: import_zod2.z.literal("find"), role: import_zod2.z.string().optional().describe("ARIA role: button, link, textbox
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha
|
|
655
|
-
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-info") }),
|
|
656
|
-
import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-solve") }),
|
|
657
|
-
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") })
|
|
658
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
|
+
}
|
|
659
697
|
|
|
660
|
-
// src/
|
|
698
|
+
// src/lib/format-result.ts
|
|
661
699
|
function resultOf(r, _type) {
|
|
662
700
|
return r.result;
|
|
663
701
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
|
|
681
|
-
|
|
682
|
-
IMPORTANT — Saved anchors (@a:<name>): Selector fields also accept persisted, per-domain anchors from the \`remember\` tool. Call \`remember get <domain>\` before a UI task; if an anchor exists, target it directly (e.g. selector="@a:composer") — no snapshot needed. After finding a new element that works, \`remember save\` it. Unlike @e refs (which reset every snapshot), @a: anchors persist across runs.
|
|
683
|
-
|
|
684
|
-
FILLING FORMS: Always use the \`fill\` step for text inputs/textareas — NOT \`evaluate\` to set .value. \`fill\` is framework-aware: it fires the React-safe native setter plus input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) actually register the value and mark the field "touched". This prevents the common "I filled every field but submit still says they're required/empty" failure — which is a form-framework state issue, not a real empty field. If a submit is still rejected as incomplete after filling, re-run \`fill\` on the flagged field (it re-triggers the blur/validation) rather than assuming the value didn't land.
|
|
685
|
-
|
|
686
|
-
Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
|
|
687
|
-
steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
|
|
688
|
-
options: import_zod3.z.object({
|
|
689
|
-
staleTimeoutMs: import_zod3.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
690
|
-
screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
|
|
691
|
-
continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
692
|
-
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
693
|
-
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
|
|
694
|
-
mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates. Use 'extension' ONLY to drive a tab in the user's real Chrome via the iframer extension — requires options.tabId (get it from the `tabs` tool)."),
|
|
695
|
-
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
696
|
-
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."),
|
|
697
|
-
tabId: import_zod3.z.number().optional().describe("Only with mode='extension': the id of the real Chrome tab to drive (from the `tabs` tool). Input is trusted OS-level (chrome.debugger); Chrome shows its 'is being debugged' bar while the run is in progress."),
|
|
698
|
-
clientId: import_zod3.z.string().optional().describe("Only with mode='extension', and only needed when several profiles/browsers are connected and a tab is ambiguous: the clientId of the profile that owns the tab (from the `tabs` tool)."),
|
|
699
|
-
focus: import_zod3.z.boolean().optional().describe("Only with mode='extension': bring the tab's window to the OS foreground while driving. Default false — the tab is driven in the background (activated in its window, focus-emulated) without interrupting the user. Set true only if a site ignores background input.")
|
|
700
|
-
}).optional()
|
|
701
|
-
}, async (params) => {
|
|
702
|
-
try {
|
|
703
|
-
if (params.options?.mode === "extension") {
|
|
704
|
-
const tabId = params.options?.tabId;
|
|
705
|
-
if (typeof tabId !== "number") {
|
|
706
|
-
return err("mode='extension' requires options.tabId. Call the `tabs` tool first to " + "find the id of the tab the user wants to drive.");
|
|
707
|
-
}
|
|
708
|
-
const extResult = await localApiPost("/extension/execute", {
|
|
709
|
-
tabId,
|
|
710
|
-
clientId: params.options?.clientId,
|
|
711
|
-
steps: params.steps,
|
|
712
|
-
options: params.options
|
|
713
|
-
});
|
|
714
|
-
const extLines = formatExecuteResult(extResult);
|
|
715
|
-
const content2 = [{ type: "text", text: extLines.join(`
|
|
716
|
-
`) }];
|
|
717
|
-
if (!extResult.ok)
|
|
718
|
-
return { content: content2, isError: true };
|
|
719
|
-
return { content: content2 };
|
|
720
|
-
}
|
|
721
|
-
const dockerRunning = await isDockerRunning();
|
|
722
|
-
async function runWithMode(mode) {
|
|
723
|
-
if (mode === "docker-headful") {
|
|
724
|
-
if (!dockerRunning) {
|
|
725
|
-
return {
|
|
726
|
-
ok: false,
|
|
727
|
-
completedSteps: 0,
|
|
728
|
-
totalSteps: params.steps.length,
|
|
729
|
-
results: [],
|
|
730
|
-
finalState: { url: "", title: "" },
|
|
731
|
-
obstacles: [],
|
|
732
|
-
durationMs: 0,
|
|
733
|
-
modeUsed: "docker-headful",
|
|
734
|
-
error: {
|
|
735
|
-
failedAtStep: 0,
|
|
736
|
-
failedStep: params.steps[0],
|
|
737
|
-
errorType: "action-failed",
|
|
738
|
-
message: "docker-headful mode was requested but the Docker API is not reachable.",
|
|
739
|
-
pageState: { url: "", title: "" },
|
|
740
|
-
suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
|
|
741
|
-
retryable: false
|
|
742
|
-
}
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
return apiPost("/execute", {
|
|
746
|
-
steps: params.steps,
|
|
747
|
-
options: { ...params.options, mode: "docker-headful", autoEscalate: false }
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
return localApiPost("/execute", {
|
|
751
|
-
steps: params.steps,
|
|
752
|
-
options: { ...params.options, mode: mode || undefined }
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
let execResult;
|
|
756
|
-
try {
|
|
757
|
-
execResult = await runWithMode(params.options?.mode);
|
|
758
|
-
} catch (execErr) {
|
|
759
|
-
const msg = execErr instanceof Error ? execErr.message : String(execErr);
|
|
760
|
-
const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
|
|
761
|
-
if (isCrash) {
|
|
762
|
-
log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
|
|
763
|
-
try {
|
|
764
|
-
await localServer.restart();
|
|
765
|
-
} catch {}
|
|
766
|
-
try {
|
|
767
|
-
execResult = await runWithMode(params.options?.mode);
|
|
768
|
-
} catch (retryErr) {
|
|
769
|
-
return err(`Browser server crashed and retry also failed.
|
|
770
|
-
` + `Original: ${msg}
|
|
771
|
-
Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
|
|
772
|
-
|
|
773
|
-
` + `Call \`session restart\` to reset, then retry.`);
|
|
774
|
-
}
|
|
775
|
-
} else {
|
|
776
|
-
throw execErr;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
const requestedMode = params.options?.mode;
|
|
780
|
-
if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
|
|
781
|
-
const escalation = ["docker-headful", "binary-headful"];
|
|
782
|
-
for (const nextMode of escalation) {
|
|
783
|
-
if (nextMode === "docker-headful" && !dockerRunning)
|
|
784
|
-
continue;
|
|
785
|
-
log2.info(`Auto-escalating to ${nextMode}`);
|
|
786
|
-
execResult = await runWithMode(nextMode);
|
|
787
|
-
if (execResult.ok)
|
|
788
|
-
break;
|
|
789
|
-
if (execResult.error?.errorType !== "bot-blocked")
|
|
790
|
-
break;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
const lines = formatExecuteResult(execResult);
|
|
794
|
-
let screenshotUrl = null;
|
|
795
|
-
if (execResult.error) {
|
|
796
|
-
screenshotUrl = execResult.error.pageState?.screenshotUrl ?? null;
|
|
797
|
-
} else {
|
|
798
|
-
screenshotUrl = execResult.finalState?.screenshotUrl ?? null;
|
|
799
|
-
}
|
|
800
|
-
if (screenshotUrl) {
|
|
801
|
-
const filePath = await resolveScreenshotPath(screenshotUrl);
|
|
802
|
-
if (filePath) {
|
|
803
|
-
lines.push(`
|
|
804
|
-
Screenshot saved: ${filePath}`);
|
|
805
|
-
lines.push("Use the Read tool on the path above to view the screenshot.");
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
const content = [{ type: "text", text: lines.join(`
|
|
809
|
-
`) }];
|
|
810
|
-
if (!execResult.ok)
|
|
811
|
-
return { content, isError: true };
|
|
812
|
-
return { content };
|
|
813
|
-
} catch (e) {
|
|
814
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
815
|
-
}
|
|
816
|
-
});
|
|
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
|
+
);
|
|
817
717
|
}
|
|
818
718
|
function formatExecuteResult(data) {
|
|
819
719
|
const lines = [];
|
|
820
720
|
lines.push(`ok: ${data.ok}`);
|
|
821
721
|
lines.push(`steps: ${data.completedSteps}/${data.totalSteps}`);
|
|
822
|
-
if (data.durationMs)
|
|
823
|
-
|
|
824
|
-
if (data.modeUsed)
|
|
825
|
-
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)" : ""}`);
|
|
826
724
|
if (data.finalState) {
|
|
827
725
|
lines.push(`
|
|
828
726
|
Final page: ${data.finalState.title}`);
|
|
829
727
|
lines.push(`URL: ${data.finalState.url}`);
|
|
830
728
|
}
|
|
831
|
-
if (data.ok && data.finalState?.url) {
|
|
832
|
-
try {
|
|
833
|
-
const domain = new URL(data.finalState.url).hostname.replace(/^www\./, "");
|
|
834
|
-
lines.push(`
|
|
835
|
-
Knowledge cache updated for ${domain}. Before the next browser run on this domain, call \`knowledge get ${domain}\` — you may be able to skip the browser entirely.`);
|
|
836
|
-
} catch {}
|
|
837
|
-
}
|
|
838
729
|
for (const r of data.results || []) {
|
|
839
730
|
if (r.tabSwitchedTo) {
|
|
840
731
|
lines.push(`
|
|
841
|
-
|
|
732
|
+
\u21B3 step ${r.stepIndex} opened a new tab \u2014 pipeline is now on: ${r.tabSwitchedTo}`);
|
|
842
733
|
}
|
|
843
734
|
}
|
|
844
|
-
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);
|
|
845
736
|
for (const r of meaningful) {
|
|
846
737
|
if (r.step.type === "snapshot") {
|
|
847
738
|
const res = resultOf(r, "snapshot");
|
|
@@ -865,7 +756,7 @@ Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchC
|
|
|
865
756
|
}
|
|
866
757
|
} else if (r.step.type === "read") {
|
|
867
758
|
const res = r.result;
|
|
868
|
-
if (res?.text !==
|
|
759
|
+
if (res?.text !== void 0) {
|
|
869
760
|
lines.push(`
|
|
870
761
|
--- Read (step ${r.stepIndex}${res.truncated ? ", truncated" : ""}) ---`);
|
|
871
762
|
lines.push(res.text);
|
|
@@ -876,15 +767,13 @@ step ${r.stepIndex} (${r.step.type}): ${JSON.stringify(r.result)}`);
|
|
|
876
767
|
}
|
|
877
768
|
}
|
|
878
769
|
if (data.obstacles && data.obstacles.length > 0) {
|
|
879
|
-
lines.push(
|
|
880
|
-
Obstacles handled:`);
|
|
770
|
+
lines.push("\nObstacles handled:");
|
|
881
771
|
for (const o of data.obstacles) {
|
|
882
772
|
lines.push(` [step ${o.detectedAtStep}] ${o.type}: ${o.resolved ? o.resolution : "UNRESOLVED - " + (o.resolution || "unknown")}`);
|
|
883
773
|
}
|
|
884
774
|
}
|
|
885
775
|
if (data.capturedApi && data.capturedApi.length > 0) {
|
|
886
|
-
lines.push(
|
|
887
|
-
--- Captured API ---`);
|
|
776
|
+
lines.push("\n--- Captured API ---");
|
|
888
777
|
for (const api of data.capturedApi) {
|
|
889
778
|
lines.push(`
|
|
890
779
|
${api.domain} (${api.baseUrl})`);
|
|
@@ -895,8 +784,7 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
895
784
|
}
|
|
896
785
|
}
|
|
897
786
|
if (data.error) {
|
|
898
|
-
lines.push(
|
|
899
|
-
--- Failure ---`);
|
|
787
|
+
lines.push("\n--- Failure ---");
|
|
900
788
|
if (typeof data.error === "string") {
|
|
901
789
|
lines.push(`Error: ${data.error}`);
|
|
902
790
|
} else {
|
|
@@ -904,15 +792,173 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
904
792
|
lines.push(`Error type: ${data.error.errorType}`);
|
|
905
793
|
lines.push(`Message: ${data.error.message}`);
|
|
906
794
|
lines.push(`Retryable: ${data.error.retryable}`);
|
|
907
|
-
if (data.error.suggestion)
|
|
908
|
-
|
|
909
|
-
if (data.error.pageState?.url)
|
|
910
|
-
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}`);
|
|
911
797
|
}
|
|
912
798
|
}
|
|
799
|
+
if (captchaBlocked(data)) {
|
|
800
|
+
lines.push(RECAPTCHA_MANUAL);
|
|
801
|
+
}
|
|
913
802
|
return lines;
|
|
914
803
|
}
|
|
915
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
|
+
|
|
916
962
|
// src/mcp/tools/session.ts
|
|
917
963
|
var import_zod5 = require("zod");
|
|
918
964
|
var import_path7 = __toESM(require("path"));
|
|
@@ -920,165 +966,121 @@ var import_fs5 = __toESM(require("fs"));
|
|
|
920
966
|
|
|
921
967
|
// src/mcp/tools/reverse-engineer.ts
|
|
922
968
|
var import_zod4 = require("zod");
|
|
923
|
-
function registerReverseEngineerTool(
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
Use
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
Output layout — save as RUNNABLE CODE to <outputDir>/:
|
|
948
|
-
auth.{js,ts} — shared cookies, tokens, authorization
|
|
949
|
-
transport/
|
|
950
|
-
rest.{js,ts} — shared REST helper (only if any rest endpoints)
|
|
951
|
-
graphql.{js,ts} — shared GraphQL client: post(operationName|docId, variables)
|
|
952
|
-
jsonRpc.{js,ts} — shared JSON-RPC client (if any)
|
|
953
|
-
grpc.{js,ts} — shared gRPC-web client (if any)
|
|
954
|
-
<protocol>/<verb>/<functionName>.{js,ts}
|
|
955
|
-
e.g. graphql/queries/getTimelineFeed.ts
|
|
956
|
-
graphql/mutations/reactToPost.ts
|
|
957
|
-
rest/read/getChannelMessages.ts
|
|
958
|
-
rest/create/createMessage.ts
|
|
959
|
-
jsonRpc/ethGetBalance.ts
|
|
960
|
-
index.{js,ts} — re-exports all endpoint functions
|
|
961
|
-
types.ts — (typed mode only) inferred interfaces from responses
|
|
962
|
-
README.md — endpoints grouped by protocol + verb, dependency chain, auth expiry warning
|
|
963
|
-
|
|
964
|
-
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.
|
|
965
|
-
|
|
966
|
-
The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`, {
|
|
967
|
-
steps: import_zod4.z.array(stepSchema).describe("Pipeline steps to execute while capturing API calls"),
|
|
968
|
-
outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
|
|
969
|
-
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."),
|
|
970
|
-
options: import_zod4.z.object({
|
|
971
|
-
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
972
|
-
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
973
|
-
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
974
|
-
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("Browser mode override. Use 'extension' to capture the API of a tab already open in the user's real Chrome — requires options.tabId from the `tabs` tool. Chrome shows its 'is being debugged' bar while the capture runs."),
|
|
975
|
-
tabId: import_zod4.z.number().optional().describe("Only with mode='extension': the real Chrome tab to reverse-engineer (from the `tabs` tool)."),
|
|
976
|
-
clientId: import_zod4.z.string().optional().describe("Only with mode='extension', when multiple profiles are connected and the tab is ambiguous: the owning profile's clientId (from the `tabs` tool).")
|
|
977
|
-
}).optional()
|
|
978
|
-
}, async (params) => {
|
|
979
|
-
try {
|
|
980
|
-
const execParams = {
|
|
981
|
-
steps: params.steps,
|
|
982
|
-
options: { ...params.options, captureApi: true }
|
|
983
|
-
};
|
|
984
|
-
const mode = params.options?.mode;
|
|
985
|
-
const dockerRunning = await isDockerRunning();
|
|
986
|
-
let captureResult;
|
|
987
|
-
if (mode === "extension") {
|
|
988
|
-
if (typeof params.options?.tabId !== "number") {
|
|
989
|
-
return err("mode='extension' requires options.tabId. Call the `tabs` tool first to find the tab to reverse-engineer.");
|
|
990
|
-
}
|
|
991
|
-
captureResult = await localApiPost("/extension/execute", {
|
|
992
|
-
tabId: params.options.tabId,
|
|
993
|
-
clientId: params.options.clientId,
|
|
994
|
-
steps: params.steps,
|
|
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,
|
|
995
993
|
options: { ...params.options, captureApi: true }
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
|
|
1013
|
-
formatCapturedApi(lines, captureResult.capturedApi, params);
|
|
1014
|
-
} else {
|
|
1015
|
-
lines.push(`
|
|
1016
|
-
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.`);
|
|
1017
|
-
}
|
|
1018
|
-
if (captureResult.error) {
|
|
1019
|
-
lines.push(`
|
|
1020
|
-
--- Failure ---`);
|
|
1021
|
-
if (typeof captureResult.error === "string") {
|
|
1022
|
-
lines.push(`Error: ${captureResult.error}`);
|
|
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);
|
|
1023
1010
|
} else {
|
|
1024
|
-
|
|
1025
|
-
lines.push(`Error type: ${captureResult.error.errorType}`);
|
|
1026
|
-
lines.push(`Message: ${captureResult.error.message}`);
|
|
1027
|
-
if (captureResult.error.suggestion)
|
|
1028
|
-
lines.push(`Suggestion: ${captureResult.error.suggestion}`);
|
|
1011
|
+
captureResult = await localApiPost("/execute", execParams);
|
|
1029
1012
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
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) {
|
|
1035
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(`
|
|
1036
1043
|
Screenshot saved: ${filePath}`);
|
|
1037
|
-
|
|
1044
|
+
lines.push("Use the Read tool on the path above to view the screenshot.");
|
|
1045
|
+
}
|
|
1038
1046
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
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(`
|
|
1050
1057
|
Full captured data saved to: ${jsonPath}`);
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1058
|
+
lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
|
|
1059
|
+
} catch (writeErr) {
|
|
1060
|
+
lines.push(`
|
|
1054
1061
|
(Could not save captured JSON: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
|
|
1062
|
+
}
|
|
1055
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.`);
|
|
1056
1073
|
}
|
|
1057
|
-
let text = lines.join(`
|
|
1058
|
-
`);
|
|
1059
|
-
if (text.length > 30000) {
|
|
1060
|
-
text = text.slice(0, 30000) + `
|
|
1061
|
-
|
|
1062
|
-
[... index truncated — read captured-api.json for the full endpoint list and all detail]`;
|
|
1063
|
-
}
|
|
1064
|
-
const content = [{ type: "text", text }];
|
|
1065
|
-
if (!captureResult.ok)
|
|
1066
|
-
return { content, isError: true };
|
|
1067
|
-
return { content };
|
|
1068
|
-
} catch (e) {
|
|
1069
|
-
return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
|
|
1070
1074
|
}
|
|
1071
|
-
|
|
1075
|
+
);
|
|
1072
1076
|
}
|
|
1073
1077
|
function formatCapturedApi(lines, capturedApi, params) {
|
|
1074
1078
|
for (const api of capturedApi) {
|
|
1075
1079
|
lines.push(`
|
|
1076
|
-
|
|
1080
|
+
\u2501\u2501\u2501 ${api.domain} (${api.baseUrl}) \u2501\u2501\u2501`);
|
|
1077
1081
|
const authParts = [];
|
|
1078
|
-
if (api.auth?.authorization)
|
|
1079
|
-
|
|
1080
|
-
if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0)
|
|
1081
|
-
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`);
|
|
1082
1084
|
if (api.auth?.tokens && Object.keys(api.auth.tokens).length > 0) {
|
|
1083
1085
|
for (const [k, v] of Object.entries(api.auth.tokens)) {
|
|
1084
1086
|
authParts.push(`${k}: ${String(v).slice(0, 30)}...`);
|
|
@@ -1087,69 +1089,62 @@ function formatCapturedApi(lines, capturedApi, params) {
|
|
|
1087
1089
|
if (authParts.length > 0) {
|
|
1088
1090
|
lines.push(`
|
|
1089
1091
|
Auth:`);
|
|
1090
|
-
for (const part of authParts)
|
|
1091
|
-
lines.push(` ${part}`);
|
|
1092
|
+
for (const part of authParts) lines.push(` ${part}`);
|
|
1092
1093
|
}
|
|
1093
|
-
const byProtocol = new Map;
|
|
1094
|
+
const byProtocol = /* @__PURE__ */ new Map();
|
|
1094
1095
|
for (const ep of api.endpoints) {
|
|
1095
1096
|
const p = ep.protocol || "rest";
|
|
1096
|
-
if (!byProtocol.has(p))
|
|
1097
|
-
byProtocol.set(p, []);
|
|
1097
|
+
if (!byProtocol.has(p)) byProtocol.set(p, []);
|
|
1098
1098
|
byProtocol.get(p).push(ep);
|
|
1099
1099
|
}
|
|
1100
1100
|
const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
|
|
1101
1101
|
lines.push(`
|
|
1102
|
-
Endpoints (${api.endpoints.length}) [${protocolSummary}]
|
|
1102
|
+
Endpoints (${api.endpoints.length}) [${protocolSummary}] \u2014 full detail in captured-api.json:`);
|
|
1103
1103
|
for (const ep of api.endpoints) {
|
|
1104
1104
|
const params2 = ep.requestBody ? extractSignalKeys(ep.requestBody) : null;
|
|
1105
1105
|
const tail = params2 ? ` {${params2}}` : "";
|
|
1106
|
-
lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path}
|
|
1106
|
+
lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path} \u2192 ${ep.functionName}${tail}`);
|
|
1107
1107
|
}
|
|
1108
1108
|
}
|
|
1109
1109
|
const mainDomain = capturedApi[0]?.domain || "api";
|
|
1110
1110
|
const dir = params.outputDir || `./${mainDomain}`;
|
|
1111
1111
|
const ext = params.typed ? ".ts" : ".js";
|
|
1112
1112
|
lines.push(`
|
|
1113
|
-
|
|
1113
|
+
\u2501\u2501\u2501 Save instructions \u2501\u2501\u2501`);
|
|
1114
1114
|
lines.push(`Save to: ${dir}`);
|
|
1115
1115
|
lines.push(`Format: ${ext} files`);
|
|
1116
|
-
const protocolsSeen = new Set;
|
|
1117
|
-
for (const api of capturedApi)
|
|
1118
|
-
for (const ep of api.endpoints)
|
|
1119
|
-
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);
|
|
1120
1118
|
lines.push(`
|
|
1121
1119
|
Layout (protocols present: ${Array.from(protocolsSeen).join(", ")}):`);
|
|
1122
1120
|
lines.push(` auth${ext}`);
|
|
1123
|
-
if (protocolsSeen.has("rest"))
|
|
1124
|
-
|
|
1125
|
-
if (protocolsSeen.has("
|
|
1126
|
-
|
|
1127
|
-
if (protocolsSeen.has("
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
lines.push(` transport/grpc${ext} — shared gRPC-web client`);
|
|
1131
|
-
if (protocolsSeen.has("form-rpc"))
|
|
1132
|
-
lines.push(` transport/formRpc${ext} — shared urlencoded RPC client`);
|
|
1133
|
-
if (protocolsSeen.has("soap"))
|
|
1134
|
-
lines.push(` transport/soap${ext} — shared SOAP client`);
|
|
1135
|
-
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`);
|
|
1136
1128
|
lines.push(` e.g. graphql/queries/<fn>${ext}, graphql/mutations/<fn>${ext}, rest/read/<fn>${ext}, rest/create/<fn>${ext}`);
|
|
1137
|
-
lines.push(` index${ext}
|
|
1138
|
-
if (params.typed)
|
|
1139
|
-
|
|
1140
|
-
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.`);
|
|
1141
1136
|
lines.push(`
|
|
1142
1137
|
Rules:`);
|
|
1143
|
-
lines.push(` - One function per (protocol, action). Use the functionName field verbatim for the file + export name.`);
|
|
1144
|
-
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).`);
|
|
1145
1140
|
lines.push(` - Each endpoint file is minimal: import transport + auth, call transport with the action id, pass variables, return typed result.`);
|
|
1146
1141
|
lines.push(` - GraphQL transport signature: post(opNameOrDocId: string, variables: object). Pick doc_id when present in captured body, else operationName.`);
|
|
1147
|
-
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.`);
|
|
1148
1143
|
lines.push(`
|
|
1149
|
-
IMPORTANT: Auth data contains real tokens/cookies
|
|
1144
|
+
IMPORTANT: Auth data contains real tokens/cookies \u2014 they expire. Remind the user.`);
|
|
1150
1145
|
}
|
|
1151
1146
|
function extractSignalKeys(body) {
|
|
1152
|
-
const NOISE = new Set([
|
|
1147
|
+
const NOISE = /* @__PURE__ */ new Set([
|
|
1153
1148
|
"__csr",
|
|
1154
1149
|
"__hsdp",
|
|
1155
1150
|
"__hblp",
|
|
@@ -1176,13 +1171,12 @@ function extractSignalKeys(body) {
|
|
|
1176
1171
|
]);
|
|
1177
1172
|
const signalEntries = [];
|
|
1178
1173
|
for (const [k, v] of Object.entries(body)) {
|
|
1179
|
-
if (NOISE.has(k))
|
|
1180
|
-
continue;
|
|
1174
|
+
if (NOISE.has(k)) continue;
|
|
1181
1175
|
if (typeof v === "string" && v.length > 80) {
|
|
1182
1176
|
signalEntries.push(`${k}=${v.slice(0, 40)}...`);
|
|
1183
1177
|
} else if (Array.isArray(v)) {
|
|
1184
1178
|
const items = v.slice(0, 8).map((x) => String(x)).join(",");
|
|
1185
|
-
signalEntries.push(`${k}=[${items}${v.length > 8 ? "
|
|
1179
|
+
signalEntries.push(`${k}=[${items}${v.length > 8 ? ",\u2026" : ""}]`);
|
|
1186
1180
|
} else if (typeof v === "object" && v !== null) {
|
|
1187
1181
|
const keys = Object.keys(v).slice(0, 5).join(",");
|
|
1188
1182
|
signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
|
|
@@ -1194,135 +1188,123 @@ function extractSignalKeys(body) {
|
|
|
1194
1188
|
}
|
|
1195
1189
|
|
|
1196
1190
|
// src/mcp/tools/session.ts
|
|
1197
|
-
function registerSessionTool(
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
}, async ({ action, mode, urls, instanceId, outputDir }) => {
|
|
1216
|
-
try {
|
|
1217
|
-
if (action === "stop") {
|
|
1218
|
-
const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
|
|
1219
|
-
return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
|
|
1220
|
-
}
|
|
1221
|
-
if (action === "clear") {
|
|
1222
|
-
await localApiDelete("/session").catch(() => {});
|
|
1223
|
-
return { content: [{ type: "text", text: "Session data cleared from database." }] };
|
|
1224
|
-
}
|
|
1225
|
-
if (action === "restart") {
|
|
1226
|
-
const parts = [];
|
|
1227
|
-
try {
|
|
1228
|
-
await localApiPost("/browser/restart");
|
|
1229
|
-
parts.push("Local browser restarted.");
|
|
1230
|
-
} catch {
|
|
1231
|
-
try {
|
|
1232
|
-
await localServer.restart();
|
|
1233
|
-
parts.push("Local server respawned.");
|
|
1234
|
-
} catch (e) {
|
|
1235
|
-
parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1236
|
-
}
|
|
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}` }] };
|
|
1237
1209
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
+
}
|
|
1242
1227
|
}
|
|
1243
|
-
} catch {}
|
|
1244
|
-
parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
|
|
1245
|
-
return { content: [{ type: "text", text: parts.join(" ") }] };
|
|
1246
|
-
}
|
|
1247
|
-
if (action === "capture-start") {
|
|
1248
|
-
const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
|
|
1249
|
-
return { content: [{ type: "text", text: result.message }] };
|
|
1250
|
-
}
|
|
1251
|
-
if (action === "capture-stop") {
|
|
1252
|
-
const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
|
|
1253
|
-
const lines = [result.message];
|
|
1254
|
-
if (result.capturedApi && result.capturedApi.length > 0) {
|
|
1255
|
-
formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
|
|
1256
|
-
const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
|
|
1257
1228
|
try {
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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(`
|
|
1262
1253
|
Full data saved to: ${jsonPath}`);
|
|
1263
|
-
|
|
1264
|
-
|
|
1254
|
+
} catch (writeErr) {
|
|
1255
|
+
lines.push(`
|
|
1265
1256
|
(Could not save: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
|
|
1257
|
+
}
|
|
1266
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 }] };
|
|
1267
1262
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
}
|
|
1276
|
-
if (action === "get-cookies") {
|
|
1277
|
-
const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1278
|
-
const lines = [result.message, ""];
|
|
1279
|
-
for (const c of result.cookies) {
|
|
1280
|
-
lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
|
|
1281
|
-
}
|
|
1282
|
-
return { content: [{ type: "text", text: lines.join(`
|
|
1283
|
-
`) }] };
|
|
1284
|
-
}
|
|
1285
|
-
if (action === "get-auth") {
|
|
1286
|
-
const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1287
|
-
const lines = [result.message, ""];
|
|
1288
|
-
lines.push("=== Cookies ===");
|
|
1289
|
-
for (const c of result.cookies) {
|
|
1290
|
-
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") }] };
|
|
1291
1270
|
}
|
|
1292
|
-
if (
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
+
}
|
|
1300
1286
|
}
|
|
1301
1287
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
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
|
+
}
|
|
1311
1296
|
}
|
|
1312
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 }] };
|
|
1313
1301
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
text = text.slice(0, 80000) + `
|
|
1318
|
-
[truncated]`;
|
|
1319
|
-
return { content: [{ type: "text", text }] };
|
|
1302
|
+
return err("Unknown action");
|
|
1303
|
+
} catch (e) {
|
|
1304
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
1320
1305
|
}
|
|
1321
|
-
return err("Unknown action");
|
|
1322
|
-
} catch (e) {
|
|
1323
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
1324
1306
|
}
|
|
1325
|
-
|
|
1307
|
+
);
|
|
1326
1308
|
}
|
|
1327
1309
|
|
|
1328
1310
|
// src/mcp/tools/credentials.ts
|
|
@@ -1343,15 +1325,15 @@ function getKnowledgePath(domain) {
|
|
|
1343
1325
|
}
|
|
1344
1326
|
function normalizeDomain(input) {
|
|
1345
1327
|
let d = (input || "").trim().toLowerCase();
|
|
1346
|
-
if (!d)
|
|
1347
|
-
return "";
|
|
1328
|
+
if (!d) return "";
|
|
1348
1329
|
try {
|
|
1349
1330
|
if (d.includes("://")) {
|
|
1350
1331
|
d = new URL(d).hostname;
|
|
1351
1332
|
} else if (d.includes("/")) {
|
|
1352
1333
|
d = new URL(`https://${d}`).hostname;
|
|
1353
1334
|
}
|
|
1354
|
-
} catch {
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1355
1337
|
d = d.replace(/:\d+$/, "");
|
|
1356
1338
|
d = d.replace(/^www\./, "");
|
|
1357
1339
|
return d;
|
|
@@ -1378,8 +1360,7 @@ function listKnowledge() {
|
|
|
1378
1360
|
}
|
|
1379
1361
|
const results = [];
|
|
1380
1362
|
for (const file of entries) {
|
|
1381
|
-
if (!file.endsWith(".md"))
|
|
1382
|
-
continue;
|
|
1363
|
+
if (!file.endsWith(".md")) continue;
|
|
1383
1364
|
const full = import_path8.default.join(dir, file);
|
|
1384
1365
|
try {
|
|
1385
1366
|
const stat = import_fs6.default.statSync(full);
|
|
@@ -1391,7 +1372,8 @@ function listKnowledge() {
|
|
|
1391
1372
|
lastMode: parsed?.lastMode ?? "unknown",
|
|
1392
1373
|
sizeBytes: stat.size
|
|
1393
1374
|
});
|
|
1394
|
-
} catch {
|
|
1375
|
+
} catch {
|
|
1376
|
+
}
|
|
1395
1377
|
}
|
|
1396
1378
|
results.sort((a, b) => a.lastVerified < b.lastVerified ? 1 : -1);
|
|
1397
1379
|
return results;
|
|
@@ -1415,22 +1397,21 @@ function clearKnowledge(domain) {
|
|
|
1415
1397
|
try {
|
|
1416
1398
|
import_fs6.default.unlinkSync(import_path8.default.join(dir, f));
|
|
1417
1399
|
removed++;
|
|
1418
|
-
} catch {
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1419
1402
|
}
|
|
1420
1403
|
}
|
|
1421
|
-
} catch {
|
|
1404
|
+
} catch {
|
|
1405
|
+
}
|
|
1422
1406
|
return { removed };
|
|
1423
1407
|
}
|
|
1424
1408
|
function parseMarkdown(raw) {
|
|
1425
1409
|
const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
1426
|
-
if (!frontmatterMatch)
|
|
1427
|
-
return null;
|
|
1410
|
+
if (!frontmatterMatch) return null;
|
|
1428
1411
|
const fm = {};
|
|
1429
|
-
for (const line of frontmatterMatch[1].split(
|
|
1430
|
-
`)) {
|
|
1412
|
+
for (const line of frontmatterMatch[1].split("\n")) {
|
|
1431
1413
|
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
1432
|
-
if (m)
|
|
1433
|
-
fm[m[1]] = m[2].trim();
|
|
1414
|
+
if (m) fm[m[1]] = m[2].trim();
|
|
1434
1415
|
}
|
|
1435
1416
|
const body = raw.slice(frontmatterMatch[0].length);
|
|
1436
1417
|
const auth = { type: fm.authType ?? "unknown" };
|
|
@@ -1453,11 +1434,9 @@ function parseMarkdown(raw) {
|
|
|
1453
1434
|
const notes = [];
|
|
1454
1435
|
const notesSection = extractSection(body, "Notes");
|
|
1455
1436
|
if (notesSection) {
|
|
1456
|
-
for (const line of notesSection.split(
|
|
1457
|
-
`)) {
|
|
1437
|
+
for (const line of notesSection.split("\n")) {
|
|
1458
1438
|
const m = line.match(/^-\s+(.+)$/);
|
|
1459
|
-
if (m)
|
|
1460
|
-
notes.push(m[1].trim());
|
|
1439
|
+
if (m) notes.push(m[1].trim());
|
|
1461
1440
|
}
|
|
1462
1441
|
}
|
|
1463
1442
|
return {
|
|
@@ -1473,8 +1452,7 @@ function parseMarkdown(raw) {
|
|
|
1473
1452
|
function extractSection(body, heading) {
|
|
1474
1453
|
const re = new RegExp(`^##\\s+${heading}\\s*$`, "m");
|
|
1475
1454
|
const match = body.match(re);
|
|
1476
|
-
if (!match || match.index ===
|
|
1477
|
-
return null;
|
|
1455
|
+
if (!match || match.index === void 0) return null;
|
|
1478
1456
|
const start = match.index + match[0].length;
|
|
1479
1457
|
const nextSection = body.slice(start).match(/^##\s+/m);
|
|
1480
1458
|
const end = nextSection?.index != null ? start + nextSection.index : body.length;
|
|
@@ -1482,360 +1460,327 @@ function extractSection(body, heading) {
|
|
|
1482
1460
|
}
|
|
1483
1461
|
function extractBackticks(re, text) {
|
|
1484
1462
|
const m = text.match(re);
|
|
1485
|
-
if (!m)
|
|
1486
|
-
return;
|
|
1463
|
+
if (!m) return void 0;
|
|
1487
1464
|
const items = [...m[1].matchAll(/`([^`]+)`/g)].map((x) => x[1]);
|
|
1488
|
-
return items.length > 0 ? items :
|
|
1465
|
+
return items.length > 0 ? items : void 0;
|
|
1489
1466
|
}
|
|
1490
1467
|
|
|
1491
1468
|
// src/mcp/tools/credentials.ts
|
|
1492
|
-
var ELICIT_TIMEOUT_MS =
|
|
1469
|
+
var ELICIT_TIMEOUT_MS = 45e3;
|
|
1493
1470
|
function mcpLog(event, data) {
|
|
1494
1471
|
try {
|
|
1495
1472
|
const dir = getDataDir();
|
|
1496
1473
|
import_fs7.default.mkdirSync(dir, { recursive: true });
|
|
1497
|
-
import_fs7.default.appendFileSync(
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
+
}
|
|
1500
1480
|
}
|
|
1501
1481
|
function domainMatches(normalized, stored) {
|
|
1502
|
-
return stored.some(
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
3. **NEVER confabulate.** If action=list returns "No credentials stored", the database is empty.
|
|
1522
|
-
|
|
1523
|
-
4. **NEVER pretend a store call succeeded if the response was an error.**
|
|
1524
|
-
|
|
1525
|
-
5. **NEVER ask the user to paste their password in chat.**
|
|
1526
|
-
|
|
1527
|
-
6. **\`force: true\` on store is ONLY for explicit password changes.**`, {
|
|
1528
|
-
action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
|
|
1529
|
-
domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
|
|
1530
|
-
force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
|
|
1531
|
-
}, async ({ action, domain, force }) => {
|
|
1532
|
-
try {
|
|
1533
|
-
if (action === "list") {
|
|
1534
|
-
const credData2 = await localApiGet("/credentials");
|
|
1535
|
-
const domains = credData2.domains || [];
|
|
1536
|
-
if (!domains.length) {
|
|
1537
|
-
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." }] };
|
|
1538
|
-
}
|
|
1539
|
-
return { content: [{ type: "text", text: `Stored credentials for:
|
|
1540
|
-
${domains.map((d) => ` - ${d}`).join(`
|
|
1541
|
-
`)}` }] };
|
|
1542
|
-
}
|
|
1543
|
-
if (action !== "store")
|
|
1544
|
-
return err("Unknown action");
|
|
1545
|
-
if (!domain)
|
|
1546
|
-
return err("domain is required for action=store");
|
|
1547
|
-
const normalized = normalizeDomain(domain);
|
|
1548
|
-
const credData = await localApiGet("/credentials");
|
|
1549
|
-
const stored = credData.domains || [];
|
|
1550
|
-
if (domainMatches(normalized, stored) && !force) {
|
|
1551
|
-
mcpLog("credentials.store.rejected_already_exists", { domain: normalized });
|
|
1552
|
-
return err(`REFUSING TO RE-STORE: credentials for "${normalized}" already exist. ` + `Login failures are NOT credential problems — retry with a stronger browser mode.
|
|
1553
|
-
|
|
1554
|
-
` + `Use \`force: true\` ONLY if the user explicitly says their password changed.`);
|
|
1555
|
-
}
|
|
1556
|
-
mcpLog("credentials.store.attempt", { domain: normalized, force: !!force });
|
|
1557
|
-
let result;
|
|
1482
|
+
return stored.some(
|
|
1483
|
+
(d) => d === normalized || normalized.endsWith("." + d) || d.endsWith("." + normalized)
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
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 }) => {
|
|
1558
1500
|
try {
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
type: "
|
|
1564
|
-
properties: {
|
|
1565
|
-
username: { type: "string", title: "Username / Email" },
|
|
1566
|
-
password: { type: "string", title: "Password" },
|
|
1567
|
-
totp_secret: { type: "string", title: "TOTP Secret (leave empty if no 2FA)" }
|
|
1568
|
-
},
|
|
1569
|
-
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." }] };
|
|
1570
1506
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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)}`);
|
|
1606
1581
|
}
|
|
1607
|
-
mcpLog("credentials.store.success", { domain: normalized });
|
|
1608
|
-
return { content: [{ type: "text", text: `Credentials stored for ${normalized}. Shared across all browser modes.` }] };
|
|
1609
|
-
} catch (e) {
|
|
1610
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
1611
1582
|
}
|
|
1612
|
-
|
|
1583
|
+
);
|
|
1613
1584
|
}
|
|
1614
1585
|
|
|
1615
1586
|
// src/mcp/tools/knowledge.ts
|
|
1616
1587
|
var import_zod7 = require("zod");
|
|
1617
|
-
function registerKnowledgeTool(
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
- Auth mechanism (which cookies / localStorage keys / headers are load-bearing)
|
|
1622
|
-
- Known API endpoints the site uses (captured from real browser runs)
|
|
1623
|
-
- Notes about captchas, bot detection, session behavior
|
|
1624
|
-
- 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.
|
|
1625
1592
|
|
|
1626
|
-
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.
|
|
1627
1594
|
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
text: "No cached knowledge yet. Every successful `execute` run populates the cache for its target domain automatically."
|
|
1649
|
-
}
|
|
1650
|
-
]
|
|
1651
|
-
};
|
|
1652
|
-
}
|
|
1653
|
-
const lines = [`${entries.length} domain${entries.length === 1 ? "" : "s"} cached:
|
|
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:
|
|
1654
1615
|
`];
|
|
1655
|
-
|
|
1656
|
-
|
|
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") }] };
|
|
1657
1621
|
}
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
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)";
|
|
1667
1640
|
return {
|
|
1668
1641
|
content: [
|
|
1669
1642
|
{
|
|
1670
1643
|
type: "text",
|
|
1671
|
-
text: `
|
|
1644
|
+
text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
|
|
1672
1645
|
}
|
|
1673
1646
|
]
|
|
1674
1647
|
};
|
|
1675
1648
|
}
|
|
1676
|
-
return
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
const { removed } = clearKnowledge(domain);
|
|
1680
|
-
const scope = domain ? `for ${sanitizeDomain(domain)}` : "(all domains)";
|
|
1681
|
-
return {
|
|
1682
|
-
content: [
|
|
1683
|
-
{
|
|
1684
|
-
type: "text",
|
|
1685
|
-
text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
|
|
1686
|
-
}
|
|
1687
|
-
]
|
|
1688
|
-
};
|
|
1649
|
+
return err("Unknown action");
|
|
1650
|
+
} catch (e) {
|
|
1651
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
1689
1652
|
}
|
|
1690
|
-
return err("Unknown action");
|
|
1691
|
-
} catch (e) {
|
|
1692
|
-
return err(`Error: ${getErrorMessage(e)}`);
|
|
1693
1653
|
}
|
|
1694
|
-
|
|
1654
|
+
);
|
|
1695
1655
|
}
|
|
1696
1656
|
|
|
1697
1657
|
// src/mcp/tools/tabs.ts
|
|
1698
1658
|
var import_zod8 = require("zod");
|
|
1699
|
-
function registerTabsTool(
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
action
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
return err(res.error || "Failed to open tab.");
|
|
1731
|
-
const t = res.tab;
|
|
1732
|
-
return {
|
|
1733
|
-
content: [
|
|
1734
|
-
{
|
|
1735
|
-
type: "text",
|
|
1736
|
-
text: `Opened tab [id ${t.id}] ${t.title || t.url}
|
|
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;
|
|
1685
|
+
return {
|
|
1686
|
+
content: [
|
|
1687
|
+
{
|
|
1688
|
+
type: "text",
|
|
1689
|
+
text: `Opened tab [id ${t.id}] ${t.title || t.url}
|
|
1737
1690
|
${t.url}
|
|
1738
1691
|
Drive it with: execute mode="extension", tabId=${t.id}.`
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
return
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
return
|
|
1762
|
-
|
|
1763
|
-
if (
|
|
1764
|
-
return err(
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
return err(res.error || "Failed to
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
""
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
lines.push("");
|
|
1829
|
-
lines.push('To drive one: execute with options.mode="extension", options.tabId=<id>.');
|
|
1830
|
-
if (multiProfile) {
|
|
1831
|
-
lines.push("Multiple profiles are connected — if two tabs share a title, also pass options.clientId " + "(shown as «profile» above maps to a clientId) so the right profile is driven.");
|
|
1692
|
+
}
|
|
1693
|
+
]
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
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
|
+
};
|
|
1709
|
+
}
|
|
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) {
|
|
1749
|
+
return {
|
|
1750
|
+
content: [
|
|
1751
|
+
{
|
|
1752
|
+
type: "text",
|
|
1753
|
+
text: filter ? `Extension connected, but no open tab matches "${filter}".` : "Extension connected, but no tabs were reported."
|
|
1754
|
+
}
|
|
1755
|
+
]
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
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)}`);
|
|
1832
1781
|
}
|
|
1833
|
-
return { content: [{ type: "text", text: lines.join(`
|
|
1834
|
-
`) }] };
|
|
1835
|
-
} catch (e) {
|
|
1836
|
-
return err(`Error listing tabs: ${getErrorMessage(e)}`);
|
|
1837
1782
|
}
|
|
1838
|
-
|
|
1783
|
+
);
|
|
1839
1784
|
}
|
|
1840
1785
|
|
|
1841
1786
|
// src/mcp/tools/anchors.ts
|
|
@@ -1872,7 +1817,7 @@ function saveAnchor(domain, input, now) {
|
|
|
1872
1817
|
selector: input.selector,
|
|
1873
1818
|
role: input.role,
|
|
1874
1819
|
description: input.description,
|
|
1875
|
-
quirks: input.quirks && input.quirks.length ? input.quirks :
|
|
1820
|
+
quirks: input.quirks && input.quirks.length ? input.quirks : void 0,
|
|
1876
1821
|
uses: 0,
|
|
1877
1822
|
fails: 0,
|
|
1878
1823
|
lastVerified: now
|
|
@@ -1881,15 +1826,14 @@ function saveAnchor(domain, input, now) {
|
|
|
1881
1826
|
}
|
|
1882
1827
|
function removeAnchor(domain, name) {
|
|
1883
1828
|
const cm = loadComponentMap(domain);
|
|
1884
|
-
if (!(name in cm.anchors))
|
|
1885
|
-
return false;
|
|
1829
|
+
if (!(name in cm.anchors)) return false;
|
|
1886
1830
|
delete cm.anchors[name];
|
|
1887
1831
|
write(cm);
|
|
1888
1832
|
return true;
|
|
1889
1833
|
}
|
|
1890
1834
|
function setDomainQuirks(domain, quirks) {
|
|
1891
1835
|
const cm = loadComponentMap(domain);
|
|
1892
|
-
cm.quirks = Array.from(new Set([...cm.quirks, ...quirks]));
|
|
1836
|
+
cm.quirks = Array.from(/* @__PURE__ */ new Set([...cm.quirks, ...quirks]));
|
|
1893
1837
|
write(cm);
|
|
1894
1838
|
}
|
|
1895
1839
|
function listAnchorDomains() {
|
|
@@ -1901,99 +1845,86 @@ function listAnchorDomains() {
|
|
|
1901
1845
|
}
|
|
1902
1846
|
|
|
1903
1847
|
// src/mcp/tools/anchors.ts
|
|
1904
|
-
function registerRememberTool(
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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") }] };
|
|
1927
1877
|
}
|
|
1928
|
-
|
|
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}:
|
|
1929
1886
|
`];
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
if (names.length === 0 && cm.quirks.length === 0) {
|
|
1944
|
-
return { content: [{ type: "text", text: `No anchors saved for ${domain} yet. Discover elements with snapshot/find, then \`remember save\` them.` }] };
|
|
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") }] };
|
|
1945
1900
|
}
|
|
1946
|
-
|
|
1947
|
-
`
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
for (const q of cm.quirks)
|
|
1951
|
-
lines.push(` - ${q}`);
|
|
1952
|
-
lines.push("");
|
|
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}.` }] };
|
|
1953
1905
|
}
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
const
|
|
1957
|
-
|
|
1958
|
-
if (a.description)
|
|
1959
|
-
lines.push(` ${a.description}`);
|
|
1960
|
-
if (a.quirks?.length)
|
|
1961
|
-
for (const q of a.quirks)
|
|
1962
|
-
lines.push(` • ${q}`);
|
|
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}.` }] };
|
|
1963
1910
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
}
|
|
1973
|
-
if (action === "forget") {
|
|
1974
|
-
if (!name)
|
|
1975
|
-
return err("`forget` requires the anchor `name`.");
|
|
1976
|
-
const removed = removeAnchor(domain, name);
|
|
1977
|
-
return { content: [{ type: "text", text: removed ? `Removed anchor @a:${name} from ${domain}.` : `No anchor named '${name}' for ${domain}.` }] };
|
|
1978
|
-
}
|
|
1979
|
-
if (!name)
|
|
1980
|
-
return err("`save` requires an anchor `name`.");
|
|
1981
|
-
if (!selector)
|
|
1982
|
-
return err("`save` requires a `selector` (the CSS that locates the element).");
|
|
1983
|
-
saveAnchor(domain, { name, selector, role, description, quirks }, new Date().toISOString());
|
|
1984
|
-
return {
|
|
1985
|
-
content: [
|
|
1986
|
-
{
|
|
1987
|
-
type: "text",
|
|
1988
|
-
text: `Saved anchor @a:${name} for ${domain} → ${selector}
|
|
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());
|
|
1914
|
+
return {
|
|
1915
|
+
content: [
|
|
1916
|
+
{
|
|
1917
|
+
type: "text",
|
|
1918
|
+
text: `Saved anchor @a:${name} for ${domain} \u2192 ${selector}
|
|
1989
1919
|
Use it in any selector field: e.g. {"type":"click","selector":"@a:${name}"}.`
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1920
|
+
}
|
|
1921
|
+
]
|
|
1922
|
+
};
|
|
1923
|
+
} catch (e) {
|
|
1924
|
+
return err(`remember failed: ${getErrorMessage(e)}`);
|
|
1925
|
+
}
|
|
1995
1926
|
}
|
|
1996
|
-
|
|
1927
|
+
);
|
|
1997
1928
|
}
|
|
1998
1929
|
|
|
1999
1930
|
// src/mcp/tools/clipboard.ts
|
|
@@ -2002,17 +1933,15 @@ var import_zod10 = require("zod");
|
|
|
2002
1933
|
// src/lib/clipboard.ts
|
|
2003
1934
|
var import_child_process2 = require("child_process");
|
|
2004
1935
|
function platformTools(mode) {
|
|
2005
|
-
if (process.platform === "darwin")
|
|
2006
|
-
|
|
2007
|
-
if (mode === "read")
|
|
2008
|
-
return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
|
|
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"]];
|
|
2009
1938
|
return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
|
|
2010
1939
|
}
|
|
2011
1940
|
function run(cmd, input) {
|
|
2012
1941
|
return new Promise((resolve) => {
|
|
2013
1942
|
let child;
|
|
2014
1943
|
try {
|
|
2015
|
-
child = import_child_process2.spawn(cmd[0], cmd.slice(1));
|
|
1944
|
+
child = (0, import_child_process2.spawn)(cmd[0], cmd.slice(1));
|
|
2016
1945
|
} catch {
|
|
2017
1946
|
resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
|
|
2018
1947
|
return;
|
|
@@ -2023,7 +1952,7 @@ function run(cmd, input) {
|
|
|
2023
1952
|
child.stderr?.on("data", (d) => errOut += d);
|
|
2024
1953
|
child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
|
|
2025
1954
|
child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
|
|
2026
|
-
if (input !==
|
|
1955
|
+
if (input !== void 0) {
|
|
2027
1956
|
child.stdin?.write(input);
|
|
2028
1957
|
child.stdin?.end();
|
|
2029
1958
|
}
|
|
@@ -2032,8 +1961,7 @@ function run(cmd, input) {
|
|
|
2032
1961
|
async function clipboardWrite(text) {
|
|
2033
1962
|
const tools = platformTools("write");
|
|
2034
1963
|
for (const cmd of tools) {
|
|
2035
|
-
if ((await run(cmd, text)).ok)
|
|
2036
|
-
return;
|
|
1964
|
+
if ((await run(cmd, text)).ok) return;
|
|
2037
1965
|
}
|
|
2038
1966
|
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}).`);
|
|
2039
1967
|
}
|
|
@@ -2042,37 +1970,35 @@ async function clipboardRead() {
|
|
|
2042
1970
|
let lastErr = "";
|
|
2043
1971
|
for (const cmd of tools) {
|
|
2044
1972
|
const r = await run(cmd);
|
|
2045
|
-
if (r.ok)
|
|
2046
|
-
return r.out;
|
|
1973
|
+
if (r.ok) return r.out;
|
|
2047
1974
|
lastErr = r.err;
|
|
2048
1975
|
}
|
|
2049
1976
|
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
|
|
2050
1977
|
}
|
|
2051
1978
|
|
|
2052
1979
|
// src/mcp/tools/clipboard.ts
|
|
2053
|
-
function registerClipboardTool(
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
return
|
|
2067
|
-
|
|
2068
|
-
|
|
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)}`);
|
|
2069
1999
|
}
|
|
2070
|
-
const value = await clipboardRead();
|
|
2071
|
-
return { content: [{ type: "text", text: value.length ? value : "(clipboard is empty)" }] };
|
|
2072
|
-
} catch (e) {
|
|
2073
|
-
return err(`clipboard failed: ${getErrorMessage(e)}`);
|
|
2074
2000
|
}
|
|
2075
|
-
|
|
2001
|
+
);
|
|
2076
2002
|
}
|
|
2077
2003
|
|
|
2078
2004
|
// src/mcp/telemetry.ts
|
|
@@ -2081,27 +2007,26 @@ var import_path11 = __toESM(require("path"));
|
|
|
2081
2007
|
var CHARS_PER_TOKEN = 4;
|
|
2082
2008
|
var est = (chars) => Math.round(chars / CHARS_PER_TOKEN);
|
|
2083
2009
|
var enabled = process.env.IFRAMER_TELEMETRY !== "0";
|
|
2084
|
-
var sessionId = `${new Date().toISOString().slice(0, 10)}-${process.pid}`;
|
|
2010
|
+
var sessionId = `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${process.pid}`;
|
|
2085
2011
|
function telemetryPath() {
|
|
2086
2012
|
return import_path11.default.join(getDataDir(), "telemetry.jsonl");
|
|
2087
2013
|
}
|
|
2088
2014
|
var sessionCalls = 0;
|
|
2089
2015
|
var sessionChars = 0;
|
|
2090
2016
|
function write2(rec) {
|
|
2091
|
-
if (!enabled)
|
|
2092
|
-
return;
|
|
2017
|
+
if (!enabled) return;
|
|
2093
2018
|
try {
|
|
2094
|
-
import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: new Date().toISOString(), session: sessionId, ...rec }) +
|
|
2095
|
-
|
|
2096
|
-
}
|
|
2019
|
+
import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), session: sessionId, ...rec }) + "\n");
|
|
2020
|
+
} catch {
|
|
2021
|
+
}
|
|
2097
2022
|
}
|
|
2098
|
-
function recordDefinitions(toolCount,
|
|
2023
|
+
function recordDefinitions(toolCount, defChars2, instructionChars) {
|
|
2099
2024
|
write2({
|
|
2100
2025
|
kind: "definitions",
|
|
2101
2026
|
toolCount,
|
|
2102
|
-
defChars,
|
|
2027
|
+
defChars: defChars2,
|
|
2103
2028
|
instructionChars,
|
|
2104
|
-
estTokens: est(
|
|
2029
|
+
estTokens: est(defChars2 + instructionChars)
|
|
2105
2030
|
});
|
|
2106
2031
|
}
|
|
2107
2032
|
function recordCall(tool, inChars, outChars, ms, isError) {
|
|
@@ -2121,20 +2046,17 @@ function recordCall(tool, inChars, outChars, ms, isError) {
|
|
|
2121
2046
|
}
|
|
2122
2047
|
function contentChars(res) {
|
|
2123
2048
|
const r = res;
|
|
2124
|
-
if (!r || !Array.isArray(r.content))
|
|
2125
|
-
return 0;
|
|
2049
|
+
if (!r || !Array.isArray(r.content)) return 0;
|
|
2126
2050
|
let n = 0;
|
|
2127
2051
|
for (const c of r.content) {
|
|
2128
|
-
if (typeof c?.text === "string")
|
|
2129
|
-
|
|
2130
|
-
else if (typeof c?.data === "string")
|
|
2131
|
-
n += c.data.length;
|
|
2052
|
+
if (typeof c?.text === "string") n += c.text.length;
|
|
2053
|
+
else if (typeof c?.data === "string") n += c.data.length;
|
|
2132
2054
|
}
|
|
2133
2055
|
return n;
|
|
2134
2056
|
}
|
|
2135
2057
|
function safeJsonLen(v) {
|
|
2136
2058
|
try {
|
|
2137
|
-
return v ===
|
|
2059
|
+
return v === void 0 ? 0 : JSON.stringify(v).length;
|
|
2138
2060
|
} catch {
|
|
2139
2061
|
return 0;
|
|
2140
2062
|
}
|
|
@@ -2142,7 +2064,7 @@ function safeJsonLen(v) {
|
|
|
2142
2064
|
|
|
2143
2065
|
// src/mcp/server.ts
|
|
2144
2066
|
var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
|
|
2145
|
-
var INSTRUCTIONS = IS_DEV ? `iframer-dev
|
|
2067
|
+
var INSTRUCTIONS = IS_DEV ? `iframer-dev \u2014 local development instance of iframer (connects to ${BASE_URL}).
|
|
2146
2068
|
|
|
2147
2069
|
CRITICAL RULES:
|
|
2148
2070
|
1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
|
|
@@ -2151,8 +2073,8 @@ CRITICAL RULES:
|
|
|
2151
2073
|
4. If execute fails, read the error. It tells you exactly what happened and where.
|
|
2152
2074
|
5. Call "session" action=stop when done to save session state.
|
|
2153
2075
|
|
|
2154
|
-
CAPTCHA: Use the "solve-captcha" step
|
|
2155
|
-
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.
|
|
2156
2078
|
|
|
2157
2079
|
CRITICAL RULES:
|
|
2158
2080
|
1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
|
|
@@ -2160,17 +2082,20 @@ CRITICAL RULES:
|
|
|
2160
2082
|
3. DO NOT present options when you can just act. Check status, credentials, execute.
|
|
2161
2083
|
4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
|
|
2162
2084
|
5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
|
|
2163
|
-
6. For UI tasks on a site, check "remember get <domain>" first
|
|
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).
|
|
2164
2086
|
7. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
|
|
2165
2087
|
7. If the browser crashes, call "session restart" and retry. Don't panic.
|
|
2166
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.
|
|
2167
2089
|
|
|
2168
|
-
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.
|
|
2169
2091
|
|
|
2170
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.
|
|
2171
2093
|
|
|
2172
2094
|
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
|
|
2173
|
-
var server = new import_mcp.McpServer(
|
|
2095
|
+
var server = new import_mcp.McpServer(
|
|
2096
|
+
{ name: "iframer", version: "3.0.0" },
|
|
2097
|
+
{ instructions: INSTRUCTIONS }
|
|
2098
|
+
);
|
|
2174
2099
|
var defChars = 0;
|
|
2175
2100
|
var defCount = 0;
|
|
2176
2101
|
{
|
|
@@ -2210,21 +2135,22 @@ process.stdin.on("end", () => process.exit(0));
|
|
|
2210
2135
|
process.stdin.on("close", () => process.exit(0));
|
|
2211
2136
|
var originalPpid = process.ppid;
|
|
2212
2137
|
var orphanCheck = setInterval(() => {
|
|
2213
|
-
if (process.ppid !== originalPpid || process.ppid === 1)
|
|
2214
|
-
|
|
2215
|
-
}, 30000);
|
|
2138
|
+
if (process.ppid !== originalPpid || process.ppid === 1) process.exit(0);
|
|
2139
|
+
}, 3e4);
|
|
2216
2140
|
orphanCheck.unref?.();
|
|
2217
2141
|
process.on("uncaughtException", (err2) => {
|
|
2218
2142
|
try {
|
|
2219
2143
|
console.error(`[mcp] uncaughtException: ${err2?.message}`);
|
|
2220
|
-
} catch {
|
|
2144
|
+
} catch {
|
|
2145
|
+
}
|
|
2221
2146
|
});
|
|
2222
2147
|
process.on("unhandledRejection", (reason) => {
|
|
2223
2148
|
try {
|
|
2224
2149
|
console.error(`[mcp] unhandledRejection: ${reason}`);
|
|
2225
|
-
} catch {
|
|
2150
|
+
} catch {
|
|
2151
|
+
}
|
|
2226
2152
|
});
|
|
2227
|
-
var transport = new import_stdio.StdioServerTransport;
|
|
2153
|
+
var transport = new import_stdio.StdioServerTransport();
|
|
2228
2154
|
(async () => {
|
|
2229
2155
|
await server.connect(transport);
|
|
2230
2156
|
})();
|