# 网页搜索 MCP v1(oh-my-pi 模式,Python 实现) 目标:给 codex 任务一个可用的 `web_search` / `web_fetch`, 不把 oh-my-pi 的 TypeScript 代码原样搬过来(codex 认不出 pi 的工具),只复用它的模式:统一工具 + provider 抽象 + `auto` 链式回退 + 抓取转干净文本。 ## 文件 - `cloud/websearch.py`:provider 链、抓取、codex-home 播种 助手。纯标准库,无第三方依赖,这样它能不经 pip 安装就 在宿主和 Docker 容器里跑。 - `cloud/websearch_mcp.py`:stdio JSON-RPC MCP 服务器, 只做协议帧,逻辑全在 websearch.py。日志只写 stderr, stdout 是 JSON-RPC 流,绝不能混日志。 - `cloud/tests/test_websearch_mcp.py`:12 个单测,全部 mock HTTP,不联网、不需要真实 key。 ## 工具 - `web_search(query, limit=5, provider="auto")`:返回 `{provider, results:[{title, url, snippet, score?}], answer?, attempted}`。 `answer` 只有 provider 本身给合成答案时才有(目前是 Perplexity Sonar);其它 provider 的总结仍由主模型做。 - `web_fetch(url, char_limit=15000)`:返回 `{url, title, text, links, truncated}`。超长按头 75% + 尾 25% 截断并标 `[TRUNCATED]`;只接受 http(s),拒绝 localhost 与字面私有 IP。 ## Provider 链(auto 顺序) 1. `perplexity`:`POST https://api.perplexity.ai/chat/completions`, 模型 `sonar`,`Authorization: Bearer $PERPLEXITY_API_KEY`。 返回合成答案 + `search_results` / `citations`。 2. `exa`:`POST https://api.exa.ai/search`,头 `x-api-key`, `type: auto`,`contents.text.maxCharacters: 2000` + `highlights: true`。 3. `tavily`:`POST https://api.tavily.com/search`, `Authorization: Bearer $TAVILY_API_KEY`, `search_depth: basic`,`include_answer: false`(工具只返 结果,总结留给模型)。 4. `brave`:`GET https://api.search.brave.com/res/v1/web/search`, 头 `X-Subscription-Token`,`extra_snippets: true`。 5. `searxng`:`GET $SEARXNG_URL/search?format=json`,需自托管 实例且 JSON 格式已打开。 6. `duckduckgo`:解析 `https://html.duckduckgo.com/html/`, 免 key 兜底,布局漂移时只会返回空结果并继续走链, 不会把工具打崩。 规则:auto 只试已配置的后端(有 key 的付费后端 + 已配 URL 的 SearXNG + DuckDuckGo);任一后端 HTTP 失败或 0 结果都继续下一个;全部失败返回各后端错误(不含 key)。 `provider` pin 单个名字时不回退,失败即报错。 可用 `WEBSEARCH_PROVIDERS`(逗号列表)改默认链顺序, `WEBSEARCH_TIMEOUT`(秒,默认 20)改单次超时。 ## 接入 codex(测试项目做法) codex 从 codex-home 的 `config.toml` 读 MCP 配置: ```toml [mcp_servers.websearch] command = "python3" args = ["/path/to/codex-home/websearch_mcp.py"] ``` `ensure_codex_home_websearch(codex_home_dir, command_args=...)` 会把 `websearch.py` / `websearch_mcp.py` 拷进该 codex-home, 并在缺失时追加上面这段(已有 section 绝不重写;key 绝不 写进 config.toml,key 只走 MCP 进程的环境变量)。 注意:同一个 config.toml 不能同时装宿主路径和容器路径, Docker 租户必须传容器内路径 `/var/lib/codex-app-server/codex-home/websearch_mcp.py`, 因为该目录正是租户 codex-home 的容器内挂载点。 ## 生产接线(worker + egress + 自托管 SearXNG) - worker 自动播种:`cloud/worker.py --websearch-mcp`(默认关)。 开启后 `_tenant_dirs` 在播种 config.toml 之后调播种助手, 写容器内路径 `/var/lib/codex-app-server/codex-home/websearch_mcp.py`。 老租户下一次任务自动合并(`_tenant_dirs` 每次派发都调到), 已有 section 不重写。autoscaler unit 已加 `--websearch-mcp`, 但 unit 文件不在 deploy tar 里,主机上须单独 scp + daemon-reload + restart autoscaler 才生效(见部署教训)。 - egress 白名单:`deploy/egress/tinyproxy-filter` 已加 Perplexity、Exa、Tavily、Brave、DuckDuckGo 与 `searx.diante.me`(精确匹配,不用 diante.me 通配,否则 任务经代理也能摸到 api.diante.me 控制面)。主机上须把 该文件拷到 `/etc/tinyproxy/tinyproxy-filter` 并重载 tinyproxy,重载后先验中转域名还通,再验新域名。 - 自托管 SearXNG(公开子域名路):主机 Docker 跑 `searxng/searxng`,经 nginx 反代到 `searx.diante.me` (DNS A 记录 + certbot 证书),MCP 侧 `SEARXNG_URL=https://searx.diante.me` 走 worker 进程 环境(`/etc/codex-app-server/env`)继承到 codex 再到 MCP,不写进 config.toml。公开实例的代价是可能被外人 刷:SearXNG limiter 保持开启,JSON 格式是 MCP 要用的 唯一格式。未加域名进白名单之前,Docker 任务的搜索 会被代理 403 挡住(fail closed,工具报错,不会静默绕过)。 - 付费后端实测:付费后端只以 mock 响应验证了请求 字段与结果映射,未用真实 key 跑过;上生产前须用真实 key 在测试项目跑一遍,再谈默认链顺序是否合理。 ## 诚实边界 - 本批只做代码 + 单测 + 测试租户目录的配置合并验证, 未推分支、未建 PR、未合并、未部署、未改生产 worker 与 egress 配置、未跑真实 codex 任务。 - DuckDuckGo 的 HTML 解析是兜底手段,布局一变就可能 0 结果;它存在的意义是“没 key 也能走通链路”,不是 长期依赖的搜索后端。实测(2026-10-28,VM 出口): html / lite 端点均回 202 + 机器人挑战页(anomaly), 不是解析器 bug——在这个出口下免 key 搜索实际不通, 免 key 可用性最终要靠自托管 SearXNG 或付费后端。 - `web_fetch` 不是安全边界:danger-full-access 的任务 本来就能 curl,fetch 只挡了最明显的非 http(s) 与 本地私有地址,不应把它当沙箱用。