iframer-toolkit 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1498 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ function __accessProp(key) {
8
+ return this[key];
9
+ }
10
+ var __toESMCache_node;
11
+ var __toESMCache_esm;
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;
19
+ }
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
+ return to;
31
+ };
32
+
33
+ // src/mcp/server.ts
34
+ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
35
+ var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
36
+
37
+ // src/mcp/helpers.ts
38
+ var import_path4 = __toESM(require("path"));
39
+ var import_os2 = __toESM(require("os"));
40
+
41
+ // src/lib/logger.ts
42
+ var LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
43
+ var currentLevel = process.env.LOG_LEVEL || "info";
44
+ function createLogger(tag) {
45
+ const prefix = `[${tag}]`;
46
+ return {
47
+ debug: (...args) => {
48
+ if (LEVELS[currentLevel] <= 0)
49
+ console.log(prefix, ...args);
50
+ },
51
+ info: (...args) => {
52
+ if (LEVELS[currentLevel] <= 1)
53
+ console.log(prefix, ...args);
54
+ },
55
+ warn: (...args) => {
56
+ if (LEVELS[currentLevel] <= 2)
57
+ console.warn(prefix, ...args);
58
+ },
59
+ error: (...args) => {
60
+ if (LEVELS[currentLevel] <= 3)
61
+ console.error(prefix, ...args);
62
+ }
63
+ };
64
+ }
65
+
66
+ // src/lib/auth/crypto.ts
67
+ var import_crypto = __toESM(require("crypto"));
68
+ var import_fs = __toESM(require("fs"));
69
+ var import_path2 = __toESM(require("path"));
70
+
71
+ // src/lib/paths.ts
72
+ var import_path = __toESM(require("path"));
73
+ var import_os = __toESM(require("os"));
74
+ function getDataDir() {
75
+ return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
76
+ }
77
+
78
+ // src/lib/auth/crypto.ts
79
+ function getLocalToken() {
80
+ if (process.env.IFRAMER_SECRET)
81
+ return process.env.IFRAMER_SECRET;
82
+ const dir = getDataDir();
83
+ const file = import_path2.default.join(dir, "secret");
84
+ try {
85
+ const existing = import_fs.default.readFileSync(file, "utf8").trim();
86
+ if (existing)
87
+ return existing;
88
+ } catch {}
89
+ try {
90
+ import_fs.default.mkdirSync(dir, { recursive: true });
91
+ const secret = import_crypto.default.randomBytes(32).toString("hex");
92
+ import_fs.default.writeFileSync(file, secret, { mode: 384 });
93
+ return secret;
94
+ } catch {
95
+ return "iframer-local-default-token";
96
+ }
97
+ }
98
+
99
+ // src/mcp/local-server.ts
100
+ var import_child_process = require("child_process");
101
+ var import_fs2 = __toESM(require("fs"));
102
+ var import_path3 = __toESM(require("path"));
103
+ var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
104
+ var PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
105
+ var STARTUP_TIMEOUT_MS = 15000;
106
+ var HEALTH_POLL_MS = 300;
107
+
108
+ class LocalServerManager {
109
+ child = null;
110
+ startingPromise = null;
111
+ baseUrl;
112
+ logPath;
113
+ constructor() {
114
+ this.baseUrl = `http://127.0.0.1:${PORT}`;
115
+ this.logPath = import_path3.default.join(getDataDir(), "local-server.log");
116
+ }
117
+ getBaseUrl() {
118
+ return this.baseUrl;
119
+ }
120
+ async ensureRunning() {
121
+ if (this.child && !this.child.killed && await this.healthCheck())
122
+ return;
123
+ if (this.startingPromise)
124
+ return this.startingPromise;
125
+ this.startingPromise = this.doStart().finally(() => {
126
+ this.startingPromise = null;
127
+ });
128
+ return this.startingPromise;
129
+ }
130
+ async doStart() {
131
+ await this.killExisting();
132
+ const dataDir = getDataDir();
133
+ import_fs2.default.mkdirSync(dataDir, { recursive: true });
134
+ const { command, args } = this.resolveRuntime();
135
+ const logFd = import_fs2.default.openSync(this.logPath, "a");
136
+ const env = {
137
+ ...process.env,
138
+ PORT: String(PORT),
139
+ IFRAMER_MODE: "local",
140
+ IFRAMER_DATA_DIR: dataDir
141
+ };
142
+ if (process.env.IFRAMER_SECRET) {
143
+ env.IFRAMER_SECRET = process.env.IFRAMER_SECRET;
144
+ } else {
145
+ try {
146
+ const secret = import_fs2.default.readFileSync(import_path3.default.join(dataDir, "secret"), "utf8").trim();
147
+ if (secret)
148
+ env.IFRAMER_SECRET = secret;
149
+ } catch {}
150
+ }
151
+ this.child = import_child_process.spawn(command, args, {
152
+ env,
153
+ stdio: ["ignore", logFd, logFd],
154
+ detached: false
155
+ });
156
+ import_fs2.default.closeSync(logFd);
157
+ this.child.on("exit", (code) => {
158
+ this.child = null;
159
+ });
160
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
161
+ while (Date.now() < deadline) {
162
+ if (await this.healthCheck())
163
+ return;
164
+ await sleep(HEALTH_POLL_MS);
165
+ }
166
+ this.kill();
167
+ const logTail = this.readLogTail();
168
+ throw new Error(`Local iframer server failed to start on port ${PORT} within ${STARTUP_TIMEOUT_MS}ms.
169
+ ` + `Last log lines:
170
+ ${logTail}`);
171
+ }
172
+ resolveRuntime() {
173
+ try {
174
+ const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
175
+ const serverTs2 = import_path3.default.join(__dirname, "..", "..", "index.ts");
176
+ if (import_fs2.default.existsSync(serverTs2)) {
177
+ return { command: bunPath, args: ["run", serverTs2] };
178
+ }
179
+ } catch {}
180
+ const serverCjs = import_path3.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
181
+ if (import_fs2.default.existsSync(serverCjs)) {
182
+ return { command: "node", args: [serverCjs] };
183
+ }
184
+ const serverTs = import_path3.default.join(__dirname, "..", "..", "index.ts");
185
+ return { command: "node", args: ["--import", "tsx", serverTs] };
186
+ }
187
+ async restart() {
188
+ this.kill();
189
+ await sleep(500);
190
+ await this.ensureRunning();
191
+ }
192
+ shutdown() {
193
+ this.kill();
194
+ }
195
+ kill() {
196
+ if (this.child && !this.child.killed) {
197
+ try {
198
+ this.child.kill("SIGTERM");
199
+ } catch {}
200
+ const c = this.child;
201
+ setTimeout(() => {
202
+ try {
203
+ if (!c.killed)
204
+ c.kill("SIGKILL");
205
+ } catch {}
206
+ }, 2000);
207
+ }
208
+ this.child = null;
209
+ }
210
+ async killExisting() {
211
+ try {
212
+ const res = await fetch(`${this.baseUrl}/health`, {
213
+ signal: AbortSignal.timeout(2000)
214
+ });
215
+ if (res.ok) {
216
+ try {
217
+ await fetch(`${this.baseUrl}/shutdown`, {
218
+ method: "POST",
219
+ signal: AbortSignal.timeout(2000)
220
+ });
221
+ } catch {}
222
+ await sleep(500);
223
+ }
224
+ } catch {}
225
+ }
226
+ async healthCheck() {
227
+ try {
228
+ const res = await fetch(`${this.baseUrl}/health`, {
229
+ signal: AbortSignal.timeout(2000)
230
+ });
231
+ const data = await res.json();
232
+ return data.ok === true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+ readLogTail() {
238
+ try {
239
+ const content = import_fs2.default.readFileSync(this.logPath, "utf8");
240
+ const lines = content.trim().split(`
241
+ `);
242
+ return lines.slice(-10).join(`
243
+ `);
244
+ } catch {
245
+ return "(no log file)";
246
+ }
247
+ }
248
+ }
249
+ function sleep(ms) {
250
+ return new Promise((r) => setTimeout(r, ms));
251
+ }
252
+
253
+ // src/mcp/helpers.ts
254
+ var log = createLogger("mcp");
255
+ var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
256
+ var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
257
+ var IFRAMER_MODE = process.env.IFRAMER_MODE;
258
+ var LOCAL_TOKEN = getLocalToken();
259
+ var localServer = new LocalServerManager;
260
+ async function ensureLocalServer() {
261
+ await localServer.ensureRunning();
262
+ }
263
+ function authHeaders(secret) {
264
+ const headers = { "Content-Type": "application/json" };
265
+ const key = secret || IFRAMER_SECRET;
266
+ if (key)
267
+ headers["x-api-key"] = key;
268
+ return headers;
269
+ }
270
+ async function apiPost(endpoint, body) {
271
+ const res = await fetch(`${BASE_URL}${endpoint}`, {
272
+ method: "POST",
273
+ headers: authHeaders(),
274
+ body: body ? JSON.stringify(body) : undefined,
275
+ signal: AbortSignal.timeout(180000)
276
+ });
277
+ return res.json();
278
+ }
279
+ async function apiGet(endpoint) {
280
+ const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders() });
281
+ return res.json();
282
+ }
283
+ async function localApiPost(endpoint, body) {
284
+ await ensureLocalServer();
285
+ const url = localServer.getBaseUrl();
286
+ const res = await fetch(`${url}${endpoint}`, {
287
+ method: "POST",
288
+ headers: authHeaders(LOCAL_TOKEN),
289
+ body: body ? JSON.stringify(body) : undefined,
290
+ signal: AbortSignal.timeout(180000)
291
+ });
292
+ return res.json();
293
+ }
294
+ async function localApiGet(endpoint) {
295
+ await ensureLocalServer();
296
+ const url = localServer.getBaseUrl();
297
+ const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN) });
298
+ return res.json();
299
+ }
300
+ async function localApiDelete(endpoint) {
301
+ await ensureLocalServer();
302
+ const url = localServer.getBaseUrl();
303
+ const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN) });
304
+ return res.json();
305
+ }
306
+ async function isDockerRunning() {
307
+ try {
308
+ const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3000) });
309
+ const data = await res.json();
310
+ return data.ok === true;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+ async function resolveScreenshotPath(url) {
316
+ try {
317
+ if (url.startsWith("file://")) {
318
+ const { fileURLToPath } = await import("url");
319
+ const filePath2 = fileURLToPath(url);
320
+ const fs4 = await import("fs");
321
+ if (fs4.existsSync(filePath2))
322
+ return filePath2;
323
+ return null;
324
+ }
325
+ const res = await fetch(url);
326
+ if (!res.ok)
327
+ return null;
328
+ const buf = Buffer.from(await res.arrayBuffer());
329
+ const fs3 = await import("fs");
330
+ const dir = import_path4.default.join(import_os2.default.tmpdir(), "iframer-screenshots", "screenshots");
331
+ fs3.mkdirSync(dir, { recursive: true });
332
+ const filePath = import_path4.default.join(dir, `docker-${Date.now()}.jpg`);
333
+ fs3.writeFileSync(filePath, buf);
334
+ return filePath;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+ function err(message) {
340
+ return { content: [{ type: "text", text: message }], isError: true };
341
+ }
342
+ function getErrorMessage(err2) {
343
+ return err2 instanceof Error ? err2.message : String(err2);
344
+ }
345
+
346
+ // src/lib/domain-modes.ts
347
+ var import_fs3 = __toESM(require("fs"));
348
+ var import_path5 = __toESM(require("path"));
349
+ var log2 = createLogger("domain-modes");
350
+ function defaultFile() {
351
+ return import_path5.default.join(getDataDir(), "domain-modes.json");
352
+ }
353
+ var TTL_DAYS = 14;
354
+ var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
355
+
356
+ class DomainModeStore {
357
+ data = {};
358
+ filePath;
359
+ constructor(filePath = defaultFile()) {
360
+ this.filePath = filePath;
361
+ this.load();
362
+ }
363
+ getMode(domain) {
364
+ const entry = this.data[domain];
365
+ if (!entry)
366
+ return null;
367
+ if (this.isExpired(entry))
368
+ return null;
369
+ return entry.mode;
370
+ }
371
+ recordSuccess(domain, mode) {
372
+ const now = new Date().toISOString();
373
+ const existing = this.data[domain];
374
+ this.data[domain] = {
375
+ mode,
376
+ lastSuccess: now,
377
+ attempts: {
378
+ ...existing?.attempts || {},
379
+ [mode]: { result: "success", lastTried: now }
380
+ }
381
+ };
382
+ this.save();
383
+ }
384
+ recordFailure(domain, mode, reason) {
385
+ const now = new Date().toISOString();
386
+ const existing = this.data[domain];
387
+ this.data[domain] = {
388
+ mode: existing?.mode || mode,
389
+ lastSuccess: existing?.lastSuccess || "",
390
+ attempts: {
391
+ ...existing?.attempts || {},
392
+ [mode]: { result: "blocked", reason, lastTried: now }
393
+ }
394
+ };
395
+ this.save();
396
+ }
397
+ getNextMode(failedMode, availableModes) {
398
+ const idx = ESCALATION_LADDER.indexOf(failedMode);
399
+ for (let i = idx + 1;i < ESCALATION_LADDER.length; i++) {
400
+ if (availableModes.includes(ESCALATION_LADDER[i])) {
401
+ return ESCALATION_LADDER[i];
402
+ }
403
+ }
404
+ return null;
405
+ }
406
+ getBestMode(domain, availableModes) {
407
+ const remembered = this.getMode(domain);
408
+ if (remembered && availableModes.includes(remembered)) {
409
+ return remembered;
410
+ }
411
+ for (const mode of ESCALATION_LADDER) {
412
+ if (availableModes.includes(mode))
413
+ return mode;
414
+ }
415
+ return "headless";
416
+ }
417
+ getSummary() {
418
+ const entries = Object.entries(this.data).filter(([, e]) => !this.isExpired(e)).sort(([, a], [, b]) => b.lastSuccess.localeCompare(a.lastSuccess));
419
+ return {
420
+ totalDomains: entries.length,
421
+ recentDomains: entries.slice(0, 5).map(([d, e]) => `${d} (${e.mode})`)
422
+ };
423
+ }
424
+ isExpired(entry) {
425
+ if (!entry.lastSuccess)
426
+ return true;
427
+ const age = Date.now() - new Date(entry.lastSuccess).getTime();
428
+ return age > TTL_DAYS * 24 * 60 * 60 * 1000;
429
+ }
430
+ load() {
431
+ try {
432
+ if (import_fs3.default.existsSync(this.filePath)) {
433
+ this.data = JSON.parse(import_fs3.default.readFileSync(this.filePath, "utf-8"));
434
+ }
435
+ } catch {
436
+ this.data = {};
437
+ }
438
+ }
439
+ save() {
440
+ try {
441
+ import_fs3.default.mkdirSync(import_path5.default.dirname(this.filePath), { recursive: true });
442
+ import_fs3.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
443
+ } catch (err2) {
444
+ log2.error("Failed to save:", err2);
445
+ }
446
+ }
447
+ }
448
+
449
+ // src/mcp/tools/status.ts
450
+ function registerStatusTool(server) {
451
+ server.tool("status", `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.`, {}, async () => {
452
+ try {
453
+ const status = {};
454
+ const dockerRunning = await isDockerRunning();
455
+ status.dockerApi = dockerRunning;
456
+ try {
457
+ const localHealth = await localApiGet("/health");
458
+ status.localServer = localHealth.ok;
459
+ } catch {
460
+ status.localServer = false;
461
+ }
462
+ try {
463
+ const browserHealth = await localApiGet("/browser/health");
464
+ status.browserAlive = browserHealth.alive;
465
+ status.runningModes = browserHealth.modes;
466
+ } catch {}
467
+ if (dockerRunning) {
468
+ try {
469
+ const sessionData = await apiGet("/interactive/status");
470
+ status.dockerSession = sessionData.active ? { active: true, noVncUrl: sessionData.noVncUrl } : { active: false };
471
+ } catch {}
472
+ }
473
+ try {
474
+ const credData = await localApiGet("/credentials");
475
+ status.credentials = credData.domains || [];
476
+ } catch {
477
+ status.credentials = [];
478
+ }
479
+ try {
480
+ const domainModes = new DomainModeStore;
481
+ status.domainMemory = domainModes.getSummary();
482
+ } catch {}
483
+ return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
484
+ } catch (e) {
485
+ return err(`Error: ${getErrorMessage(e)}`);
486
+ }
487
+ });
488
+ }
489
+
490
+ // src/mcp/tools/browse.ts
491
+ var import_zod = require("zod");
492
+ function registerBrowseTool(server) {
493
+ server.tool("browse", `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.
494
+
495
+ 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 — it's orders of magnitude faster.`, {
496
+ url: import_zod.z.string().describe("URL to navigate to"),
497
+ extract: import_zod.z.string().optional().describe("JavaScript expression to evaluate (e.g. 'document.title')"),
498
+ actions: import_zod.z.array(import_zod.z.object({
499
+ type: import_zod.z.enum(["click", "fill", "wait", "scroll", "human-click", "human-type"]),
500
+ selector: import_zod.z.string().optional(),
501
+ value: import_zod.z.string().optional(),
502
+ ms: import_zod.z.number().optional()
503
+ })).optional().describe("Actions to execute before extracting"),
504
+ returnHtml: import_zod.z.boolean().optional().describe("Return full page HTML"),
505
+ waitForSelector: import_zod.z.string().optional().describe("Wait for this CSS selector before proceeding"),
506
+ sessionless: import_zod.z.boolean().optional().describe("Skip session persistence")
507
+ }, async (params) => {
508
+ try {
509
+ const fetchResult = await localApiPost("/fetch", params);
510
+ if (!fetchResult.ok)
511
+ return err(`Error: ${fetchResult.error}`);
512
+ const { html, ...rest } = fetchResult;
513
+ const text = html ? JSON.stringify(rest, null, 2) + `
514
+
515
+ --- HTML ---
516
+ ` + html : JSON.stringify(rest, null, 2);
517
+ return { content: [{ type: "text", text }] };
518
+ } catch (e) {
519
+ return err(`Error: ${getErrorMessage(e)}`);
520
+ }
521
+ });
522
+ }
523
+
524
+ // src/mcp/tools/execute.ts
525
+ var import_zod3 = require("zod");
526
+
527
+ // src/mcp/tools/step-schema.ts
528
+ var import_zod2 = require("zod");
529
+ var stepSchema = import_zod2.z.discriminatedUnion("type", [
530
+ import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
531
+ import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
532
+ import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
533
+ 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() }),
534
+ 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() }),
535
+ import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
536
+ import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
537
+ import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
538
+ import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
539
+ import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
540
+ import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional() }),
541
+ import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string() }),
542
+ import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
543
+ 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() }),
544
+ import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
545
+ import_zod2.z.object({ type: import_zod2.z.literal("screenshot"), annotate: import_zod2.z.boolean().optional().describe("Overlay numbered badges on interactive elements. Returns refs (@e1, @e2...) you can use in subsequent steps.") }),
546
+ import_zod2.z.object({ type: import_zod2.z.literal("snapshot"), interactiveOnly: import_zod2.z.boolean().optional().describe("Only include interactive elements (default: true)"), maxElements: import_zod2.z.number().optional().describe("Max elements to return (default: 80)") }),
547
+ import_zod2.z.object({ type: import_zod2.z.literal("find"), role: import_zod2.z.string().optional().describe("ARIA role: button, link, textbox, checkbox, etc."), name: import_zod2.z.string().optional().describe("Accessible name — button text, aria-label"), text: import_zod2.z.string().optional().describe("Visible text content"), placeholder: import_zod2.z.string().optional().describe("Input placeholder text"), label: import_zod2.z.string().optional().describe("Associated label text"), exact: import_zod2.z.boolean().optional().describe("Exact match vs substring (default: substring)") }),
548
+ import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-click") }),
549
+ import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-select"), tiles: import_zod2.z.array(import_zod2.z.number()) }),
550
+ import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-verify") }),
551
+ import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-info") }),
552
+ import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-solve") }),
553
+ import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-answer"), tiles: import_zod2.z.array(import_zod2.z.number()) })
554
+ ]);
555
+
556
+ // src/mcp/tools/execute.ts
557
+ function registerExecuteTool(server) {
558
+ server.tool("execute", `Execute a pipeline of browser steps. Auto-starts a session if needed. Handles obstacles (captcha, cookie banners) automatically.
559
+
560
+ MANDATORY PRE-FLIGHT: Before calling this tool for any website, call \`knowledge get <domain>\` first. If the cache shows a direct-API path that satisfies the user's request, skip this tool entirely — direct API calls are orders of magnitude faster. Only fall through to \`execute\` when the cache is missing, stale, or insufficient. Every successful run here updates the knowledge cache automatically.
561
+
562
+ Steps run sequentially. Each step has a 20-second stale-state timeout — if nothing changes on the page for 20s, execution stops and returns a detailed error so you can decide what to do.
563
+
564
+ Key step types:
565
+ - navigate: go to a URL (obstacle detection runs after this)
566
+ - snapshot: get the page's interactive elements as a structured list with refs (@e1, @e2...). Use this BEFORE interacting to see what's on the page. Then use refs in click/fill/human-click/human-type steps instead of CSS selectors.
567
+ - find: locate a specific element by role, name, text, placeholder, or label. Returns a ref.
568
+ - screenshot: take a screenshot. Add annotate=true to overlay numbered badges on interactive elements.
569
+ - extract: evaluate JS and include the result in the response
570
+ - solve-captcha: auto-detect and solve reCAPTCHA/hCaptcha with vision AI
571
+ - login: fill login form with stored credentials (never exposes passwords). Handles email-first flows (Slack, Microsoft, Google) and standard forms.
572
+
573
+ IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
574
+
575
+ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
576
+ steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
577
+ options: import_zod3.z.object({
578
+ staleTimeoutMs: import_zod3.z.number().optional().describe("Override the 20s stale-state timeout per step"),
579
+ screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
580
+ continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
581
+ continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
582
+ captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
583
+ mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates."),
584
+ autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)")
585
+ }).optional()
586
+ }, async (params) => {
587
+ try {
588
+ const dockerRunning = await isDockerRunning();
589
+ async function runWithMode(mode) {
590
+ if (mode === "docker-headful") {
591
+ if (!dockerRunning) {
592
+ return {
593
+ ok: false,
594
+ completedSteps: 0,
595
+ totalSteps: params.steps.length,
596
+ results: [],
597
+ finalState: { url: "", title: "" },
598
+ obstacles: [],
599
+ durationMs: 0,
600
+ modeUsed: "docker-headful",
601
+ error: {
602
+ failedAtStep: 0,
603
+ failedStep: params.steps[0],
604
+ errorType: "action-failed",
605
+ message: "docker-headful mode was requested but the Docker API is not reachable.",
606
+ pageState: { url: "", title: "" },
607
+ suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
608
+ retryable: false
609
+ }
610
+ };
611
+ }
612
+ return apiPost("/execute", {
613
+ steps: params.steps,
614
+ options: { ...params.options, mode: "docker-headful", autoEscalate: false }
615
+ });
616
+ }
617
+ return localApiPost("/execute", {
618
+ steps: params.steps,
619
+ options: { ...params.options, mode: mode || undefined }
620
+ });
621
+ }
622
+ let execResult;
623
+ try {
624
+ execResult = await runWithMode(params.options?.mode);
625
+ } catch (execErr) {
626
+ const msg = execErr instanceof Error ? execErr.message : String(execErr);
627
+ const isCrash = /connect|ECONNREFUSED|EPIPE|closed|timed out|abort|fetch failed/i.test(msg);
628
+ if (isCrash) {
629
+ log.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
630
+ try {
631
+ await localServer.restart();
632
+ } catch {}
633
+ try {
634
+ execResult = await runWithMode(params.options?.mode);
635
+ } catch (retryErr) {
636
+ return err(`Browser server crashed and retry also failed.
637
+ ` + `Original: ${msg}
638
+ Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
639
+
640
+ ` + `Call \`session restart\` to reset, then retry.`);
641
+ }
642
+ } else {
643
+ throw execErr;
644
+ }
645
+ }
646
+ const requestedMode = params.options?.mode;
647
+ if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
648
+ const escalation = ["docker-headful", "binary-headful"];
649
+ for (const nextMode of escalation) {
650
+ if (nextMode === "docker-headful" && !dockerRunning)
651
+ continue;
652
+ log.info(`Auto-escalating to ${nextMode}`);
653
+ execResult = await runWithMode(nextMode);
654
+ if (execResult.ok)
655
+ break;
656
+ if (execResult.error?.errorType !== "bot-blocked")
657
+ break;
658
+ }
659
+ }
660
+ const lines = formatExecuteResult(execResult);
661
+ let screenshotUrl = null;
662
+ if (execResult.error) {
663
+ screenshotUrl = execResult.error.pageState?.screenshotUrl ?? null;
664
+ } else {
665
+ screenshotUrl = execResult.finalState?.screenshotUrl ?? null;
666
+ }
667
+ if (screenshotUrl) {
668
+ const filePath = await resolveScreenshotPath(screenshotUrl);
669
+ if (filePath) {
670
+ lines.push(`
671
+ Screenshot saved: ${filePath}`);
672
+ lines.push("Use the Read tool on the path above to view the screenshot.");
673
+ }
674
+ }
675
+ const content = [{ type: "text", text: lines.join(`
676
+ `) }];
677
+ if (!execResult.ok)
678
+ return { content, isError: true };
679
+ return { content };
680
+ } catch (e) {
681
+ return err(`Error: ${getErrorMessage(e)}`);
682
+ }
683
+ });
684
+ }
685
+ function formatExecuteResult(data) {
686
+ const lines = [];
687
+ lines.push(`ok: ${data.ok}`);
688
+ lines.push(`steps: ${data.completedSteps}/${data.totalSteps}`);
689
+ if (data.durationMs)
690
+ lines.push(`duration: ${data.durationMs}ms`);
691
+ if (data.modeUsed)
692
+ lines.push(`mode: ${data.modeUsed}${data.modeEscalated ? " (auto-escalated)" : ""}`);
693
+ if (data.finalState) {
694
+ lines.push(`
695
+ Final page: ${data.finalState.title}`);
696
+ lines.push(`URL: ${data.finalState.url}`);
697
+ }
698
+ if (data.ok && data.finalState?.url) {
699
+ try {
700
+ const domain = new URL(data.finalState.url).hostname.replace(/^www\./, "");
701
+ lines.push(`
702
+ 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.`);
703
+ } catch {}
704
+ }
705
+ const meaningful = (data.results || []).filter((r) => r.ok && r.result !== undefined && r.result !== null);
706
+ for (const r of meaningful) {
707
+ if (r.step?.type === "snapshot" && r.result?.snapshot) {
708
+ lines.push(`
709
+ --- Snapshot (${r.result.elementCount} elements) ---`);
710
+ lines.push(r.result.snapshot);
711
+ } else if (r.step?.type === "find" && r.result?.ref) {
712
+ lines.push(`
713
+ Found: ${r.result.ref} ${r.result.role} "${r.result.name}" (${r.result.matchCount} match${r.result.matchCount > 1 ? "es" : ""})`);
714
+ } else if (r.step?.type === "screenshot" && r.result?.refs) {
715
+ lines.push(`
716
+ --- Annotated screenshot refs ---`);
717
+ lines.push(r.result.refs);
718
+ } else if (r.result !== undefined && r.result !== null) {
719
+ lines.push(`
720
+ step ${r.stepIndex}: ${JSON.stringify(r.result)}`);
721
+ }
722
+ }
723
+ if (data.obstacles && data.obstacles.length > 0) {
724
+ lines.push(`
725
+ Obstacles handled:`);
726
+ for (const o of data.obstacles) {
727
+ lines.push(` [step ${o.detectedAtStep}] ${o.type}: ${o.resolved ? o.resolution : "UNRESOLVED - " + (o.resolution || "unknown")}`);
728
+ }
729
+ }
730
+ if (data.capturedApi && data.capturedApi.length > 0) {
731
+ lines.push(`
732
+ --- Captured API ---`);
733
+ for (const api of data.capturedApi) {
734
+ lines.push(`
735
+ ${api.domain} (${api.baseUrl})`);
736
+ lines.push(" Endpoints:");
737
+ for (const ep of api.endpoints) {
738
+ lines.push(` ${ep.method} ${ep.path} [step ${ep.triggeredAtStep}, status ${ep.responseStatus}]`);
739
+ }
740
+ }
741
+ }
742
+ if (data.error) {
743
+ lines.push(`
744
+ --- Failure ---`);
745
+ if (typeof data.error === "string") {
746
+ lines.push(`Error: ${data.error}`);
747
+ } else {
748
+ lines.push(`Failed at step ${data.error.failedAtStep}: ${JSON.stringify(data.error.failedStep)}`);
749
+ lines.push(`Error type: ${data.error.errorType}`);
750
+ lines.push(`Message: ${data.error.message}`);
751
+ lines.push(`Retryable: ${data.error.retryable}`);
752
+ if (data.error.suggestion)
753
+ lines.push(`Suggestion: ${data.error.suggestion}`);
754
+ if (data.error.pageState?.url)
755
+ lines.push(`URL at failure: ${data.error.pageState.url}`);
756
+ }
757
+ }
758
+ return lines;
759
+ }
760
+
761
+ // src/mcp/tools/session.ts
762
+ var import_zod4 = require("zod");
763
+ function registerSessionTool(server) {
764
+ server.tool("session", `Manage the browser session and lifecycle.
765
+
766
+ Actions:
767
+ - **stop**: save cookies/localStorage to the store, then close the browser. Session data persists for the next run.
768
+ - **clear**: wipe all stored session data (cookies/localStorage) from the database. Does NOT kill running browsers.
769
+ - **restart**: kill all running browser instances (local + Docker) and reset state. The next \`execute\` call will launch a fresh browser automatically. Use this when the browser is frozen, crashed, or in a bad state. Credentials and knowledge cache are NOT affected.
770
+
771
+ Sessions live in the single local SQLite database (~/.iframer/iframer.db), shared across every browser mode.`, {
772
+ action: import_zod4.z.enum(["stop", "clear", "restart"]).describe("stop: save session state + close browser | clear: wipe stored session data | restart: kill all browsers, fresh start on next execute")
773
+ }, async ({ action }) => {
774
+ try {
775
+ if (action === "stop") {
776
+ const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
777
+ return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
778
+ }
779
+ if (action === "clear") {
780
+ await localApiDelete("/session").catch(() => {});
781
+ return { content: [{ type: "text", text: "Session data cleared from database." }] };
782
+ }
783
+ if (action === "restart") {
784
+ const parts = [];
785
+ try {
786
+ await localApiPost("/browser/restart");
787
+ parts.push("Local browser restarted.");
788
+ } catch {
789
+ try {
790
+ await localServer.restart();
791
+ parts.push("Local server respawned.");
792
+ } catch (e) {
793
+ parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
794
+ }
795
+ }
796
+ try {
797
+ if (await isDockerRunning()) {
798
+ await apiPost("/browser/restart");
799
+ parts.push("Docker browser restarted.");
800
+ }
801
+ } catch {}
802
+ parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
803
+ return { content: [{ type: "text", text: parts.join(" ") }] };
804
+ }
805
+ return err("Unknown action");
806
+ } catch (e) {
807
+ return err(`Error: ${getErrorMessage(e)}`);
808
+ }
809
+ });
810
+ }
811
+
812
+ // src/mcp/tools/credentials.ts
813
+ var import_zod5 = require("zod");
814
+ var import_fs5 = __toESM(require("fs"));
815
+ var import_path7 = __toESM(require("path"));
816
+
817
+ // src/lib/knowledge.ts
818
+ var import_fs4 = __toESM(require("fs"));
819
+ var import_path6 = __toESM(require("path"));
820
+ var log3 = createLogger("knowledge");
821
+ function getKnowledgeDir() {
822
+ return import_path6.default.join(getDataDir(), "knowledge");
823
+ }
824
+ function getKnowledgePath(domain) {
825
+ const safe = sanitizeDomain(domain);
826
+ return import_path6.default.join(getKnowledgeDir(), `${safe}.md`);
827
+ }
828
+ function normalizeDomain(input) {
829
+ let d = (input || "").trim().toLowerCase();
830
+ if (!d)
831
+ return "";
832
+ try {
833
+ if (d.includes("://")) {
834
+ d = new URL(d).hostname;
835
+ } else if (d.includes("/")) {
836
+ d = new URL(`https://${d}`).hostname;
837
+ }
838
+ } catch {}
839
+ d = d.replace(/:\d+$/, "");
840
+ d = d.replace(/^www\./, "");
841
+ return d;
842
+ }
843
+ function sanitizeDomain(input) {
844
+ const normalized = normalizeDomain(input);
845
+ return normalized.replace(/[^a-z0-9.-]/g, "_");
846
+ }
847
+ function readKnowledge(domain) {
848
+ const p = getKnowledgePath(domain);
849
+ try {
850
+ return import_fs4.default.readFileSync(p, "utf8");
851
+ } catch {
852
+ return null;
853
+ }
854
+ }
855
+ function listKnowledge() {
856
+ const dir = getKnowledgeDir();
857
+ let entries = [];
858
+ try {
859
+ entries = import_fs4.default.readdirSync(dir);
860
+ } catch {
861
+ return [];
862
+ }
863
+ const results = [];
864
+ for (const file of entries) {
865
+ if (!file.endsWith(".md"))
866
+ continue;
867
+ const full = import_path6.default.join(dir, file);
868
+ try {
869
+ const stat = import_fs4.default.statSync(full);
870
+ const raw = import_fs4.default.readFileSync(full, "utf8");
871
+ const parsed = parseMarkdown(raw);
872
+ results.push({
873
+ domain: parsed?.domain ?? file.replace(/\.md$/, ""),
874
+ lastVerified: parsed?.lastVerified ?? new Date(stat.mtimeMs).toISOString(),
875
+ lastMode: parsed?.lastMode ?? "unknown",
876
+ sizeBytes: stat.size
877
+ });
878
+ } catch {}
879
+ }
880
+ results.sort((a, b) => a.lastVerified < b.lastVerified ? 1 : -1);
881
+ return results;
882
+ }
883
+ function clearKnowledge(domain) {
884
+ const dir = getKnowledgeDir();
885
+ if (domain) {
886
+ const p = getKnowledgePath(domain);
887
+ try {
888
+ import_fs4.default.unlinkSync(p);
889
+ return { removed: 1 };
890
+ } catch {
891
+ return { removed: 0 };
892
+ }
893
+ }
894
+ let removed = 0;
895
+ try {
896
+ const entries = import_fs4.default.readdirSync(dir);
897
+ for (const f of entries) {
898
+ if (f.endsWith(".md")) {
899
+ try {
900
+ import_fs4.default.unlinkSync(import_path6.default.join(dir, f));
901
+ removed++;
902
+ } catch {}
903
+ }
904
+ }
905
+ } catch {}
906
+ return { removed };
907
+ }
908
+ function parseMarkdown(raw) {
909
+ const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
910
+ if (!frontmatterMatch)
911
+ return null;
912
+ const fm = {};
913
+ for (const line of frontmatterMatch[1].split(`
914
+ `)) {
915
+ const m = line.match(/^(\w+):\s*(.*)$/);
916
+ if (m)
917
+ fm[m[1]] = m[2].trim();
918
+ }
919
+ const body = raw.slice(frontmatterMatch[0].length);
920
+ const auth = { type: fm.authType ?? "unknown" };
921
+ const authSection = extractSection(body, "Auth material");
922
+ if (authSection) {
923
+ auth.cookieNames = extractBackticks(/\*\*Required cookies:\*\*\s+(.+)/, authSection);
924
+ auth.localStorageKeys = extractBackticks(/\*\*localStorage keys:\*\*\s+(.+)/, authSection);
925
+ auth.sessionStorageKeys = extractBackticks(/\*\*sessionStorage keys:\*\*\s+(.+)/, authSection);
926
+ auth.headers = extractBackticks(/\*\*Request headers:\*\*\s+(.+)/, authSection);
927
+ }
928
+ const endpoints = [];
929
+ const endpointSection = extractSection(body, "Known endpoints");
930
+ if (endpointSection) {
931
+ const endpointRegex = /^### (GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)\s*$/gim;
932
+ let m;
933
+ while ((m = endpointRegex.exec(endpointSection)) !== null) {
934
+ endpoints.push({ method: m[1], path: m[2] });
935
+ }
936
+ }
937
+ const notes = [];
938
+ const notesSection = extractSection(body, "Notes");
939
+ if (notesSection) {
940
+ for (const line of notesSection.split(`
941
+ `)) {
942
+ const m = line.match(/^-\s+(.+)$/);
943
+ if (m)
944
+ notes.push(m[1].trim());
945
+ }
946
+ }
947
+ return {
948
+ domain: fm.domain ?? "",
949
+ lastVerified: fm.lastVerified ?? "",
950
+ lastMode: fm.lastMode ?? "unknown",
951
+ browserRequired: fm.browserRequired !== "false",
952
+ auth,
953
+ endpoints,
954
+ notes
955
+ };
956
+ }
957
+ function extractSection(body, heading) {
958
+ const re = new RegExp(`^##\\s+${heading}\\s*$`, "m");
959
+ const match = body.match(re);
960
+ if (!match || match.index === undefined)
961
+ return null;
962
+ const start = match.index + match[0].length;
963
+ const nextSection = body.slice(start).match(/^##\s+/m);
964
+ const end = nextSection?.index != null ? start + nextSection.index : body.length;
965
+ return body.slice(start, end);
966
+ }
967
+ function extractBackticks(re, text) {
968
+ const m = text.match(re);
969
+ if (!m)
970
+ return;
971
+ const items = [...m[1].matchAll(/`([^`]+)`/g)].map((x) => x[1]);
972
+ return items.length > 0 ? items : undefined;
973
+ }
974
+
975
+ // src/mcp/tools/credentials.ts
976
+ var ELICIT_TIMEOUT_MS = 45000;
977
+ function mcpLog(event, data) {
978
+ try {
979
+ const dir = getDataDir();
980
+ import_fs5.default.mkdirSync(dir, { recursive: true });
981
+ import_fs5.default.appendFileSync(import_path7.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
982
+ `);
983
+ } catch {}
984
+ }
985
+ function domainMatches(normalized, stored) {
986
+ return stored.some((d) => d === normalized || normalized.endsWith("." + d) || d.endsWith("." + normalized));
987
+ }
988
+ function registerCredentialsTool(server) {
989
+ server.tool("credentials", `Manage stored login credentials. This tool ONLY stores and lists credentials — it does NOT log you into anything. Actual logins happen via the \`execute\` tool with a \`login\` step. Credentials are stored in a single local SQLite database shared by ALL browser modes (headless, binary-headful, docker-headful) — store once, login anywhere.
990
+
991
+ CORRECT WORKFLOW when the user needs to be logged into a site:
992
+ 1. Call \`credentials\` with \`action=list\`. READ THE RESPONSE LITERALLY. If it says "No credentials stored" then NO credentials exist. If it lists domains, those are the only ones stored.
993
+ 2. If the target domain IS in the list → skip to step 4. Credentials exist and are valid. Move on.
994
+ 3. If the target domain is NOT in the list → call \`credentials\` with \`action=store, domain=<site>\`. This attempts to pop a secure form in the user's UI. The response is either \`Credentials stored for <site>.\` (success) OR a loud error telling you the client doesn't support form elicitation, with instructions for the user to run a CLI command. Relay the error verbatim and STOP — do not proceed with login until the user confirms they ran the command.
995
+ 4. Call \`execute\` with \`[{type:"navigate", url:"https://<site>/login"}, {type:"login", domain:"<site>"}]\`. The login step auto-detects the form, fills stored credentials, handles 2FA, submits, and auto-escalates browser modes if blocked.
996
+
997
+ ═══════════════════════════════════════════════════════════════════════
998
+ CRITICAL RULES
999
+ ═══════════════════════════════════════════════════════════════════════
1000
+
1001
+ 1. **NEVER re-store credentials as a recovery from a failed login.** If credentials already exist, the store call will be REJECTED. Login failures are browser-mode / bot-detection / page-structure problems, not credential problems.
1002
+
1003
+ 2. **NEVER ask the user "do you have credentials?"** — call action=list and read the response.
1004
+
1005
+ 3. **NEVER confabulate.** If action=list returns "No credentials stored", the database is empty.
1006
+
1007
+ 4. **NEVER pretend a store call succeeded if the response was an error.**
1008
+
1009
+ 5. **NEVER ask the user to paste their password in chat.**
1010
+
1011
+ 6. **\`force: true\` on store is ONLY for explicit password changes.**`, {
1012
+ action: import_zod5.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
1013
+ domain: import_zod5.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
1014
+ force: import_zod5.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
1015
+ }, async ({ action, domain, force }) => {
1016
+ try {
1017
+ if (action === "list") {
1018
+ const credData2 = await localApiGet("/credentials");
1019
+ const domains = credData2.domains || [];
1020
+ if (!domains.length) {
1021
+ 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." }] };
1022
+ }
1023
+ return { content: [{ type: "text", text: `Stored credentials for:
1024
+ ${domains.map((d) => ` - ${d}`).join(`
1025
+ `)}` }] };
1026
+ }
1027
+ if (action !== "store")
1028
+ return err("Unknown action");
1029
+ if (!domain)
1030
+ return err("domain is required for action=store");
1031
+ const normalized = normalizeDomain(domain);
1032
+ const credData = await localApiGet("/credentials");
1033
+ const stored = credData.domains || [];
1034
+ if (domainMatches(normalized, stored) && !force) {
1035
+ mcpLog("credentials.store.rejected_already_exists", { domain: normalized });
1036
+ return err(`REFUSING TO RE-STORE: credentials for "${normalized}" already exist. ` + `Login failures are NOT credential problems — retry with a stronger browser mode.
1037
+
1038
+ ` + `Use \`force: true\` ONLY if the user explicitly says their password changed.`);
1039
+ }
1040
+ mcpLog("credentials.store.attempt", { domain: normalized, force: !!force });
1041
+ let result;
1042
+ try {
1043
+ const elicitPromise = server.server.elicitInput({
1044
+ mode: "form",
1045
+ message: `Enter your login credentials for ${normalized}. These are encrypted and stored locally — the agent never sees them.`,
1046
+ requestedSchema: {
1047
+ type: "object",
1048
+ properties: {
1049
+ username: { type: "string", title: "Username / Email" },
1050
+ password: { type: "string", title: "Password" },
1051
+ totp_secret: { type: "string", title: "TOTP Secret (leave empty if no 2FA)" }
1052
+ },
1053
+ required: ["username", "password"]
1054
+ }
1055
+ });
1056
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`elicitation timed out after ${ELICIT_TIMEOUT_MS}ms`)), ELICIT_TIMEOUT_MS));
1057
+ result = await Promise.race([elicitPromise, timeoutPromise]);
1058
+ } catch (elicitErr) {
1059
+ const msg = elicitErr instanceof Error ? elicitErr.message : String(elicitErr);
1060
+ mcpLog("credentials.store.elicit_failed", { domain: normalized, error: msg });
1061
+ return err(`FAILED TO STORE CREDENTIALS for ${normalized}: ${msg}
1062
+
1063
+ ` + `The user MUST run this command in their terminal:
1064
+
1065
+ ` + ` iframer-toolkit credentials add ${normalized}
1066
+
1067
+ ` + `After they run it, retry the login. DO NOT pretend credentials were stored.`);
1068
+ }
1069
+ mcpLog("credentials.store.elicit_result", { domain: normalized, action: result.action, hasContent: !!result.content });
1070
+ if (result.action !== "accept" || !result.content) {
1071
+ return err(`Credential form was ${result.action || "dismissed"}. No credentials saved for ${normalized}.`);
1072
+ }
1073
+ const { username, password, totp_secret } = result.content;
1074
+ if (!username || !password) {
1075
+ return err(`Form submitted but username or password was empty. No credentials saved.`);
1076
+ }
1077
+ try {
1078
+ const storeResult = await localApiPost("/credentials", {
1079
+ domain: normalized,
1080
+ username,
1081
+ password,
1082
+ totp_secret: totp_secret || undefined
1083
+ });
1084
+ if (!storeResult.ok)
1085
+ return err(`Failed to store: ${storeResult.error}`);
1086
+ } catch (storeErr) {
1087
+ const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
1088
+ mcpLog("credentials.store.write_failed", { domain: normalized, error: msg });
1089
+ return err(`Failed to write credentials: ${msg}`);
1090
+ }
1091
+ mcpLog("credentials.store.success", { domain: normalized });
1092
+ return { content: [{ type: "text", text: `Credentials stored for ${normalized}. Shared across all browser modes.` }] };
1093
+ } catch (e) {
1094
+ return err(`Error: ${getErrorMessage(e)}`);
1095
+ }
1096
+ });
1097
+ }
1098
+
1099
+ // src/mcp/tools/reverse-engineer.ts
1100
+ var import_zod6 = require("zod");
1101
+ function registerReverseEngineerTool(server) {
1102
+ server.tool("reverse-engineer", `Reverse-engineer a website's API. Navigates to a URL, performs the steps you specify, and captures every XHR/fetch request the page makes — including auth tokens, cookies, headers, request/response bodies, and ready-to-use curl commands.
1103
+
1104
+ Use this when the user asks to:
1105
+ - "reverse engineer" or "map" a site's API
1106
+ - "capture the endpoints" or "save the API"
1107
+ - "figure out how this site works under the hood"
1108
+ - "record the API calls" so they can be replayed later
1109
+
1110
+ How it works:
1111
+ 1. You provide steps (same as execute) — navigate, click, fill, extract, etc.
1112
+ 2. iframer runs them while recording all API calls the page makes
1113
+ 3. Returns structured data per domain: shared auth + endpoints classified by protocol (rest, graphql, json-rpc, grpc-web, form-rpc, soap) with method, path, action, verb, headers, body, response, curl
1114
+
1115
+ IMPORTANT — one request ≠ one endpoint. Each endpoint has (protocol, action):
1116
+ - REST: action = "METHOD /parameterized/path". Verb from HTTP method.
1117
+ - GraphQL: action = operationName (or doc_id for persisted queries). Many ops share the single /graphql URL — EACH operation is its own endpoint.
1118
+ - JSON-RPC: action = body.method (e.g. eth_getBalance, user.list).
1119
+ - gRPC-web: action = request path.
1120
+ - Form-RPC (FB-style urlencoded): action = fb_api_req_friendly_name / doc_id.
1121
+ - SOAP: action = SOAPAction header.
1122
+
1123
+ Generate ONE function per (protocol, action). Do NOT merge different GraphQL operations into one function just because they share the URL.
1124
+
1125
+ Output layout — save as RUNNABLE CODE to <outputDir>/:
1126
+ auth.{js,ts} — shared cookies, tokens, authorization
1127
+ transport/
1128
+ rest.{js,ts} — shared REST helper (only if any rest endpoints)
1129
+ graphql.{js,ts} — shared GraphQL client: post(operationName|docId, variables)
1130
+ jsonRpc.{js,ts} — shared JSON-RPC client (if any)
1131
+ grpc.{js,ts} — shared gRPC-web client (if any)
1132
+ <protocol>/<verb>/<functionName>.{js,ts}
1133
+ e.g. graphql/queries/getTimelineFeed.ts
1134
+ graphql/mutations/reactToPost.ts
1135
+ rest/read/getChannelMessages.ts
1136
+ rest/create/createMessage.ts
1137
+ jsonRpc/ethGetBalance.ts
1138
+ index.{js,ts} — re-exports all endpoint functions
1139
+ types.ts — (typed mode only) inferred interfaces from responses
1140
+ README.md — endpoints grouped by protocol + verb, dependency chain, auth expiry warning
1141
+
1142
+ 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.
1143
+
1144
+ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`, {
1145
+ steps: import_zod6.z.array(stepSchema).describe("Pipeline steps to execute while capturing API calls"),
1146
+ outputDir: import_zod6.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
1147
+ typed: import_zod6.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."),
1148
+ options: import_zod6.z.object({
1149
+ staleTimeoutMs: import_zod6.z.number().optional().describe("Override the 20s stale-state timeout per step"),
1150
+ continueOnObstacle: import_zod6.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
1151
+ continueOnError: import_zod6.z.boolean().optional().describe("Continue past failing steps (default: false)"),
1152
+ mode: import_zod6.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
1153
+ }).optional()
1154
+ }, async (params) => {
1155
+ try {
1156
+ const execParams = {
1157
+ steps: params.steps,
1158
+ options: { ...params.options, captureApi: true }
1159
+ };
1160
+ const mode = params.options?.mode;
1161
+ const dockerRunning = await isDockerRunning();
1162
+ const captureResult = mode === "docker-headful" && dockerRunning ? await apiPost("/execute", execParams) : await localApiPost("/execute", execParams);
1163
+ const lines = [];
1164
+ lines.push(`ok: ${captureResult.ok}`);
1165
+ lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
1166
+ if (captureResult.durationMs)
1167
+ lines.push(`duration: ${captureResult.durationMs}ms`);
1168
+ if (captureResult.finalState) {
1169
+ lines.push(`
1170
+ Final page: ${captureResult.finalState.title}`);
1171
+ lines.push(`URL: ${captureResult.finalState.url}`);
1172
+ }
1173
+ if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
1174
+ formatCapturedApi(lines, captureResult.capturedApi, params);
1175
+ } else {
1176
+ lines.push(`
1177
+ 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.`);
1178
+ }
1179
+ if (captureResult.error) {
1180
+ lines.push(`
1181
+ --- Failure ---`);
1182
+ if (typeof captureResult.error === "string") {
1183
+ lines.push(`Error: ${captureResult.error}`);
1184
+ } else {
1185
+ lines.push(`Failed at step ${captureResult.error.failedAtStep}: ${JSON.stringify(captureResult.error.failedStep)}`);
1186
+ lines.push(`Error type: ${captureResult.error.errorType}`);
1187
+ lines.push(`Message: ${captureResult.error.message}`);
1188
+ if (captureResult.error.suggestion)
1189
+ lines.push(`Suggestion: ${captureResult.error.suggestion}`);
1190
+ }
1191
+ }
1192
+ const screenshotUrl = captureResult.error?.pageState?.screenshotUrl ?? captureResult.finalState?.screenshotUrl;
1193
+ if (screenshotUrl) {
1194
+ const filePath = await resolveScreenshotPath(screenshotUrl);
1195
+ if (filePath) {
1196
+ lines.push(`
1197
+ Screenshot saved: ${filePath}`);
1198
+ lines.push("Use the Read tool on the path above to view the screenshot.");
1199
+ }
1200
+ }
1201
+ if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
1202
+ const mainDomain = captureResult.capturedApi[0]?.domain || "api";
1203
+ const outDir = params.outputDir || `./${mainDomain}`;
1204
+ try {
1205
+ const fs6 = await import("fs");
1206
+ const path8 = await import("path");
1207
+ fs6.mkdirSync(outDir, { recursive: true });
1208
+ const jsonPath = path8.join(outDir, "captured-api.json");
1209
+ fs6.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
1210
+ lines.push(`
1211
+ Full captured data saved to: ${jsonPath}`);
1212
+ lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
1213
+ } catch (writeErr) {
1214
+ lines.push(`
1215
+ (Could not save captured JSON: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
1216
+ }
1217
+ }
1218
+ const content = [{ type: "text", text: lines.join(`
1219
+ `) }];
1220
+ if (!captureResult.ok)
1221
+ return { content, isError: true };
1222
+ return { content };
1223
+ } catch (e) {
1224
+ return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
1225
+ }
1226
+ });
1227
+ }
1228
+ function formatCapturedApi(lines, capturedApi, params) {
1229
+ for (const api of capturedApi) {
1230
+ lines.push(`
1231
+ ━━━ ${api.domain} (${api.baseUrl}) ━━━`);
1232
+ const authParts = [];
1233
+ if (api.auth?.authorization)
1234
+ authParts.push(`Authorization: ${api.auth.authorization.slice(0, 30)}...`);
1235
+ if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0)
1236
+ authParts.push(`${Object.keys(api.auth.cookies).length} cookies`);
1237
+ if (api.auth?.tokens && Object.keys(api.auth.tokens).length > 0) {
1238
+ for (const [k, v] of Object.entries(api.auth.tokens)) {
1239
+ authParts.push(`${k}: ${String(v).slice(0, 30)}...`);
1240
+ }
1241
+ }
1242
+ if (authParts.length > 0) {
1243
+ lines.push(`
1244
+ Auth:`);
1245
+ for (const part of authParts)
1246
+ lines.push(` ${part}`);
1247
+ }
1248
+ const byProtocol = new Map;
1249
+ for (const ep of api.endpoints) {
1250
+ const p = ep.protocol || "rest";
1251
+ if (!byProtocol.has(p))
1252
+ byProtocol.set(p, []);
1253
+ byProtocol.get(p).push(ep);
1254
+ }
1255
+ const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
1256
+ lines.push(`
1257
+ Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
1258
+ for (const ep of api.endpoints) {
1259
+ lines.push(`
1260
+ [${ep.protocol}/${ep.verb}] ${ep.functionName}`);
1261
+ lines.push(` Action: ${ep.action}`);
1262
+ lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
1263
+ if (ep.requestBody) {
1264
+ const body = ep.requestBody;
1265
+ const signalKeys = extractSignalKeys(body);
1266
+ if (signalKeys)
1267
+ lines.push(` Params: ${signalKeys}`);
1268
+ }
1269
+ }
1270
+ }
1271
+ const mainDomain = capturedApi[0]?.domain || "api";
1272
+ const dir = params.outputDir || `./${mainDomain}`;
1273
+ const ext = params.typed ? ".ts" : ".js";
1274
+ lines.push(`
1275
+ ━━━ Save instructions ━━━`);
1276
+ lines.push(`Save to: ${dir}`);
1277
+ lines.push(`Format: ${ext} files`);
1278
+ const protocolsSeen = new Set;
1279
+ for (const api of capturedApi)
1280
+ for (const ep of api.endpoints)
1281
+ protocolsSeen.add(ep.protocol);
1282
+ lines.push(`
1283
+ Layout (protocols present: ${Array.from(protocolsSeen).join(", ")}):`);
1284
+ lines.push(` auth${ext}`);
1285
+ if (protocolsSeen.has("rest"))
1286
+ lines.push(` transport/rest${ext} — shared fetch wrapper with auth headers`);
1287
+ if (protocolsSeen.has("graphql"))
1288
+ lines.push(` transport/graphql${ext} — post(operationName|docId, variables) → shared GraphQL client`);
1289
+ if (protocolsSeen.has("json-rpc"))
1290
+ lines.push(` transport/jsonRpc${ext} — shared JSON-RPC client`);
1291
+ if (protocolsSeen.has("grpc-web"))
1292
+ lines.push(` transport/grpc${ext} — shared gRPC-web client`);
1293
+ if (protocolsSeen.has("form-rpc"))
1294
+ lines.push(` transport/formRpc${ext} — shared urlencoded RPC client`);
1295
+ if (protocolsSeen.has("soap"))
1296
+ lines.push(` transport/soap${ext} — shared SOAP client`);
1297
+ lines.push(` <protocol>/<verb>/<functionName>${ext} — one file per endpoint, uses shared transport`);
1298
+ lines.push(` e.g. graphql/queries/<fn>${ext}, graphql/mutations/<fn>${ext}, rest/read/<fn>${ext}, rest/create/<fn>${ext}`);
1299
+ lines.push(` index${ext} — re-export all endpoint functions`);
1300
+ if (params.typed)
1301
+ lines.push(` types.ts — interfaces inferred from response bodies`);
1302
+ lines.push(` README.md — endpoints grouped by protocol + verb, auth expiry warning`);
1303
+ lines.push(`
1304
+ Rules:`);
1305
+ lines.push(` - One function per (protocol, action). Use the functionName field verbatim for the file + export name.`);
1306
+ lines.push(` - verb=read|list → queries/ (GraphQL) or read/ (REST). verb=create|update|delete|action → mutations/ (GraphQL) or verb dir (REST).`);
1307
+ lines.push(` - Each endpoint file is minimal: import transport + auth, call transport with the action id, pass variables, return typed result.`);
1308
+ lines.push(` - GraphQL transport signature: post(opNameOrDocId: string, variables: object). Pick doc_id when present in captured body, else operationName.`);
1309
+ lines.push(` - Do NOT inline auth headers per endpoint — they live in auth${ext} and the transport reads them.`);
1310
+ lines.push(`
1311
+ IMPORTANT: Auth data contains real tokens/cookies — they expire. Remind the user.`);
1312
+ }
1313
+ function extractSignalKeys(body) {
1314
+ const NOISE = new Set([
1315
+ "__csr",
1316
+ "__hsdp",
1317
+ "__hblp",
1318
+ "__dyn",
1319
+ "__a",
1320
+ "__req",
1321
+ "__hs",
1322
+ "__comet_req",
1323
+ "__ccg",
1324
+ "__spin_r",
1325
+ "__spin_b",
1326
+ "__spin_t",
1327
+ "__jssesw",
1328
+ "lsd",
1329
+ "fb_dtsg",
1330
+ "fb_api_caller_class",
1331
+ "fb_api_req_friendly_name",
1332
+ "jazoest",
1333
+ "server_timestamps",
1334
+ "__s",
1335
+ "__user",
1336
+ "dpr",
1337
+ "__rev"
1338
+ ]);
1339
+ const signalEntries = [];
1340
+ for (const [k, v] of Object.entries(body)) {
1341
+ if (NOISE.has(k))
1342
+ continue;
1343
+ if (typeof v === "string" && v.length > 80) {
1344
+ signalEntries.push(`${k}=${v.slice(0, 40)}...`);
1345
+ } else if (typeof v === "object" && v !== null) {
1346
+ const keys = Object.keys(v).slice(0, 5).join(",");
1347
+ signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
1348
+ } else {
1349
+ signalEntries.push(`${k}=${String(v).slice(0, 40)}`);
1350
+ }
1351
+ }
1352
+ return signalEntries.length > 0 ? signalEntries.join(", ") : null;
1353
+ }
1354
+
1355
+ // src/mcp/tools/knowledge.ts
1356
+ var import_zod7 = require("zod");
1357
+ function registerKnowledgeTool(server) {
1358
+ server.tool("knowledge", `Per-domain knowledge cache. Call this BEFORE every \`execute\` or \`browse\` on a website — it's orders of magnitude faster than launching a browser when the cache already has what you need.
1359
+
1360
+ Each domain's cache is a plain markdown file at ~/.iframer/knowledge/<domain>.md containing:
1361
+ - Auth mechanism (which cookies / localStorage keys / headers are load-bearing)
1362
+ - Known API endpoints the site uses (captured from real browser runs)
1363
+ - Notes about captchas, bot detection, session behavior
1364
+ - Which browser mode last worked
1365
+
1366
+ MANDATORY WORKFLOW — for any task that targets a specific website:
1367
+
1368
+ 1. Call \`knowledge get <domain>\` first.
1369
+ 2. If the cache shows a direct-API path (auth material + endpoints) that satisfies the request, hit the endpoints directly using the agent's own fetch capability — the session cookies/headers are already injected into the current browser context, and the cache tells you exactly how to call them. Skip the browser entirely.
1370
+ 3. If the cache is empty, outdated, or doesn't cover what you need, fall through to \`execute\` with a pipeline. After \`execute\` succeeds, the cache gets updated automatically — future calls benefit.
1371
+ 4. If cached endpoints return 401/403, the session is stale — run an \`execute\` pipeline containing a \`login\` step to refresh. The cache will be re-verified automatically on success.
1372
+
1373
+ Actions:
1374
+ - get: return the markdown cache for a specific domain
1375
+ - list: show all cached domains with last-verified timestamps
1376
+ - clear: delete cache for a specific domain, or everything if domain is omitted`, {
1377
+ action: import_zod7.z.enum(["get", "list", "clear"]).describe("get: return cache for a domain | list: all cached domains | clear: delete cache"),
1378
+ domain: import_zod7.z.string().optional().describe("Domain (required for get; optional for clear — omit to clear everything)")
1379
+ }, async ({ action, domain }) => {
1380
+ try {
1381
+ if (action === "list") {
1382
+ const entries = listKnowledge();
1383
+ if (entries.length === 0) {
1384
+ return {
1385
+ content: [
1386
+ {
1387
+ type: "text",
1388
+ text: "No cached knowledge yet. Every successful `execute` run populates the cache for its target domain automatically."
1389
+ }
1390
+ ]
1391
+ };
1392
+ }
1393
+ const lines = [`${entries.length} domain${entries.length === 1 ? "" : "s"} cached:
1394
+ `];
1395
+ for (const e of entries) {
1396
+ lines.push(` ${e.domain} (${e.lastMode}, verified ${e.lastVerified}, ${e.sizeBytes}B)`);
1397
+ }
1398
+ lines.push("\nCall `knowledge get <domain>` for the full cache contents.");
1399
+ return { content: [{ type: "text", text: lines.join(`
1400
+ `) }] };
1401
+ }
1402
+ if (action === "get") {
1403
+ if (!domain)
1404
+ return err("domain is required for action=get");
1405
+ const md = readKnowledge(domain);
1406
+ if (!md) {
1407
+ return {
1408
+ content: [
1409
+ {
1410
+ type: "text",
1411
+ text: `No cache for ${sanitizeDomain(domain)}. Run \`execute\` with a pipeline that navigates to this domain (and optionally enables captureApi) — the cache will be populated automatically on success.`
1412
+ }
1413
+ ]
1414
+ };
1415
+ }
1416
+ return { content: [{ type: "text", text: md }] };
1417
+ }
1418
+ if (action === "clear") {
1419
+ const { removed } = clearKnowledge(domain);
1420
+ const scope = domain ? `for ${sanitizeDomain(domain)}` : "(all domains)";
1421
+ return {
1422
+ content: [
1423
+ {
1424
+ type: "text",
1425
+ text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
1426
+ }
1427
+ ]
1428
+ };
1429
+ }
1430
+ return err("Unknown action");
1431
+ } catch (e) {
1432
+ return err(`Error: ${getErrorMessage(e)}`);
1433
+ }
1434
+ });
1435
+ }
1436
+
1437
+ // src/mcp/server.ts
1438
+ var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
1439
+ var INSTRUCTIONS = IS_DEV ? `iframer-dev — local development instance of iframer (connects to ${BASE_URL}).
1440
+
1441
+ CRITICAL RULES:
1442
+ 1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
1443
+ 2. NEVER ask the user for passwords in chat. Use credentials action=store (secure form) or the CLI fallback.
1444
+ 3. DO NOT present options when you can just act. Check credentials, execute, show results.
1445
+ 4. If execute fails, read the error. It tells you exactly what happened and where.
1446
+ 5. Call "session" action=stop when done to save session state.
1447
+
1448
+ CAPTCHA: Use the "solve-captcha" step — auto-detects reCAPTCHA/hCaptcha and solves with vision AI.
1449
+ REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map APIs.` : `iframer — browser access for AI agents when normal methods fail.
1450
+
1451
+ CRITICAL RULES:
1452
+ 1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
1453
+ 2. NEVER ask for passwords in chat. Use credentials action=store or the CLI fallback.
1454
+ 3. DO NOT present options when you can just act. Check status, credentials, execute.
1455
+ 4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
1456
+ 5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
1457
+ 6. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
1458
+ 7. If the browser crashes, call "session restart" and retry. Don't panic.
1459
+
1460
+ BROWSER MODES: Don't specify options.mode — iframer auto-selects and auto-escalates (headless → binary-headful). Only set a mode if the user explicitly asks.
1461
+
1462
+ CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. In docker-headful mode, use the "solve-captcha" step.
1463
+
1464
+ REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
1465
+ var server = new import_mcp.McpServer({ name: "iframer", version: "3.0.0" }, { instructions: INSTRUCTIONS });
1466
+ registerStatusTool(server);
1467
+ registerBrowseTool(server);
1468
+ registerExecuteTool(server);
1469
+ registerSessionTool(server);
1470
+ registerCredentialsTool(server);
1471
+ registerReverseEngineerTool(server);
1472
+ registerKnowledgeTool(server);
1473
+ function cleanup() {
1474
+ localServer.shutdown();
1475
+ }
1476
+ process.on("exit", cleanup);
1477
+ process.on("SIGTERM", () => {
1478
+ cleanup();
1479
+ process.exit(0);
1480
+ });
1481
+ process.on("SIGINT", () => {
1482
+ cleanup();
1483
+ process.exit(0);
1484
+ });
1485
+ process.on("uncaughtException", (err2) => {
1486
+ try {
1487
+ console.error(`[mcp] uncaughtException: ${err2?.message}`);
1488
+ } catch {}
1489
+ });
1490
+ process.on("unhandledRejection", (reason) => {
1491
+ try {
1492
+ console.error(`[mcp] unhandledRejection: ${reason}`);
1493
+ } catch {}
1494
+ });
1495
+ var transport = new import_stdio.StdioServerTransport;
1496
+ (async () => {
1497
+ await server.connect(transport);
1498
+ })();