Layer 1 · MCP surface
Six tools, two servers, two very different shapes of answer.
crw publishes five tools that return page text verbatim. The repository adds a sixth, on a separate stdio server, that returns a synthesised answer and never lets page bodies into your agent's context. Both stay registered — deliberately.
An MCP tool is a contract about what comes back. That matters more here than usual, because what comes back from a web search is text a stranger wrote, and it is landing in the context window of a model that can run commands. The two servers in this repository differ in exactly one respect: whether that text reaches you.
/v1/search and /v1/scrape in websearch-mcp.py and
health.sh, /health and /v1/capabilities in the health
check, and /mcp in the registration line. crw's crawl, map and extract tools are
upstream fastCRW features that localbrowser exposes rather than configures.
ReferenceThe six tools
| tool | server | returns | reach for it when |
|---|---|---|---|
| crw_search | crw · HTTP | Ranked results with page markdown | You need the exact wording, code or tables from several pages at once |
| crw_scrape | crw · HTTP | One URL as clean markdown | You already know the URL and want it whole |
| crw_crawl | crw · HTTP | Multiple pages, followed from a root | Reading a docs section rather than a page |
| crw_map | crw · HTTP | The URLs a site exposes | You want the shape of a site before spending fetches on it |
| crw_extract | crw · HTTP | Structured fields | You want values, not prose — this path runs through [extraction.llm] |
| search_answer | websearch · stdio | A synthesised answer plus a Sources: list |
Factual questions and research, where a summary is genuinely enough |
The two servers are registered separately, and the second one is a plain Python process the agent spawns rather than a network service:
# the raw path — crw's own MCP endpoint, over HTTP $ claude mcp add --transport http --scope user crw http://127.0.0.1:3002/mcp # the quarantined path — a stdio server the agent starts itself $ claude mcp add --scope user --transport stdio websearch \ -- python3 "$PWD/mcp/websearch-mcp.py"
ProtocolThe stdio server on the wire
websearch-mcp.py does not use an MCP SDK. It reads JSON-RPC objects from stdin one
line at a time, dispatches on method, and writes a single line of JSON back — which
means you can drive the whole thing from a shell pipe and see exactly what your agent sees.
$ printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' \ | python3 mcp/websearch-mcp.py {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "websearch", "version": "1.0.0"}}} {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "search_answer", "description": "Web search that returns a SYNTHESISED answer with citations, not raw page text. …", "inputSchema": {"type": "object", "properties": {"query": {…}, "limit": {…}}, "required": ["query"]}}]}} {"jsonrpc": "2.0", "id": 3, "error": {"code": -32601, "message": "unknown method resources/list"}}
Three details in that exchange are load-bearing. Notifications — JSON-RPC messages with no
id — get no response at all, per spec. Any method the server does not implement
but which expects a reply gets an explicit -32601 rather than silence: the
comment in the source is fail closed, don't hang, and a hung stdio server is an agent
that stops mid-task with no error. And every response is flushed immediately, because stdio
transports deadlock politely and forever otherwise.
ImplementationWhat search_answer actually does
63 def search_answer(args): 64 query = str(args.get("query") or "").strip() 65 if not query: 66 return "error: query is required" 67 limit = args.get("limit") or 3 68 try: 69 limit = max(1, min(8, int(limit))) 70 except (TypeError, ValueError): 71 limit = 3 … 73 payload = { 74 "query": query, 75 "limit": limit, 76 "answer": True, 77 "scrapeOptions": {"formats": ["markdown"]}, 78 } … 96 if not answer: 97 # No synthesis happened — say so plainly rather than silently degrading 98 # to raw text, which would defeat the point of this tool. 99 note = f" warnings={warnings}" if warnings else "" 100 return ( 101 f"No synthesised answer returned for {query!r}.{note}\n" 102 "The summariser may be down, or [extraction.llm] is not configured in " 103 "crw.toml. Use crw_search for the raw path." 104 )
- line 69 — the clamp
- Models pass whatever number they like.
max(1, min(8, int(limit)))turnslimit: 40into 8 rather than forty page fetches, and a non-integer into the default of 3 rather than a traceback. - line 76 —
"answer": True - The single flag that selects the quarantined path. crw scrapes the results, hands them to
the model configured in
[extraction.llm], and returns that model's answer. - lines 96–104 — refusing to degrade
- If synthesis did not happen — summariser down, or
[extraction.llm]never configured — the tool does not quietly hand back raw text. It says so and names the likely cause. A tool whose safety property silently switches off is worse than one that fails. - line 35 / line 36 — the caller's assumptions
CRW_URL = "http://127.0.0.1:3002/v1/search"andTIMEOUT = 300. Five minutes is not generous: one call can mean a metasearch fan-out, up to eight page renders, and an LLM pass over all of them.
When crw is not reachable at all, the error text names the address it tried and asks the one question that is usually the answer:
error: crw unreachable at http://127.0.0.1:3002/v1/search
(<urlopen error [Errno 61] Connection refused>). Is the tunnel up?
Because the intended way to use this stack from a second machine is an SSH tunnel, not a LAN bind. crw has no authentication and will fetch any URL it is handed, so exposing it beyond loopback gives everyone on the network an SSRF primitive pointed at your internal services. The error message is written for the failure people actually hit. See Trust boundaries → network exposure.
PolicyWhy both paths stay registered
It would be tidier to expose only the safe tool. The repository argues against that, twice, in two different files — and the argument is about capability, not convenience:
Both paths stay available on purpose: use
mcp/websearch-mcp.py — module docstring, lines 25–27search_answerby default, fall back tocrw_search/crw_scrapewhen you need exact syntax, code blocks, tables, or links that a summary would drop.
A summariser is a lossy compressor tuned for prose. Ask it about an API and you get an accurate
paragraph describing a function signature you cannot copy. The correct division is by task:
search_answer for what is true, the raw tools for what is written.
The cost of that division is that the raw tools exist, and using them puts untrusted text in your
privileged context — which is exactly the trade the
trust boundaries page is about.