iframer-toolkit 0.10.5 → 0.10.6
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 +165 -65
- package/dist/cli.js +140 -14
- package/dist/local-server.cjs +129 -13
- package/dist/mcp-server.cjs +2 -0
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/skills/iframer.md +4 -3
package/README.md
CHANGED
|
@@ -1,23 +1,62 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="docs/logo.svg" width="104" alt="iframer">
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
No. This is a tool I use almost EVERY SINGLE DAY and I have put a lot of thought and real engineering to actually make this a tool I am proud of.
|
|
7
|
-
Why did I develop this? Because I felt like the other agentic browsers didn't give me enough power and felt very expensive token-wise.
|
|
5
|
+
# iframer
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Resulting rev API example:
|
|
12
|
-
<img width="2141" height="1173" alt="image" src="https://github.com/user-attachments/assets/19a27904-ebe0-4e65-8a44-a8cfb3756ed8" />
|
|
7
|
+
**Give an AI agent a real browser, get an API back.**<br>
|
|
8
|
+
Drive a site once and iframer hands the agent the actual request behind it, no devtools, no HAR file.
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
10
|
+

|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+

|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
<img src="docs/example/demo.gif" width="760" alt="left: a demo page being clicked. right: the terminal, running iframer reverse-engineer, showing the captured endpoint">
|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
Most agent-driven browsers only replay clicks. Every visit to the same page means rendering the whole thing again, screenshot after screenshot, just to read one field off it.
|
|
22
|
+
|
|
23
|
+
iframer drives a real, stealth-patched Chromium, or your own logged-in Chrome through the extension, and while it does:
|
|
24
|
+
|
|
25
|
+
- Records the fetch and XHR calls the page makes while it drives it (not static assets), no manual devtools work
|
|
26
|
+
- Hands back the real request behind a login form or a button click: method, headers, auth, a working curl
|
|
27
|
+
- Generates typed functions ready to `import`, when an agent is doing the driving
|
|
28
|
+
- Caches the domain after the first run, so the next visit skips the browser and hits the endpoint directly, seconds down to milliseconds
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
The left side of the GIF above is [`docs/example/site/index.html`](docs/example/site/index.html): one button, one hidden API call. The right side is a real terminal, only the typing pace was staged for the recording; the command and its output are exactly what running it produces. Capturing it for real:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
cd docs/example/site && python3 -m http.server 8934 &
|
|
36
|
+
iframer-toolkit reverse-engineer '[
|
|
37
|
+
{"type":"navigate","url":"http://localhost:8934/index.html"},
|
|
38
|
+
{"type":"click","selector":"#load"},
|
|
39
|
+
{"type":"wait","ms":1000}
|
|
40
|
+
]' --output docs/example/api
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That produced [`docs/example/api/captured-api.json`](docs/example/api/captured-api.json): the exact request, headers, response body, and a curl command, all automatic, no agent required.
|
|
44
|
+
|
|
45
|
+
The rest of `docs/example/api/` (`getTodos.ts`, `transport/rest.ts`, `types.ts`, `README.md`) is what an agent writes from that capture: iframer tells it the endpoint's protocol, verb, and function name, and the agent turns that into a typed function. That's what happened here, driven by Claude through the MCP `reverse-engineer` tool.
|
|
46
|
+
|
|
47
|
+
The whole round trip, one click to one typed endpoint, cost about 590 tokens back to the agent. This is the whole thing you get: no HAR file, no manual devtools work, a function you can call.
|
|
48
|
+
|
|
49
|
+
## Token cost
|
|
19
50
|
|
|
20
|
-
|
|
51
|
+
Most of iframer's savings come from the snapshot it hands back: capped and filtered, instead of the full accessibility tree other tools dump into context. Measured with the same tokenizer, same steps, on the same three tasks:
|
|
52
|
+
|
|
53
|
+
| Task | iframer (MCP) | iframer (CLI) | agent-browser | Playwright MCP |
|
|
54
|
+
|------|---------------|----------------|----------------|-----------------|
|
|
55
|
+
| Wiki link chase (6 hops, Everest → Gojo) | 517 | 721 | 913 | 1,405 |
|
|
56
|
+
| Store purchase (login → sort → cart → checkout) | 552 | 697 | 851 | 2,739 |
|
|
57
|
+
| Heavy-page recon (GitHub: look + pull 4 facts) | 986 | 1,058 | 8,433 | 19,906 |
|
|
58
|
+
|
|
59
|
+
The gap grows with how much DOM the page has. On the GitHub page, iframer spent about 986 tokens where Playwright MCP spent about 19,900, roughly 20x. That's per-task cost. It doesn't include the roughly 5.1k tokens of MCP tool-definition overhead loaded once per session, not per task.
|
|
21
60
|
|
|
22
61
|
## Install
|
|
23
62
|
|
|
@@ -47,11 +86,17 @@ iframer-toolkit update # update via npm (also refreshes the extensio
|
|
|
47
86
|
iframer-toolkit update --check # just report whether a newer version exists
|
|
48
87
|
```
|
|
49
88
|
|
|
50
|
-
|
|
89
|
+
Your agent can also just run `iframer-toolkit install` for you; it figures out the rest.
|
|
90
|
+
|
|
91
|
+
Ships as:
|
|
92
|
+
- **CLI** (`iframer-toolkit` / `iframer`): browse, screenshot, credentials, sessions, reverse-engineer APIs
|
|
93
|
+
- **MCP server**: plugs into Claude Code or Codex so agents drive the browser themselves
|
|
94
|
+
- **Browser extension** (optional): lets the agent drive tabs in your real Chrome, on your real logged-in session
|
|
95
|
+
- **Self-hosted Docker server** (optional): adds live headful browsing over noVNC for remote or multi-user setups
|
|
51
96
|
|
|
52
97
|
## Quick start
|
|
53
98
|
|
|
54
|
-
Once installed,
|
|
99
|
+
Once installed, drive the browser via the CLI directly, or ask your agent (Claude Code / Codex) to do it through the MCP.
|
|
55
100
|
|
|
56
101
|
**CLI:**
|
|
57
102
|
|
|
@@ -60,7 +105,7 @@ iframer-toolkit status # system + browser m
|
|
|
60
105
|
iframer-toolkit browse https://example.com --extract 'document.title'
|
|
61
106
|
iframer-toolkit screenshot https://news.ycombinator.com -o /tmp/hn.png
|
|
62
107
|
iframer-toolkit credentials add github.com # interactive masked prompt
|
|
63
|
-
iframer-toolkit reverse-engineer https://some-spa.com
|
|
108
|
+
iframer-toolkit reverse-engineer https://some-spa.com # capture the APIs it calls
|
|
64
109
|
iframer-toolkit --cache # list cached domains
|
|
65
110
|
```
|
|
66
111
|
|
|
@@ -68,44 +113,80 @@ iframer-toolkit --cache # list cached domain
|
|
|
68
113
|
|
|
69
114
|
> "Log into my account on example.com and extract the latest invoice."
|
|
70
115
|
|
|
71
|
-
The agent
|
|
116
|
+
The agent calls `knowledge` first to check for a cached direct-API path, falls back to `credentials` + `execute` if there isn't one, auto-escalates browser modes if a site blocks headless, and returns the result. No copying cookies, no proxies, no manual login.
|
|
72
117
|
|
|
73
118
|
## How it works
|
|
74
119
|
|
|
75
120
|
```
|
|
76
|
-
Claude / Codex (MCP)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
Claude / Codex (MCP) ──▶ iframer MCP server ──▶ shared local server (127.0.0.1)
|
|
122
|
+
├─ patchright (stealth Chromium)
|
|
123
|
+
├─ Chrome for Testing
|
|
124
|
+
├─ your real Chrome (optional, via extension)
|
|
125
|
+
└─ SQLite at ~/.iframer: one file for
|
|
126
|
+
encrypted credentials, session state,
|
|
127
|
+
and per-domain knowledge cache
|
|
83
128
|
```
|
|
84
129
|
|
|
85
|
-
By default, `install mcp` runs in **local mode
|
|
130
|
+
By default, `install mcp` runs in **local mode**, no Docker needed. Both the MCP clients and the `iframer execute` CLI talk to one shared local server (discovered via `~/.iframer/server.json`, loopback only) that keeps a stealth-patched Chromium warm between calls, tracks every browser it spawns in an on-disk PID registry so nothing leaks, and idle-exits when no one needs it. `install mcp` registers the server in both Claude Code (`~/.claude.json`) and Codex (`~/.codex/config.toml`).
|
|
86
131
|
|
|
87
|
-
**Warm CLI.** `iframer execute` routes to that shared daemon instead of launching a cold browser per invocation, so after the first call it's as fast as the MCP (pass `--in-process` to force a private throwaway browser). Each agent gets its own browser instance
|
|
132
|
+
**Warm CLI.** `iframer execute` routes to that shared daemon instead of launching a cold browser per invocation, so after the first call it's as fast as the MCP (pass `--in-process` to force a private throwaway browser). Each agent gets its own browser instance, keyed off `CLAUDE_CODE_SESSION_ID` / `TERM_SESSION_ID` or `IFRAMER_INSTANCE`, so concurrent agents never share a window, while all CLI calls share one login session.
|
|
88
133
|
|
|
89
|
-
**Resuming a task.** The browser and its page persist in the daemon between calls and survive an interrupt (Ctrl-C). Name a multi-step or human-in-the-loop task's browser with a stable `instanceId`, and to resume
|
|
134
|
+
**Resuming a task.** The browser and its page persist in the daemon between calls and survive an interrupt (Ctrl-C). Name a multi-step or human-in-the-loop task's browser with a stable `instanceId`, and to resume, run `execute` again with the same `instanceId` and act on the current page (`snapshot` / `read` / `find`). Don't navigate again; that reloads the page and loses state like an OTP screen. `iframer instances` lists the live windows and which page each is on.
|
|
90
135
|
|
|
91
|
-
**One credential store for every browser mode.** Stored credentials, session cookies/localStorage, and the knowledge cache all live in
|
|
136
|
+
**One credential store for every browser mode.** Stored credentials, session cookies/localStorage, and the knowledge cache all live in one SQLite file at `~/.iframer/iframer.db`. Store a password once, from the CLI or the MCP, and every mode (`headless`, `binary-headful`, `docker-headful`) uses the same row.
|
|
92
137
|
|
|
93
|
-
**Auto-escalation.** When a pipeline is blocked in `headless` (bot detection, captcha), iframer
|
|
138
|
+
**Auto-escalation.** When a pipeline is blocked in `headless` (bot detection, captcha), iframer retries in `binary-headful` and then `docker-headful` without a round trip to the agent. The mode that worked is recorded in `~/.iframer/domain-modes.json`, so the next run on that domain starts at the right mode.
|
|
94
139
|
|
|
95
|
-
**Knowledge cache.** After every successful run, iframer writes a per-domain markdown file at `~/.iframer/knowledge/<domain>.md` recording which cookies
|
|
140
|
+
**Knowledge cache.** After every successful run, iframer writes a per-domain markdown file at `~/.iframer/knowledge/<domain>.md` recording which cookies, localStorage keys, and headers the site uses for auth, plus any API endpoints seen along the way. The next time an agent needs data from that domain, it reads the cache first; if there's a direct-API path, it skips the browser entirely. See [Knowledge cache](#knowledge-cache) below.
|
|
96
141
|
|
|
97
142
|
For live remote viewing, multi-user, or Linux server deployments, see [Self-hosting with Docker](#self-hosting-with-docker) below.
|
|
98
143
|
|
|
99
|
-
## Extension mode
|
|
144
|
+
## Extension mode: drive your real Chrome
|
|
145
|
+
|
|
146
|
+
The optional browser extension lets the agent drive a tab you already have open in your real Chrome. No relaunch, no remote-debugging port, your real logged-in session. The extension dials out to iframer's local server and relays the CDP protocol via `chrome.debugger`, so your live tab is driven by the exact same pipeline engine (find, click, snapshot, obstacle handling, API capture) as every other mode, with real trusted input. While a run is active, Chrome shows its yellow "started debugging" bar.
|
|
100
147
|
|
|
101
|
-
|
|
148
|
+
There's no Chrome Web Store listing. You load it as an unpacked, developer-mode extension, straight from the files npm just installed on your machine.
|
|
102
149
|
|
|
150
|
+
### Installing it
|
|
151
|
+
|
|
152
|
+
1. Run:
|
|
153
|
+
```sh
|
|
154
|
+
iframer install extension chrome
|
|
155
|
+
```
|
|
156
|
+
This installs a small pairing host and prints a folder path. That folder is the extension itself.
|
|
157
|
+
|
|
158
|
+
2. Open `chrome://extensions` in Chrome.
|
|
159
|
+
3. Turn on **Developer mode**, the toggle is top right.
|
|
160
|
+
4. Click **Load unpacked**.
|
|
161
|
+
5. In the file picker, paste the folder path from step 1 and select it.
|
|
162
|
+
|
|
163
|
+
Chrome loads the extension and pins its icon to the toolbar. That's the whole install, one time only.
|
|
164
|
+
|
|
165
|
+
### Finding the path again
|
|
166
|
+
|
|
167
|
+
Lost the path, or setting up a second machine? Run:
|
|
103
168
|
```sh
|
|
104
|
-
iframer
|
|
105
|
-
iframer extension path # prints it again
|
|
169
|
+
iframer extension path
|
|
106
170
|
```
|
|
171
|
+
It prints the same folder every time.
|
|
107
172
|
|
|
108
|
-
|
|
173
|
+
That folder lives inside wherever npm put `iframer-toolkit` globally, not inside a git checkout of this repo. On most machines that's something like `/usr/local/lib/node_modules/iframer-toolkit/extension` or, with nvm, a path under `~/.nvm/versions/node/<version>/lib/node_modules/iframer-toolkit/extension`. Run the command above instead of guessing, the exact path depends on your Node setup.
|
|
174
|
+
|
|
175
|
+
Loading it from that exact path matters: it's what lets `iframer update` refresh the extension files in place, then tell Chrome to reload it, no repeating the install steps after every update.
|
|
176
|
+
|
|
177
|
+
### Pairing (automatic)
|
|
178
|
+
|
|
179
|
+
Once loaded, the extension pairs itself. The pairing host installed in step 1 hands it the same local token the CLI and MCP already use, straight from `~/.iframer/secret`. Nothing to copy, nothing to type.
|
|
180
|
+
|
|
181
|
+
If that ever fails (host not installed, or you're on a platform it doesn't support), click the iframer icon in the toolbar and paste the token yourself:
|
|
182
|
+
```sh
|
|
183
|
+
cat ~/.iframer/secret
|
|
184
|
+
```
|
|
185
|
+
then **Save & connect**.
|
|
186
|
+
|
|
187
|
+
Either way, check the popup: the dot next to "iframer" turns green once it finds the running local server.
|
|
188
|
+
|
|
189
|
+
You can repeat this whole install on more than one Chrome profile, or in Brave, Edge, or another Chromium-based browser, at the same time. Each one pairs under its own profile name, and iframer routes each task to whichever profile actually has the tab. Once paired, the agent lists your open tabs with the `tabs` tool and drives the one you mean. See [`extension/README.md`](extension/README.md) for how the pipeline and multi-profile routing work under the hood.
|
|
109
190
|
|
|
110
191
|
## CLI reference
|
|
111
192
|
|
|
@@ -175,7 +256,7 @@ Setup:
|
|
|
175
256
|
|
|
176
257
|
Browser:
|
|
177
258
|
modes Show available browser modes
|
|
178
|
-
status
|
|
259
|
+
status Show system status
|
|
179
260
|
```
|
|
180
261
|
|
|
181
262
|
The binary is available as either `iframer-toolkit` (full name) or `iframer` (short alias). `npx iframer-toolkit ...` also works without a global install.
|
|
@@ -184,33 +265,33 @@ The binary is available as either `iframer-toolkit` (full name) or `iframer` (sh
|
|
|
184
265
|
|
|
185
266
|
Once the MCP is registered, the agent has access to:
|
|
186
267
|
|
|
187
|
-
- **`status
|
|
188
|
-
- **`knowledge
|
|
189
|
-
- **`execute
|
|
190
|
-
- **`browse
|
|
191
|
-
- **`reverse-engineer
|
|
192
|
-
- **`remember
|
|
193
|
-
- **`tabs
|
|
194
|
-
- **`clipboard
|
|
195
|
-
- **`session
|
|
196
|
-
- **`credentials
|
|
268
|
+
- **`status`**: system health, session state, stored credentials, available browser modes
|
|
269
|
+
- **`knowledge`**: read/list/clear the per-domain knowledge cache. Agents are told to check this before every `execute` or `browse`; if the cache already has a direct-API path, the browser never launches.
|
|
270
|
+
- **`execute`**: run a pipeline of browser steps (navigate, click, right-click, fill, select, human-click, human-type, type-code, read, evaluate, extract, wait, wait-for, scroll, keyboard, upload, paste, download, login, solve-captcha, recaptcha, screenshot, snapshot, find). Each step has a 20s stale-state timeout. On failure, it returns the exact step, error type, and a screenshot of the page at the point of failure. Auto-escalates browser modes on bot-block.
|
|
271
|
+
- **`browse`**: fast headless fetch with session persistence for pages that don't need a full pipeline
|
|
272
|
+
- **`reverse-engineer`**: capture the APIs a site calls (feeds the knowledge cache so future runs can skip the browser)
|
|
273
|
+
- **`remember`**: per-domain element anchors. Save a selector once under a name, then target it as `@a:<name>` in any later pipeline, no re-finding the element on every visit. Anchors self-heal (a failed anchor triggers re-discovery, not blind retries) and the tool also records per-site quirks.
|
|
274
|
+
- **`tabs`**: list and target tabs in your real Chrome (requires the [extension](#extension-mode-drive-your-real-chrome))
|
|
275
|
+
- **`clipboard`**: read/write the machine's clipboard, the same one your Chrome uses: read a code a site just copied, or stage text to paste into a field via the `paste` step
|
|
276
|
+
- **`session`**: `stop` (save state) or `clear` (wipe)
|
|
277
|
+
- **`credentials`**: `store` (secure form via MCP elicitation), `list`. `store` refuses to overwrite existing credentials unless told to (`force: true`), which blocks the common "login failed, re-ask for password" mistake when the real problem is browser mode or bot detection.
|
|
197
278
|
|
|
198
279
|
## Session persistence
|
|
199
280
|
|
|
200
|
-
Session data (cookies + localStorage) and credentials are stored in SQLite at `~/.iframer/iframer.db` and encrypted with AES-256-GCM. Data is automatically re-injected on the next `execute` or `browse` so agents stay logged in across restarts
|
|
281
|
+
Session data (cookies + localStorage) and credentials are stored in SQLite at `~/.iframer/iframer.db` and encrypted with AES-256-GCM. Data is automatically re-injected on the next `execute` or `browse` so agents stay logged in across restarts, and across browser modes: a session captured in `binary-headful` will load into `headless` on the next run.
|
|
201
282
|
|
|
202
|
-
The encryption key lives at `~/.iframer/secret` (0600 permissions), generated on first `install mcp`. Set `IFRAMER_SECRET` in your environment to override it
|
|
283
|
+
The encryption key lives at `~/.iframer/secret` (0600 permissions), generated on first `install mcp`. Set `IFRAMER_SECRET` in your environment to override it, useful if you want to sync the key across machines.
|
|
203
284
|
|
|
204
285
|
## Knowledge cache
|
|
205
286
|
|
|
206
|
-
Every successful `execute` run updates a plain markdown file at `~/.iframer/knowledge/<domain>.md
|
|
287
|
+
Every successful `execute` run updates a plain markdown file at `~/.iframer/knowledge/<domain>.md`, human-readable, grep-able, editable. Each file captures:
|
|
207
288
|
|
|
208
|
-
- Which cookies, localStorage keys, and headers are load-bearing for authentication (names only
|
|
209
|
-
- Which API endpoints the site called during the run
|
|
289
|
+
- Which cookies, localStorage keys, and headers are load-bearing for authentication (names only; values stay encrypted in the session store)
|
|
290
|
+
- Which API endpoints the site called during the run, with method, path, example curl, and status code
|
|
210
291
|
- Which browser mode last worked for the domain
|
|
211
|
-
- Notes about captcha
|
|
292
|
+
- Notes about captcha or bot-detection behavior
|
|
212
293
|
|
|
213
|
-
The MCP `knowledge` tool exposes this
|
|
294
|
+
The MCP `knowledge` tool exposes this with `get`, `list`, and `clear` actions. Agents are instructed to call it before every browser-touching tool; if the cache already has a direct-API path for the data, the agent hits the API directly and never launches a browser. First request takes seconds, repeat requests on the same domain take milliseconds.
|
|
214
295
|
|
|
215
296
|
Inspect the cache yourself:
|
|
216
297
|
|
|
@@ -223,7 +304,7 @@ iframer-toolkit --clear-cache figma.com # wipe one domain
|
|
|
223
304
|
|
|
224
305
|
## Captcha solving
|
|
225
306
|
|
|
226
|
-
iframer
|
|
307
|
+
iframer detects and solves reCAPTCHA and hCaptcha using Claude's vision API. Use the `solve-captcha` step in a pipeline:
|
|
227
308
|
|
|
228
309
|
```json
|
|
229
310
|
{ "type": "solve-captcha" }
|
|
@@ -245,9 +326,9 @@ Requires `ANTHROPIC_API_KEY` in your environment.
|
|
|
245
326
|
|
|
246
327
|
## Self-hosting with Docker
|
|
247
328
|
|
|
248
|
-
The Docker server adds a live headful browsing mode over noVNC (watch the agent drive the browser in real time) and lets multiple clients share one browser pool. The Docker path
|
|
329
|
+
The Docker server adds a live headful browsing mode over noVNC (watch the agent drive the browser in real time) and lets multiple clients share one browser pool. The Docker path only runs when a pipeline explicitly requests `mode: "docker-headful"`; `headless` and `binary-headful` always run directly on the host even when Docker is up, so host-stored credentials stay visible.
|
|
249
330
|
|
|
250
|
-
|
|
331
|
+
`docker-compose.yml` bind-mounts `~/.iframer` from the host into the container as `/iframer-data` and sets `IFRAMER_DATA_DIR=/iframer-data`, so container and host share the same SQLite file. Credentials stored via the CLI or MCP on the host are immediately visible to `docker-headful`, no copy step.
|
|
251
332
|
|
|
252
333
|
**1. Clone and configure**
|
|
253
334
|
|
|
@@ -255,7 +336,7 @@ The `docker-compose.yml` bind-mounts `~/.iframer` from the host into the contain
|
|
|
255
336
|
git clone https://github.com/EduardoFazolo/iframer-toolkit.git
|
|
256
337
|
cd iframer-toolkit
|
|
257
338
|
cp .env.example .env
|
|
258
|
-
# Edit .env
|
|
339
|
+
# Edit .env: set ANTHROPIC_API_KEY (for captcha) and IFRAMER_SECRET (for auth)
|
|
259
340
|
```
|
|
260
341
|
|
|
261
342
|
**2. Start**
|
|
@@ -268,13 +349,13 @@ bun run stop:docker # stop containers
|
|
|
268
349
|
|
|
269
350
|
**3. Point the MCP at it (remote host only)**
|
|
270
351
|
|
|
271
|
-
If
|
|
352
|
+
If Docker runs on a remote machine and the MCP on a different one, install the MCP with the remote URL:
|
|
272
353
|
|
|
273
354
|
```sh
|
|
274
355
|
IFRAMER_URL=https://your-host:3021 iframer-toolkit install mcp --dev
|
|
275
356
|
```
|
|
276
357
|
|
|
277
|
-
When Docker is on the same host as the MCP, no extra setup is needed
|
|
358
|
+
When Docker is on the same host as the MCP, no extra setup is needed; the local MCP server auto-detects the Docker API on `localhost:3021` and routes only `docker-headful` requests through it.
|
|
278
359
|
|
|
279
360
|
**4. Watch the browser live**
|
|
280
361
|
|
|
@@ -292,17 +373,36 @@ Or run `iframer-toolkit watch` to auto-open it.
|
|
|
292
373
|
|--------------------|------------|
|
|
293
374
|
| Browser engine | [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) (stealth-patched Playwright fork) |
|
|
294
375
|
| Browser binary | Chrome for Testing (downloaded to `~/.iframer/chrome/`) |
|
|
295
|
-
| Local server | One shared warm server per machine (loopback
|
|
296
|
-
| Extension mode | Chrome extension relaying CDP over WebSocket (`chrome.debugger` → `connectOverCDP`)
|
|
376
|
+
| Local server | One shared warm server per machine (loopback only, discovered via `~/.iframer/server.json`), on-disk browser PID registry, idle auto-exit |
|
|
377
|
+
| Extension mode | Chrome extension relaying CDP over WebSocket (`chrome.debugger` → `connectOverCDP`), drives tabs in your real Chrome |
|
|
297
378
|
| Stealth | Fingerprint injection, WebRTC leak prevention, worker patching |
|
|
298
379
|
| Credential store | SQLite at `~/.iframer/iframer.db`, AES-256-GCM encrypted, shared by every browser mode |
|
|
299
|
-
| Session persistence| Same SQLite file
|
|
380
|
+
| Session persistence| Same SQLite file, cookies + localStorage re-injected across runs and modes |
|
|
300
381
|
| Knowledge cache | Plain markdown at `~/.iframer/knowledge/<domain>.md` |
|
|
301
382
|
| Captcha solving | Anthropic vision API (`@anthropic-ai/sdk`) |
|
|
302
383
|
| Live viewing | Xvfb + x11vnc + noVNC + websockify (Docker mode only) |
|
|
303
384
|
| MCP server | `@modelcontextprotocol/sdk` |
|
|
304
385
|
| Runtime | Node.js ≥18 (Bun for development) |
|
|
305
386
|
|
|
387
|
+
## Repo layout
|
|
388
|
+
|
|
389
|
+
```
|
|
390
|
+
src/lib/ core: pipeline executor, actions, auth, screenshot, knowledge cache
|
|
391
|
+
src/mcp/ MCP server + tool definitions
|
|
392
|
+
bin/cli.js CLI entry point
|
|
393
|
+
extension/ Chrome extension (manifest v3, CDP relay)
|
|
394
|
+
docs/example/ the demo in this README: site/ + captured api/
|
|
395
|
+
benchmark/ token-cost benchmark harness vs agent-browser and Playwright MCP (gitignored, local only)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## What's not done yet
|
|
399
|
+
|
|
400
|
+
- Stealth fingerprinting is injected right after the page loads, not at browser-context creation, so a site checking for it in the first tick of navigation can still catch it. This is the main remaining cause of false bot-blocks.
|
|
401
|
+
- The knowledge cache's markdown merge can drop an endpoint's description or example fields when the same domain is captured a second time.
|
|
402
|
+
- Consent banners rendered inside a cross-origin iframe (common with some CMPs) aren't recognized as an obstacle yet.
|
|
403
|
+
- Element anchors (`remember`) still have to be saved by hand after a successful run; auto-saving the selectors that worked isn't wired up.
|
|
404
|
+
- A few internal modules (the SQLite adapter, the stealth/humanize helpers) still carry loose `any` types.
|
|
405
|
+
|
|
306
406
|
## Development
|
|
307
407
|
|
|
308
408
|
```sh
|
|
@@ -310,7 +410,7 @@ git clone https://github.com/EduardoFazolo/iframer-toolkit.git
|
|
|
310
410
|
cd iframer-toolkit
|
|
311
411
|
bun install
|
|
312
412
|
|
|
313
|
-
# Run the CLI from source (no build needed
|
|
413
|
+
# Run the CLI from source (no build needed, bun runs .ts directly)
|
|
314
414
|
bun run bin/cli.js status
|
|
315
415
|
|
|
316
416
|
# Run the MCP server from source
|
package/dist/cli.js
CHANGED
|
@@ -902,6 +902,10 @@ function findChromeExecutable() {
|
|
|
902
902
|
if (import_fs3.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
|
|
903
903
|
return void 0;
|
|
904
904
|
}
|
|
905
|
+
async function getHeadedBrowser() {
|
|
906
|
+
if (!headedBrowser?.isConnected()) headedBrowser = await import_patchright.chromium.launch({ headless: false, args: STEALTH_ARGS });
|
|
907
|
+
return headedBrowser;
|
|
908
|
+
}
|
|
905
909
|
async function getBrowser(_name = "chromium") {
|
|
906
910
|
if (cachedBrowser) {
|
|
907
911
|
if (cachedBrowser.isConnected()) return cachedBrowser;
|
|
@@ -919,6 +923,11 @@ async function getBrowser(_name = "chromium") {
|
|
|
919
923
|
return cachedBrowser;
|
|
920
924
|
}
|
|
921
925
|
async function closeBrowser() {
|
|
926
|
+
if (headedBrowser) {
|
|
927
|
+
await headedBrowser.close().catch(() => {
|
|
928
|
+
});
|
|
929
|
+
headedBrowser = null;
|
|
930
|
+
}
|
|
922
931
|
if (!cachedBrowser) return;
|
|
923
932
|
try {
|
|
924
933
|
await cachedBrowser.close();
|
|
@@ -955,7 +964,7 @@ async function launchHeadful(displayNum) {
|
|
|
955
964
|
log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
|
|
956
965
|
return import_patchright.chromium.launch(launchOpts);
|
|
957
966
|
}
|
|
958
|
-
var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser;
|
|
967
|
+
var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser, headedBrowser;
|
|
959
968
|
var init_launcher = __esm({
|
|
960
969
|
"src/lib/browser/launcher.ts"() {
|
|
961
970
|
"use strict";
|
|
@@ -966,6 +975,7 @@ var init_launcher = __esm({
|
|
|
966
975
|
log2 = createLogger("launcher");
|
|
967
976
|
UBLOCK_PATH = "/extensions/uBlock0.chromium";
|
|
968
977
|
cachedBrowser = null;
|
|
978
|
+
headedBrowser = null;
|
|
969
979
|
}
|
|
970
980
|
});
|
|
971
981
|
|
|
@@ -2273,6 +2283,59 @@ var init_ref_store = __esm({
|
|
|
2273
2283
|
}
|
|
2274
2284
|
});
|
|
2275
2285
|
|
|
2286
|
+
// src/lib/site-protection.ts
|
|
2287
|
+
async function paceProtectedDomain(domain) {
|
|
2288
|
+
const now = Date.now();
|
|
2289
|
+
const slot = Math.max(now, nextRequest.get(domain) || 0);
|
|
2290
|
+
nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
|
|
2291
|
+
if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
|
|
2292
|
+
}
|
|
2293
|
+
async function detectSiteProtection(page) {
|
|
2294
|
+
const cookies = await page.context().cookies(page.url()).catch(() => []);
|
|
2295
|
+
if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
|
|
2296
|
+
if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
|
|
2297
|
+
const html = await page.content().catch(() => "");
|
|
2298
|
+
if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
|
|
2299
|
+
if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
|
|
2300
|
+
return null;
|
|
2301
|
+
}
|
|
2302
|
+
function protectionHooks(headless, respectful = false) {
|
|
2303
|
+
return {
|
|
2304
|
+
before: async (page, step) => {
|
|
2305
|
+
const protection = await detectSiteProtection(page);
|
|
2306
|
+
if (protection) {
|
|
2307
|
+
respectful = true;
|
|
2308
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
2309
|
+
}
|
|
2310
|
+
if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
|
|
2311
|
+
},
|
|
2312
|
+
after: async (page) => {
|
|
2313
|
+
const protection = await detectSiteProtection(page);
|
|
2314
|
+
if (!protection) return;
|
|
2315
|
+
respectful = true;
|
|
2316
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
2317
|
+
}
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
var PROTECTED_INTERVAL_MS, nextRequest, HeadedRequiredError;
|
|
2321
|
+
var init_site_protection = __esm({
|
|
2322
|
+
"src/lib/site-protection.ts"() {
|
|
2323
|
+
"use strict";
|
|
2324
|
+
PROTECTED_INTERVAL_MS = 3e3;
|
|
2325
|
+
nextRequest = /* @__PURE__ */ new Map();
|
|
2326
|
+
HeadedRequiredError = class extends Error {
|
|
2327
|
+
constructor(url) {
|
|
2328
|
+
super("This site requires a headed browser.");
|
|
2329
|
+
this.url = url;
|
|
2330
|
+
}
|
|
2331
|
+
url;
|
|
2332
|
+
stepIndex = 0;
|
|
2333
|
+
results = [];
|
|
2334
|
+
obstacles = [];
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2276
2339
|
// src/lib/actions/types.ts
|
|
2277
2340
|
function failedStepResult(step, error, durationMs, stepIndex = -1) {
|
|
2278
2341
|
return { stepIndex, step, ok: false, error, durationMs };
|
|
@@ -5561,6 +5624,7 @@ var DEFAULT_STALE_TIMEOUT_MS2, PipelineRunner;
|
|
|
5561
5624
|
var init_pipeline = __esm({
|
|
5562
5625
|
"src/lib/pipeline.ts"() {
|
|
5563
5626
|
"use strict";
|
|
5627
|
+
init_site_protection();
|
|
5564
5628
|
init_registry2();
|
|
5565
5629
|
init_types();
|
|
5566
5630
|
init_stale_monitor();
|
|
@@ -5574,10 +5638,12 @@ var init_pipeline = __esm({
|
|
|
5574
5638
|
init_resolve_selector();
|
|
5575
5639
|
DEFAULT_STALE_TIMEOUT_MS2 = 2e4;
|
|
5576
5640
|
PipelineRunner = class {
|
|
5577
|
-
constructor(ctx) {
|
|
5641
|
+
constructor(ctx, hooks) {
|
|
5578
5642
|
this.ctx = ctx;
|
|
5643
|
+
this.hooks = hooks;
|
|
5579
5644
|
}
|
|
5580
5645
|
ctx;
|
|
5646
|
+
hooks;
|
|
5581
5647
|
async run(initialPage, pipeline) {
|
|
5582
5648
|
const tracker = new TabTracker(initialPage.context(), initialPage);
|
|
5583
5649
|
try {
|
|
@@ -5623,12 +5689,19 @@ var init_pipeline = __esm({
|
|
|
5623
5689
|
const monitor = new StaleStateMonitor(page, staleTimeoutMs);
|
|
5624
5690
|
let stepResult;
|
|
5625
5691
|
try {
|
|
5692
|
+
await this.hooks?.before(page, step);
|
|
5626
5693
|
stepResult = await monitor.withMonitoring(async () => {
|
|
5627
5694
|
const r = await executeAction(page, step, this.ctx, monitor);
|
|
5695
|
+
if (step.type === "navigate") await this.hooks?.after(page, step.url);
|
|
5628
5696
|
r.stepIndex = i;
|
|
5629
5697
|
return r;
|
|
5630
5698
|
});
|
|
5631
5699
|
} catch (err) {
|
|
5700
|
+
if (err instanceof HeadedRequiredError) {
|
|
5701
|
+
Object.assign(err, { stepIndex: i, results, obstacles });
|
|
5702
|
+
await finishCapture();
|
|
5703
|
+
throw err;
|
|
5704
|
+
}
|
|
5632
5705
|
const asError = err instanceof Error ? err : new Error(String(err));
|
|
5633
5706
|
const errorType = classifyError(asError, step);
|
|
5634
5707
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
@@ -10119,7 +10192,7 @@ var init_cdp_relay = __esm({
|
|
|
10119
10192
|
return {};
|
|
10120
10193
|
case "Browser.close":
|
|
10121
10194
|
return {};
|
|
10122
|
-
//
|
|
10195
|
+
// Disconnect without closing the user's Chrome.
|
|
10123
10196
|
case "Target.setDiscoverTargets":
|
|
10124
10197
|
return {};
|
|
10125
10198
|
case "Target.getTargets":
|
|
@@ -10226,6 +10299,7 @@ var import_playwright_core, log18, PipelineExecutor;
|
|
|
10226
10299
|
var init_pipeline_executor = __esm({
|
|
10227
10300
|
"src/lib/execution/pipeline-executor.ts"() {
|
|
10228
10301
|
"use strict";
|
|
10302
|
+
init_site_protection();
|
|
10229
10303
|
import_playwright_core = require("playwright-core");
|
|
10230
10304
|
init_daemon();
|
|
10231
10305
|
init_pipeline();
|
|
@@ -10245,8 +10319,8 @@ var init_pipeline_executor = __esm({
|
|
|
10245
10319
|
this.deps = deps;
|
|
10246
10320
|
}
|
|
10247
10321
|
deps;
|
|
10248
|
-
/**
|
|
10249
|
-
*
|
|
10322
|
+
/** Shared runtime hook read by local and extension execution;
|
|
10323
|
+
* execute() clears it when the call finishes. */
|
|
10250
10324
|
pendingElicitOtp;
|
|
10251
10325
|
/** One pipeline per real tab at a time: chrome.debugger and the CDP relay
|
|
10252
10326
|
* can't share a tab, so concurrent executes on the same tab queue up here. */
|
|
@@ -10292,6 +10366,7 @@ var init_pipeline_executor = __esm({
|
|
|
10292
10366
|
mode = availableModes[0] || "headless";
|
|
10293
10367
|
}
|
|
10294
10368
|
let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
|
|
10369
|
+
mode = result.modeUsed || mode;
|
|
10295
10370
|
if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
|
|
10296
10371
|
const failedMode = mode;
|
|
10297
10372
|
if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
|
|
@@ -10328,11 +10403,11 @@ var init_pipeline_executor = __esm({
|
|
|
10328
10403
|
}
|
|
10329
10404
|
return result;
|
|
10330
10405
|
}
|
|
10331
|
-
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
10406
|
+
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
10332
10407
|
if (mode === "docker-headful") {
|
|
10333
|
-
return this.executeDocker(userId, token, pipeline);
|
|
10408
|
+
return this.executeDocker(userId, token, pipeline, respectful);
|
|
10334
10409
|
}
|
|
10335
|
-
return this.executeLocal(userId, token, pipeline, mode, instanceId);
|
|
10410
|
+
return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
|
|
10336
10411
|
}
|
|
10337
10412
|
/** Execute against the user's real Chrome tab via the extension CDP relay,
|
|
10338
10413
|
* using the SAME PipelineRunner as every other mode. No session inject/extract
|
|
@@ -10425,7 +10500,7 @@ var init_pipeline_executor = __esm({
|
|
|
10425
10500
|
}
|
|
10426
10501
|
}
|
|
10427
10502
|
/** Execute via Docker session-manager. */
|
|
10428
|
-
async executeDocker(userId, token, pipeline) {
|
|
10503
|
+
async executeDocker(userId, token, pipeline, respectful = false) {
|
|
10429
10504
|
let session = getSession(userId);
|
|
10430
10505
|
if (!session) {
|
|
10431
10506
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
@@ -10434,7 +10509,7 @@ var init_pipeline_executor = __esm({
|
|
|
10434
10509
|
}
|
|
10435
10510
|
resetTimeout(userId);
|
|
10436
10511
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
10437
|
-
const runner = new PipelineRunner(ctx);
|
|
10512
|
+
const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
|
|
10438
10513
|
const result = await runner.run(session.page, pipeline);
|
|
10439
10514
|
if (result.ok) {
|
|
10440
10515
|
const blockResult = await detectBlock(session.page);
|
|
@@ -10461,7 +10536,7 @@ var init_pipeline_executor = __esm({
|
|
|
10461
10536
|
return result;
|
|
10462
10537
|
}
|
|
10463
10538
|
/** Execute via local Chrome daemon (headless + binary-headful). */
|
|
10464
|
-
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
10539
|
+
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
10465
10540
|
const startTime = Date.now();
|
|
10466
10541
|
let acquired = false;
|
|
10467
10542
|
try {
|
|
@@ -10483,8 +10558,38 @@ var init_pipeline_executor = __esm({
|
|
|
10483
10558
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
10484
10559
|
if (sessionData) ctx.sessionData = sessionData;
|
|
10485
10560
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
10486
|
-
const runner = new PipelineRunner(ctx);
|
|
10487
|
-
|
|
10561
|
+
const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
|
|
10562
|
+
let result;
|
|
10563
|
+
try {
|
|
10564
|
+
result = await runner.run(page, pipeline);
|
|
10565
|
+
} catch (err) {
|
|
10566
|
+
if (!(err instanceof HeadedRequiredError)) throw err;
|
|
10567
|
+
this.deps.daemon.release(mode, instanceId);
|
|
10568
|
+
acquired = false;
|
|
10569
|
+
await this.deps.daemon.stopMode(mode, instanceId);
|
|
10570
|
+
const available = this.deps.availableModes();
|
|
10571
|
+
const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
|
|
10572
|
+
if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
|
|
10573
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
10574
|
+
const index = err.stepIndex;
|
|
10575
|
+
const remaining = pipeline.steps.slice(index);
|
|
10576
|
+
const insertedNavigation = remaining[0]?.type !== "navigate";
|
|
10577
|
+
if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
|
|
10578
|
+
const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
|
|
10579
|
+
const offset = index - Number(insertedNavigation);
|
|
10580
|
+
resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
|
|
10581
|
+
resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
|
|
10582
|
+
resumed.totalSteps = pipeline.steps.length;
|
|
10583
|
+
resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
|
|
10584
|
+
if (resumed.error) {
|
|
10585
|
+
resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
|
|
10586
|
+
resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
|
|
10587
|
+
}
|
|
10588
|
+
resumed.durationMs = Date.now() - startTime;
|
|
10589
|
+
resumed.modeEscalated = true;
|
|
10590
|
+
resumed.modeUsed = headed;
|
|
10591
|
+
return resumed;
|
|
10592
|
+
}
|
|
10488
10593
|
if (result.ok) {
|
|
10489
10594
|
const blockResult = await detectBlock(page);
|
|
10490
10595
|
if (blockResult.blocked) {
|
|
@@ -10557,6 +10662,8 @@ var FetchService;
|
|
|
10557
10662
|
var init_fetch_service = __esm({
|
|
10558
10663
|
"src/lib/execution/fetch-service.ts"() {
|
|
10559
10664
|
"use strict";
|
|
10665
|
+
init_site_protection();
|
|
10666
|
+
init_launcher();
|
|
10560
10667
|
init_launcher();
|
|
10561
10668
|
init_stealth();
|
|
10562
10669
|
init_humanize();
|
|
@@ -10587,12 +10694,30 @@ var init_fetch_service = __esm({
|
|
|
10587
10694
|
const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
|
|
10588
10695
|
context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
10589
10696
|
if (sessionData) await injectCookies(context, sessionData);
|
|
10590
|
-
|
|
10697
|
+
let page = await context.newPage();
|
|
10591
10698
|
await applyStealthToPage(page);
|
|
10592
10699
|
await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
|
|
10700
|
+
let protection = false;
|
|
10701
|
+
const pivotIfDetected = async () => {
|
|
10702
|
+
if (protection || !await detectSiteProtection(page)) return;
|
|
10703
|
+
protection = true;
|
|
10704
|
+
const targetUrl = page.url();
|
|
10705
|
+
const cookies = await context.cookies();
|
|
10706
|
+
await context.close();
|
|
10707
|
+
context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
10708
|
+
await context.addCookies(cookies);
|
|
10709
|
+
page = await context.newPage();
|
|
10710
|
+
await applyStealthToPage(page);
|
|
10711
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
10712
|
+
await paceProtectedDomain(new URL(targetUrl).hostname);
|
|
10713
|
+
await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
10714
|
+
};
|
|
10715
|
+
await pivotIfDetected();
|
|
10593
10716
|
if (sessionData) await injectStorage(page, sessionData);
|
|
10594
10717
|
if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
|
|
10595
10718
|
for (const action of actions) {
|
|
10719
|
+
await pivotIfDetected();
|
|
10720
|
+
if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
|
|
10596
10721
|
switch (action.type) {
|
|
10597
10722
|
case "click":
|
|
10598
10723
|
await page.click(action.selector);
|
|
@@ -10623,6 +10748,7 @@ var init_fetch_service = __esm({
|
|
|
10623
10748
|
break;
|
|
10624
10749
|
}
|
|
10625
10750
|
}
|
|
10751
|
+
await pivotIfDetected();
|
|
10626
10752
|
const finalUrl = page.url();
|
|
10627
10753
|
const html = returnHtml ? await page.content() : void 0;
|
|
10628
10754
|
const result = extract2 ? await page.evaluate(extract2) : void 0;
|
package/dist/local-server.cjs
CHANGED
|
@@ -551,6 +551,11 @@ function findChromeExecutable() {
|
|
|
551
551
|
return void 0;
|
|
552
552
|
}
|
|
553
553
|
var cachedBrowser = null;
|
|
554
|
+
var headedBrowser = null;
|
|
555
|
+
async function getHeadedBrowser() {
|
|
556
|
+
if (!headedBrowser?.isConnected()) headedBrowser = await import_patchright.chromium.launch({ headless: false, args: STEALTH_ARGS });
|
|
557
|
+
return headedBrowser;
|
|
558
|
+
}
|
|
554
559
|
async function getBrowser(_name = "chromium") {
|
|
555
560
|
if (cachedBrowser) {
|
|
556
561
|
if (cachedBrowser.isConnected()) return cachedBrowser;
|
|
@@ -568,6 +573,11 @@ async function getBrowser(_name = "chromium") {
|
|
|
568
573
|
return cachedBrowser;
|
|
569
574
|
}
|
|
570
575
|
async function closeBrowser() {
|
|
576
|
+
if (headedBrowser) {
|
|
577
|
+
await headedBrowser.close().catch(() => {
|
|
578
|
+
});
|
|
579
|
+
headedBrowser = null;
|
|
580
|
+
}
|
|
571
581
|
if (!cachedBrowser) return;
|
|
572
582
|
try {
|
|
573
583
|
await cachedBrowser.close();
|
|
@@ -1880,6 +1890,53 @@ var RefStore = class {
|
|
|
1880
1890
|
}
|
|
1881
1891
|
};
|
|
1882
1892
|
|
|
1893
|
+
// src/lib/site-protection.ts
|
|
1894
|
+
var PROTECTED_INTERVAL_MS = 3e3;
|
|
1895
|
+
var nextRequest = /* @__PURE__ */ new Map();
|
|
1896
|
+
async function paceProtectedDomain(domain) {
|
|
1897
|
+
const now = Date.now();
|
|
1898
|
+
const slot = Math.max(now, nextRequest.get(domain) || 0);
|
|
1899
|
+
nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
|
|
1900
|
+
if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
|
|
1901
|
+
}
|
|
1902
|
+
async function detectSiteProtection(page) {
|
|
1903
|
+
const cookies = await page.context().cookies(page.url()).catch(() => []);
|
|
1904
|
+
if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
|
|
1905
|
+
if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
|
|
1906
|
+
const html = await page.content().catch(() => "");
|
|
1907
|
+
if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
|
|
1908
|
+
if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
|
|
1909
|
+
return null;
|
|
1910
|
+
}
|
|
1911
|
+
var HeadedRequiredError = class extends Error {
|
|
1912
|
+
constructor(url) {
|
|
1913
|
+
super("This site requires a headed browser.");
|
|
1914
|
+
this.url = url;
|
|
1915
|
+
}
|
|
1916
|
+
url;
|
|
1917
|
+
stepIndex = 0;
|
|
1918
|
+
results = [];
|
|
1919
|
+
obstacles = [];
|
|
1920
|
+
};
|
|
1921
|
+
function protectionHooks(headless, respectful = false) {
|
|
1922
|
+
return {
|
|
1923
|
+
before: async (page, step) => {
|
|
1924
|
+
const protection = await detectSiteProtection(page);
|
|
1925
|
+
if (protection) {
|
|
1926
|
+
respectful = true;
|
|
1927
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
1928
|
+
}
|
|
1929
|
+
if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
|
|
1930
|
+
},
|
|
1931
|
+
after: async (page) => {
|
|
1932
|
+
const protection = await detectSiteProtection(page);
|
|
1933
|
+
if (!protection) return;
|
|
1934
|
+
respectful = true;
|
|
1935
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1883
1940
|
// src/lib/execution/pipeline-executor.ts
|
|
1884
1941
|
var import_playwright_core = require("playwright-core");
|
|
1885
1942
|
|
|
@@ -4912,10 +4969,12 @@ function getSuggestion(errorType, step) {
|
|
|
4912
4969
|
}
|
|
4913
4970
|
}
|
|
4914
4971
|
var PipelineRunner = class {
|
|
4915
|
-
constructor(ctx) {
|
|
4972
|
+
constructor(ctx, hooks) {
|
|
4916
4973
|
this.ctx = ctx;
|
|
4974
|
+
this.hooks = hooks;
|
|
4917
4975
|
}
|
|
4918
4976
|
ctx;
|
|
4977
|
+
hooks;
|
|
4919
4978
|
async run(initialPage, pipeline) {
|
|
4920
4979
|
const tracker = new TabTracker(initialPage.context(), initialPage);
|
|
4921
4980
|
try {
|
|
@@ -4961,12 +5020,19 @@ var PipelineRunner = class {
|
|
|
4961
5020
|
const monitor = new StaleStateMonitor(page, staleTimeoutMs);
|
|
4962
5021
|
let stepResult;
|
|
4963
5022
|
try {
|
|
5023
|
+
await this.hooks?.before(page, step);
|
|
4964
5024
|
stepResult = await monitor.withMonitoring(async () => {
|
|
4965
5025
|
const r = await executeAction(page, step, this.ctx, monitor);
|
|
5026
|
+
if (step.type === "navigate") await this.hooks?.after(page, step.url);
|
|
4966
5027
|
r.stepIndex = i;
|
|
4967
5028
|
return r;
|
|
4968
5029
|
});
|
|
4969
5030
|
} catch (err) {
|
|
5031
|
+
if (err instanceof HeadedRequiredError) {
|
|
5032
|
+
Object.assign(err, { stepIndex: i, results, obstacles });
|
|
5033
|
+
await finishCapture();
|
|
5034
|
+
throw err;
|
|
5035
|
+
}
|
|
4970
5036
|
const asError = err instanceof Error ? err : new Error(String(err));
|
|
4971
5037
|
const errorType = classifyError(asError, step);
|
|
4972
5038
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
@@ -5743,7 +5809,7 @@ var CdpRelay = class {
|
|
|
5743
5809
|
return {};
|
|
5744
5810
|
case "Browser.close":
|
|
5745
5811
|
return {};
|
|
5746
|
-
//
|
|
5812
|
+
// Disconnect without closing the user's Chrome.
|
|
5747
5813
|
case "Target.setDiscoverTargets":
|
|
5748
5814
|
return {};
|
|
5749
5815
|
case "Target.getTargets":
|
|
@@ -5850,8 +5916,8 @@ var PipelineExecutor = class {
|
|
|
5850
5916
|
this.deps = deps;
|
|
5851
5917
|
}
|
|
5852
5918
|
deps;
|
|
5853
|
-
/**
|
|
5854
|
-
*
|
|
5919
|
+
/** Shared runtime hook read by local and extension execution;
|
|
5920
|
+
* execute() clears it when the call finishes. */
|
|
5855
5921
|
pendingElicitOtp;
|
|
5856
5922
|
/** One pipeline per real tab at a time: chrome.debugger and the CDP relay
|
|
5857
5923
|
* can't share a tab, so concurrent executes on the same tab queue up here. */
|
|
@@ -5897,6 +5963,7 @@ var PipelineExecutor = class {
|
|
|
5897
5963
|
mode = availableModes[0] || "headless";
|
|
5898
5964
|
}
|
|
5899
5965
|
let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
|
|
5966
|
+
mode = result.modeUsed || mode;
|
|
5900
5967
|
if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
|
|
5901
5968
|
const failedMode = mode;
|
|
5902
5969
|
if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
|
|
@@ -5933,11 +6000,11 @@ var PipelineExecutor = class {
|
|
|
5933
6000
|
}
|
|
5934
6001
|
return result;
|
|
5935
6002
|
}
|
|
5936
|
-
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
6003
|
+
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
5937
6004
|
if (mode === "docker-headful") {
|
|
5938
|
-
return this.executeDocker(userId, token, pipeline);
|
|
6005
|
+
return this.executeDocker(userId, token, pipeline, respectful);
|
|
5939
6006
|
}
|
|
5940
|
-
return this.executeLocal(userId, token, pipeline, mode, instanceId);
|
|
6007
|
+
return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
|
|
5941
6008
|
}
|
|
5942
6009
|
/** Execute against the user's real Chrome tab via the extension CDP relay,
|
|
5943
6010
|
* using the SAME PipelineRunner as every other mode. No session inject/extract
|
|
@@ -6030,7 +6097,7 @@ var PipelineExecutor = class {
|
|
|
6030
6097
|
}
|
|
6031
6098
|
}
|
|
6032
6099
|
/** Execute via Docker session-manager. */
|
|
6033
|
-
async executeDocker(userId, token, pipeline) {
|
|
6100
|
+
async executeDocker(userId, token, pipeline, respectful = false) {
|
|
6034
6101
|
let session = getSession(userId);
|
|
6035
6102
|
if (!session) {
|
|
6036
6103
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
@@ -6039,7 +6106,7 @@ var PipelineExecutor = class {
|
|
|
6039
6106
|
}
|
|
6040
6107
|
resetTimeout(userId);
|
|
6041
6108
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
6042
|
-
const runner = new PipelineRunner(ctx);
|
|
6109
|
+
const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
|
|
6043
6110
|
const result = await runner.run(session.page, pipeline);
|
|
6044
6111
|
if (result.ok) {
|
|
6045
6112
|
const blockResult = await detectBlock(session.page);
|
|
@@ -6066,7 +6133,7 @@ var PipelineExecutor = class {
|
|
|
6066
6133
|
return result;
|
|
6067
6134
|
}
|
|
6068
6135
|
/** Execute via local Chrome daemon (headless + binary-headful). */
|
|
6069
|
-
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
6136
|
+
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
6070
6137
|
const startTime = Date.now();
|
|
6071
6138
|
let acquired = false;
|
|
6072
6139
|
try {
|
|
@@ -6088,8 +6155,38 @@ var PipelineExecutor = class {
|
|
|
6088
6155
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
6089
6156
|
if (sessionData) ctx.sessionData = sessionData;
|
|
6090
6157
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
6091
|
-
const runner = new PipelineRunner(ctx);
|
|
6092
|
-
|
|
6158
|
+
const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
|
|
6159
|
+
let result;
|
|
6160
|
+
try {
|
|
6161
|
+
result = await runner.run(page, pipeline);
|
|
6162
|
+
} catch (err) {
|
|
6163
|
+
if (!(err instanceof HeadedRequiredError)) throw err;
|
|
6164
|
+
this.deps.daemon.release(mode, instanceId);
|
|
6165
|
+
acquired = false;
|
|
6166
|
+
await this.deps.daemon.stopMode(mode, instanceId);
|
|
6167
|
+
const available = this.deps.availableModes();
|
|
6168
|
+
const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
|
|
6169
|
+
if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
|
|
6170
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
6171
|
+
const index = err.stepIndex;
|
|
6172
|
+
const remaining = pipeline.steps.slice(index);
|
|
6173
|
+
const insertedNavigation = remaining[0]?.type !== "navigate";
|
|
6174
|
+
if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
|
|
6175
|
+
const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
|
|
6176
|
+
const offset = index - Number(insertedNavigation);
|
|
6177
|
+
resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
|
|
6178
|
+
resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
|
|
6179
|
+
resumed.totalSteps = pipeline.steps.length;
|
|
6180
|
+
resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
|
|
6181
|
+
if (resumed.error) {
|
|
6182
|
+
resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
|
|
6183
|
+
resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
|
|
6184
|
+
}
|
|
6185
|
+
resumed.durationMs = Date.now() - startTime;
|
|
6186
|
+
resumed.modeEscalated = true;
|
|
6187
|
+
resumed.modeUsed = headed;
|
|
6188
|
+
return resumed;
|
|
6189
|
+
}
|
|
6093
6190
|
if (result.ok) {
|
|
6094
6191
|
const blockResult = await detectBlock(page);
|
|
6095
6192
|
if (blockResult.blocked) {
|
|
@@ -6180,12 +6277,30 @@ var FetchService = class {
|
|
|
6180
6277
|
const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
|
|
6181
6278
|
context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
6182
6279
|
if (sessionData) await injectCookies(context, sessionData);
|
|
6183
|
-
|
|
6280
|
+
let page = await context.newPage();
|
|
6184
6281
|
await applyStealthToPage(page);
|
|
6185
6282
|
await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
|
|
6283
|
+
let protection = false;
|
|
6284
|
+
const pivotIfDetected = async () => {
|
|
6285
|
+
if (protection || !await detectSiteProtection(page)) return;
|
|
6286
|
+
protection = true;
|
|
6287
|
+
const targetUrl = page.url();
|
|
6288
|
+
const cookies = await context.cookies();
|
|
6289
|
+
await context.close();
|
|
6290
|
+
context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
6291
|
+
await context.addCookies(cookies);
|
|
6292
|
+
page = await context.newPage();
|
|
6293
|
+
await applyStealthToPage(page);
|
|
6294
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
6295
|
+
await paceProtectedDomain(new URL(targetUrl).hostname);
|
|
6296
|
+
await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
6297
|
+
};
|
|
6298
|
+
await pivotIfDetected();
|
|
6186
6299
|
if (sessionData) await injectStorage(page, sessionData);
|
|
6187
6300
|
if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
|
|
6188
6301
|
for (const action of actions) {
|
|
6302
|
+
await pivotIfDetected();
|
|
6303
|
+
if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
|
|
6189
6304
|
switch (action.type) {
|
|
6190
6305
|
case "click":
|
|
6191
6306
|
await page.click(action.selector);
|
|
@@ -6216,6 +6331,7 @@ var FetchService = class {
|
|
|
6216
6331
|
break;
|
|
6217
6332
|
}
|
|
6218
6333
|
}
|
|
6334
|
+
await pivotIfDetected();
|
|
6219
6335
|
const finalUrl = page.url();
|
|
6220
6336
|
const html = returnHtml ? await page.content() : void 0;
|
|
6221
6337
|
const result = extract2 ? await page.evaluate(extract2) : void 0;
|
package/dist/mcp-server.cjs
CHANGED
|
@@ -2087,6 +2087,8 @@ CRITICAL RULES:
|
|
|
2087
2087
|
7. If the browser crashes, call "session restart" and retry. Don't panic.
|
|
2088
2088
|
8. ALWAYS call "session" action=stop when you are done with browser work. It saves session state and frees the browser. Idle browsers are auto-reclaimed, but don't rely on that.
|
|
2089
2089
|
|
|
2090
|
+
If headless detects Imperva or Akamai, immediately stop headless and pivot to headed in the same run; do not wait for a block, retry headless, or defer the switch to a future request. Use headed from the outset when the provider is already known in the current task. On detection, switch to headed and use respectful pacing (at least 3 seconds between top-level actions; no parallel bursts or repeated blocked retries). Do not announce the provider or explain the switch unless the user asks why. Only when asked, explain that the site is covered by Imperva or Akamai and iframer adjusts its browser mode and pacing to respect it.
|
|
2091
|
+
|
|
2090
2092
|
BROWSER MODES: Don't specify options.mode \u2014 iframer auto-selects and auto-escalates (headless \u2192 binary-headful). Only set a mode if the user explicitly asks.
|
|
2091
2093
|
|
|
2092
2094
|
CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. In docker-headful mode, use the "solve-captcha" step.
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "iframer",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.6",
|
|
5
5
|
"description": "Let iframer drive a Chrome tab you choose, on your real logged-in session.",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp2wbce98k+xDnEgtuzLyNXYBLEDY0nqChuvolb94B3VV1HsHFUulUyNe92eN6MjQuadDohQ2j5mj7zWs4yQoixgTQPe7Gm/+GHWdgxwM2s9t5opnLHwF90LPiJm6q1NGWLM4aK6JT+2I6FnUGmj2bzB4WIlf55C3aMEBoXTqIzG2U8s2cxkXGebWWqbPlMK/Phsmsvo4POWnidbGHPhBOq0iyPTu7RdSDlNdJdJ+dtdNXkMmltKUS881MQI9T6GcCCKhIKcZrJGGnk0xphlWyl+ublTAO0tbQSVB/K7D3JmCxMv690TPtYUPPhGi+C6VCv3IeutLmNo60mvux3wbGQIDAQAB",
|
|
7
7
|
"permissions": [
|
package/package.json
CHANGED
package/skills/iframer.md
CHANGED
|
@@ -20,7 +20,8 @@ You are an expert at using the iframer browser automation toolkit. The user want
|
|
|
20
20
|
2. **NEVER guess CSS selectors.** Use `snapshot` to see what's on the page, then use `@e` refs from the snapshot for click/fill/etc.
|
|
21
21
|
3. **NEVER re-store credentials when login fails.** If credentials exist in `credentials list`, they are valid. Login failures are browser-mode or bot-detection problems, not credential problems. Retry with a stronger mode.
|
|
22
22
|
4. **NEVER pass explicit selectors to the login step** unless the auto-detect failed AND you took a snapshot to find the right ones. The login step auto-detects email, password, submit, and OTP fields.
|
|
23
|
-
5.
|
|
23
|
+
5. If headless detects Imperva or Akamai, immediately stop headless and pivot to headed in the same run; do not wait for a block, retry headless, or defer the switch to a future request. Use headed from the outset when the provider is already known in the current task. On detection, switch to headed and use respectful pacing (at least 3 seconds between top-level actions; no parallel bursts or repeated blocked retries). Do not announce the provider or explain the switch unless the user asks why. Only when asked, explain that the site is covered by Imperva or Akamai and iframer adjusts its browser mode and pacing to respect it.
|
|
24
|
+
6. **Let iframer handle mode escalation.** Don't hardcode modes. If you must pick one, prefer no mode (auto-select) or `binary-headful` for sites known to block headless.
|
|
24
25
|
|
|
25
26
|
## How to build pipelines
|
|
26
27
|
|
|
@@ -199,7 +200,7 @@ session: { action: "restart" }
|
|
|
199
200
|
```
|
|
200
201
|
This kills ALL running browser instances (local + Docker) and resets state. Credentials and knowledge cache are NOT affected. The next `execute` call launches a fresh browser automatically.
|
|
201
202
|
|
|
202
|
-
**The execute tool also auto-recovers:** if it detects a crash (connection closed,
|
|
203
|
+
**The execute tool also auto-recovers:** if it detects a *connection-level* crash (connection closed, ECONNREFUSED/ECONNRESET/EPIPE, socket hang up), it restarts the browser server and retries once before reporting failure. Timeouts are deliberately NOT auto-retried — a slow-but-healthy pipeline that hit the client timeout must not be re-run, or non-idempotent steps (form submits, purchases) would double-execute. If auto-recovery fails, or a step timed out, call `session restart` manually and continue from where you left off (don't blindly re-run the whole pipeline).
|
|
203
204
|
|
|
204
205
|
### Manage credentials
|
|
205
206
|
|
|
@@ -274,7 +275,7 @@ Many modern sites don't have email+password on one page. The login step handles
|
|
|
274
275
|
|
|
275
276
|
## Recovering from MCP disconnect
|
|
276
277
|
|
|
277
|
-
The iframer MCP server
|
|
278
|
+
The iframer MCP server does NOT run Chrome — Chrome lives in a separate, shared local background server (the daemon on 127.0.0.1). The MCP process is just a thin HTTP/stdio client. So a browser crash can't take the MCP down with it: if Chrome dies, the daemon's own recovery handles it and the MCP keeps running. A real "MCP disconnected" in Claude Code means the MCP client process itself dropped (session killed, pipe closed), not a browser crash — the daemon and its warm browsers survive that too.
|
|
278
279
|
|
|
279
280
|
**Recovery is simple — Claude Code auto-reconnects MCP servers.** Just wait 2-3 seconds and retry the tool call. Don't:
|
|
280
281
|
- Try to run `claude mcp` commands
|