Skip to main content
Glama
README.md
# dolphin-anty-mcp

An MCP server for the [Dolphin{anty}](https://dolphin-anty.com) anti-detect browser, built from
its public OpenAPI document (v1.0.7).

71 tools covering browser profiles, proxies, folders, statuses, fingerprints, cookies, local
storage, extensions, homepages, bookmarks and team management — plus page-level automation that
drives the Anty browser over the DevTools protocol, and a raw-request escape hatch for anything
not modelled explicitly.

## Setup

```bash
npm install && npm run build
```

Generate a JWT at <https://dolphin-anty.com/panel/index.html#/api>. It is shown once, so copy it
before closing the page.

Register the server with your MCP client:

```json
{
  "mcpServers": {
    "dolphin-anty": {
      "command": "node",
      "args": ["C:/Users/meow/Documents/Dolphin/dist/index.js"],
      "env": { "DOLPHIN_API_TOKEN": "your-jwt-here" }
    }
  }
}
```

| Variable | Default | Purpose |
| --- | --- | --- |
| `DOLPHIN_API_TOKEN` | — | JWT for the remote APIs. Without it only Local API tools work. |
| `DOLPHIN_LOCAL_API_URL` | `http://localhost:3001` | Local API origin. |
| `DOLPHIN_TIMEOUT_MS` | `60000` | Per-request timeout. |

If port 3001 was busy when Dolphin started, it silently picks the next free port. Check
**Settings → Health** in the app and set `DOLPHIN_LOCAL_API_URL` to match.

## The two APIs

This matters more than anything else in the setup:

- **Remote** — profiles, proxies, folders, statuses, fingerprints, remote cookies. Works from
  anywhere with a valid JWT.
- **Local** — `start_profile`, `stop_profile`, cookie robot, local storage. Only answers while the
  Dolphin{anty} desktop app is running **on the same machine**, bound to loopback.

Three remote hosts are involved (`dolphin-anty-api.com`, `apiv2.…/api/v2`,
`darkwing.…/api/v1`); each tool is pinned to the right one, so you never pick.

## Using a profile by name

People say "spin up jannytest", not "start profile 832201155". `start_profile` and `stop_profile`
therefore accept `name` and resolve it themselves:

```json
{ "name": "jannytest", "automation": true }
```

This exists because the obvious design failed in practice. Every lifecycle endpoint is keyed by
numeric id, so the only tool that took a profile *name* was `create_profile` — and a model handed
a name followed the path of least resistance and created a duplicate, silently losing the cookies,
logins and history the user actually wanted.

Three things close that off:

- `start_profile` / `stop_profile` take `name` and report the id they resolved.
- `create_profile` **refuses** a name that already exists, naming the existing id and pointing at
  `start_profile`. Override with `allowDuplicateName: true` when a separate profile really is
  intended.
- An unresolvable name fails loudly and says not to create a replacement, instead of quietly
  producing one.

## Driving the browser

```
create_profile      → coherent fingerprint attached automatically
start_profile       → automation endpoint cached; browser_* tools attach on their own
browser_snapshot    → see the page as an accessibility tree
browser_click/type  → act on it
stop_profile        → syncs the data directory back to the cloud
```

Skipping `stop_profile` leaves the session's cookies and storage unsynced.

**Targeting.** `browser_snapshot` prints lines like `button "Log in"` — the role and quoted name
are exactly what `target` takes:

```json
{ "profileId": 123, "target": { "role": "button", "name": "Log in" } }
```

Fall back to `text`, `label`, `placeholder`, `testId` or raw `css`; add `nth` when several match.
There are no opaque element refs to keep in sync, because `ariaSnapshot({ ref: true })` does not
actually emit them in playwright-core 1.62 — role+name is what the snapshot gives you, so it is
what the tools consume.

Snapshot first, act second. `browser_screenshot` exists but costs far more context; reach for it
only when the visual rendering itself matters.

### Tabs

`browser_tabs` lists, opens, switches and closes tabs. Every other browser tool also takes a
`tabIndex`.

```json
{ "profileId": 123, "action": "list" }                      → [0] Inbox — https://…
{ "profileId": 123, "action": "select", "index": 1 }        → switch for good
{ "profileId": 123, "action": "new", "url": "https://…" }
{ "profileId": 123, "action": "close", "index": 1 }
```

The two are deliberately different. `action: "select"` changes the active tab for every later
call; `tabIndex` acts on one tab for a single call and leaves the active tab alone. That split
matters — a read-only `browser_snapshot { tabIndex: 2 }` silently redirecting all subsequent
clicks to tab 2 is the kind of thing a model never recovers from.

**Popups are reported on the action that caused them.** A `target="_blank"` link leaves the
current page looking unchanged, so a click that spawns a tab appends:

```
🔗 1 new tab(s) opened — the snapshot above is still the original tab:
  [1] https://example.com/b
Use browser_tabs { action: "select", index: 1 } to switch to it, or pass tabIndex to peek.
```

The new tab is not auto-selected — the model is told and decides. Detection uses the context's
`page` event rather than comparing tab counts, because the popup registers a moment *after* the
click resolves and a count check races it.

### CAPTCHAs and human handoff

The server **detects** human-verification challenges and stops. It does not solve or bypass them —
automated attempts fail and get the profile flagged.

Every navigation, click and snapshot is checked for reCAPTCHA, hCaptcha, Cloudflare Turnstile and
its interstitial, Arkose/FunCaptcha, DataDome, PerimeterX, GeeTest, and generic "verify you are
human" text. When one is found the tool result carries an explicit instruction:

```
⚠️ HUMAN VERIFICATION DETECTED: reCAPTCHA (widget present and visible on the page).
Do NOT try to solve, click or type your way through this … Call `browser_await_human` …
```

`browser_await_human` brings the window to the front, shows the operator your `reason`, and blocks
until the challenge clears — then returns a fresh snapshot so the run continues. It also covers
login walls, 2FA/OTP prompts, SMS codes and payment steps.

```json
{ "profileId": 123, "reason": "Solve the reCAPTCHA on the login page", "timeoutSeconds": 300 }
```

Two things make this reliable with weaker models. The instruction is embedded in the tool *output*,
where a model that has just walked into a captcha will actually read it, rather than only in a
description it saw once. And on timeout the tool says "ask the operator, do not loop" instead of
returning something retryable.

Where the host supports MCP **elicitation**, the operator gets a real prompt and confirms when
done. Where it does not — most harnesses today — the tool falls back to polling the page, so the
behavior is the same either way. `until` accepts `challengeGone` (default), `urlChanged`,
`textPresent`, `textGone`, or `manual`.

Handoff requires a headed profile; the tool refuses immediately on a headless one rather than
blocking on a window nobody can see.

### Connecting your own client instead

```js
const url = `ws://127.0.0.1:${port}${wsEndpoint}`;
await chromium.connectOverCDP(url);                    // Playwright
await puppeteer.connect({ browserWSEndpoint: url });   // Puppeteer
```

Both fields are needed. The OpenAPI document describes `wsEndpoint` as a full `ws://` URL, but a
live build returns a bare path — see below.

The MCP resource `dolphin://guide/automation` carries the full workflow, including hand-tuning a
fingerprint instead of letting `create_profile` do it.

## Fingerprints

Dolphin does **not** generate fingerprints server-side, and an incoherent one defeats the point of
an anti-detect browser. `create_profile` therefore pulls a real fingerprint from Dolphin's dataset
and attaches it by default. Pass your own `fingerprint` object (from `get_fingerprint`) to
override, or `autoFingerprint: false` to send none.

One asymmetry worth knowing: `create_profiles_bulk` **ignores** a nested `fingerprint` object.
Bulk creation needs the fields flattened to the top level (`useragent`, `uaFullVersion`,
`screenWidth`, `cpu`, `webglInfo`, …). The tool description repeats this.

## Tools

| Group | Tools |
| --- | --- |
| Profiles | `list_profiles` `list_profiles_cursor` `get_profile` `create_profile` `create_profiles_bulk` `update_profile` `delete_profiles` `transfer_profiles` `share_profile_access` |
| Lifecycle | `start_profile` `start_temporary_profile` `stop_profile` `local_api_login` |
| Proxies | `list_proxies` `create_proxy` `update_proxy` `delete_proxy` |
| Folders | `list_folders` `create_folder` `update_folder` `delete_folder` `get_folder_profile_ids` `move_profiles_to_folder` `reorder_folders` |
| Statuses | `list_statuses` `create_status` `update_status` `delete_statuses` `assign_status` |
| Fingerprints | `get_fingerprint` `get_useragent` `list_webgl` `list_fonts` |
| Cookies | `export_cookies` `import_cookies` `export_cookies_local` `import_cookies_local` `run_cookie_robot` `stop_cookie_robot` |
| Local storage | `export_local_storage` `import_local_storage` |
| Extensions | `list_extensions` `add_extension` `delete_extensions` |
| Homepages | `list_homepages` `create_homepages` `update_homepage` `delete_homepages` |
| Bookmarks | `list_bookmarks` `create_bookmark` `update_bookmark` `delete_bookmarks` |
| Team | `list_team_users` `create_team_user` `update_team_user` `delete_team_user` |
| Browser | `browser_await_human` `browser_connect` `browser_disconnect` `browser_navigate` `browser_snapshot` `browser_screenshot` `browser_click` `browser_type` `browser_select_option` `browser_press_key` `browser_scroll` `browser_wait_for` `browser_evaluate` `browser_tabs` |
| Escape hatch | `dolphin_request` |

Deletes and transfers carry `destructiveHint` annotations, so clients that gate on those will
prompt before running them.

Some tools collapse several endpoints where the API split them arbitrarily: `delete_profiles`
routes single-id deletes through the per-profile endpoint (the only one that accepts a profile
password), `move_profiles_to_folder` attaches or detaches depending on whether you pass a
`folderId`, and `assign_status` either references an existing status or creates one inline.

## Error handling

HTTP status codes get translated into something actionable rather than surfaced as bare numbers:

| Code | Meaning |
| --- | --- |
| 401 | JWT missing or expired |
| 402 | Paid-plan feature — automation, cookie robot, remote cookies |
| 429 | 500 req/min rate limit, or too many concurrent launches |
| 499 | Free plan: three profiles already running |

Rate-limit headers are read on every response; when fewer than 25 requests remain in the current
minute, the tool result says so. Connection failures against the Local API explain the desktop-app
and port requirements instead of returning a bare `ECONNREFUSED`.

Responses are capped at 60k characters — a page of 100 profiles carries a full fingerprint each,
which would otherwise flood the context window.

## Not covered

- **`POST /extensions/upload-zipped`** — multipart file upload. `dolphin_request` sends JSON only,
  so this endpoint is genuinely unreachable through this server. Use the app or a direct HTTP call.
- `getFolder`, `getBrowserProfileStatus`, the single-profile `shareAccessToBrowserProfile`, and the
  multi-profile local-storage export are reachable via `dolphin_request` — they were left out
  because a dedicated tool would duplicate one that already exists.

## Real-world run

```bash
node examples/bot-detection-check.mjs
```

Drives a real Anty profile through [bot.sannysoft.com](https://bot.sannysoft.com/) and then a live
DuckDuckGo search, using only MCP tools. Result: **58 detection tests, 0 failures or warnings.**

```
✅ WebDriver (New)                missing (passed)
✅ WebDriver Advanced             passed
✅ Chrome (New)                   present (passed)
✅ Permissions (New)              prompt
✅ Plugins Length (Old)           5
✅ HEADCHR_UA / _CHROME_OBJ / _PERMISSIONS / _PLUGINS   ok
✅ WebGL Renderer  ANGLE (Intel, Intel(R) Iris(R) Xe Graphics …)
```

The interactive half found the search box by role from a snapshot, typed, submitted, and read the
results back — which included `API - Dolphin {Anty}`. So CDP automation through this server does
not break the profile's anti-detect properties.

## Findings from a live build

Measured against Dolphin{anty} running Anty 150 on Windows 11, using temporary profiles.

**`wsEndpoint` is a path, not a URL.** The OpenAPI document says "Full `ws://` URL" and gives
`ws://127.0.0.1:38927/devtools/browser/abc123` as its example. The actual response is
`{"port": 59618, "wsEndpoint": "/devtools/browser/8e0a4ca0-…"}` — a bare path, matching Dolphin's
own automation blog post rather than its spec. This server accepts either shape.

**A nested `fingerprint` object produces a broken profile.** The docs say you can pass the whole
fingerprint to `POST /browser_profiles` and "the backend flattens its derived fields for you". It
does not. The profile is created, but `GET /browser_profiles/{id}` and the Local API's start
endpoint both then return HTTP 500. `create_profile` flattens it here instead, into the
`{mode, value}` shape a profile actually stores:

```
useragent   {"mode":"manual","value":"Mozilla/5.0 …"}
cpu         {"mode":"manual","value":8}
webglInfo   {"mode":"manual","vendor":"Google Inc. (NVIDIA)","renderer":"ANGLE (NVIDIA, …)"}
```

**A profile without spoofing-mode objects cannot launch.** Create accepts a body with no `webrtc`,
`canvas`, `webgl`, `clientRect`, `timezone`, `locale`, `ports` or `geolocation` and stores them as
`null`. The profile reads back fine and then fails to start with HTTP 500. `create_profile` now
sends the same defaults a UI-built profile carries (`PROFILE_MODE_DEFAULTS` in
[src/fingerprint.ts](src/fingerprint.ts)).

**`browser_version` is required on `/fingerprints/fingerprint`, and there is no fallback.** The
spec marks it optional and says omitting it (or passing a version above stable) falls back to an
internal default. Neither is true: omitting it returns HTTP 422 `validation.required`, and a
too-high value returns HTTP 200 with an empty object. `create_profile`'s default path was broken by
this. It now discovers the dataset's current maximum by binary search and caches it
([src/fingerprint-version.ts](src/fingerprint-version.ts)) rather than hard-coding a version that
would rot — requesting a year-old Chrome is itself a fingerprinting signal. At the time of writing
the dataset covers up to Chrome 150.

**`fonts` silently does not persist.** Sending a 214-entry `fonts` array stores `[]`. Pairing that
with `fontsMode: "manual"` yields a browser reporting *zero* fonts — a louder signal than not
spoofing at all. Fonts are therefore left at Dolphin's `auto`, which is also what UI-built profiles
use.

**Headless leaks `HeadlessChrome` — but only on temporary profiles.**

| profile | headed | `headless: true` |
| --- | --- | --- |
| temporary | `Chrome/150.0.0.0` | `HeadlessChrome/150.0.0.0` ⚠️ |
| saved (via `create_profile`) | `Chrome/138.0.0.0` | `Chrome/138.0.0.0` ✅ |

Temporary profiles use default fingerprint preferences and carry no user-agent of their own, so
the headless token shows through. A saved profile's manual user-agent overrides it — verified
byte-identical in both modes. `start_temporary_profile` warns when `headless` is set;
`start_profile` does not, because it does not need to.

`navigator.webdriver` is `false` and no `cdc_`/`$cdc_` CDP artifacts are injected in either mode.

**`page.setContent` fails on Anty's default page** with a `TrustedHTML` error — its internal pages
enforce Trusted Types. Navigate to a real URL instead. No tool here uses `setContent`.

**Detaching does not kill the browser.** `browser.close()` on a CDP-attached connection releases
the client while Dolphin keeps the process alive, so `browser_disconnect` is safe and
`stop_profile` remains the only thing that ends a session and syncs its data.

## Tests

```bash
npm run build
npm test        # everything below
npm run smoke   # mock Local API, no app required
npm run e2e     # real Dolphin{anty}; skips itself if the app is not running
```

`smoke` runs the built server over stdio against a mock and asserts on the tool surface, routing,
query serialization, error translation and input validation.

`e2e` starts a real temporary profile through the Local API and drives the actual Anty browser:
navigate, snapshot, type, select, click, evaluate (page and element scope), scroll, tabs,
screenshot, ambiguous-target error handling, and a real request to `example.com`. The fixture page
is served locally so assertions are deterministic, but the browser under test is genuinely the one
Dolphin launched.

`test/challenge.e2e.test.js` covers the handoff path and tab handling: each vendor's widget markup is detected,
an ordinary page does not false-positive, `browser_await_human` returns as soon as a challenge
clears (~6s, not the full timeout), it times out without looping, and a **live** Google reCAPTCHA
demo page is recognised. It never attempts to solve one.

`test/profile-lookup.e2e.test.js` pins the name-resolution behavior against a live account:
duplicate names are refused, the refusal is overridable, `start_profile` resolves a name to the
existing id, and an unknown name fails without creating anything.

33 tests total, all passing. Run them serially — `npm test` sets `--test-concurrency=1`, because
the two e2e files each launch a browser and collide over profile data directories in parallel.

Both API halves are now verified against a live account:

```bash
node examples/remote-check.mjs        # read-only sweep of the cloud endpoints
node examples/fingerprint-verify.mjs  # create → launch → assert fingerprint → clean up
```

`remote-check` confirms `list_profiles`, `list_proxies`, `list_folders`, `list_statuses`,
`get_fingerprint`, `get_useragent`, `list_fonts` and `list_team_users` against real data.

`fingerprint-verify` creates a profile from a dataset fingerprint, launches it headless and headed,
and asserts the spoof actually applied — user-agent, core count, memory, screen resolution and
WebGL renderer all matched the requested values exactly. It deletes the profile it created.

Still unverified: cookie import/export, local storage, homepages, bookmarks, extensions, folder
and team mutations. `dolphin_request` is the workaround if one of those has a field mismatch.

### Local API session

Starting a *saved* profile needs the desktop app to hold a valid session. If `start_profile`
returns HTTP 500 while `start_temporary_profile` works, run `local_api_login` — it stores your JWT
in the app. Note that `GET /v1.0/browser_profiles` returning `invalid session token` is **not** a
signal of this; that endpoint uses the app's own internal session and 401s regardless.

TDQS

A3.6/5.0

Scored across 71 tools

Disambiguation4/5

Each tool targets a distinct resource/action, with detailed descriptions that clarify edge cases (e.g., list_profiles vs list_profiles_cursor, export_cookies vs export_cookies_local). The main risk is the sheer number of similarly named tools, but the descriptions resolve most ambiguity.

Naming Consistency4/5

The dominant pattern is verb_noun (list_, create_, update_, delete_, get_, start_, stop_, browser_), which is predictable. Minor deviations exist: plural forms (delete_profiles, create_profiles_bulk), the suffix in list_profiles_cursor, and the catch-all dolphin_request, but these do not obscure the convention.

Tool Count2/5

At 71 tools, this is far above the 25+ threshold for 'too many' and will be heavy for an agent to navigate. While the tools cover a wide range of the Dolphin{anty} product's features, the sheer volume makes the server feel like a kitchen sink rather than a focused toolset; splitting into modular servers would improve coherence.

Completeness5/5

The surface is remarkably complete: CRUD for profiles, proxies, folders, statuses, bookmarks, homepages, extensions, and team members, plus fingerprint generation, cookie sync, and browser automation. Minor gaps like a dedicated get_proxy are covered by list endpoints, so there are no functional dead ends.

Maintenance

ActivitySlowing
ResponsivenessNo issues