iframer-toolkit 0.7.1 → 0.9.0

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.
@@ -35,8 +35,8 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
35
35
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
36
36
 
37
37
  // src/mcp/helpers.ts
38
- var import_path4 = __toESM(require("path"));
39
- var import_os2 = __toESM(require("os"));
38
+ var import_path5 = __toESM(require("path"));
39
+ var import_os3 = __toESM(require("os"));
40
40
 
41
41
  // src/lib/logger.ts
42
42
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
@@ -66,6 +66,7 @@ function createLogger(tag) {
66
66
  // src/lib/auth/crypto.ts
67
67
  var import_crypto = __toESM(require("crypto"));
68
68
  var import_fs = __toESM(require("fs"));
69
+ var import_os2 = __toESM(require("os"));
69
70
  var import_path2 = __toESM(require("path"));
70
71
 
71
72
  // src/lib/paths.ts
@@ -79,164 +80,226 @@ function getDataDir() {
79
80
  function getLocalToken() {
80
81
  if (process.env.IFRAMER_SECRET)
81
82
  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";
83
+ const candidates = [
84
+ import_path2.default.join(getDataDir(), "secret"),
85
+ import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
86
+ ];
87
+ for (const file of candidates) {
88
+ try {
89
+ const existing = import_fs.default.readFileSync(file, "utf8").trim();
90
+ if (existing)
91
+ return existing;
92
+ } catch {}
93
+ }
94
+ for (const file of candidates) {
95
+ try {
96
+ import_fs.default.mkdirSync(import_path2.default.dirname(file), { recursive: true });
97
+ const secret = import_crypto.default.randomBytes(32).toString("hex");
98
+ import_fs.default.writeFileSync(file, secret, { mode: 384 });
99
+ return secret;
100
+ } catch {}
96
101
  }
102
+ throw new Error("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).");
97
103
  }
98
104
 
99
105
  // src/mcp/local-server.ts
100
106
  var import_child_process = require("child_process");
107
+ var import_net = __toESM(require("net"));
108
+ var import_fs3 = __toESM(require("fs"));
109
+ var import_path4 = __toESM(require("path"));
110
+
111
+ // src/lib/browser/registry.ts
101
112
  var import_fs2 = __toESM(require("fs"));
102
113
  var import_path3 = __toESM(require("path"));
114
+ var log = createLogger("registry");
115
+ function serverInfoPath() {
116
+ return import_path3.default.join(getDataDir(), "server.json");
117
+ }
118
+ function isPidAlive(pid) {
119
+ if (!Number.isInteger(pid) || pid <= 1)
120
+ return false;
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+ function readServerInfo() {
129
+ try {
130
+ const info = JSON.parse(import_fs2.default.readFileSync(serverInfoPath(), "utf8"));
131
+ if (!Number.isInteger(info.pid) || !Number.isInteger(info.port))
132
+ return null;
133
+ return info;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ // src/mcp/local-server.ts
103
140
  var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
104
- var PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
141
+ var BASE_PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
142
+ var PORT_SCAN_ATTEMPTS = 200;
105
143
  var STARTUP_TIMEOUT_MS = 15000;
106
144
  var HEALTH_POLL_MS = 300;
145
+ var SPAWN_LOCK_STALE_MS = 20000;
107
146
 
108
147
  class LocalServerManager {
109
- child = null;
110
148
  startingPromise = null;
111
- baseUrl;
149
+ baseUrl = "";
112
150
  logPath;
113
151
  constructor() {
114
- this.baseUrl = `http://127.0.0.1:${PORT}`;
115
- this.logPath = import_path3.default.join(getDataDir(), "local-server.log");
152
+ this.logPath = import_path4.default.join(getDataDir(), "local-server.log");
116
153
  }
117
154
  getBaseUrl() {
155
+ if (!this.baseUrl) {
156
+ throw new Error("Local server not started yet — call ensureRunning() first.");
157
+ }
118
158
  return this.baseUrl;
119
159
  }
120
160
  async ensureRunning() {
121
- if (this.child && !this.child.killed && await this.healthCheck())
122
- return;
123
161
  if (this.startingPromise)
124
162
  return this.startingPromise;
125
- this.startingPromise = this.doStart().finally(() => {
163
+ if (await this.adoptExisting())
164
+ return;
165
+ this.startingPromise = this.startShared().finally(() => {
126
166
  this.startingPromise = null;
127
167
  });
128
168
  return this.startingPromise;
129
169
  }
130
- async doStart() {
131
- await this.killExisting();
170
+ async adoptExisting() {
171
+ const info = readServerInfo();
172
+ if (!info || !isPidAlive(info.pid))
173
+ return false;
174
+ const url = `http://127.0.0.1:${info.port}`;
175
+ if (await healthCheck(url)) {
176
+ this.baseUrl = url;
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+ async startShared() {
132
182
  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 {}
183
+ import_fs3.default.mkdirSync(dataDir, { recursive: true });
184
+ const lockDir = import_path4.default.join(dataDir, "server.spawn-lock");
185
+ let holdingLock = false;
186
+ try {
187
+ import_fs3.default.mkdirSync(lockDir);
188
+ holdingLock = true;
189
+ } catch {
190
+ const stale = (() => {
191
+ try {
192
+ return Date.now() - import_fs3.default.statSync(lockDir).mtimeMs > SPAWN_LOCK_STALE_MS;
193
+ } catch {
194
+ return true;
195
+ }
196
+ })();
197
+ if (stale) {
198
+ try {
199
+ import_fs3.default.rmdirSync(lockDir);
200
+ import_fs3.default.mkdirSync(lockDir);
201
+ holdingLock = true;
202
+ } catch {}
203
+ }
150
204
  }
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())
205
+ try {
206
+ if (!holdingLock) {
207
+ const deadline2 = Date.now() + STARTUP_TIMEOUT_MS;
208
+ while (Date.now() < deadline2) {
209
+ if (await this.adoptExisting())
210
+ return;
211
+ await sleep(HEALTH_POLL_MS);
212
+ }
213
+ throw new Error("Timed out waiting for another session to start the shared iframer server.");
214
+ }
215
+ if (await this.adoptExisting())
163
216
  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.
217
+ const port = await findFreePort(BASE_PORT, PORT_SCAN_ATTEMPTS);
218
+ const url = `http://127.0.0.1:${port}`;
219
+ const { command, args } = this.resolveRuntime();
220
+ const logFd = import_fs3.default.openSync(this.logPath, "a");
221
+ const env = {
222
+ ...process.env,
223
+ PORT: String(port),
224
+ IFRAMER_MODE: "local",
225
+ IFRAMER_DATA_DIR: dataDir
226
+ };
227
+ if (process.env.IFRAMER_SECRET) {
228
+ env.IFRAMER_SECRET = process.env.IFRAMER_SECRET;
229
+ } else {
230
+ try {
231
+ const secret = import_fs3.default.readFileSync(import_path4.default.join(dataDir, "secret"), "utf8").trim();
232
+ if (secret)
233
+ env.IFRAMER_SECRET = secret;
234
+ } catch {}
235
+ }
236
+ const child = import_child_process.spawn(command, args, {
237
+ env,
238
+ stdio: ["ignore", logFd, logFd],
239
+ detached: true
240
+ });
241
+ child.unref();
242
+ import_fs3.default.closeSync(logFd);
243
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
244
+ while (Date.now() < deadline) {
245
+ if (await healthCheck(url)) {
246
+ this.baseUrl = url;
247
+ return;
248
+ }
249
+ await sleep(HEALTH_POLL_MS);
250
+ }
251
+ try {
252
+ child.kill("SIGKILL");
253
+ } catch {}
254
+ throw new Error(`Local iframer server failed to start on port ${port} within ${STARTUP_TIMEOUT_MS}ms.
169
255
  ` + `Last log lines:
170
- ${logTail}`);
256
+ ${this.readLogTail()}`);
257
+ } finally {
258
+ if (holdingLock) {
259
+ try {
260
+ import_fs3.default.rmdirSync(lockDir);
261
+ } catch {}
262
+ }
263
+ }
171
264
  }
172
265
  resolveRuntime() {
173
266
  try {
174
267
  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)) {
268
+ const serverTs2 = import_path4.default.join(__dirname, "..", "..", "index.ts");
269
+ if (import_fs3.default.existsSync(serverTs2)) {
177
270
  return { command: bunPath, args: ["run", serverTs2] };
178
271
  }
179
272
  } catch {}
180
- const serverCjs = import_path3.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
181
- if (import_fs2.default.existsSync(serverCjs)) {
273
+ const serverCjs = import_path4.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
274
+ if (import_fs3.default.existsSync(serverCjs)) {
182
275
  return { command: "node", args: [serverCjs] };
183
276
  }
184
- const serverTs = import_path3.default.join(__dirname, "..", "..", "index.ts");
277
+ const serverTs = import_path4.default.join(__dirname, "..", "..", "index.ts");
185
278
  return { command: "node", args: ["--import", "tsx", serverTs] };
186
279
  }
187
280
  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) {
281
+ const info = readServerInfo();
282
+ if (info && this.baseUrl) {
197
283
  try {
198
- this.child.kill("SIGTERM");
284
+ await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3000) });
199
285
  } 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) {
286
+ const deadline = Date.now() + 12000;
287
+ while (Date.now() < deadline && isPidAlive(info.pid)) {
288
+ await sleep(200);
289
+ }
290
+ if (isPidAlive(info.pid)) {
216
291
  try {
217
- await fetch(`${this.baseUrl}/shutdown`, {
218
- method: "POST",
219
- signal: AbortSignal.timeout(2000)
220
- });
292
+ process.kill(info.pid, "SIGKILL");
221
293
  } catch {}
222
- await sleep(500);
223
294
  }
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
295
  }
296
+ this.baseUrl = "";
297
+ await this.ensureRunning();
236
298
  }
299
+ shutdown() {}
237
300
  readLogTail() {
238
301
  try {
239
- const content = import_fs2.default.readFileSync(this.logPath, "utf8");
302
+ const content = import_fs3.default.readFileSync(this.logPath, "utf8");
240
303
  const lines = content.trim().split(`
241
304
  `);
242
305
  return lines.slice(-10).join(`
@@ -249,9 +312,43 @@ ${logTail}`);
249
312
  function sleep(ms) {
250
313
  return new Promise((r) => setTimeout(r, ms));
251
314
  }
315
+ async function healthCheck(baseUrl) {
316
+ try {
317
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
318
+ const data = await res.json();
319
+ return data.ok === true;
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+ function findFreePort(start, attempts) {
325
+ return new Promise((resolve, reject) => {
326
+ const tryPort = (p) => {
327
+ if (p >= start + attempts) {
328
+ reject(new Error(`No free port found in ${start}-${start + attempts}`));
329
+ return;
330
+ }
331
+ const srv = import_net.default.createServer();
332
+ srv.once("error", () => {
333
+ srv.close();
334
+ tryPort(p + 1);
335
+ });
336
+ srv.once("listening", () => {
337
+ srv.close(() => resolve(p));
338
+ });
339
+ srv.listen(p, "127.0.0.1");
340
+ };
341
+ tryPort(start);
342
+ });
343
+ }
344
+
345
+ // src/lib/errors.ts
346
+ function getErrorMessage(err) {
347
+ return err instanceof Error ? err.message : String(err);
348
+ }
252
349
 
253
350
  // src/mcp/helpers.ts
254
- var log = createLogger("mcp");
351
+ var log2 = createLogger("mcp");
255
352
  var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
256
353
  var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
257
354
  var IFRAMER_MODE = process.env.IFRAMER_MODE;
@@ -317,8 +414,8 @@ async function resolveScreenshotPath(url) {
317
414
  if (url.startsWith("file://")) {
318
415
  const { fileURLToPath } = await import("url");
319
416
  const filePath2 = fileURLToPath(url);
320
- const fs4 = await import("fs");
321
- if (fs4.existsSync(filePath2))
417
+ const fs5 = await import("fs");
418
+ if (fs5.existsSync(filePath2))
322
419
  return filePath2;
323
420
  return null;
324
421
  }
@@ -326,11 +423,11 @@ async function resolveScreenshotPath(url) {
326
423
  if (!res.ok)
327
424
  return null;
328
425
  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);
426
+ const fs4 = await import("fs");
427
+ const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
428
+ fs4.mkdirSync(dir, { recursive: true });
429
+ const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
430
+ fs4.writeFileSync(filePath, buf);
334
431
  return filePath;
335
432
  } catch {
336
433
  return null;
@@ -339,16 +436,13 @@ async function resolveScreenshotPath(url) {
339
436
  function err(message) {
340
437
  return { content: [{ type: "text", text: message }], isError: true };
341
438
  }
342
- function getErrorMessage(err2) {
343
- return err2 instanceof Error ? err2.message : String(err2);
344
- }
345
439
 
346
440
  // 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");
441
+ var import_fs4 = __toESM(require("fs"));
442
+ var import_path6 = __toESM(require("path"));
443
+ var log3 = createLogger("domain-modes");
350
444
  function defaultFile() {
351
- return import_path5.default.join(getDataDir(), "domain-modes.json");
445
+ return import_path6.default.join(getDataDir(), "domain-modes.json");
352
446
  }
353
447
  var TTL_DAYS = 14;
354
448
  var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
@@ -429,8 +523,8 @@ class DomainModeStore {
429
523
  }
430
524
  load() {
431
525
  try {
432
- if (import_fs3.default.existsSync(this.filePath)) {
433
- this.data = JSON.parse(import_fs3.default.readFileSync(this.filePath, "utf-8"));
526
+ if (import_fs4.default.existsSync(this.filePath)) {
527
+ this.data = JSON.parse(import_fs4.default.readFileSync(this.filePath, "utf-8"));
434
528
  }
435
529
  } catch {
436
530
  this.data = {};
@@ -438,10 +532,10 @@ class DomainModeStore {
438
532
  }
439
533
  save() {
440
534
  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));
535
+ import_fs4.default.mkdirSync(import_path6.default.dirname(this.filePath), { recursive: true });
536
+ import_fs4.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
443
537
  } catch (err2) {
444
- log2.error("Failed to save:", err2);
538
+ log3.error("Failed to save:", err2);
445
539
  }
446
540
  }
447
541
  }
@@ -554,6 +648,9 @@ var stepSchema = import_zod2.z.discriminatedUnion("type", [
554
648
  ]);
555
649
 
556
650
  // src/mcp/tools/execute.ts
651
+ function resultOf(r, _type) {
652
+ return r.result;
653
+ }
557
654
  function registerExecuteTool(server) {
558
655
  server.tool("execute", `Execute a pipeline of browser steps. Auto-starts a session if needed. Handles obstacles (captcha, cookie banners) automatically.
559
656
 
@@ -581,7 +678,8 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
581
678
  continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
582
679
  captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
583
680
  mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates."),
584
- autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)")
681
+ autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
682
+ instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state.")
585
683
  }).optional()
586
684
  }, async (params) => {
587
685
  try {
@@ -624,9 +722,9 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
624
722
  execResult = await runWithMode(params.options?.mode);
625
723
  } catch (execErr) {
626
724
  const msg = execErr instanceof Error ? execErr.message : String(execErr);
627
- const isCrash = /connect|ECONNREFUSED|EPIPE|closed|timed out|abort|fetch failed/i.test(msg);
725
+ const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
628
726
  if (isCrash) {
629
- log.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
727
+ log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
630
728
  try {
631
729
  await localServer.restart();
632
730
  } catch {}
@@ -649,7 +747,7 @@ Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
649
747
  for (const nextMode of escalation) {
650
748
  if (nextMode === "docker-headful" && !dockerRunning)
651
749
  continue;
652
- log.info(`Auto-escalating to ${nextMode}`);
750
+ log2.info(`Auto-escalating to ${nextMode}`);
653
751
  execResult = await runWithMode(nextMode);
654
752
  if (execResult.ok)
655
753
  break;
@@ -704,17 +802,26 @@ Knowledge cache updated for ${domain}. Before the next browser run on this domai
704
802
  }
705
803
  const meaningful = (data.results || []).filter((r) => r.ok && r.result !== undefined && r.result !== null);
706
804
  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(`
805
+ if (r.step.type === "snapshot") {
806
+ const res = resultOf(r, "snapshot");
807
+ if (res?.snapshot) {
808
+ lines.push(`
809
+ --- Snapshot (${res.elementCount} elements) ---`);
810
+ lines.push(res.snapshot);
811
+ }
812
+ } else if (r.step.type === "find") {
813
+ const res = resultOf(r, "find");
814
+ if (res?.ref) {
815
+ lines.push(`
816
+ Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchCount > 1 ? "es" : ""})`);
817
+ }
818
+ } else if (r.step.type === "screenshot") {
819
+ const res = resultOf(r, "screenshot");
820
+ if (res?.refs) {
821
+ lines.push(`
716
822
  --- Annotated screenshot refs ---`);
717
- lines.push(r.result.refs);
823
+ lines.push(res.refs);
824
+ }
718
825
  } else if (r.result !== undefined && r.result !== null) {
719
826
  lines.push(`
720
827
  step ${r.stepIndex}: ${JSON.stringify(r.result)}`);
@@ -759,71 +866,419 @@ ${api.domain} (${api.baseUrl})`);
759
866
  }
760
867
 
761
868
  // src/mcp/tools/session.ts
869
+ var import_zod5 = require("zod");
870
+ var import_path7 = __toESM(require("path"));
871
+ var import_fs5 = __toESM(require("fs"));
872
+
873
+ // src/mcp/tools/reverse-engineer.ts
762
874
  var import_zod4 = require("zod");
763
- function registerSessionTool(server) {
764
- server.tool("session", `Manage the browser session and lifecycle.
875
+ function registerReverseEngineerTool(server) {
876
+ 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.
765
877
 
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.
878
+ Use this when the user asks to:
879
+ - "reverse engineer" or "map" a site's API
880
+ - "capture the endpoints" or "save the API"
881
+ - "figure out how this site works under the hood"
882
+ - "record the API calls" so they can be replayed later
770
883
 
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 }) => {
884
+ How it works:
885
+ 1. You provide steps (same as execute) — navigate, click, fill, extract, etc.
886
+ 2. iframer runs them while recording all API calls the page makes
887
+ 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
888
+
889
+ IMPORTANT — one request ≠ one endpoint. Each endpoint has (protocol, action):
890
+ - REST: action = "METHOD /parameterized/path". Verb from HTTP method.
891
+ - GraphQL: action = operationName (or doc_id for persisted queries). Many ops share the single /graphql URL — EACH operation is its own endpoint.
892
+ - JSON-RPC: action = body.method (e.g. eth_getBalance, user.list).
893
+ - gRPC-web: action = request path.
894
+ - Form-RPC (FB-style urlencoded): action = fb_api_req_friendly_name / doc_id.
895
+ - SOAP: action = SOAPAction header.
896
+
897
+ Generate ONE function per (protocol, action). Do NOT merge different GraphQL operations into one function just because they share the URL.
898
+
899
+ Output layout — save as RUNNABLE CODE to <outputDir>/:
900
+ auth.{js,ts} — shared cookies, tokens, authorization
901
+ transport/
902
+ rest.{js,ts} — shared REST helper (only if any rest endpoints)
903
+ graphql.{js,ts} — shared GraphQL client: post(operationName|docId, variables)
904
+ jsonRpc.{js,ts} — shared JSON-RPC client (if any)
905
+ grpc.{js,ts} — shared gRPC-web client (if any)
906
+ <protocol>/<verb>/<functionName>.{js,ts}
907
+ e.g. graphql/queries/getTimelineFeed.ts
908
+ graphql/mutations/reactToPost.ts
909
+ rest/read/getChannelMessages.ts
910
+ rest/create/createMessage.ts
911
+ jsonRpc/ethGetBalance.ts
912
+ index.{js,ts} — re-exports all endpoint functions
913
+ types.ts — (typed mode only) inferred interfaces from responses
914
+ README.md — endpoints grouped by protocol + verb, dependency chain, auth expiry warning
915
+
916
+ Use capturedApi[i].endpoints[j].protocol, .action, .verb, .functionName directly — iframer already classified them. Put queries (verb=read|list) under queries/, mutations (verb=create|update|delete|action) under mutations/ for GraphQL. For REST, group by verb dir.
917
+
918
+ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`, {
919
+ steps: import_zod4.z.array(stepSchema).describe("Pipeline steps to execute while capturing API calls"),
920
+ outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
921
+ typed: import_zod4.z.boolean().optional().describe("Save as .ts with inferred types instead of .js. Set to true when the user asks for types, typescript, or type inference."),
922
+ options: import_zod4.z.object({
923
+ staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
924
+ continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
925
+ continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
926
+ mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
927
+ }).optional()
928
+ }, async (params) => {
774
929
  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}` }] };
930
+ const execParams = {
931
+ steps: params.steps,
932
+ options: { ...params.options, captureApi: true }
933
+ };
934
+ const mode = params.options?.mode;
935
+ const dockerRunning = await isDockerRunning();
936
+ const captureResult = mode === "docker-headful" && dockerRunning ? await apiPost("/execute", execParams) : await localApiPost("/execute", execParams);
937
+ const lines = [];
938
+ lines.push(`ok: ${captureResult.ok}`);
939
+ lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
940
+ if (captureResult.durationMs)
941
+ lines.push(`duration: ${captureResult.durationMs}ms`);
942
+ if (captureResult.finalState) {
943
+ lines.push(`
944
+ Final page: ${captureResult.finalState.title}`);
945
+ lines.push(`URL: ${captureResult.finalState.url}`);
778
946
  }
779
- if (action === "clear") {
780
- await localApiDelete("/session").catch(() => {});
781
- return { content: [{ type: "text", text: "Session data cleared from database." }] };
947
+ if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
948
+ formatCapturedApi(lines, captureResult.capturedApi, params);
949
+ } else {
950
+ lines.push(`
951
+ No API calls were captured. The page may not have made any XHR/fetch requests during the steps, or the steps may not have triggered the expected behavior.`);
782
952
  }
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
- }
953
+ if (captureResult.error) {
954
+ lines.push(`
955
+ --- Failure ---`);
956
+ if (typeof captureResult.error === "string") {
957
+ lines.push(`Error: ${captureResult.error}`);
958
+ } else {
959
+ lines.push(`Failed at step ${captureResult.error.failedAtStep}: ${JSON.stringify(captureResult.error.failedStep)}`);
960
+ lines.push(`Error type: ${captureResult.error.errorType}`);
961
+ lines.push(`Message: ${captureResult.error.message}`);
962
+ if (captureResult.error.suggestion)
963
+ lines.push(`Suggestion: ${captureResult.error.suggestion}`);
964
+ }
965
+ }
966
+ const screenshotUrl = captureResult.error?.pageState?.screenshotUrl ?? captureResult.finalState?.screenshotUrl;
967
+ if (screenshotUrl) {
968
+ const filePath = await resolveScreenshotPath(screenshotUrl);
969
+ if (filePath) {
970
+ lines.push(`
971
+ Screenshot saved: ${filePath}`);
972
+ lines.push("Use the Read tool on the path above to view the screenshot.");
795
973
  }
974
+ }
975
+ if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
976
+ const mainDomain = captureResult.capturedApi[0]?.domain || "api";
977
+ const outDir = params.outputDir || `./${mainDomain}`;
796
978
  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(" ") }] };
979
+ const fs5 = await import("fs");
980
+ const path7 = await import("path");
981
+ fs5.mkdirSync(outDir, { recursive: true });
982
+ const jsonPath = path7.join(outDir, "captured-api.json");
983
+ fs5.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
984
+ lines.push(`
985
+ Full captured data saved to: ${jsonPath}`);
986
+ lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
987
+ } catch (writeErr) {
988
+ lines.push(`
989
+ (Could not save captured JSON: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
990
+ }
804
991
  }
805
- return err("Unknown action");
806
- } catch (e) {
807
- return err(`Error: ${getErrorMessage(e)}`);
992
+ let text = lines.join(`
993
+ `);
994
+ if (text.length > 80000) {
995
+ text = text.slice(0, 80000) + `
996
+
997
+ [... response truncated — read captured-api.json for full data]`;
998
+ }
999
+ const content = [{ type: "text", text }];
1000
+ if (!captureResult.ok)
1001
+ return { content, isError: true };
1002
+ return { content };
1003
+ } catch (e) {
1004
+ return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
1005
+ }
1006
+ });
1007
+ }
1008
+ function formatCapturedApi(lines, capturedApi, params) {
1009
+ for (const api of capturedApi) {
1010
+ lines.push(`
1011
+ ━━━ ${api.domain} (${api.baseUrl}) ━━━`);
1012
+ const authParts = [];
1013
+ if (api.auth?.authorization)
1014
+ authParts.push(`Authorization: ${api.auth.authorization.slice(0, 30)}...`);
1015
+ if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0)
1016
+ authParts.push(`${Object.keys(api.auth.cookies).length} cookies`);
1017
+ if (api.auth?.tokens && Object.keys(api.auth.tokens).length > 0) {
1018
+ for (const [k, v] of Object.entries(api.auth.tokens)) {
1019
+ authParts.push(`${k}: ${String(v).slice(0, 30)}...`);
1020
+ }
1021
+ }
1022
+ if (authParts.length > 0) {
1023
+ lines.push(`
1024
+ Auth:`);
1025
+ for (const part of authParts)
1026
+ lines.push(` ${part}`);
1027
+ }
1028
+ const byProtocol = new Map;
1029
+ for (const ep of api.endpoints) {
1030
+ const p = ep.protocol || "rest";
1031
+ if (!byProtocol.has(p))
1032
+ byProtocol.set(p, []);
1033
+ byProtocol.get(p).push(ep);
1034
+ }
1035
+ const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
1036
+ lines.push(`
1037
+ Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
1038
+ for (const ep of api.endpoints) {
1039
+ lines.push(`
1040
+ [${ep.protocol}/${ep.verb}] ${ep.functionName}`);
1041
+ lines.push(` Action: ${ep.action}`);
1042
+ lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
1043
+ if (ep.requestBody) {
1044
+ const body = ep.requestBody;
1045
+ const signalKeys = extractSignalKeys(body);
1046
+ if (signalKeys)
1047
+ lines.push(` Params: ${signalKeys}`);
1048
+ }
1049
+ }
1050
+ }
1051
+ const mainDomain = capturedApi[0]?.domain || "api";
1052
+ const dir = params.outputDir || `./${mainDomain}`;
1053
+ const ext = params.typed ? ".ts" : ".js";
1054
+ lines.push(`
1055
+ ━━━ Save instructions ━━━`);
1056
+ lines.push(`Save to: ${dir}`);
1057
+ lines.push(`Format: ${ext} files`);
1058
+ const protocolsSeen = new Set;
1059
+ for (const api of capturedApi)
1060
+ for (const ep of api.endpoints)
1061
+ protocolsSeen.add(ep.protocol);
1062
+ lines.push(`
1063
+ Layout (protocols present: ${Array.from(protocolsSeen).join(", ")}):`);
1064
+ lines.push(` auth${ext}`);
1065
+ if (protocolsSeen.has("rest"))
1066
+ lines.push(` transport/rest${ext} — shared fetch wrapper with auth headers`);
1067
+ if (protocolsSeen.has("graphql"))
1068
+ lines.push(` transport/graphql${ext} — post(operationName|docId, variables) → shared GraphQL client`);
1069
+ if (protocolsSeen.has("json-rpc"))
1070
+ lines.push(` transport/jsonRpc${ext} — shared JSON-RPC client`);
1071
+ if (protocolsSeen.has("grpc-web"))
1072
+ lines.push(` transport/grpc${ext} — shared gRPC-web client`);
1073
+ if (protocolsSeen.has("form-rpc"))
1074
+ lines.push(` transport/formRpc${ext} — shared urlencoded RPC client`);
1075
+ if (protocolsSeen.has("soap"))
1076
+ lines.push(` transport/soap${ext} — shared SOAP client`);
1077
+ lines.push(` <protocol>/<verb>/<functionName>${ext} — one file per endpoint, uses shared transport`);
1078
+ lines.push(` e.g. graphql/queries/<fn>${ext}, graphql/mutations/<fn>${ext}, rest/read/<fn>${ext}, rest/create/<fn>${ext}`);
1079
+ lines.push(` index${ext} — re-export all endpoint functions`);
1080
+ if (params.typed)
1081
+ lines.push(` types.ts — interfaces inferred from response bodies`);
1082
+ lines.push(` README.md — endpoints grouped by protocol + verb, auth expiry warning`);
1083
+ lines.push(`
1084
+ Rules:`);
1085
+ lines.push(` - One function per (protocol, action). Use the functionName field verbatim for the file + export name.`);
1086
+ lines.push(` - verb=read|list → queries/ (GraphQL) or read/ (REST). verb=create|update|delete|action → mutations/ (GraphQL) or verb dir (REST).`);
1087
+ lines.push(` - Each endpoint file is minimal: import transport + auth, call transport with the action id, pass variables, return typed result.`);
1088
+ lines.push(` - GraphQL transport signature: post(opNameOrDocId: string, variables: object). Pick doc_id when present in captured body, else operationName.`);
1089
+ lines.push(` - Do NOT inline auth headers per endpoint — they live in auth${ext} and the transport reads them.`);
1090
+ lines.push(`
1091
+ IMPORTANT: Auth data contains real tokens/cookies — they expire. Remind the user.`);
1092
+ }
1093
+ function extractSignalKeys(body) {
1094
+ const NOISE = new Set([
1095
+ "__csr",
1096
+ "__hsdp",
1097
+ "__hblp",
1098
+ "__dyn",
1099
+ "__a",
1100
+ "__req",
1101
+ "__hs",
1102
+ "__comet_req",
1103
+ "__ccg",
1104
+ "__spin_r",
1105
+ "__spin_b",
1106
+ "__spin_t",
1107
+ "__jssesw",
1108
+ "lsd",
1109
+ "fb_dtsg",
1110
+ "fb_api_caller_class",
1111
+ "fb_api_req_friendly_name",
1112
+ "jazoest",
1113
+ "server_timestamps",
1114
+ "__s",
1115
+ "__user",
1116
+ "dpr",
1117
+ "__rev"
1118
+ ]);
1119
+ const signalEntries = [];
1120
+ for (const [k, v] of Object.entries(body)) {
1121
+ if (NOISE.has(k))
1122
+ continue;
1123
+ if (typeof v === "string" && v.length > 80) {
1124
+ signalEntries.push(`${k}=${v.slice(0, 40)}...`);
1125
+ } else if (typeof v === "object" && v !== null) {
1126
+ const keys = Object.keys(v).slice(0, 5).join(",");
1127
+ signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
1128
+ } else {
1129
+ signalEntries.push(`${k}=${String(v).slice(0, 40)}`);
1130
+ }
1131
+ }
1132
+ return signalEntries.length > 0 ? signalEntries.join(", ") : null;
1133
+ }
1134
+
1135
+ // src/mcp/tools/session.ts
1136
+ function registerSessionTool(server) {
1137
+ server.tool("session", `Manage the browser session and lifecycle.
1138
+
1139
+ Actions:
1140
+ - **stop**: save cookies/localStorage to the store, then close the browser. Session data persists for the next run.
1141
+ - **clear**: wipe all stored session data (cookies/localStorage) from the database. Does NOT kill running browsers.
1142
+ - **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.
1143
+ - **capture-start**: attach a persistent XHR/fetch listener to the running browser. Accumulates ALL requests indefinitely — not tied to any pipeline. Use before triggering the action you want to capture (e.g. upload, delete, async mutation).
1144
+ - **capture-stop**: stop the persistent listener and return all captured endpoints + save to disk. Call this when you're satisfied you've seen the requests you need.
1145
+ - **get-cookies**: extract ALL cookies from the browser context via CDP (including HttpOnly/Secure — below the JS sandbox). Pass urls to scope. Returns name, value, domain, path, httpOnly, secure, expiry.
1146
+ - **get-auth**: extract cookies + localStorage + sessionStorage in one shot. Everything needed to replay authenticated requests from Node.js.
1147
+
1148
+ Sessions live in the single local SQLite database (~/.iframer/iframer.db), shared across every browser mode.`, {
1149
+ 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"),
1150
+ mode: import_zod5.z.enum(["headless", "binary-headful"]).optional().describe("Browser mode (default: binary-headful)"),
1151
+ urls: import_zod5.z.array(import_zod5.z.string()).optional().describe("URLs to scope cookie extraction (get-cookies/get-auth). Omit for all cookies."),
1152
+ instanceId: import_zod5.z.string().optional().describe("Target a named parallel browser within this session (default: 'default'). Match the instanceId used in execute."),
1153
+ outputDir: import_zod5.z.string().optional().describe("Where to save captured-api.json for capture-stop")
1154
+ }, async ({ action, mode, urls, instanceId, outputDir }) => {
1155
+ try {
1156
+ if (action === "stop") {
1157
+ const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
1158
+ return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
1159
+ }
1160
+ if (action === "clear") {
1161
+ await localApiDelete("/session").catch(() => {});
1162
+ return { content: [{ type: "text", text: "Session data cleared from database." }] };
1163
+ }
1164
+ if (action === "restart") {
1165
+ const parts = [];
1166
+ try {
1167
+ await localApiPost("/browser/restart");
1168
+ parts.push("Local browser restarted.");
1169
+ } catch {
1170
+ try {
1171
+ await localServer.restart();
1172
+ parts.push("Local server respawned.");
1173
+ } catch (e) {
1174
+ parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
1175
+ }
1176
+ }
1177
+ try {
1178
+ if (await isDockerRunning()) {
1179
+ await apiPost("/browser/restart");
1180
+ parts.push("Docker browser restarted.");
1181
+ }
1182
+ } catch {}
1183
+ parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
1184
+ return { content: [{ type: "text", text: parts.join(" ") }] };
1185
+ }
1186
+ if (action === "capture-start") {
1187
+ const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
1188
+ return { content: [{ type: "text", text: result.message }] };
1189
+ }
1190
+ if (action === "capture-stop") {
1191
+ const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
1192
+ const lines = [result.message];
1193
+ if (result.capturedApi && result.capturedApi.length > 0) {
1194
+ formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
1195
+ const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
1196
+ try {
1197
+ import_fs5.default.mkdirSync(outDir, { recursive: true });
1198
+ const jsonPath = import_path7.default.join(outDir, "captured-api.json");
1199
+ import_fs5.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
1200
+ lines.push(`
1201
+ Full data saved to: ${jsonPath}`);
1202
+ } catch (writeErr) {
1203
+ lines.push(`
1204
+ (Could not save: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
1205
+ }
1206
+ }
1207
+ let text = lines.join(`
1208
+ `);
1209
+ if (text.length > 80000)
1210
+ text = text.slice(0, 80000) + `
1211
+
1212
+ [truncated — read captured-api.json]`;
1213
+ return { content: [{ type: "text", text }] };
1214
+ }
1215
+ if (action === "get-cookies") {
1216
+ const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
1217
+ const lines = [result.message, ""];
1218
+ for (const c of result.cookies) {
1219
+ lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
1220
+ }
1221
+ return { content: [{ type: "text", text: lines.join(`
1222
+ `) }] };
1223
+ }
1224
+ if (action === "get-auth") {
1225
+ const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
1226
+ const lines = [result.message, ""];
1227
+ lines.push("=== Cookies ===");
1228
+ for (const c of result.cookies) {
1229
+ lines.push(`${c.name}=${c.value} [domain=${c.domain} httpOnly=${c.httpOnly}]`);
1230
+ }
1231
+ if (Object.keys(result.localStorage || {}).length > 0) {
1232
+ lines.push(`
1233
+ === localStorage ===`);
1234
+ for (const [origin, store] of Object.entries(result.localStorage)) {
1235
+ lines.push(`[${origin}]`);
1236
+ for (const [k, v] of Object.entries(store)) {
1237
+ const val = String(v);
1238
+ lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "…" : val}`);
1239
+ }
1240
+ }
1241
+ }
1242
+ if (Object.keys(result.sessionStorage || {}).length > 0) {
1243
+ lines.push(`
1244
+ === sessionStorage ===`);
1245
+ for (const [origin, store] of Object.entries(result.sessionStorage)) {
1246
+ lines.push(`[${origin}]`);
1247
+ for (const [k, v] of Object.entries(store)) {
1248
+ const val = String(v);
1249
+ lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "…" : val}`);
1250
+ }
1251
+ }
1252
+ }
1253
+ let text = lines.join(`
1254
+ `);
1255
+ if (text.length > 80000)
1256
+ text = text.slice(0, 80000) + `
1257
+ [truncated]`;
1258
+ return { content: [{ type: "text", text }] };
1259
+ }
1260
+ return err("Unknown action");
1261
+ } catch (e) {
1262
+ return err(`Error: ${getErrorMessage(e)}`);
808
1263
  }
809
1264
  });
