iframer-toolkit 0.7.1 → 0.8.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.
- package/README.md +6 -1
- package/dist/cli.js +276 -86
- package/dist/mcp-server.cjs +520 -414
- package/package.json +6 -3
package/dist/mcp-server.cjs
CHANGED
|
@@ -98,23 +98,28 @@ function getLocalToken() {
|
|
|
98
98
|
|
|
99
99
|
// src/mcp/local-server.ts
|
|
100
100
|
var import_child_process = require("child_process");
|
|
101
|
+
var import_net = __toESM(require("net"));
|
|
101
102
|
var import_fs2 = __toESM(require("fs"));
|
|
102
103
|
var import_path3 = __toESM(require("path"));
|
|
103
104
|
var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
|
|
104
|
-
var
|
|
105
|
+
var BASE_PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
|
|
106
|
+
var PORT_SCAN_ATTEMPTS = 200;
|
|
105
107
|
var STARTUP_TIMEOUT_MS = 15000;
|
|
106
108
|
var HEALTH_POLL_MS = 300;
|
|
107
109
|
|
|
108
110
|
class LocalServerManager {
|
|
109
111
|
child = null;
|
|
110
112
|
startingPromise = null;
|
|
111
|
-
|
|
113
|
+
port = null;
|
|
114
|
+
baseUrl = "";
|
|
112
115
|
logPath;
|
|
113
116
|
constructor() {
|
|
114
|
-
this.baseUrl = `http://127.0.0.1:${PORT}`;
|
|
115
117
|
this.logPath = import_path3.default.join(getDataDir(), "local-server.log");
|
|
116
118
|
}
|
|
117
119
|
getBaseUrl() {
|
|
120
|
+
if (!this.baseUrl) {
|
|
121
|
+
throw new Error("Local server not started yet — call ensureRunning() first.");
|
|
122
|
+
}
|
|
118
123
|
return this.baseUrl;
|
|
119
124
|
}
|
|
120
125
|
async ensureRunning() {
|
|
@@ -128,16 +133,18 @@ class LocalServerManager {
|
|
|
128
133
|
return this.startingPromise;
|
|
129
134
|
}
|
|
130
135
|
async doStart() {
|
|
131
|
-
await
|
|
136
|
+
this.port = await findFreePort(BASE_PORT, PORT_SCAN_ATTEMPTS);
|
|
137
|
+
this.baseUrl = `http://127.0.0.1:${this.port}`;
|
|
132
138
|
const dataDir = getDataDir();
|
|
133
139
|
import_fs2.default.mkdirSync(dataDir, { recursive: true });
|
|
134
140
|
const { command, args } = this.resolveRuntime();
|
|
135
141
|
const logFd = import_fs2.default.openSync(this.logPath, "a");
|
|
136
142
|
const env = {
|
|
137
143
|
...process.env,
|
|
138
|
-
PORT: String(
|
|
144
|
+
PORT: String(this.port),
|
|
139
145
|
IFRAMER_MODE: "local",
|
|
140
|
-
IFRAMER_DATA_DIR: dataDir
|
|
146
|
+
IFRAMER_DATA_DIR: dataDir,
|
|
147
|
+
IFRAMER_PARENT_PID: String(process.pid)
|
|
141
148
|
};
|
|
142
149
|
if (process.env.IFRAMER_SECRET) {
|
|
143
150
|
env.IFRAMER_SECRET = process.env.IFRAMER_SECRET;
|
|
@@ -165,7 +172,7 @@ class LocalServerManager {
|
|
|
165
172
|
}
|
|
166
173
|
this.kill();
|
|
167
174
|
const logTail = this.readLogTail();
|
|
168
|
-
throw new Error(`Local iframer server failed to start on port ${
|
|
175
|
+
throw new Error(`Local iframer server failed to start on port ${this.port} within ${STARTUP_TIMEOUT_MS}ms.
|
|
169
176
|
` + `Last log lines:
|
|
170
177
|
${logTail}`);
|
|
171
178
|
}
|
|
@@ -207,23 +214,9 @@ ${logTail}`);
|
|
|
207
214
|
}
|
|
208
215
|
this.child = null;
|
|
209
216
|
}
|
|
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
217
|
async healthCheck() {
|
|
218
|
+
if (!this.baseUrl)
|
|
219
|
+
return false;
|
|
227
220
|
try {
|
|
228
221
|
const res = await fetch(`${this.baseUrl}/health`, {
|
|
229
222
|
signal: AbortSignal.timeout(2000)
|
|
@@ -249,6 +242,26 @@ ${logTail}`);
|
|
|
249
242
|
function sleep(ms) {
|
|
250
243
|
return new Promise((r) => setTimeout(r, ms));
|
|
251
244
|
}
|
|
245
|
+
function findFreePort(start, attempts) {
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
const tryPort = (p) => {
|
|
248
|
+
if (p >= start + attempts) {
|
|
249
|
+
reject(new Error(`No free port found in ${start}-${start + attempts}`));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const srv = import_net.default.createServer();
|
|
253
|
+
srv.once("error", () => {
|
|
254
|
+
srv.close();
|
|
255
|
+
tryPort(p + 1);
|
|
256
|
+
});
|
|
257
|
+
srv.once("listening", () => {
|
|
258
|
+
srv.close(() => resolve(p));
|
|
259
|
+
});
|
|
260
|
+
srv.listen(p, "127.0.0.1");
|
|
261
|
+
};
|
|
262
|
+
tryPort(start);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
252
265
|
|
|
253
266
|
// src/mcp/helpers.ts
|
|
254
267
|
var log = createLogger("mcp");
|
|
@@ -581,7 +594,8 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
|
|
|
581
594
|
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
582
595
|
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
|
|
583
596
|
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)")
|
|
597
|
+
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
598
|
+
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
599
|
}).optional()
|
|
586
600
|
}, async (params) => {
|
|
587
601
|
try {
|
|
@@ -759,150 +773,498 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
759
773
|
}
|
|
760
774
|
|
|
761
775
|
// src/mcp/tools/session.ts
|
|
776
|
+
var import_zod5 = require("zod");
|
|
777
|
+
var import_path6 = __toESM(require("path"));
|
|
778
|
+
var import_fs4 = __toESM(require("fs"));
|
|
779
|
+
|
|
780
|
+
// src/mcp/tools/reverse-engineer.ts
|
|
762
781
|
var import_zod4 = require("zod");
|
|
763
|
-
function
|
|
764
|
-
server.tool("
|
|
782
|
+
function registerReverseEngineerTool(server) {
|
|
783
|
+
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
784
|
|
|
766
|
-
|
|
767
|
-
-
|
|
768
|
-
-
|
|
769
|
-
-
|
|
785
|
+
Use this when the user asks to:
|
|
786
|
+
- "reverse engineer" or "map" a site's API
|
|
787
|
+
- "capture the endpoints" or "save the API"
|
|
788
|
+
- "figure out how this site works under the hood"
|
|
789
|
+
- "record the API calls" so they can be replayed later
|
|
770
790
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
791
|
+
How it works:
|
|
792
|
+
1. You provide steps (same as execute) — navigate, click, fill, extract, etc.
|
|
793
|
+
2. iframer runs them while recording all API calls the page makes
|
|
794
|
+
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
|
|
795
|
+
|
|
796
|
+
IMPORTANT — one request ≠ one endpoint. Each endpoint has (protocol, action):
|
|
797
|
+
- REST: action = "METHOD /parameterized/path". Verb from HTTP method.
|
|
798
|
+
- GraphQL: action = operationName (or doc_id for persisted queries). Many ops share the single /graphql URL — EACH operation is its own endpoint.
|
|
799
|
+
- JSON-RPC: action = body.method (e.g. eth_getBalance, user.list).
|
|
800
|
+
- gRPC-web: action = request path.
|
|
801
|
+
- Form-RPC (FB-style urlencoded): action = fb_api_req_friendly_name / doc_id.
|
|
802
|
+
- SOAP: action = SOAPAction header.
|
|
803
|
+
|
|
804
|
+
Generate ONE function per (protocol, action). Do NOT merge different GraphQL operations into one function just because they share the URL.
|
|
805
|
+
|
|
806
|
+
Output layout — save as RUNNABLE CODE to <outputDir>/:
|
|
807
|
+
auth.{js,ts} — shared cookies, tokens, authorization
|
|
808
|
+
transport/
|
|
809
|
+
rest.{js,ts} — shared REST helper (only if any rest endpoints)
|
|
810
|
+
graphql.{js,ts} — shared GraphQL client: post(operationName|docId, variables)
|
|
811
|
+
jsonRpc.{js,ts} — shared JSON-RPC client (if any)
|
|
812
|
+
grpc.{js,ts} — shared gRPC-web client (if any)
|
|
813
|
+
<protocol>/<verb>/<functionName>.{js,ts}
|
|
814
|
+
e.g. graphql/queries/getTimelineFeed.ts
|
|
815
|
+
graphql/mutations/reactToPost.ts
|
|
816
|
+
rest/read/getChannelMessages.ts
|
|
817
|
+
rest/create/createMessage.ts
|
|
818
|
+
jsonRpc/ethGetBalance.ts
|
|
819
|
+
index.{js,ts} — re-exports all endpoint functions
|
|
820
|
+
types.ts — (typed mode only) inferred interfaces from responses
|
|
821
|
+
README.md — endpoints grouped by protocol + verb, dependency chain, auth expiry warning
|
|
822
|
+
|
|
823
|
+
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.
|
|
824
|
+
|
|
825
|
+
The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`, {
|
|
826
|
+
steps: import_zod4.z.array(stepSchema).describe("Pipeline steps to execute while capturing API calls"),
|
|
827
|
+
outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
|
|
828
|
+
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."),
|
|
829
|
+
options: import_zod4.z.object({
|
|
830
|
+
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
831
|
+
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
832
|
+
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
833
|
+
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
|
|
834
|
+
}).optional()
|
|
835
|
+
}, async (params) => {
|
|
774
836
|
try {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
837
|
+
const execParams = {
|
|
838
|
+
steps: params.steps,
|
|
839
|
+
options: { ...params.options, captureApi: true }
|
|
840
|
+
};
|
|
841
|
+
const mode = params.options?.mode;
|
|
842
|
+
const dockerRunning = await isDockerRunning();
|
|
843
|
+
const captureResult = mode === "docker-headful" && dockerRunning ? await apiPost("/execute", execParams) : await localApiPost("/execute", execParams);
|
|
844
|
+
const lines = [];
|
|
845
|
+
lines.push(`ok: ${captureResult.ok}`);
|
|
846
|
+
lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
|
|
847
|
+
if (captureResult.durationMs)
|
|
848
|
+
lines.push(`duration: ${captureResult.durationMs}ms`);
|
|
849
|
+
if (captureResult.finalState) {
|
|
850
|
+
lines.push(`
|
|
851
|
+
Final page: ${captureResult.finalState.title}`);
|
|
852
|
+
lines.push(`URL: ${captureResult.finalState.url}`);
|
|
778
853
|
}
|
|
779
|
-
if (
|
|
780
|
-
|
|
781
|
-
|
|
854
|
+
if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
|
|
855
|
+
formatCapturedApi(lines, captureResult.capturedApi, params);
|
|
856
|
+
} else {
|
|
857
|
+
lines.push(`
|
|
858
|
+
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
859
|
}
|
|
783
|
-
if (
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
860
|
+
if (captureResult.error) {
|
|
861
|
+
lines.push(`
|
|
862
|
+
--- Failure ---`);
|
|
863
|
+
if (typeof captureResult.error === "string") {
|
|
864
|
+
lines.push(`Error: ${captureResult.error}`);
|
|
865
|
+
} else {
|
|
866
|
+
lines.push(`Failed at step ${captureResult.error.failedAtStep}: ${JSON.stringify(captureResult.error.failedStep)}`);
|
|
867
|
+
lines.push(`Error type: ${captureResult.error.errorType}`);
|
|
868
|
+
lines.push(`Message: ${captureResult.error.message}`);
|
|
869
|
+
if (captureResult.error.suggestion)
|
|
870
|
+
lines.push(`Suggestion: ${captureResult.error.suggestion}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const screenshotUrl = captureResult.error?.pageState?.screenshotUrl ?? captureResult.finalState?.screenshotUrl;
|
|
874
|
+
if (screenshotUrl) {
|
|
875
|
+
const filePath = await resolveScreenshotPath(screenshotUrl);
|
|
876
|
+
if (filePath) {
|
|
877
|
+
lines.push(`
|
|
878
|
+
Screenshot saved: ${filePath}`);
|
|
879
|
+
lines.push("Use the Read tool on the path above to view the screenshot.");
|
|
795
880
|
}
|
|
881
|
+
}
|
|
882
|
+
if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
|
|
883
|
+
const mainDomain = captureResult.capturedApi[0]?.domain || "api";
|
|
884
|
+
const outDir = params.outputDir || `./${mainDomain}`;
|
|
796
885
|
try {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
886
|
+
const fs4 = await import("fs");
|
|
887
|
+
const path6 = await import("path");
|
|
888
|
+
fs4.mkdirSync(outDir, { recursive: true });
|
|
889
|
+
const jsonPath = path6.join(outDir, "captured-api.json");
|
|
890
|
+
fs4.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
|
|
891
|
+
lines.push(`
|
|
892
|
+
Full captured data saved to: ${jsonPath}`);
|
|
893
|
+
lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
|
|
894
|
+
} catch (writeErr) {
|
|
895
|
+
lines.push(`
|
|
896
|
+
(Could not save captured JSON: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
|
|
897
|
+
}
|
|
804
898
|
}
|
|
805
|
-
|
|
899
|
+
let text = lines.join(`
|
|
900
|
+
`);
|
|
901
|
+
if (text.length > 80000) {
|
|
902
|
+
text = text.slice(0, 80000) + `
|
|
903
|
+
|
|
904
|
+
[... response truncated — read captured-api.json for full data]`;
|
|
905
|
+
}
|
|
906
|
+
const content = [{ type: "text", text }];
|
|
907
|
+
if (!captureResult.ok)
|
|
908
|
+
return { content, isError: true };
|
|
909
|
+
return { content };
|
|
806
910
|
} catch (e) {
|
|
807
|
-
return err(`
|
|
911
|
+
return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
|
|
808
912
|
}
|
|
809
913
|
});
|
|
810
914
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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 {}
|
|
915
|
+
function formatCapturedApi(lines, capturedApi, params) {
|
|
916
|
+
for (const api of capturedApi) {
|
|
917
|
+
lines.push(`
|
|
918
|
+
━━━ ${api.domain} (${api.baseUrl}) ━━━`);
|
|
919
|
+
const authParts = [];
|
|
920
|
+
if (api.auth?.authorization)
|
|
921
|
+
authParts.push(`Authorization: ${api.auth.authorization.slice(0, 30)}...`);
|
|
922
|
+
if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0)
|
|
923
|
+
authParts.push(`${Object.keys(api.auth.cookies).length} cookies`);
|
|
924
|
+
if (api.auth?.tokens && Object.keys(api.auth.tokens).length > 0) {
|
|
925
|
+
for (const [k, v] of Object.entries(api.auth.tokens)) {
|
|
926
|
+
authParts.push(`${k}: ${String(v).slice(0, 30)}...`);
|
|
903
927
|
}
|
|
904
928
|
}
|
|
905
|
-
|
|
929
|
+
if (authParts.length > 0) {
|
|
930
|
+
lines.push(`
|
|
931
|
+
Auth:`);
|
|
932
|
+
for (const part of authParts)
|
|
933
|
+
lines.push(` ${part}`);
|
|
934
|
+
}
|
|
935
|
+
const byProtocol = new Map;
|
|
936
|
+
for (const ep of api.endpoints) {
|
|
937
|
+
const p = ep.protocol || "rest";
|
|
938
|
+
if (!byProtocol.has(p))
|
|
939
|
+
byProtocol.set(p, []);
|
|
940
|
+
byProtocol.get(p).push(ep);
|
|
941
|
+
}
|
|
942
|
+
const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
|
|
943
|
+
lines.push(`
|
|
944
|
+
Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
|
|
945
|
+
for (const ep of api.endpoints) {
|
|
946
|
+
lines.push(`
|
|
947
|
+
[${ep.protocol}/${ep.verb}] ${ep.functionName}`);
|
|
948
|
+
lines.push(` Action: ${ep.action}`);
|
|
949
|
+
lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
|
|
950
|
+
if (ep.requestBody) {
|
|
951
|
+
const body = ep.requestBody;
|
|
952
|
+
const signalKeys = extractSignalKeys(body);
|
|
953
|
+
if (signalKeys)
|
|
954
|
+
lines.push(` Params: ${signalKeys}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const mainDomain = capturedApi[0]?.domain || "api";
|
|
959
|
+
const dir = params.outputDir || `./${mainDomain}`;
|
|
960
|
+
const ext = params.typed ? ".ts" : ".js";
|
|
961
|
+
lines.push(`
|
|
962
|
+
━━━ Save instructions ━━━`);
|
|
963
|
+
lines.push(`Save to: ${dir}`);
|
|
964
|
+
lines.push(`Format: ${ext} files`);
|
|
965
|
+
const protocolsSeen = new Set;
|
|
966
|
+
for (const api of capturedApi)
|
|
967
|
+
for (const ep of api.endpoints)
|
|
968
|
+
protocolsSeen.add(ep.protocol);
|
|
969
|
+
lines.push(`
|
|
970
|
+
Layout (protocols present: ${Array.from(protocolsSeen).join(", ")}):`);
|
|
971
|
+
lines.push(` auth${ext}`);
|
|
972
|
+
if (protocolsSeen.has("rest"))
|
|
973
|
+
lines.push(` transport/rest${ext} — shared fetch wrapper with auth headers`);
|
|
974
|
+
if (protocolsSeen.has("graphql"))
|
|
975
|
+
lines.push(` transport/graphql${ext} — post(operationName|docId, variables) → shared GraphQL client`);
|
|
976
|
+
if (protocolsSeen.has("json-rpc"))
|
|
977
|
+
lines.push(` transport/jsonRpc${ext} — shared JSON-RPC client`);
|
|
978
|
+
if (protocolsSeen.has("grpc-web"))
|
|
979
|
+
lines.push(` transport/grpc${ext} — shared gRPC-web client`);
|
|
980
|
+
if (protocolsSeen.has("form-rpc"))
|
|
981
|
+
lines.push(` transport/formRpc${ext} — shared urlencoded RPC client`);
|
|
982
|
+
if (protocolsSeen.has("soap"))
|
|
983
|
+
lines.push(` transport/soap${ext} — shared SOAP client`);
|
|
984
|
+
lines.push(` <protocol>/<verb>/<functionName>${ext} — one file per endpoint, uses shared transport`);
|
|
985
|
+
lines.push(` e.g. graphql/queries/<fn>${ext}, graphql/mutations/<fn>${ext}, rest/read/<fn>${ext}, rest/create/<fn>${ext}`);
|
|
986
|
+
lines.push(` index${ext} — re-export all endpoint functions`);
|
|
987
|
+
if (params.typed)
|
|
988
|
+
lines.push(` types.ts — interfaces inferred from response bodies`);
|
|
989
|
+
lines.push(` README.md — endpoints grouped by protocol + verb, auth expiry warning`);
|
|
990
|
+
lines.push(`
|
|
991
|
+
Rules:`);
|
|
992
|
+
lines.push(` - One function per (protocol, action). Use the functionName field verbatim for the file + export name.`);
|
|
993
|
+
lines.push(` - verb=read|list → queries/ (GraphQL) or read/ (REST). verb=create|update|delete|action → mutations/ (GraphQL) or verb dir (REST).`);
|
|
994
|
+
lines.push(` - Each endpoint file is minimal: import transport + auth, call transport with the action id, pass variables, return typed result.`);
|
|
995
|
+
lines.push(` - GraphQL transport signature: post(opNameOrDocId: string, variables: object). Pick doc_id when present in captured body, else operationName.`);
|
|
996
|
+
lines.push(` - Do NOT inline auth headers per endpoint — they live in auth${ext} and the transport reads them.`);
|
|
997
|
+
lines.push(`
|
|
998
|
+
IMPORTANT: Auth data contains real tokens/cookies — they expire. Remind the user.`);
|
|
999
|
+
}
|
|
1000
|
+
function extractSignalKeys(body) {
|
|
1001
|
+
const NOISE = new Set([
|
|
1002
|
+
"__csr",
|
|
1003
|
+
"__hsdp",
|
|
1004
|
+
"__hblp",
|
|
1005
|
+
"__dyn",
|
|
1006
|
+
"__a",
|
|
1007
|
+
"__req",
|
|
1008
|
+
"__hs",
|
|
1009
|
+
"__comet_req",
|
|
1010
|
+
"__ccg",
|
|
1011
|
+
"__spin_r",
|
|
1012
|
+
"__spin_b",
|
|
1013
|
+
"__spin_t",
|
|
1014
|
+
"__jssesw",
|
|
1015
|
+
"lsd",
|
|
1016
|
+
"fb_dtsg",
|
|
1017
|
+
"fb_api_caller_class",
|
|
1018
|
+
"fb_api_req_friendly_name",
|
|
1019
|
+
"jazoest",
|
|
1020
|
+
"server_timestamps",
|
|
1021
|
+
"__s",
|
|
1022
|
+
"__user",
|
|
1023
|
+
"dpr",
|
|
1024
|
+
"__rev"
|
|
1025
|
+
]);
|
|
1026
|
+
const signalEntries = [];
|
|
1027
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1028
|
+
if (NOISE.has(k))
|
|
1029
|
+
continue;
|
|
1030
|
+
if (typeof v === "string" && v.length > 80) {
|
|
1031
|
+
signalEntries.push(`${k}=${v.slice(0, 40)}...`);
|
|
1032
|
+
} else if (typeof v === "object" && v !== null) {
|
|
1033
|
+
const keys = Object.keys(v).slice(0, 5).join(",");
|
|
1034
|
+
signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
|
|
1035
|
+
} else {
|
|
1036
|
+
signalEntries.push(`${k}=${String(v).slice(0, 40)}`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return signalEntries.length > 0 ? signalEntries.join(", ") : null;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// src/mcp/tools/session.ts
|
|
1043
|
+
function registerSessionTool(server) {
|
|
1044
|
+
server.tool("session", `Manage the browser session and lifecycle.
|
|
1045
|
+
|
|
1046
|
+
Actions:
|
|
1047
|
+
- **stop**: save cookies/localStorage to the store, then close the browser. Session data persists for the next run.
|
|
1048
|
+
- **clear**: wipe all stored session data (cookies/localStorage) from the database. Does NOT kill running browsers.
|
|
1049
|
+
- **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.
|
|
1050
|
+
- **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).
|
|
1051
|
+
- **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.
|
|
1052
|
+
- **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.
|
|
1053
|
+
- **get-auth**: extract cookies + localStorage + sessionStorage in one shot. Everything needed to replay authenticated requests from Node.js.
|
|
1054
|
+
|
|
1055
|
+
Sessions live in the single local SQLite database (~/.iframer/iframer.db), shared across every browser mode.`, {
|
|
1056
|
+
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"),
|
|
1057
|
+
mode: import_zod5.z.enum(["headless", "binary-headful"]).optional().describe("Browser mode (default: binary-headful)"),
|
|
1058
|
+
urls: import_zod5.z.array(import_zod5.z.string()).optional().describe("URLs to scope cookie extraction (get-cookies/get-auth). Omit for all cookies."),
|
|
1059
|
+
instanceId: import_zod5.z.string().optional().describe("Target a named parallel browser within this session (default: 'default'). Match the instanceId used in execute."),
|
|
1060
|
+
outputDir: import_zod5.z.string().optional().describe("Where to save captured-api.json for capture-stop")
|
|
1061
|
+
}, async ({ action, mode, urls, instanceId, outputDir }) => {
|
|
1062
|
+
try {
|
|
1063
|
+
if (action === "stop") {
|
|
1064
|
+
const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
|
|
1065
|
+
return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
|
|
1066
|
+
}
|
|
1067
|
+
if (action === "clear") {
|
|
1068
|
+
await localApiDelete("/session").catch(() => {});
|
|
1069
|
+
return { content: [{ type: "text", text: "Session data cleared from database." }] };
|
|
1070
|
+
}
|
|
1071
|
+
if (action === "restart") {
|
|
1072
|
+
const parts = [];
|
|
1073
|
+
try {
|
|
1074
|
+
await localApiPost("/browser/restart");
|
|
1075
|
+
parts.push("Local browser restarted.");
|
|
1076
|
+
} catch {
|
|
1077
|
+
try {
|
|
1078
|
+
await localServer.restart();
|
|
1079
|
+
parts.push("Local server respawned.");
|
|
1080
|
+
} catch (e) {
|
|
1081
|
+
parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
try {
|
|
1085
|
+
if (await isDockerRunning()) {
|
|
1086
|
+
await apiPost("/browser/restart");
|
|
1087
|
+
parts.push("Docker browser restarted.");
|
|
1088
|
+
}
|
|
1089
|
+
} catch {}
|
|
1090
|
+
parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
|
|
1091
|
+
return { content: [{ type: "text", text: parts.join(" ") }] };
|
|
1092
|
+
}
|
|
1093
|
+
if (action === "capture-start") {
|
|
1094
|
+
const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
|
|
1095
|
+
return { content: [{ type: "text", text: result.message }] };
|
|
1096
|
+
}
|
|
1097
|
+
if (action === "capture-stop") {
|
|
1098
|
+
const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
|
|
1099
|
+
const lines = [result.message];
|
|
1100
|
+
if (result.capturedApi && result.capturedApi.length > 0) {
|
|
1101
|
+
formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
|
|
1102
|
+
const outDir = outputDir || import_path6.default.join(getDataDir(), "capture");
|
|
1103
|
+
try {
|
|
1104
|
+
import_fs4.default.mkdirSync(outDir, { recursive: true });
|
|
1105
|
+
const jsonPath = import_path6.default.join(outDir, "captured-api.json");
|
|
1106
|
+
import_fs4.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
|
|
1107
|
+
lines.push(`
|
|
1108
|
+
Full data saved to: ${jsonPath}`);
|
|
1109
|
+
} catch (writeErr) {
|
|
1110
|
+
lines.push(`
|
|
1111
|
+
(Could not save: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
let text = lines.join(`
|
|
1115
|
+
`);
|
|
1116
|
+
if (text.length > 80000)
|
|
1117
|
+
text = text.slice(0, 80000) + `
|
|
1118
|
+
|
|
1119
|
+
[truncated — read captured-api.json]`;
|
|
1120
|
+
return { content: [{ type: "text", text }] };
|
|
1121
|
+
}
|
|
1122
|
+
if (action === "get-cookies") {
|
|
1123
|
+
const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1124
|
+
const lines = [result.message, ""];
|
|
1125
|
+
for (const c of result.cookies) {
|
|
1126
|
+
lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
|
|
1127
|
+
}
|
|
1128
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1129
|
+
`) }] };
|
|
1130
|
+
}
|
|
1131
|
+
if (action === "get-auth") {
|
|
1132
|
+
const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
|
|
1133
|
+
const lines = [result.message, ""];
|
|
1134
|
+
lines.push("=== Cookies ===");
|
|
1135
|
+
for (const c of result.cookies) {
|
|
1136
|
+
lines.push(`${c.name}=${c.value} [domain=${c.domain} httpOnly=${c.httpOnly}]`);
|
|
1137
|
+
}
|
|
1138
|
+
if (Object.keys(result.localStorage || {}).length > 0) {
|
|
1139
|
+
lines.push(`
|
|
1140
|
+
=== localStorage ===`);
|
|
1141
|
+
for (const [origin, store] of Object.entries(result.localStorage)) {
|
|
1142
|
+
lines.push(`[${origin}]`);
|
|
1143
|
+
for (const [k, v] of Object.entries(store)) {
|
|
1144
|
+
const val = String(v);
|
|
1145
|
+
lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "…" : val}`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (Object.keys(result.sessionStorage || {}).length > 0) {
|
|
1150
|
+
lines.push(`
|
|
1151
|
+
=== sessionStorage ===`);
|
|
1152
|
+
for (const [origin, store] of Object.entries(result.sessionStorage)) {
|
|
1153
|
+
lines.push(`[${origin}]`);
|
|
1154
|
+
for (const [k, v] of Object.entries(store)) {
|
|
1155
|
+
const val = String(v);
|
|
1156
|
+
lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "…" : val}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
let text = lines.join(`
|
|
1161
|
+
`);
|
|
1162
|
+
if (text.length > 80000)
|
|
1163
|
+
text = text.slice(0, 80000) + `
|
|
1164
|
+
[truncated]`;
|
|
1165
|
+
return { content: [{ type: "text", text }] };
|
|
1166
|
+
}
|
|
1167
|
+
return err("Unknown action");
|
|
1168
|
+
} catch (e) {
|
|
1169
|
+
return err(`Error: ${getErrorMessage(e)}`);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// src/mcp/tools/credentials.ts
|
|
1175
|
+
var import_zod6 = require("zod");
|
|
1176
|
+
var import_fs6 = __toESM(require("fs"));
|
|
1177
|
+
var import_path8 = __toESM(require("path"));
|
|
1178
|
+
|
|
1179
|
+
// src/lib/knowledge.ts
|
|
1180
|
+
var import_fs5 = __toESM(require("fs"));
|
|
1181
|
+
var import_path7 = __toESM(require("path"));
|
|
1182
|
+
var log3 = createLogger("knowledge");
|
|
1183
|
+
function getKnowledgeDir() {
|
|
1184
|
+
return import_path7.default.join(getDataDir(), "knowledge");
|
|
1185
|
+
}
|
|
1186
|
+
function getKnowledgePath(domain) {
|
|
1187
|
+
const safe = sanitizeDomain(domain);
|
|
1188
|
+
return import_path7.default.join(getKnowledgeDir(), `${safe}.md`);
|
|
1189
|
+
}
|
|
1190
|
+
function normalizeDomain(input) {
|
|
1191
|
+
let d = (input || "").trim().toLowerCase();
|
|
1192
|
+
if (!d)
|
|
1193
|
+
return "";
|
|
1194
|
+
try {
|
|
1195
|
+
if (d.includes("://")) {
|
|
1196
|
+
d = new URL(d).hostname;
|
|
1197
|
+
} else if (d.includes("/")) {
|
|
1198
|
+
d = new URL(`https://${d}`).hostname;
|
|
1199
|
+
}
|
|
1200
|
+
} catch {}
|
|
1201
|
+
d = d.replace(/:\d+$/, "");
|
|
1202
|
+
d = d.replace(/^www\./, "");
|
|
1203
|
+
return d;
|
|
1204
|
+
}
|
|
1205
|
+
function sanitizeDomain(input) {
|
|
1206
|
+
const normalized = normalizeDomain(input);
|
|
1207
|
+
return normalized.replace(/[^a-z0-9.-]/g, "_");
|
|
1208
|
+
}
|
|
1209
|
+
function readKnowledge(domain) {
|
|
1210
|
+
const p = getKnowledgePath(domain);
|
|
1211
|
+
try {
|
|
1212
|
+
return import_fs5.default.readFileSync(p, "utf8");
|
|
1213
|
+
} catch {
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
function listKnowledge() {
|
|
1218
|
+
const dir = getKnowledgeDir();
|
|
1219
|
+
let entries = [];
|
|
1220
|
+
try {
|
|
1221
|
+
entries = import_fs5.default.readdirSync(dir);
|
|
1222
|
+
} catch {
|
|
1223
|
+
return [];
|
|
1224
|
+
}
|
|
1225
|
+
const results = [];
|
|
1226
|
+
for (const file of entries) {
|
|
1227
|
+
if (!file.endsWith(".md"))
|
|
1228
|
+
continue;
|
|
1229
|
+
const full = import_path7.default.join(dir, file);
|
|
1230
|
+
try {
|
|
1231
|
+
const stat = import_fs5.default.statSync(full);
|
|
1232
|
+
const raw = import_fs5.default.readFileSync(full, "utf8");
|
|
1233
|
+
const parsed = parseMarkdown(raw);
|
|
1234
|
+
results.push({
|
|
1235
|
+
domain: parsed?.domain ?? file.replace(/\.md$/, ""),
|
|
1236
|
+
lastVerified: parsed?.lastVerified ?? new Date(stat.mtimeMs).toISOString(),
|
|
1237
|
+
lastMode: parsed?.lastMode ?? "unknown",
|
|
1238
|
+
sizeBytes: stat.size
|
|
1239
|
+
});
|
|
1240
|
+
} catch {}
|
|
1241
|
+
}
|
|
1242
|
+
results.sort((a, b) => a.lastVerified < b.lastVerified ? 1 : -1);
|
|
1243
|
+
return results;
|
|
1244
|
+
}
|
|
1245
|
+
function clearKnowledge(domain) {
|
|
1246
|
+
const dir = getKnowledgeDir();
|
|
1247
|
+
if (domain) {
|
|
1248
|
+
const p = getKnowledgePath(domain);
|
|
1249
|
+
try {
|
|
1250
|
+
import_fs5.default.unlinkSync(p);
|
|
1251
|
+
return { removed: 1 };
|
|
1252
|
+
} catch {
|
|
1253
|
+
return { removed: 0 };
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
let removed = 0;
|
|
1257
|
+
try {
|
|
1258
|
+
const entries = import_fs5.default.readdirSync(dir);
|
|
1259
|
+
for (const f of entries) {
|
|
1260
|
+
if (f.endsWith(".md")) {
|
|
1261
|
+
try {
|
|
1262
|
+
import_fs5.default.unlinkSync(import_path7.default.join(dir, f));
|
|
1263
|
+
removed++;
|
|
1264
|
+
} catch {}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
} catch {}
|
|
906
1268
|
return { removed };
|
|
907
1269
|
}
|
|
908
1270
|
function parseMarkdown(raw) {
|
|
@@ -977,8 +1339,8 @@ var ELICIT_TIMEOUT_MS = 45000;
|
|
|
977
1339
|
function mcpLog(event, data) {
|
|
978
1340
|
try {
|
|
979
1341
|
const dir = getDataDir();
|
|
980
|
-
|
|
981
|
-
|
|
1342
|
+
import_fs6.default.mkdirSync(dir, { recursive: true });
|
|
1343
|
+
import_fs6.default.appendFileSync(import_path8.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
|
|
982
1344
|
`);
|
|
983
1345
|
} catch {}
|
|
984
1346
|
}
|
|
@@ -1009,9 +1371,9 @@ CRITICAL RULES
|
|
|
1009
1371
|
5. **NEVER ask the user to paste their password in chat.**
|
|
1010
1372
|
|
|
1011
1373
|
6. **\`force: true\` on store is ONLY for explicit password changes.**`, {
|
|
1012
|
-
action:
|
|
1013
|
-
domain:
|
|
1014
|
-
force:
|
|
1374
|
+
action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
|
|
1375
|
+
domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
|
|
1376
|
+
force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
|
|
1015
1377
|
}, async ({ action, domain, force }) => {
|
|
1016
1378
|
try {
|
|
1017
1379
|
if (action === "list") {
|
|
@@ -1096,262 +1458,6 @@ ${domains.map((d) => ` - ${d}`).join(`
|
|
|
1096
1458
|
});
|
|
1097
1459
|
}
|
|
1098
1460
|
|
|
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
1461
|
// src/mcp/tools/knowledge.ts
|
|
1356
1462
|
var import_zod7 = require("zod");
|
|
1357
1463
|
function registerKnowledgeTool(server) {
|