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

235 lines
12 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。
## 工具
- `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`),链继续走下一个后端;全部后端都空才
报错。
### 抓取回退链与 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 是
另一功能,不在本轨)、清单外项目官网域、老租户
脚本升级。