使用说明
Web Tools
Web tools for AI agents to search, read, and act on the web, on your own infrastructure.
Fifteen tools your agent calls over MCP, REST, or a CLI:
- Search:
web_search(SearXNG metasearch: Google, Brave, DuckDuckGo and more) - Read:
web_fetchreturns clean markdown, plusweb_html,web_crawl,web_screenshot,web_pdf,web_bytes, and the Wayback Machine (web_snapshots,web_archive) - Act:
web_form_submitfills and submits a form with a guarded single POST;web_execute_js,web_evalandweb_spa_fetchdrive pages in a real browser - Operate:
web_recycle(fresh browser and exit IP),web_usage_stats(call counts and proxy cost)
It is an open-source, self-hosted alternative to Firecrawl, Linkup, Tavily, Exa, Bright Data and Browser Use (see Comparison). The stack is five services you own, deployable to Railway in one click. There is no per-call fee and no LLM tokens are spent on web access. Under the hood it runs SearXNG, Scrapling (Chromium) and Camoufox (stealth Firefox on a residential exit). The server picks the fetcher for each host, so callers never have to.
Architecture
graph LR
MCP["MCP Client<br/>(Claude, Cursor, etc.)"] -->|POST /mcp| Server["Web Tools Server"]
API["REST Client"] -->|POST /api/v0/*| Server
CLI["CLI"] -->|direct call| Toolkit["@web-tools/toolkit"]
Server --> Toolkit
Toolkit --> SearXNG
SearXNG --> Redis
Toolkit --> Scrapling
Toolkit --> Camoufox
Toolkit --> Wayback["Wayback Machine"]
Why two fetchers
They are not redundant. Each reaches pages the other cannot, and the split is measured rather than aesthetic:
| Scrapling | Camoufox | |
|---|---|---|
| Browser | Patchright Chromium | Firefox |
| Egress | direct (fast) / rotating US residential (stealth) / solving (solve) |
rotating Italian residential |
| JS challenges | yes (solve, evidence-gated) |
n/a (coherent fingerprint) |
| Markdown + captures | yes (/markdown, /screenshot, /pdf, /eval) |
no |
| LinkedIn profiles | 94% (34/36) | not measured |
| Italian bot-gated sites | wrong country | the point |
| Trustpilot reviews | interstitial only (escalation deliberately refused) | full page with a forced wait |
| Binary / PDF fetch | no | yes (web_bytes) |
| Anti-bot sensor sessions | no | yes (web_spa_fetch) |
| Ordinary pages | ~0.7-1.9s (fast), markdown in 14-200ms |
~8-12s |
Camoufox is Firefox on purpose: stealth-patched headless Chrome was flagged by
Akamai even through an Italian residential IP, while Camoufox's fingerprint is
internally coherent: its locale and timezone are derived from the exit IP, so
web_eval on an Italian site reports Europe/Rome. Italian sources either
bot-gate datacenter IPs outright or score the exit country as part of a sensor
decision, and a US residential exit is not a milder version of the right answer.
A third fetcher (Crawl4AI) was removed on 2026-09-27 after a benchmark; see
AGENTS.md.
Fetch strategy
Callers never choose an engine. web_fetch and web_html take a URL; how to
reach it is decided internally, in three tiers:
| Tier | Egress | Chosen when |
|---|---|---|
fast |
direct, no challenge solving | the default |
stealth |
rotating residential proxy | host is known to wall datacenter IPs (LinkedIn's HTTP 999) |
solve |
direct, solves the JS challenge | the fast response looks like a challenge page |
fast is the default rather than solve even though solve is functionally a
superset: solving roughly doubles latency on ordinary pages, and on a challenge
it cannot solve it blocks for the whole timeout instead of failing fast. So
solving is paid for only on evidence. A small body carrying a known
interstitial title with a 403/429/503 gets retried once in solve, and the
response reports escalated: true.
So: Scrapling owns fetch, markdown and captures; Camoufox owns the Italian
exit. Every URL tool — web_fetch, web_html, web_crawl, web_screenshot,
web_pdf, web_execute_js — routes by host (Italian and bot-walled hosts to
Camoufox, the rest to Scrapling). When one backend fails, the other is the
fallback in both directions; if both fail, the error names both causes. HTML is
rendered to markdown by Scrapling's CPU-only /markdown, or locally in the
Tools process (turndown) when Scrapling is down, so web_fetch, web_crawl
and web_archive survive a Scrapling outage. PDFs are Chromium-only: for a
Camoufox host, Camoufox renders the page and Scrapling prints that DOM.
The project is structured as a monorepo with three packages:
packages/toolkit: Core business logic: Zod schemas, tool definitions, SearXNG/Scrapling/Camoufox/Wayback clients. Framework-agnostic.packages/api: Express HTTP server exposing MCP (POST /mcp) and REST (POST /api/v0/{tool_name}) endpoints.packages/cli: Commander.js CLI for terminal usage.
The full stack deploys as 5 services: Redis, SearXNG, Scrapling, Camoufox, and the Web Tools server.
Browser form execution uses Camoufox's single-attempt form contract.
Tools
The server exposes eighteen tools over MCP (a few REST-only diagnostics besides).
web_search
Lightweight web search via SearXNG with parallel request strategy for reliability.
| Parameter | Type | Description |
|---|---|---|
query |
string (required) | The search query |
limit |
number (optional) | Max results to return (default: 10, max: 20) |
engines |
string (optional) | Comma-separated engines (e.g. "google,brave") |
Returns a JSON array of { url, title, description } results.
web_fetch
Fetch a single URL and return its content as clean markdown. Fetched and
rendered by Scrapling (/fetch + /markdown); Italian and bot-walled hosts
route through Camoufox.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL to fetch |
f |
enum (optional) | Content filter: fit (body only, default) or raw (whole document) |
delay |
number (optional) | Seconds to settle after the page is stable (default: 0) |
Returns the page content as markdown.
There is no engine or mode parameter. Which fetcher runs, whether it goes out through the residential proxy, and whether it solves a JS challenge are all decided under the hood. See Fetch strategy.
web_html
Fetch a URL and return the raw HTML as served, plus the upstream status. Use
this rather than web_fetch when you need markup that markdown conversion
destroys: JSON-LD, meta tags, attributes.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL to fetch |
network_idle |
boolean (optional) | Wait for the network to go quiet (default: false) |
wait_until |
enum (optional) | load (default), domcontentloaded, networkidle, commit |
wait_ms |
number (optional) | Extra settle time after load, in ms (max 60000) |
click_all |
string[] (optional) | CSS selectors clicked (every match) before capture, for lazy accordions and tabs |
settle_ms |
number (optional) | Time for AJAX to settle after click_all (default: 3000, max 30000) |
fresh_ip |
boolean (optional) | Serve from a new browser context on a new exit IP with clean cookies (~1s) |
timeout_ms |
number (optional) | Upstream fetch timeout (default: 60000, max: 90000) |
These describe how to treat the page, not which backend runs it. Each is honoured where the serving backend supports it and ignored where it does not.
Returns a JSON object: { status, url, mode, escalated, size, html }. A
non-2xx upstream status is reported in status rather than raised as an error,
so callers can branch on 999 vs 404 themselves.
web_screenshot
Capture a full-page PNG screenshot of a URL. Scrapling's /screenshot; Italian
and bot-walled hosts go through Camoufox.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL to screenshot |
screenshot_wait_for |
number (optional) | Seconds to wait before capture (default: 2) |
MCP returns it as image content (mimeType: image/png); REST keeps the v0
shape, { content: [{ type: "text", text: <base64 PNG> }], isError }.
web_pdf
Convert a URL to PDF (Chromium print-to-PDF). For Italian and bot-walled hosts Camoufox renders the page and Scrapling prints the rendered DOM.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL to convert to PDF |
MCP returns an embedded resource (mimeType: application/pdf, base64 blob);
REST keeps the v0 shape, base64 PDF as text content.
web_execute_js
Execute JavaScript snippets on a URL in order and return their results as JSON.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL to execute scripts on |
scripts |
string[] (required) | List of JavaScript snippets to execute in order |
Returns { status, url, mode, results } — one entry per script, in order.
web_crawl
Crawl one or more URLs sequentially through the same pipeline as web_fetch
(fetch + markdown render per URL) and return one payload.
| Parameter | Type | Description |
|---|---|---|
urls |
string[] (required) | URLs to crawl, in order (max 20) |
css_selector |
string (optional) | Convert only elements matching this selector |
timeout_ms |
number (optional) | Per-URL fetch timeout (default: 60000, max: 90000) |
Returns { results: [{ url, status_code, success, mode, renderer, markdown }] }
(renderer is scrapling or local). A failed URL reports
{ url, status_code: 0, success: false, error } in its own slot without
sinking the batch. The whole crawl has a 300 s budget; URLs it does not reach
come back the same way.
web_snapshots
List Wayback Machine snapshots for a URL.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL to check for snapshots |
from |
string (optional) | Start date in YYYYMMDD format |
to |
string (optional) | End date in YYYYMMDD format |
limit |
number (optional) | Max number of snapshots to return (default: 100) |
match_type |
enum (optional) | URL matching: exact, prefix, host, or domain (default: exact) |
filter |
string[] (optional) | CDX API filters (e.g. ["statuscode:200", "mimetype:text/html"]) |
Returns a JSON array of snapshots with timestamps, status codes, and archive URLs.
web_archive
Retrieve an archived page from the Wayback Machine.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL of the page to retrieve |
timestamp |
string (required) | Timestamp in YYYYMMDDHHMMSS format |
original |
boolean (optional) | Get original content without Wayback Machine banner (default: false) |
Returns the archived page content.
web_bytes
Download a URL's raw bytes through the residential exit, base64-encoded. Use for PDFs and other binaries behind a bot-gated or geo-sensitive origin, where rendering the page as text would lose the document.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL of the binary to download |
timeout_ms |
number (optional) | Fetch timeout (default: 60000) |
Returns { status, url, size_b64, b64 }. A non-2xx arrives in status rather
than raised, so a caller fetching a PDF that 404s still learns what happened.
web_form_inspect
Read a form without submitting it: call this first, then web_form_submit
once. Same browser path as the submit, but it never fills, clicks or dismisses
anything, and it blocks every mutating request (any origin).
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | Page holding the form |
wait_until, wait_ms |
(optional) | Navigation condition and settle after load (default 4000 ms) |
timeout_ms |
number (optional) | Whole-run deadline (default: 60000) |
fresh_ip, exit_session |
(optional) | Exit IP; reuse the exit_session token to submit from the same IP |
profile, headed |
(optional) | As web_form_submit |
It returns { ok, url, status, forms, captcha, cookie_banners, wizard, diagnostics }.
Each form has action, method, fields, submit_candidates,
honeypot_candidates and suggested. Each field is
{ selector, name, type, action, label, required, placeholder?, autocomplete?, options? }.
A radio group is one field: to choose an option, check its options[].selector.
suggested is a ready web_form_submit request (required fields, best submit
control, dismiss, captcha_field). Add the values and leave out the
honeypots. captcha names the provider (reCAPTCHA v2/v3/enterprise, hCaptcha,
Turnstile) and says whether a sitekey is present; the key itself is never
returned. No field values are ever returned, and any failure is safe to retry.
web_form_submit
Fill a form and click submit once in a real browser (Camoufox, on the
residential exit), then report what actually happened. You supply the
selectors and values; the service owns navigation, typing (keystroke by
keystroke), cookie isolation, cleanup, and a context-wide guard that blocks
duplicate POSTs. It never solves or mints a CAPTCHA: the page's own handler runs
and its own integration mints the token. The full contract is in
services/camoufox/FORMS.md.
Defaults are tuned for agents; usually pass only url, fields, submit.
pacing: auto types fast on forms with no CAPTCHA (~20 s a call) and at a
human rhythm where a CAPTCHA (reCAPTCHA, hCaptcha, Turnstile…) can score the
session (~1 min). Refusals and failures that provably sent nothing are retried
on a fresh exit (3 retries, the retries score-gated), and at most 2
submissions per site run at once (FORM_SITE_CONCURRENCY); more queue. With
async: true the call returns a job_id immediately — poll
web_form_result.
Measured on Atoka's reCAPTCHA v3 sign-up (2026-10-05, synthetic identities): every call succeeded end to end at ≤2 in flight (10/10, 6/6, 13/13, 12/12), ~57 s each, one success every ~45 s per site. More parallel traffic to one reCAPTCHA v3 site lowers every token's score (4 in flight: 50% per attempt), so the per-site limit is a feature, not a bottleneck to remove.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | Page holding the form |
async |
boolean (optional) | Return { job_id, status } at once and run in the background; poll web_form_result |
pacing |
string (optional) | auto (default: human rhythm when a CAPTCHA loads, fast when none), fast, lab (force the human rhythm) |
fields |
object[] (required) | { selector, value?, action? } in order; action is type (default), check, or select |
submit |
string (required) | CSS selector of the submit control |
dismiss |
string[] (optional) | Selectors clicked first (cookie walls) |
success_url |
string (optional) | Regex; a final URL matching it means success |
submission_urls |
string[] (optional) | Same-origin POST endpoints sharing one submission budget (default: url) |
captcha_field |
string (optional) | POST field checked for token presence, never its value (default g-recaptcha-response) |
require_captcha_token |
boolean (optional) | Block the POST when that field is empty or unreadable; no retry |
ready_expression |
string (optional) | Main-world boolean expression that must hold before the click |
inspect_only |
boolean (optional) | Navigate only: no fill, no click, same-origin mutating requests blocked |
wait_until, wait_ms |
(optional) | Navigation condition and settle after load (default 4000 ms) |
settle_ms |
number (optional) | How long to wait for the outcome (default: 20000) |
timeout_ms |
number (optional) | Whole-run deadline incl. any retries (default: 300000; max 360000, or 540000 with retries) |
fresh_ip |
boolean (optional) | New context and exit IP (default: true) |
exit_session |
string (optional) | Pin the exit: the same token lands on the same IP, so an exit that passed can be reused |
headed |
boolean (optional) | Headed browser under Xvfb (default: true; reCAPTCHA v3 scores headless at 0) |
profile |
string (optional) | Named persistent profile: cookies and fingerprint reused across submissions (per replica) |
gate_text |
string (optional) | Wizard: regex on a gate button's text, clicked once after step 0 |
step2, step2_submit |
(optional) | Wizard: second-step fields and submit (default form button), used only if that step renders |
completion_markers |
string[] (optional) | Wizard: body-text regexes that count as completion when the URL never changes |
stop_after_posts |
number (optional) | Wizard warm-up: stop once this many POSTs (1-3) have been answered |
score_gate |
boolean (optional) | Gate EVERY attempt on our reCAPTCHA oracle (≥ score_threshold). Default: attempt 1 ungated, retries gated at FORM_RETRY_GATE (0.9), falling back to a fresh exit when none qualifies; false disables gating |
score_threshold |
number (optional) | Score gate threshold, 0-1 (default: 0.7) |
score_gate_tries |
number (optional) | Exits the gate probes at most, 1-6 (default: 3) |
retry_on_captcha_rejection |
number (optional) | 0-4 (default 3). Fresh attempts (new context, new exit) after an explicit step-0 CAPTCHA refusal (one 2xx POST, same URL, every error node matching captcha_rejection_text) or any failure that provably sent nothing. Never with a pinned exit_session/profile; each retry must fit timeout_ms |
captcha_rejection_text |
string (optional) | Case-insensitive regex the form's error nodes must all match (default error verifying recaptcha|captcha (?:non |in)?valid|recaptcha) |
Returns { contract_version: 2, ok, form_submissions, status, error, url, html, exit_session, diagnostics }.
With retry_on_captcha_rejection, the result is the final attempt's plus
attempts: [{ n, error, ok, status, form_submissions, score_gate_score, asn }],
and form_submissions is the total across attempts (each refused attempt was a
real POST).
status is the matching POST's response, not the initial GET. ok needs at
least one POST, a 2xx/3xx answer, and a success_url match (on a wizard, a
completion marker instead). diagnostics reports passive CAPTCHA and network
counts and whether a token was present. It never includes field values, tokens
or request bodies.
Retry rule. This tool is single-attempt and not idempotent. Persist your
own reservation before calling it, and treat a lost response, a timeout, or a
502 as an unknown outcome: something may have been submitted. The only
sanctioned replay is the sidecar's 503 with retryable: true. It is sent
only when the service can prove that nothing was posted: the run stalled before
the click, failed at launch, navigation or field filling with zero POSTs, or
require_captcha_token aborted a tokenless POST (captcha_token_missing).
Through the Tools API every failure is structured JSON with isError: true:
{ ok: false, retryable: true, outcome: "not_submitted", form_submissions: 0 }: replay is allowed.{ retryable: false, outcome: "unknown" }: never replay.{ retryable: false, outcome: "invalid_request" }: your own validation error.
Answered runs carry retryable: false. Replay at most a couple of times,
seconds apart. Do not wrap this tool in a generic HTTP retry policy.
web_form_result
Poll an async web_form_submit.
| Parameter | Type | Description |
|---|---|---|
job_id |
string (required) | The job_id the async submit returned |
Returns { job_id, status: "queued" | "running" | "done", site, created_at, finished_at?, result? };
result is exactly what a synchronous web_form_submit returns, with the same
retry rule. Jobs live in the Tools process for 1 h: an unknown job (expired, or
the server restarted) is an unknown outcome — never a reason to resubmit.
web_eval
Evaluate JavaScript in a residential browser page and return its JSON result. Use
for driving or inspecting a JS app (open a facet, read the codes behind it) on a
site that bot-gates this host's own IP, which web_execute_js cannot reach
because it runs from that IP.
| Parameter | Type | Description |
|---|---|---|
url |
string (required) | URL to open |
js |
string (required) | Expression or IIFE evaluated in the page; must return JSON |
wait_until |
enum (optional) | load, domcontentloaded, networkidle, commit |
wait_ms |
number (optional) | Extra settle time after load (default: 6000) |
timeout_ms |
number (optional) | Navigation timeout (default: 90000) |
fresh_ip |
boolean (optional) | Serve from a new context on a new exit IP, with clean cookies |
Returns { status, url, result }.
web_spa_fetch
Perform a same-origin in-page fetch on a warmed browser session, for origins that gate requests on an anti-bot sensor cookie.
| Parameter | Type | Description |
|---|---|---|
base_url |
string (required) | Origin to warm and fetch against |
path |
string (required) | Same-origin path for the in-page fetch |
warm_path |
string (optional) | Path navigated to warm the sensor (default: /) |
method |
string (optional) | HTTP method (default: GET) |
body |
object (optional) | JSON body, sent as a JSON string |
accept |
string (optional) | Accept header (default: application/json) |
sensor_wait_ms |
number (optional) | Time spent seeding the sensor on a warm (default: 20000) |
mature_probe |
object (optional) | {method,path,body,accept} replayed until it stops returning 403 |
mature_max_tries |
number (optional) | Maturation attempts (default: 6) |
timeout_ms |
number (optional) | Client timeout (default: 180000) |
Returns { status, text }, where status is the in-page fetch's own HTTP status.
A 403 means the sensor has not cleared, so the caller should re-mature or recycle.
This is the only stateful tool here. The sidecar keeps one warmed page per
(base_url, warm_path), pins it to a sticky residential exit and feeds the sensor
on a keepalive so the cookie stays validated. Treat the session as shared:
web_recycle, or anything that tears the browser down, costs whoever is mid-crawl
their maturation.
web_recycle
Drop the warmed session and the render browser, and take a fresh exit IP. No parameters.
Expensive (a full browser relaunch) and disruptive to any crawl in flight, so
reach for it only when an exit IP has been rate-hardened by a target and will not
recover on its own. For a fresh IP on a single request, pass fresh_ip to
web_eval instead, which costs about a second.
web_usage_stats
Process-local usage counters: per-tool call counts, approximate proxy bandwidth and an estimated cost. No parameters.
In-memory only, so it resets on container restart; the started_at field lets a
caller detect that. The proxied tools' byte counts are an upper bound rather
than a measurement: this process cannot see which sidecar mode actually served a
call, and over-counting a cost estimate is the safe direction.
Coming soon
Not shipped yet; the names and shapes below may change.
web_agent: give it a task in plain language and it browses until done, then returns a structured result within a step cap. It will be built on browser-use in Chromium. Form submission will be delegated toweb_form_submit, so its single-POST guard still applies. It needs an LLM key and will report itself as disabled when none is set.
Comparison
How web-tools compares with the hosted products it replaces, from each vendor's public docs and pricing pages as of 2026-10-02. "—" means the vendor does not document the feature.
| Firecrawl | Tavily | Exa | Linkup | Bright Data | Browser Use Cloud | web-tools | |
|---|---|---|---|---|---|---|---|
| Web search | /search |
/search |
own neural index | /search |
SERP API | — | SearXNG metasearch (no own index, no semantic search) |
| Fetch → markdown | /scrape |
/extract |
/contents (text) |
/fetch |
Web Unlocker, MCP scrape_as_markdown |
— | web_fetch |
| Crawl | /crawl |
/crawl |
subpages (≤100) | — | Crawl API | — | web_crawl (your URL list; no link discovery yet) |
| Site map | /map |
/map |
— | — | Crawl API | — | not yet |
| Structured (schema) extract | JSON format | — | JSON-schema summaries | structured output | per-site Scraper APIs | — | not yet |
| Screenshot / page → PDF | screenshot / — | — | — | — | via Browser API | — | both |
| Anti-bot fetch | yes (cloud only) | — | — | — | Web Unlocker, CAPTCHA solving | stealth, proxies, CAPTCHA solving | residential exits you supply, stealth Firefox, JS-challenge solve; no CAPTCHA solver |
| Form submission | actions (click/write) | — | — | — | Browser API | via agent | web_form_submit (guarded single POST) |
| Agent browser | Agent | — | — | — | — | yes (core product) | coming (web_agent) |
| Deep research | — | /research |
Deep search, Agent | /research |
— | — | not yet |
| Self-hosted | AGPL-3.0, without Fire-engine anti-bot, screenshots, actions or agent | no | no | no | no (MCP server is MIT, calls their API) | library is MIT; stealth is cloud-only | yes, MIT, all features |
| Price | 1,000 free credits/mo; from $16/mo for 5,000 | 1,000 free credits/mo; $0.008/credit | $7/1k searches, $1/1k pages | $0.005-0.006/search, $0.001-0.006/fetch | Web Unlocker $1.5/1k; 5,000 free MCP req/mo | $0.02/browser-hour + proxy $5/GB + model cost +20% | Railway hosting + your proxy bandwidth; no per-call fee |
What the hosted products still do better: their own search indexes (Exa's semantic search especially), schema-driven extraction, site maps, research endpoints, and managed CAPTCHA solving. web-tools covers search, read, capture and forms on infrastructure you control.
Interfaces
MCP
All MCP-compatible clients can connect via HTTP:
Claude Code (CLI)
claude mcp add web_tools \
--transport http \
https://your-server.up.railway.app/mcp \
--header "Authorization: Bearer your-api-key"
Project-level config (.mcp.json)
{
"mcpServers": {
"web_tools": {
"type": "http",
"url": "https://your-server.up.railway.app/mcp",
"headers": {
"Authorization": "Bearer your-api-key"
}
}
}
}
Claude Desktop (claude_desktop_config.json)
{
"mcpServers": {
"web_tools": {
"type": "http",
"url": "https://your-server.up.railway.app/mcp",
"headers": {
"Authorization": "Bearer your-api-key"
}
}
}
}
REST API
Every tool is also available as a REST endpoint. Bodies are validated with the
same schemas as MCP: invalid input is HTTP 400 { error: "invalid_params", issues }. web_search, web_snapshots, web_archive and web_usage_stats
answer with their JSON payload (HTTP 500 { error } on failure); the other
tools answer with { content, isError }.
Long calls (past 20 s) are always HTTP 200 — read the body. Railway's public
edge closes a request after 5 minutes with no bytes transferred, and
web_form_submit (score gate + CAPTCHA retries) or web_agent can legitimately
run longer. So a call that has not finished within HEARTBEAT_MS (default
20 000) commits 200 application/json, writes a single space every
HEARTBEAT_MS while the tool works, then writes the usual JSON body. Leading
whitespace is valid JSON, so JSON.parse / response.json() keep working —
but the status can no longer change, so a failure past 20 s arrives as the same
body it always had: { error } for the JSON-native tools, { content, isError: true } for the rest. Calls that finish sooner keep today's status
codes (400, 500, …). Check isError / error, not only the HTTP status. MCP
gets the equivalent: : ping SSE comments on the tools/call response stream,
which MCP clients ignore.
# Discovery: list all tools
curl https://your-server.up.railway.app/api/v0 \
-H "Authorization: Bearer your-api-key"
# Search
curl -X POST https://your-server.up.railway.app/api/v0/web_search \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"query": "railway deployment"}'
# Fetch
curl -X POST https://your-server.up.railway.app/api/v0/web_fetch \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
CLI
# Search
web-tools search "railway deployment" --limit 5
# Fetch page as markdown
web-tools fetch https://example.com
# Screenshot
web-tools screenshot https://example.com
# Crawl multiple URLs
web-tools crawl https://a.com https://b.com --selector "main"
# Wayback Machine
web-tools snapshots https://example.com --from 20200101
web-tools archive https://example.com --timestamp 20200101120000
Replace Claude Code's Built-in Web Search & Web Fetch (Optional)
1. Add the MCP server globally:
claude mcp add web_tools --scope user \
--transport http \
https://your-server.up.railway.app/mcp \
--header "Authorization: Bearer your-api-key"
2. Disable the built-in tools by editing ~/.claude/settings.json:
{
"permissions": {
"deny": ["WebSearch", "WebFetch"]
}
}
3. Guide Claude via ~/.claude/CLAUDE.md so it uses your tools:
## Search & Fetch
- Use the web_search MCP tool for all web searches
- Use the web_fetch MCP tool to fetch and read web pages
- Do not attempt to use the built-in WebSearch or WebFetch tools
Deployment (Railway)
- Click Deploy on Railway: you'll see the services listed (Redis, SearXNG, Scrapling, Camoufox, Web Tools Server)
- Click Deploy: Railway provisions everything and wires the services together automatically
- An
API_KEYis auto-generated during deployment. Find it in your Web Tools service's Variables tab and use it as your Bearer token
Railway Configuration
The Web Tools Server service uses the root Dockerfile, so no config changes are needed.
The SearXNG and Scrapling services build from the repo instead of a Docker image, and each one must have its Root Directory set:
| Service | Root Directory | Env |
|---|---|---|
| SearXNG | services/searxng |
PROXY_URL (optional), the proxy for outgoing search requests |
| Scrapling | services/scrapling |
PROXY_URL (US-geo, for the residential path), PORT=8000 |
| Camoufox | services/camoufox |
PROXY_URL (IT-geo), PORT=8000, WORKERS=1 |
Camoufox keeps
WORKERS=1, because one warmed anti-bot session per container cannot be shared across processes. Scale it with replicas, not workers:railway service scale --service camoufox eu-west=2. Keep it in EU West; a US container reaches an Italian exit and an Italian target across the Atlantic twice. NotescaleADDS to existing regions, so passus-east=0to move rather than spread.
Set Root Directory before connecting the repo. Railway resolves a service's build config by walking up from its Root Directory, so a subfolder service without one inherits the repo root's
Dockerfile, which is the Node server. The symptom is confusing: the build goes green, then the container crashes onZodError: API_KEY Required, because it is running the API server instead of the sidecar. It also repeats on every push, so a service deployed correctly by hand will replace itself with the API server the next time the repo changes.
railway upcannot fix this: it uploads the right files but leaves the stored config pointing at/. Root Directory is not exposed by the CLI either; set it in the dashboard, or via the public API:curl https://backboard.railway.com/graphql/v2 \ -H "Authorization: Bearer $RAILWAY_TOKEN" -H "Content-Type: application/json" \ -d '{"query":"mutation($s:String!,$e:String,$i:ServiceInstanceUpdateInput!){serviceInstanceUpdate(serviceId:$s,environmentId:$e,input:$i)}", "variables":{"s":"<serviceId>","e":"<environmentId>", "i":{"rootDirectory":"/services/scrapling", "dockerfilePath":"/services/scrapling/Dockerfile", "watchPatterns":["/services/scrapling/**"]}}}'Do not pass
builder, because theBuilderenum has noDOCKERFILEvalue (only HEROKU/NIXPACKS/PAKETO/RAILPACK) and the whole mutation fails with a generic "Problem processing request". Railway detects the Dockerfile from the path.The anchored
watchPatternsis worth setting too: without it every push to the repo rebuilds the sidecar, including pushes that do not touch it.
Point the server at its siblings with reference variables rather than hardcoded hostnames, so renaming or moving a service does not silently break private networking:
SCRAPLING_URL = http://${{Scrapling.RAILWAY_PRIVATE_DOMAIN}}:${{Scrapling.PORT}}
CAMOUFOX_URL = http://${{Camoufox.RAILWAY_PRIVATE_DOMAIN}}:${{Camoufox.PORT}}
SEARXNG_URL = http://${{SearXNG.RAILWAY_PRIVATE_DOMAIN}}:8080
Reference ${{Service.PORT}} only where the service actually binds it and has
no default of its own to diverge from. That holds for the two images in this repo:
their CMD is uvicorn --port ${PORT} and they deliberately ship no ENV PORT, so
the Railway variable is the single source of truth for both the bind and the URL,
and a missing one stops the container at boot rather than yielding http://host:.
It does not hold for the third-party SearXNG image, whose URL keeps a literal
port: SearXNG hardcodes --port 8080 in its entrypoint, so PORT is decoration
and a reference to it is a guess that fails open.
Service names are case-sensitive: ${{camoufox.…}} against a service named
Camoufox resolves to an empty string rather than erroring, giving http://:8000.
Quick Start (Local)
1. Clone and install
git clone https://github.com/arnaudjnn/web-tools
cd web-tools
pnpm install
2. Configure environment
cp .env.example .env.local
3. Run the sidecars you need, locally
Only the Tools service has a public domain. The four backing services are
private, reachable at *.railway.internal from inside the project and from
nowhere else, so a laptop cannot point at them. That is deliberate (see
Exposure).
For most work you do not need them. Run the server against whichever sidecars you build locally; each is self-contained, and every URL is optional:
docker build -t searxng services/searxng && docker run -d -p 8080:8080 \
-e SEARXNG_SECRET_KEY=dev -e SEARXNG_REDIS_URL=redis://host.docker.internal:6379/0 searxng
docker run -d -p 6379:6379 redis:7-alpine
API_KEY=any-local-value \
SEARXNG_URL=http://localhost:8080 \
pnpm run start
The server is at http://localhost:3000. API_KEY is required but arbitrary
locally, since it only guards your own endpoint.
Leave a URL out and that path degrades rather than fails: SEARXNG_URL alone gives
you web_search; without SCRAPLING_URL the fetch/markdown/capture tools report
the missing sidecar instead of failing opaquely. The two stealth sidecars each
bake a browser into their image (~200MB Chromium for Scrapling, Camoufox's Firefox
plus a GeoIP database), and their residential paths need a PROXY_URL you supply,
so build them only when you are working on those paths specifically.
If you genuinely need to reach a deployed sidecar from your machine, add a service
domain temporarily (railway domain --service Scrapling) and delete it when you
are done. Do not leave one on: an exposed SearXNG is an open search proxy that
spends your metered residential bandwidth.
Exposure
Only Tools should have a public domain. It is the authenticated front door
(API_KEY as a Bearer token); everything behind it talks over Railway's private
network:
| Service | Public domain | Why |
|---|---|---|
| Tools | yes | the API surface: MCP + REST, API-key guarded |
| SearXNG | no | it has no authentication of its own, so a public domain is an open search proxy, and its outgoing requests egress through your metered PROXY_URL |
| Scrapling | no | residential egress + challenge solving; nothing should reach it but Tools |
| Camoufox | no | residential egress + warmed anti-bot sessions |
SEARXNG_SECRET_KEY is not an access credential. It is SearXNG's internal
signing secret, needed whether or not the service is exposed. Removing a public
domain is what makes a service private; deleting its credentials just makes it
broken or open.
Environment Variables
| Variable | Required | Description |
|---|---|---|
API_KEY |
Yes | Bearer token for authentication (auto-generated on Railway) |
SEARXNG_URL |
No | SearXNG URL (default: http://searxng.railway.internal:8080) |
SCRAPLING_URL |
No | Scrapling URL (default: http://scrapling.railway.internal:8000) |
CAMOUFOX_URL |
No | Camoufox URL (default: http://camoufox.railway.internal:8000) |
CAMOUFOX_FORMS_URL |
No | Forms-only Camoufox (CAMOUFOX_ROLE=forms) that takes every form call (default: CAMOUFOX_URL) |
SEARXNG_ENGINES |
No | Default engines (e.g. "brave,bing") |
PROXY_URL |
No | Rotating residential proxy. Set on the SearXNG, Scrapling and Camoufox services, not the server. US-geo for Scrapling, IT-geo for Camoufox. |
Authentication
The API_KEY environment variable is required.
On Railway, the key is auto-generated at deploy time (via ${{secret()}}). For local development, set it in your .env.local file.
Clients provide the key as a Bearer token in the Authorization header or as an ?api_key= query parameter. The /health endpoint is unauthenticated.
License
MIT