810
1265
  }
811
1266
 
812
1267
  // 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"));
1268
+ var import_zod6 = require("zod");
1269
+ var import_fs7 = __toESM(require("fs"));
1270
+ var import_path9 = __toESM(require("path"));
816
1271
 
817
1272
  // src/lib/knowledge.ts
818
- var import_fs4 = __toESM(require("fs"));
819
- var import_path6 = __toESM(require("path"));
820
- var log3 = createLogger("knowledge");
1273
+ var import_fs6 = __toESM(require("fs"));
1274
+ var import_path8 = __toESM(require("path"));
1275
+ var log4 = createLogger("knowledge");
821
1276
  function getKnowledgeDir() {
822
- return import_path6.default.join(getDataDir(), "knowledge");
1277
+ return import_path8.default.join(getDataDir(), "knowledge");
823
1278
  }
824
1279
  function getKnowledgePath(domain) {
825
1280
  const safe = sanitizeDomain(domain);
826
- return import_path6.default.join(getKnowledgeDir(), `${safe}.md`);
1281
+ return import_path8.default.join(getKnowledgeDir(), `${safe}.md`);
827
1282
  }
828
1283
  function normalizeDomain(input) {
829
1284
  let d = (input || "").trim().toLowerCase();
@@ -847,7 +1302,7 @@ function sanitizeDomain(input) {
847
1302
  function readKnowledge(domain) {
848
1303
  const p = getKnowledgePath(domain);
849
1304
  try {
850
- return import_fs4.default.readFileSync(p, "utf8");
1305
+ return import_fs6.default.readFileSync(p, "utf8");
851
1306
  } catch {
852
1307
  return null;
853
1308
  }
@@ -856,7 +1311,7 @@ function listKnowledge() {
856
1311
  const dir = getKnowledgeDir();
857
1312
  let entries = [];
858
1313
  try {
859
- entries = import_fs4.default.readdirSync(dir);
1314
+ entries = import_fs6.default.readdirSync(dir);
860
1315
  } catch {
861
1316
  return [];
862
1317
  }
@@ -864,10 +1319,10 @@ function listKnowledge() {
864
1319
  for (const file of entries) {
865
1320
  if (!file.endsWith(".md"))
866
1321
  continue;
867
- const full = import_path6.default.join(dir, file);
1322
+ const full = import_path8.default.join(dir, file);
868
1323
  try {
869
- const stat = import_fs4.default.statSync(full);
870
- const raw = import_fs4.default.readFileSync(full, "utf8");
1324
+ const stat = import_fs6.default.statSync(full);
1325
+ const raw = import_fs6.default.readFileSync(full, "utf8");
871
1326
  const parsed = parseMarkdown(raw);
872
1327
  results.push({
873
1328
  domain: parsed?.domain ?? file.replace(/\.md$/, ""),
@@ -885,7 +1340,7 @@ function clearKnowledge(domain) {
885
1340
  if (domain) {
886
1341
  const p = getKnowledgePath(domain);
887
1342
  try {
888
- import_fs4.default.unlinkSync(p);
1343
+ import_fs6.default.unlinkSync(p);
889
1344
  return { removed: 1 };
890
1345
  } catch {
891
1346
  return { removed: 0 };
@@ -893,11 +1348,11 @@ function clearKnowledge(domain) {
893
1348
  }
894
1349
  let removed = 0;
895
1350
  try {
896
- const entries = import_fs4.default.readdirSync(dir);
1351
+ const entries = import_fs6.default.readdirSync(dir);
897
1352
  for (const f of entries) {
898
1353
  if (f.endsWith(".md")) {
899
1354
  try {
900
- import_fs4.default.unlinkSync(import_path6.default.join(dir, f));
1355
+ import_fs6.default.unlinkSync(import_path8.default.join(dir, f));
901
1356
  removed++;
902
1357
  } catch {}
903
1358
  }
@@ -977,8 +1432,8 @@ var ELICIT_TIMEOUT_MS = 45000;
977
1432
  function mcpLog(event, data) {
978
1433
  try {
979
1434
  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 }) + `
1435
+ import_fs7.default.mkdirSync(dir, { recursive: true });
1436
+ import_fs7.default.appendFileSync(import_path9.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
982
1437
  `);
983
1438
  } catch {}
984
1439
  }
@@ -1009,9 +1464,9 @@ CRITICAL RULES
1009
1464
  5. **NEVER ask the user to paste their password in chat.**
1010
1465
 
1011
1466
  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.")
1467
+ action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
1468
+ domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
1469
+ force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
1015
1470
  }, async ({ action, domain, force }) => {
1016
1471
  try {
1017
1472
  if (action === "list") {
@@ -1096,262 +1551,6 @@ ${domains.map((d) => ` - ${d}`).join(`
1096
1551
  });
1097
1552
  }
1098
1553
 
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
1554
  // src/mcp/tools/knowledge.ts
1356
1555
  var import_zod7 = require("zod");
1357
1556
  function registerKnowledgeTool(server) {
@@ -1456,6 +1655,7 @@ CRITICAL RULES:
1456
1655
  5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
1457
1656
  6. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
1458
1657
  7. If the browser crashes, call "session restart" and retry. Don't panic.
1658
+ 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.
1459
1659
 
1460
1660
  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
1661
 
@@ -1470,18 +1670,8 @@ registerSessionTool(server);
1470
1670
  registerCredentialsTool(server);
1471
1671
  registerReverseEngineerTool(server);
1472
1672
  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
- });
1673
+ process.on("SIGTERM", () => process.exit(0));
1674
+ process.on("SIGINT", () => process.exit(0));
1485
1675
  process.on("uncaughtException", (err2) => {
1486
1676
  try {
1487
1677
  console.error(`[mcp] uncaughtException: ${err2?.message}`);