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.

The tool surface: two MCP servers, one engine, two return shapes The agent connects to crw's built-in MCP server over HTTP, which exposes five raw tools, and to websearch-mcp.py over stdio, which exposes one synthesised tool. Both reach the same crw engine. The raw path returns page markdown into the privileged context; the quarantined path returns an answer plus source URLs. agent privileged context holds shell + creds MCP client crw's built-in MCP server http://127.0.0.1:3002/mcp crw_search crw_scrape crw_crawl crw_map crw_extract Returns raw page markdown, verbatim. Lossless, and unfiltered. mcp/websearch-mcp.py stdio · 173 lines · stdlib only search_answer POST /v1/search with: "limit": 1–8 (clamped) "answer": true "scrapeOptions": {"formats": ["markdown"]} crw one engine, both paths ENDPOINTS IN USE POST /v1/search POST /v1/scrape GET /health GET /v1/capabilities /mcp read_only container cap_drop: ALL mem 2g · pids 512 parses untrusted HTML RAW PATH page markdown, verbatim 50–100k tokens of prefill exact code, tables, links attacker-controlled text, in your context QUARANTINED PATH answer + "Sources:" list a few hundred tokens summary loses exact syntax page bodies never reach the privileged context TOOL SURFACE · BOTH SERVERS STAY REGISTERED
Figure 3 — the tool surface The endpoint list is limited to paths that appear in this repository's own code: /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 descriptions for search_answer are quoted from mcp/websearch-mcp.py lines 38–60. The crw tools are named in the README's "What you get" list; their behaviour is fastCRW's, not localbrowser's.
toolserverreturnsreach for it when
crw_searchcrw · HTTP Ranked results with page markdown You need the exact wording, code or tables from several pages at once
crw_scrapecrw · HTTP One URL as clean markdown You already know the URL and want it whole
crw_crawlcrw · HTTP Multiple pages, followed from a root Reading a docs section rather than a page
crw_mapcrw · HTTP The URLs a site exposes You want the shape of a site before spending fetches on it
crw_extractcrw · HTTP Structured fields You want values, not prose — this path runs through [extraction.llm]
search_answerwebsearch · 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:

registrationREADME.md — Quick start & Dual-LLM mode
# 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.

stdio handshakemcp/websearch-mcp.py — initialize, tools/list, unknown method
$ 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

mcp/websearch-mcp.pylines 63–104, abridged
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))) turns limit: 40 into 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" and TIMEOUT = 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:

failure modewebsearch-mcp.py line 87
error: crw unreachable at http://127.0.0.1:3002/v1/search
(<urlopen error [Errno 61] Connection refused>). Is the tunnel up?
Why "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 search_answer by default, fall back to crw_search/crw_scrape when you need exact syntax, code blocks, tables, or links that a summary would drop.

mcp/websearch-mcp.py — module docstring, lines 25–27

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.