Files
codex-app-server/docs/web-search-mcp.md
T
host e9fa43dd8b websearch: task-level allowed_hosts extension for scope=code + JS-heavy extract evaluation (mock-first)
- search(..., allowed_hosts=[...]) extends (never replaces) the 15-domain
  code list for that call only; scope=general + allowed_hosts raises,
  empty/illegal values raise before any HTTP, dot-boundary matching,
  extension values never in error strings or attempted entries.
- Native-filter parity: Tavily/Exa get the effective list, Perplexity
  skips its native filter past its 20-domain cap (post-filter stays
  authoritative), Brave/SearXNG/DuckDuckGo rely on post-filtering.
- MCP web_search gains allowed_hosts passthrough (only when supplied,
  so older search_fn fakes keep working).
- JS-heavy evaluation documented in docs/web-search-mcp.md, mock-first:
  direct fetch is shell-only by construction; provider extract can
  supply rendered text in mock only; live SearXNG in this VM env
  failed (remote closed connection) and paid-backend live effects are
  unvalidated blockers, not claimed.
- Tests: test_code_search_ext (9) + test_js_heavy_extract (5 + 1 live
  skipped); existing websearch/code-search suites still green.

(cherry picked from commit 9c26bbe848438e1f34bcb000700aeb4c284f4f23)
2026-11-01 09:06:28 +00:00

