Testing agent2web locally

The test suite and wrangler dev both run the real Worker locally, with local D1 and R2 — no Cloudflare account needed until you deploy. Everything except the Claude web connector can be exercised this way, including the full MCP publish flow. Six steps, about five minutes.

You need: Node 22 or newer and this repository. Nothing else — no Cloudflare account, no Docker, no DNS. Local D1 and R2 state lands in .wrangler/, which is gitignored.

01

Install and run the test suite

The suite bundles the Worker exactly as wrangler does for deploy, boots it in Miniflare with local D1 and R2 bindings, and talks to it over HTTP. A clean pass means the deployed code path works, not merely that some in-process stand-in does.

npm install npm test
Expect
# tests 98 # pass 98 # fail 0
02

Generate secrets for local dev

Save the admin password it prints — it is stored nowhere else.

npm run gen-secrets cp .dev.vars.example .dev.vars # then paste the values in

.dev.vars is what wrangler dev reads for secrets, and it is gitignored. The same variable names are what the Deploy to Cloudflare flow prompts for, so the two stay in step.

No account needed yet Everything up to step 06 runs entirely on your machine. D1 and R2 are emulated locally and their state lives in .wrangler/; delete that directory to start clean.
03

Run the Worker

npm run dev
Expect
[wrangler:info] Ready on http://localhost:8787

Bad configuration stops the Worker with the reason rather than half-starting. In another terminal:

curl -s localhost:8787/healthz
{"status":"ok","sites":0,"version":"0.3.0"}
04

Publish a page

This is the same call Claude makes. Export A2W_API_TOKEN from your .dev.vars first.

curl -s localhost:8787/mcp \ -H "authorization: Bearer $A2W_API_TOKEN" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ "name":"site_publish", "arguments":{"slug":"hello","html":"<h1>Hello from a Worker</h1>"}}}'
Expect (inside the JSON-RPC result)
Published **hello** (1 file, 28 B) http://localhost:8787/s/hello/

Then curl -i localhost:8787/s/hello/. Note two headers: an ETag straight from R2, so conditional requests work, and a sandbox CSP, because the page is served from the Worker's own origin.

HTTP/1.1 200 OK Cache-Control: no-cache ETag: "1c4bdd12f43f962ac767f711fa5ac6e5" Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups …
Gotcha The accept header must list both application/json and text/event-stream. That is the MCP spec, not a quirk of this server — omit either and you get 406 Not Acceptable.
05

Password-protect it, and open the admin UI

curl -s localhost:8787/mcp \ -H "authorization: Bearer $A2W_API_TOKEN" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ "name":"site_set_access", "arguments":{"slug":"hello","visibility":"password","password":"open-sesame"}}}' curl -s -o /dev/null -w "%{http_code}\n" localhost:8787/s/hello/ curl -s -o /dev/null -w "%{http_code}\n" -u :open-sesame localhost:8787/s/hello/
Expect
401 ← the unlock form 200 ← HTTP Basic works, for scripts

Basic auth is throttled on the same counter as the form, because it issues no cookie and so pays the full key derivation on every request. Then sign in at localhost:8787/admin with the admin password from step 02.

06

Connect a real client

Both credentials work locally. The static token skips the browser; the OAuth flow uses a loopback callback, which the redirect policy allows on any port.

npx @modelcontextprotocol/inspector # point it at http://localhost:8787/mcp claude mcp add --transport http a2w-local http://localhost:8787/mcp \ --header "Authorization: Bearer $A2W_API_TOKEN"
Not testable locally Claude web custom connectors need a public HTTPS URL with a certificate a browser trusts, so that flow cannot run against localhost. Deploy, or expose the port with cloudflared tunnel --url http://localhost:8787 and set A2W_PUBLIC_URL to the tunnel hostname — it must match exactly, since it is the OAuth issuer and the audience of every token issued.

Troubleshooting

Every row here is a failure we actually hit while building this, with the symptom that showed up first.

SymptomCause and fix
Worker returns 500 with a configuration message Deliberate: bad config stops startup rather than half-working. The body names the variable. A scrypt. password hash from the old self-hosted build lands here — Cloudflare has no scrypt, so regenerate with npm run gen-secrets.
406 Not Acceptable from /mcp The accept header must list both application/json and text/event-stream.
Admin login says Incorrect credentials with the right password Check .dev.vars is present and that the hash is intact. Hashes are dot-separated precisely so an unquoted $ cannot be eaten by a shell.
Subdomain URLs 404 A2W_SITES_BASE_DOMAIN must be a bare hostname, no port and no leading dot. On workers.dev wildcards do not exist at all, so only path URLs work there.
A code change appears to do nothing npm test and npm run dev both build first, but read the build output — a failed tsc leaves the previous bundle in place and looks exactly like a change that had no effect.
Port 8787 already in use An earlier session is still alive: pkill -f wrangler, or pass --port. If you change the port, change A2W_PUBLIC_URL with it.
Starting over rm -rf .wrangler. Local D1 and R2 state is recreated on the next boot; published sites and OAuth clients are gone.
Login is slow, or CPU cost looks high Expected. PBKDF2 at 600,000 iterations is ~130 ms by design, which is why the Workers Paid plan is required — the Free plan caps a request at 10 ms of CPU. It is paid once per session, not per request.