Via MCP
Give an agent the solver as a tool it can reach for mid-task, rather than writing the challenge handling in ahead of time.
Every other page here is written the other way round: you pick a target, and the handling for that one site gets written before anything runs. That's the right shape for a scraper and the wrong shape for an agent, which doesn't know which page will block it until the 403 comes back.
claude mcp add --transport http xhrdev https://mcp.xhr.dev/mcpAnything that reads the standard mcpServers block — Claude Desktop, Cursor, Windsurf, Zed:
{
"mcpServers": {
"xhrdev": {
"type": "http",
"url": "https://mcp.xhr.dev/mcp"
}
}
}There's nothing to clone, install, or keep running. That URL is a hosted server we operate.
This is not the same as Via Claude
That page is about getting an agent to write your integration — a prompt and a skill, so the code it produces is right. This page is about an agent using the solver at runtime, on pages it hits while working. They compose: use the skill to build your scraper, use MCP for the agent's own browsing.
Whether it can reach your solver
One question decides whether this is any use to you: is your solver reachable from the public internet?
The server runs in our AWS account. When it calls a solver, the call leaves from there. A container on a private subnet — which is the posture we recommend — is not reachable from AWS, and no configuration changes that.
| Your deployment | What to use |
|---|---|
| Evaluating, no container yet | This, with no headers. It uses our trial box. |
| Container published on the internet, behind TLS and a key | This, with x-solver-host and x-api-key. |
| Container on a private network | Not this. The HTTP integration — your agent's code calls your own solver directly, and nothing has to cross a boundary. |
That last row is the common one for customers, and it isn't a downgrade: an agent that can run code can call POST /dd/solve on your own host in the same four requests every other page here describes. What MCP buys is not capability but reach — tools an agent can discover and call without you having written an integration first, which is exactly what's useful while evaluating and exactly what a locked-down deployment doesn't need.
Pointing the hosted server at your own solver
With no headers you get our trial box on a shared key, rate limited — enough to try the tools, not to run on. Two optional headers change that, and each is independent:
claude mcp add --transport http xhrdev https://mcp.xhr.dev/mcp \
--header "x-solver-host: https://solver.example.com" \
--header "x-api-key: <your key>"| Header | What it does |
|---|---|
x-solver-host | Your deployment instead of the trial box. Same two forms the examples take: 10.0.0.5 means http://10.0.0.5:3000, a full URL is used as given. |
x-api-key | Sent on to the solver as x-api-key. Supplying it also lifts the rate limit here. authorization: Bearer <key> works too, for clients that only offer a token field. |
x-solver-host has to name something routable from the internet — so give it https, not http://…:3000, since your key and the clearance material cross the public network on that hop. A container exposed that way needs the reverse proxy and x-api-key gate described in Deployment; the container itself has no authentication of its own.
If that sounds like more exposure than you want, that's the right instinct — take the HTTP integration instead.
There is no proxy header, on purpose
Neither solve tool makes a request that a vendor binds anything to. A DataDome solve makes no outbound request at all — that is why iframeData is required, and why a proxy in the solve body is accepted and ignored. The two fetches this server does make on your behalf are the DataDome challenge document and the Akamai SBSD bundle, and neither is a submission.
So the proxy that matters is yours, for the requests you make yourself: the blocked one, the submission or the ledger's rows, and the retry. Pin that session and send them all through it.
Call solver_info if you want the server to tell you which mode it decided you were in. It's the quickest way to find a mistyped header name.
The tools
| Tool | What it does |
|---|---|
health_check | GET /hc — is the solver reachable |
solver_stats | your own solve counts and success rate |
akamai_queue_metrics | queue depth, for backpressure |
datadome_solve | solve a DataDome captcha or interstitial |
akamai_sbsd_solve | answer Akamai's SBSD channel for one document |
solver_info | which solver, which mode, which tools |
Both solve tools have the same shape, and it's the shape the whole server is built around: we compute, you send. Neither returns a cookie and neither touches your target. What comes back is a request — or a list of them — for your agent to make itself, from the address it is browsing from.
datadome_solve takes the HTML body of the 403 you just received, parses the challenge out of it, fetches the challenge document, and returns a prepared submission — {url, body?, origin, referer}. Your agent sends that itself, from the address it will browse from, and gets the clearance cookie back. That's rule 1 doing its job: a submission made by anything else earns a cookie that's void where you need it.
The browser identity is filled in for you from the profile the examples repo shares, so an agent calls these with a URL rather than having to invent a coherent profile and js_profile — which it can't, since those fields are cross-checked against each other and against the headers actually sent. That's rule 2, handled.
akamai_sbsd_solve
Give it the URL and the HTML you were served. It finds the SBSD bundle in that HTML, fetches the bundle for you, and returns the ledger — a FIFO list of bodies. You POST each submissions[i].body to its resolvedUrl, in index order, from the client that got the document. That is what puts bm_s / bm_so in your jar.
1. GET https://www.aircanada.com/ca/en/aco/home.html
-> HTTP 200, the document loads a script whose src carries a UUID `v=`
2. akamai_sbsd_solve { url, html, cookieHeader }
-> { complete: true, expectedCap: 3, submissions: [...] }
3. POST each row to its resolvedUrl, in order, same client
-> 200, 200, 202 — bm_so lands in the jarRunnable, with no browser anywhere in it: npm run mcp:aircanada.
Three things about the ledger fail quietly, and they are the endpoint's rather than this server's — the SBSD reference has them in full: rows are capacity, not a promise (if the page emits more carriers than you have rows, abort the extras rather than letting a native body through), the ledger is bound to one document for five minutes, and if you are driving a real browser, send akBmTabId so every ledger describes the same tab.
The only argument you normally need beyond url and html is cookieHeader — document.cookie, not the HTTP header. The bundle is fetched for you because it is ~500 KiB of obfuscation and no agent should be pasting that through a tool call; if a property refuses us the bundle you'll get a 403 saying so, and can fetch it yourself and pass bundleSource.
The catch is the transport, not the channel
You still have to get the document. A protected property will refuse a client whose TLS and HTTP/2 fingerprint is your HTTP library's, often with a 403 before any bundle is served — so there is no challenge to solve. Headers move that line further than you'd expect: www.aircanada.com answers a bare fetch with an Access Denied page and answers the same request carrying full navigation headers with a bundle. Where they aren't enough, that's the problem to fix first, and it's the same problem on every channel.
There is no _abck sensor solve on the hosted server
This is not a port that hasn't happened yet. The Akamai sensor channel is a browser bridge: a WebSocket held across rounds, with Chrome relaying each sensor submission on its own connection. This server is stateless by construction and has no browser, and what a solve earns is bound to the address that earned it — cookies solved in our AWS account are void the moment your agent retries from its own.
The two browserless doors are shut too, and were tested rather than assumed:
POST /akamai/solveneeds an outbound path to fetch the sensor script, which the solver box does not have. Given a live ISP proxy, it sat atsensors_sent: 0until the deadline, with and withoutscriptsupplied.- The solve returns the sensor's first request, a
GET /_bm/get_params?type=get-akidbootstrap rather than telemetry — round one of a conversation with nowhere to go from here.
And a fetch-driven session against the real socket ran 18 rounds with correctly sized payloads without _abck ever leaving ~-1~, while a browser bridge reached ~0~ at round 5 on the same target and address minutes earlier. The blocker is who submits.
So the sensor lane has to run from the machine doing the browsing: drive the browser bridge directly, with comcast.ts as the runnable example. SBSD has none of this in its way, which is why it is here: one POST answers a ledger, and you send the rows.
Using it
Give the agent a target and tell it what to do when the target says no:
Fetch
https://www.grainger.com/through the proxy in.env. If the response is a403whose body containsvar dd =, pass that whole body to the xhrdev MCP server'sdatadome_solve, send the prepared submission it returns — same proxy — and retry with thedatadomecookie you get back.
That's the whole shape. The agent makes its request, and only if it's actually blocked does it reach for a tool:
1. GET https://www.grainger.com/ (through the proxy)
-> HTTP 403, body carries `var dd = {...}`
2. datadome_solve { url, blockedHtml }
-> { url, body, origin, referer } a request for you to make
3. POST that submission, same proxy
-> Set-Cookie: datadome=...
4. GET the same URL again, same proxy, sending that cookie
-> HTTP 200That exact flow is a runnable script — npm run mcp:grainger in the examples repo, which is grainger-undici.ts with its middle two requests replaced by the one tool call. Diffing the two is the shortest description of what the server does for you.
Every step has to leave from the same address. Whatever your agent makes requests with has to honour the proxy — worth stating in the prompt, as above, rather than hoping. An agent that solves through the proxy and then retries with a built-in web-fetch tool from somewhere else gets a fresh 403 that looks exactly like a failed solve.
Raise your client's request timeout
A tool call here is cut off at 55 seconds, which is comfortable for both solve tools — a DataDome solve is answered in a second or two, an SBSD ledger in under ten. Raising your client's default matters for the browser bridge instead, where an Akamai sensor solve launches a real Chrome and takes 20–60s.
Talking to it with curl
Useful when a client is misbehaving and you want to see the wire. The Accept header is not optional — the MCP spec requires both types, and the server answers 406 without them:
curl -s https://mcp.xhr.dev/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'GET /hc answers {"status":"ok"} with no headers and no key, if you want to point an uptime monitor at it.
Next
- Via Claude — a prompt and a skill for agent-written integrations
- How to integrate — the non-agent paths, and the two rules
- DataDome API reference — what
datadome_solvecalls underneath - Akamai SBSD API reference — what
akamai_sbsd_solvecalls underneath - Deployment — what an internet-facing container needs in front of it