353 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 网页搜索 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。
- `cloud/tests/test_code_search_ext.py`:任务级
`allowed_hosts` 扩展的单测(指定 / 未指定 / 点边界 /
空值与非法值 / Perplexity 超 20 域 / MCP 透传)。
- `cloud/tests/test_js_heavy_extract.py`:JS 重型页面
抽取评估的 mock 单测(直接抓取壳-only、provider 抽取
补与不补、回退语义;live SearXNG 烟囱默认跳过)。
## 工具
- `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 配置:
```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 在测试项目跑一遍,再谈默认链顺序是否合理。
## 编程搜索 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`),链继续走下一个后端;全部后端都空才
报错。
### 清单外项目官网域:任务级显式扩展 `allowed_hosts`
这不是“任意站搜索”,仍是显式白名单机制,只是白名单
可以由调用方在当次调用里显式加域。选型是任务级参数
`(a)`,不是配置级扩展清单 `(b)`,理由:改动最小
(只动 `search()` 签名与 MCP 透传,不动 worker、
unit、env、播种与主机配置);按调用隔离(某任务要
某项目官网,不会把该域放宽给所有任务——配置级清单
一旦写入就是全局放宽,安全姿态更差);用完即弃
(不持久化,下次调用不带就不生效)。
用法:`search(..., scope="code",
allowed_hosts=["docs.example-project.dev"])`,MCP
侧 `web_search` 的 `allowed_hosts` 数组字段同名透传。
规则(全部由 `cloud/websearch.py` 的
`_normalize_allowed_hosts` 在发任何 HTTP 之前强制):
- 只在 `scope="code"` 下生效;`scope="general"` 下传
`allowed_hosts` 直接报 `ValueError`,绝不静默忽略;
不传时行为与本版之前完全一致(仍是 15 域)。
- 生效清单 = 内置 15 域 + 本次显式 extras(去重、保序);
返回结果的 `code_domains` 即为该生效清单。
- 匹配仍是点边界:子域名算(`api.docs.example-project.dev`
算),`evildocs.example-project.dev` 与
`docs.example-project.dev.evil.com` 都不算。
- 空值 / 非法值报错不静默退化:`[]`、裸字符串
(可迭代但不是域名列表)、`*` 通配、带 scheme /
端口 / 路径 / userinfo 的值、前导点、空标签、非字符串
元素、以及全部等于内置域(即“扩展了个寂寞”)都报
`ValueError`,且此时尚未发任何 HTTP。
- 扩展值绝不写进日志:错误串与 `attempted` 条目里不
出现扩展值;扩展值只出现在调用方自己那次结果的
`code_domains` 回显里(调用方自己指定的值回显给
自己,不是日志)。
- 与原生后端限定方式的关系,和现有逻辑一致:原生
支持的后端(Tavily / Exa 的 `include_domains`)直接传
生效清单;Perplexity 的 `search_domain_filter` 上限
20 个域,内置 15 + 扩展超 20 时跳过其原生过滤、只靠
事后过滤(事后过滤永远是权威,语义不变);原生不
支持的后端(Brave、SearXNG、DuckDuckGo)本来就不传
参数,只靠 `search()` 返回后的统一事后过滤。
- `web_fetch` 不受 `allowed_hosts` 影响:fetch 本来就是
按指定 URL 抓取,没有域名白名单可扩展。
### 抓取回退链与 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 是
另一功能,不在本轨)、老租户脚本升级。清单外项目
官网域本轨已做(见上节 `allowed_hosts`,本地提交、
未推未部署),仍缺真实 key 的 live 验证。
## JS 重型站点抽取效果评估(本轨新增,mock 为主)
评估对象是已有抽取链,不改默认抽取顺序、不因评估换
后端:`fetch_best` 固定为 Tavily extract → Exa contents
→ 直接抓取。结论只写在这里,不改代码行为。
测试文件:`cloud/tests/test_js_heavy_extract.py`(全部
mock HTTP,只有一个显式开关的 live 烟囱测试,默认跳过)。
### 阶段 1:直接抓取能拿到什么、拿不到什么
直接抓取是标准库 HTML 解析:`<script>` / `<style>` /
`<noscript>` 内容一律剥离,只留静态文本与绝对 http(s)
链接(相对链接不进 `links` 列表)。所以:
- 能拿到:静态壳里的标题、占位文本(如 "Loading…")、
静态正文、绝对链接;首屏静态内容本身就是正文的页面
不受影响。
- 拿不到:正文靠 JS 渲染的段落(mock 壳里
`RENDERED-BODY-PARAGRAPH` 只存在于 `<script>` 数据块
中,直接抓取结果里没有它,也不把数据块原文泄漏进
文本);分页靠 JS 加载的后续项(mock 里第 2 页
`ITEM-PAGE-2` 只经 `/api/items?page=2` 到达,直接抓取
只有第 1 页静态项,也拿不到该 API 地址本身)。
- 为什么:解析器没有 JS 引擎,也从不执行脚本或追
JS 发起的请求;这是结构性缺失,不是解析器 bug,
调解析参数补不回来。直接抓取在 JS 重型站上只能
给出“壳 + 首屏静态部分”,不能谎称是全文。
### 阶段 2:provider 抽取在 mock 层面能补什么、补不了什么
mock 层面(伪造的 Tavily extract 响应返回完整渲染文本)
证明链路本身能补:`fetch_via_provider` 返回
`source=tavily-extract` 的完整文本;抽取与直接都失败时
`fetch_best` 回退到直接抓取的壳(`source=direct-fetch`),
仍拿不到渲染正文——回退保证的是“不断链”,不是“补全文”。
mock 补不了、也证明不了的部分:
- 真实 provider 是否真的渲染该站的 JS(取决于各家
extract 实现是否跑无头浏览器、等多久、反爬策略),
mock 响应是“我假设它渲染了”,不是实测证据。
- JS 分页 / 交互(点“加载更多”、登录后内容、滚动加载)
provider 抽取一般只取单 URL 的渲染结果,不会替调用方
翻页或交互;多页内容仍需逐 URL fetch。
- 抽取返回空时是失败,不是空页:`fetch_via_provider`
空结果报 `WebSearchError`,不伪造页面;但 `fetch_best`
随后回退直接抓取会返回壳文本——调用方须以 `source`
字段分辨“渲染全文”与“壳”,不能只看 `text` 非空就当全文。
### Live 对照(与 mock 分开写,未验证的不写成已验证)
- 无 key 的 SearXNG 路线:本轨在 VM 测试环境实测一次
(`SEARXNG_URL=https://searx.diante.me`,`provider="searxng"`,
产品自身 `search()` 代码):失败,`URLError: Remote end
closed connection without response`。这是在本 VM 出口下
的一次结果,不能据此断言 SearXNG 服务本身挂了(生产
文档记载主机侧经代理 pinned 引擎曾 0.47s 通),live
对照在本测试环境记为**未完成**,需在主机侧可达的环境
重测;测试文件里的 live 烟囱测试默认跳过,只在
`WEBSEARCH_EVAL_LIVE_SEARXNG=1` 且配了 `SEARXNG_URL`
时才跑。
- 付费后端 live 效果(Tavily extract / Exa contents /
Perplexity / Brave 的真实 JS 渲染能力):无 key,本轨
**一律未验证**,不编造效果,逐项列为 blocker(见回执),
在拿到 key 之前,文档里关于 provider 渲染能力的任何
表述都只是 mock 层面的链路结论。