Files
codex-app-server/docs/web-search-mcp.md
T
host 397a46f850 websearch: add scope=code programming search + provider extract chain
- search(..., scope=code) restricts to 15 programming domains
  (official docs, GitHub, registries, Stack Overflow, CVE/NVD):
  native include_domains/search_domain_filter where supported
  (Tavily, Exa, Perplexity), post-filtering for all providers
  (Brave, SearXNG, DuckDuckGo have no native filter).
- web_fetch now uses fetch_best: Tavily extract, then Exa
  contents, then direct fetch; both-fail errors name both
  failures, direct-fetch 403 under the egress whitelist is
  fail-closed, not bypassed. Default general scope and direct
  fetch_url behaviour unchanged.
- MCP web_search gains scope enum (general/code); docs updated.
- Tests all mock HTTP, no real keys.
2026-11-01 08:16:48 +00:00

12 KiB
Raw Blame History

网页搜索 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。
  • cloud/tests/test_code_search.py:编程作用域与抽取回退链 的单测,同样全部 mock HTTP、不联网、不需要真实 key。

工具

  • web_search(query, limit=5, provider="auto", scope="general"):返回 {provider, results:[{title, url, snippet, score?}], answer?, attempted, scope}, scope="code" 时另带 code_domains。 answer 只有 provider 本身给合成答案时才有(目前是 Perplexity Sonar);其它 provider 的总结仍由主模型做。 scope 详见下面“编程搜索 scope=code”一节。
  • web_fetch(url, char_limit=15000):走 fetch_best 抽取回退链 (Tavily extract → Exa contents → 直接抓取),返回 {url, title, text, links, truncated, source}(source 为 tavily-extract / exa-contents / direct-fetch)。 超长按头 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 配置:

[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 在测试项目跑一遍,再谈默认链顺序是否合理。

编程搜索 scope=code(v2 新增,只做编程任务搜索)

选型:web_search 加 scope="code" 参数,不另立 code_search 工具。理由:同一条 provider 链、同一套 回退与 key 规则,只多一层域名限定;另立工具会复制 schema、handler 与播种文档两套,且调用方要在两个 工具间二选一。代码(cloud/websearch.py 的 CODE_SEARCH_DOMAINS / search(..., scope=...))、 MCP 工具描述(cloud/websearch_mcp.py 的 web_search 描述与 scope enum)与本文三处一致, 只有 general / code 两个值,未知 scope 在发任何 HTTP 之前就报 ValueError,绝不静默退化成无限定搜索。

允许域清单(实际落代码的 15 个,含子域名)

  • 官方文档:docs.python.org、nodejs.org、go.dev、 pkg.go.dev、docs.rs、doc.rust-lang.org、 developer.mozilla.org
  • 代码托管:github.com、githubusercontent.com (GitHub 自己的 raw host,blob 页之外原始文件所用)
  • 包仓库:pypi.org、npmjs.com、crates.io
  • 问答与安全:stackoverflow.com、nvd.nist.gov、cve.org

匹配规则是域名本身或其子域名(点边界匹配: gist.github.com 算、evilgithub.com 不算)。 changelog / release notes 只有落在上述域名时才在 范围内(例如项目在 GitHub 的 Releases 页);没有 “项目官方域名自动识别”——不在清单里的项目官网 (如某框架自己的 docs 站)本版不返回,这是明确的 缺口,不是泛化成任意站的口子。

不在范围清单(scope=code 下一律过滤掉)

通用新闻、购物、社交、地点、机票酒店、天气、行情、 视频/图片站、博客聚合与其它任意站。scope="code" 不是“优先编程来源”,是硬过滤:非清单域的结果不 返回、不降级展示,只在 attempted 里记 domain_filtered_out 数量。

各后端如何限定(先核实请求字段再写代码)

  • 原生支持,直接传:Tavily include_domains、 Exa include_domains、Perplexity Sonar chat completions 的顶层 search_domain_filter(上限 20 个域,本清单 15 个,整单可传)。
  • 原生不支持,不编造参数:Brave 网页搜索端点没有 include_domains 参数(多域 site: 改写语义未经 证实,不改写查询),SearXNG / DuckDuckGo 同样不传; 这三家只靠 search() 返回后的统一事后过滤。
  • 双保险:原生限定的后端返回后同样再过一次事后 过滤,所以各后端在 code 作用域下的语义一致。
  • 链语义:某后端结果被全部滤掉时,该后端记为 “no results”(code-domain filter removed all N results),链继续走下一个后端;全部后端都空才 报错。

抓取回退链与 403 语义(web_fetch)

web_fetch 实际调 fetch_best,顺序固定:

  1. Tavily extract(POST https://api.tavily.com/extract, urls:[url],取 results[0].raw_content;空结果或 failed_results 命中都算失败)——只在配了 TAVILY_API_KEY 时试。
  2. Exa contents(POST https://api.exa.ai/contents, ids:[url] + text.maxCharacters,取 results[0].text, 无 text 时取 highlights 拼接;空算失败)——只在 配了 EXA_API_KEY 时试。
  3. 直接抓取(旧 fetch_url 的 HTML 清洗与截断)。

抽取与直接都失败时,错误同时写明两边失败,不伪造 页面。无任何抽取 key 时 fetch_via_provider 单独调会 明确报“no extract provider configured”,但 fetch_best 会直接走直接抓取(与旧 web_fetch 行为一致),不是 报错。localhost / 私有 IP 在抽取之前就拒绝,不会经 抽取商去探内网。

403 语义:抽取走的是搜索商 API 域名(tavily.com、 exa.ai,已在 Tinyproxy 白名单里),不直接碰目标站, 所以文档站本身不在白名单时抽取仍可通;只有回退到 直接抓取时,目标域不在白名单才会被代理 403 挡住。 这是 fail-closed:报错、不静默绕过、不承诺“任意 文档站都能直接抓”。本轨没有改 deploy/egress/tinyproxy-filter(也不改主机上的文件): 若将来主机侧要让直接抓取也通,需另行走主机变更, filter 行须用 POSIX BASIC 正则(^\(.*\.\)*域名$)、 文件 644,extended 锚定写法会把允许域也挡成 403 (见部署教训);该变更不在本轨授权内。

安全与隔离(不变)

  • key 只走 MCP 进程环境变量,绝不写进 config.toml、 日志、测试、文档;本轨测试用的 key 全是 *-SECRET-* 假值。
  • worker 开关默认关;租户播种幂等、已有 section 不重写;Docker 环境变量走 extra_env + [mcp_servers.websearch.env] 路径。
  • web_fetch 仍只接受 http(s)、拒 localhost 与私有 IP,且它不是沙箱边界(danger-full-access 任务本 来就能 curl)。
  • 老租户注意:播种助手从不覆盖内容已变的租户脚本 (既有防覆盖规则),所以 scope 只对新播种的租户 立即生效;老租户拿到新脚本需另行决策,不在本轨 改动该规则。

诚实边界

  • 本批只做代码 + 单测 + 测试租户目录的配置合并验证, 未推分支、未建 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) 与 本地私有地址,不应把它当沙箱用。
  • 编程搜索本轨未做(见回执):真实 key 的 live 测试、 主机 unit / filter / env 接线、GitHub API token (本设计走网页搜索后端限定域名,不调 GitHub API, 所以不需要 token;真要 GitHub 代码内容搜索 API 是 另一功能,不在本轨)、清单外项目官网域、老租户 脚本升级。