icecrawl MCP server
ICEcrawl
Self-hosted Firecrawl-alternative for personal use: crawl + JS-render + markdown-convert web pages via Crawl4AI, with optional Claude-powered structured extraction, behind a small job-queued HTTP API, all in Docker Compose on one machine.
Architecture: FastAPI (api) accepts jobs → Redis/RQ queue → Python
worker containers call the Crawl4AI container's REST API for fetch/render/
markdown, then (optionally) the Claude Messages API for extraction → results
land in SQLite on a shared volume → clients poll job/crawl endpoints.
Status: verified working end-to-end (2026-07-22).
docker compose up,/healthz, a real scrape of a live site, and both extraction endpoints (real Claude API calls) have all been tested against a running stack. SeeDEVIATIONS.mdfor the one real bug that turned up during that verification and how it was fixed — it's already applied here.
What you need before you start
Docker Desktop (or another Docker Compose-compatible runtime) installed and running.
Two values to put in a config file (below): any random password you make up yourself, and — only if you want AI-powered data extraction, not just plain scraping — an Anthropic API key from console.anthropic.com. Without a key, everything except the
extractfeature works fine.
Quickstart
cp .env.example .envOpen .env in any text editor and fill in:
SCRAPER_API_KEY— make up any long random string, e.g.openssl rand -hex 32. This is the password your own requests will use.CRAWL4AI_API_TOKEN— same idea, another random string. Required for the containers to talk to each other correctly.ANTHROPIC_API_KEY— optional, only needed for theextractfeature.
Then, from this folder:
docker compose up -d --buildThe first run pulls a few images and can take a few minutes. Once
docker compose ps shows everything healthy:
curl http://localhost:8080/healthzshould return {"status": "ok", "redis": true, "db": true, "crawl4ai": true}.
If crawl4ai shows false, give it another 10-20 seconds and retry — it's
usually still starting up.
That's it — the API is live at http://localhost:8080.
Scale workers if you're running a lot of jobs at once (each worker holds a Chromium page in the crawl4ai container while a job is in flight):
docker compose up -d --scale worker=4API reference
All endpoints except /healthz require X-API-Key: <SCRAPER_API_KEY> (the
value you put in .env). All bodies are JSON. Errors are
{"error": {"code": "<UPPER_SNAKE>", "message": "..."}}.
Scrape a single page
curl -X POST http://localhost:8080/v1/scrape \
-H "X-API-Key: $SCRAPER_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com", "wait": true}'Fire-and-poll instead of waiting:
curl -X POST http://localhost:8080/v1/scrape \
-H "X-API-Key: $SCRAPER_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
# -> {"job_id": "...", "status": "queued", "status_url": "/v1/jobs/..."}
curl http://localhost:8080/v1/jobs/<job_id> -H "X-API-Key: $SCRAPER_API_KEY"Scrape with structured extraction (requires ANTHROPIC_API_KEY):
curl -X POST http://localhost:8080/v1/scrape \
-H "X-API-Key: $SCRAPER_API_KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"wait": true,
"extract": {
"schema": {"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]},
"prompt": "Extract the page title"
}
}'Crawl a site (depth/page-limited BFS)
curl -X POST http://localhost:8080/v1/crawl \
-H "X-API-Key: $SCRAPER_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://books.toscrape.com/", "max_depth": 1, "max_pages": 5}'
# -> {"crawl_id": "...", "status_url": "/v1/crawls/..."}
curl http://localhost:8080/v1/crawls/<crawl_id> -H "X-API-Key: $SCRAPER_API_KEY"
# with full page results, paginated:
curl "http://localhost:8080/v1/crawls/<crawl_id>?include=results&limit=50&offset=0" \
-H "X-API-Key: $SCRAPER_API_KEY"
curl -X DELETE http://localhost:8080/v1/crawls/<crawl_id> -H "X-API-Key: $SCRAPER_API_KEY"Recommended pattern for extraction on a crawl: crawl without extract
first (extraction on every page of a crawl multiplies Claude calls by page
count — the response echoes estimated_extraction_calls as a warning when
extract is present), then run /v1/extract selectively on the pages you
actually want structured data from.
Extract structured data from raw content (no fetch, synchronous)
curl -X POST http://localhost:8080/v1/extract \
-H "X-API-Key: $SCRAPER_API_KEY" -H "Content-Type: application/json" \
-d '{
"content": "# Hello World\n\nThis page is about widgets.",
"schema": {"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]}
}'Returns {"extraction": {...}, "extraction_meta": {"model", "input_tokens", "output_tokens", "validation_errors", "truncated"}}. 422 if ANTHROPIC_API_KEY
is not configured (EXTRACTION_UNCONFIGURED), or if content/schema fail
validation.
Using it from Claude (MCP server)
mcp-server/ wraps the HTTP API as two Claude-friendly tools — parse (one
page) and scrape (a whole domain, following internal links). This is
optional; the HTTP API above works fine on its own.
cd mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txtThen register it with Claude Code:
claude mcp add icecrawl --scope user \
--env ICECRAWL_BASE_URL=http://localhost:8080 \
--env SCRAPER_API_KEY=<same key as your .env> \
-- /full/path/to/mcp-server/.venv/bin/python /full/path/to/mcp-server/server.py(For Claude Desktop, add the same command/env under mcpServers in its
config file instead.)
By default, every completed parse/scrape also saves a markdown note to
~/Library/Mobile Documents/iCloud~md~obsidian/Documents/My Brain/LLM World/ICEcrawl
— that's an Obsidian vault path specific to the original author's machine.
If you don't have (or want) that, set ICECRAWL_OBSIDIAN_DIR to any folder
on your own machine, or just ignore it — it'll create that folder path the
first time it saves a note.
Security notes
Single static API key (
X-API-Key), constant-time compared. No TLS — this service is meant to sit on localhost/LAN, not the public internet. Put a reverse proxy with real TLS + auth in front before ever exposing it publicly.SSRF guard: every URL that reaches crawl4ai is checked first — it rejects loopback/private/link-local/reserved/multicast/unique-local addresses (including the cloud metadata IP
169.254.169.254) and non-http(s) schemes.allow_private: true(default false) opts a specific request out of the private/reserved range check for deliberately scraping your own LAN hosts — loopback and link-local stay blocked even then.Residual risk: this is a pre-fetch check only; a public URL that redirects (or DNS-rebinds) to an internal address can still be fetched. Compensating control:
redisandcrawl4aiaren't published to the host, and the whole stack is meant to stay off the public internet.
ANTHROPIC_API_KEYis read only from.envat container runtime, never baked into the image, never logged, never returned by any endpoint.Request bodies are capped at 2 MB;
/v1/extractcontent is separately capped at 200,000 characters.
Known limitations
Limitation | Fallback |
Anti-bot walls (Cloudflare/CAPTCHAs) | Job fails with |
Heavy SPA / infinite scroll / login-gated content |
|
Extraction cost on crawls | Prefer crawl-then-selective- |
SQLite under concurrency | Fine at moderate volume; keep workers ≤ 4. |
Worker crash mid-job | Startup reaper auto-marks stuck jobs failed on next API boot. |
No dedupe/caching across jobs | Re-scraping a URL re-fetches every time. |
Cost trade-offs (single glance)
Haiku default vs Sonnet override — Sonnet-class is roughly 10x the per-token price of Haiku-class; override (
extract.model) only when a page is hard enough to need it.EXTRACT_MAX_INPUT_CHARS/EXTRACT_MAX_TOKENSin.envare the two spend knobs; defaults (120,000 chars / 4096 tokens) are conservative.No Claude call ever happens implicitly — extraction only runs when a request explicitly includes
extractor hits/v1/extract.
Development
docker compose run --rm api pytest # full suite inside the container
docker compose run --rm api pytest -m "not integration" # skip the live crawl4ai check115 tests pass (1 deselected without -m "not integration" — the live
crawl4ai integration test self-skips outside a running compose stack).