{"id":"e961e5a2-e66a-419e-84f5-6c7df1b45f38","slug":"crisweb1994-search-failover","kind":"mcp","name":"search-failover","summary":"","owner":"crisweb1994","repoUrl":"https://github.com/crisweb1994/search-failover","license":"MIT","licenseTier":"permissive","trust":"community","stars":1,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-09-30T07:15:00.000Z","addedAt":"2026-10-22T16:50:55.222Z","logoUrl":"https://avatars.githubusercontent.com/u/194888824?u=dc1db867559f54de9e28450c2c00ae1d0b7fa42b&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["search"],"tags":[],"runtime":"node","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-25T01:07:04.466Z","sample":false,"url":"https://conduits.dev/tools/crisweb1994-search-failover","language":"TypeScript","transports":["stdio"],"install":{"npm":"search-failover"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# search-failover\n\n[简体中文](README.md) | [English](README.en.md)\n\n[![npm](https://img.shields.io/npm/v/search-failover)](https://www.npmjs.com/package/search-failover) [![CI](https://github.com/crisweb1994/search-failover/actions/workflows/ci.yml/badge.svg)](https://github.com/crisweb1994/search-failover/actions/workflows/ci.yml) ![Node](https://img.shields.io/badge/node-%E2%89%A520.10-339933) ![MCP](https://img.shields.io/badge/MCP-stdio%20%7C%20HTTP-6E43B8) ![license](https://img.shields.io/badge/license-MIT-blue)\n\n**一个 `web_search` 工具，背后 10 家搜索来源。** 哪家额度用完、被限流、超时或没有结果，自动换下一家。本地宿主走 stdio，远程平台走 Streamable HTTP；发布包是单个文件，没有运行时依赖。\n\n[快速开始](#快速开始) · [搜索源](#搜索源) · [工作方式](#工作方式) · [接入宿主](#接入宿主) · [配置](#配置) · [网络与代理](#网络与代理) · [工具与返回值](#工具与返回值) · [局限](#状态与局限)\n\n## 为什么需要它\n\nAgent 的搜索工具只接一家 API 时，那一家的月度额度用完、被限流，或者网络抖一下，这次搜索就失败了。更麻烦的是，如果工具把失败吞掉，只返回一个空数组，Agent 会以为网上没有相关信息，转头用训练数据作答，语气还很笃定。\n\nsearch-failover 把多家搜索 API 放在同一个 `web_search` 工具后面，按优先级依次尝试，第一家返回结果就结束。每一步的成败都写进返回值，Agent 能看到这次搜索走过哪几家、跳过了哪几家、为什么。\n\n## 一次返回长什么样\n\n博查余额用完、Tavily 被限流、Brave 给出结果的一次搜索（上游响应是模拟的；实际输出是紧凑 JSON，这里排过版）：\n\n```jsonc\n// web_search({ \"query\": \"mcp stdio transport\" })\n{\n  \"results\": [\n    {\n      \"title\": \"Example: stdio transport\",\n      \"url\": \"https://example.com/mcp/transports\",\n      \"snippet\": \"Clients launch the server as a subprocess and talk JSON-RPC over stdin/stdout.\",\n      \"publishedDate\": \"2026-09-18T00:00:00\",\n      \"provider\": \"brave\"\n    }\n    // …其余结果省略\n  ],\n  \"meta\": {\n    \"provider_used\": \"brave\",\n    \"fallback_chain\": [\n      { \"provider\": \"bocha\", \"outcome\": \"quota_exhausted\", \"detail\": \"http_403 You do not have enough money\", \"elapsedMs\": 13 },\n      { \"provider\": \"tavily\", \"outcome\": \"rate_limited\", \"detail\": \"http_429\", \"elapsedMs\": 0 }\n    ],\n    \"cache_hit\": false,\n    \"elapsed_ms\": 14\n  }\n}\n```\n\n`fallback_chain` 记录了落空的每一家：博查返回 403（余额不足），记为 `quota_exhausted`，之后一段时间不再请求它；Tavily 返回 429，记为 `rate_limited`，冷却后才会再试；Brave 给出结果，搜索结束。\n\n结果为空时也看这条链。链里全是 `no_results`，才是真的没搜到；只要有一家报错或被跳过，MCP 就返回 `isError: true`，表示这次搜索没有完成。\n\n## 特点\n\n- **自动兜底。** 默认链是博查 → Tavily → Brave → Exa → SearXNG → DuckDuckGo，配了凭据的才会进链，DuckDuckGo 免 key 兜底。失败不在同一家重试，直接换下一家。\n- **按各家的真实信号分类。** 博查的 403 是余额不足，Brave 的 key 无效是 422（不是 401），同一个 429 可能是秒级限流也可能是月额度用完，DuckDuckGo 限流时返回 202 加一个异常页。每家 adapter 单独适配，归成 8 类错误，每类有各自的冷却策略。\n- **返回值是写给模型看的。** 带完整的决策链；\"没搜到\"和\"没搜成\"用 MCP 的 `isError` 区分；带 `include_domains` 时先试能过滤的来源，最终给出结果的来源做不到时在 `note` 里说明；工具描述只列出当前真正可用的来源。\n- **一份配置，所有宿主共用。** 凭据和来源顺序可以全部用环境变量配置，也可以写进 `~/.config/search-failover/config.json`，每个宿主的配置只剩一行 `npx -y search-failover`。宿主没展开的 `${...}` 模板不会被当成 key。\n- **本地和远程都能接。** stdio 给 Claude Code、Cursor、Codex 这类本地宿主；`--http` 启动 Streamable HTTP 服务，给只支持远程 MCP 的平台用，多个客户端共享同一份额度计数、冷却和缓存。\n- **不乱花钱。** 智谱、DeepSeek、千帆、Serper 这类付费或一次性额度的来源，只配 key 不会进链，必须显式列出。多数来源带本地请求预算和限速，并发请求下也不会超发。\n- **能走代理。** 读取 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`（Node 自带的 fetch 不读这些变量）。\n- **小，而且可审计。** 发布包是 esbuild 打出的单个文件，`npx` 冷启动只下载一个包；没有数据库、没有后台任务，状态只在进程内存里；日志全部走 stderr。\n\n## 快速开始\n\n需要 Node ≥ 20.10，不用安装，`npx` 直接跑。\n\n**1. 准备凭据。** 配几个用几个；一个都不配也能跑，只是只有 DuckDuckGo（国内网络需要代理，见[网络与代理](#网络与代理)）。推荐写进用户级配置文件，所有宿主共用：\n\n```jsonc\n// ~/.config/search-failover/config.json（Windows：%APPDATA%\\search-failover\\config.json）\n{\n  \"credentials\": {\n    \"BOCHA_API_KEY\": \"...\",\n    \"TAVILY_API_KEY\": \"...\"\n  }\n}\n```\n\n建议 `chmod 600` 这个文件。也可以照常用环境变量（变量名见[搜索源](#搜索源)），环境变量优先。\n\n**2. 接入宿主。** 以 Claude Code 为例：\n\n```bash\nclaude mcp add search-failover -- npx -y search-failover\n```\n\nCodex、Cursor、ZCode、VS Code、OpenCode、Claude Desktop 等见[接入宿主](#接入宿主)。\n\n**3. 确认状态。** 让 Agent 调用一次 `web_search_status`：每家来源是 `active`、`unconfigured`（没读到凭据）、`disabled`（不在链里）还是 `blocked`，配置文件在哪、有没有错误，一目了然。\n\n## 搜索源\n\n<!-- generated:providers -->\n| 来源 | 配置变量 | 默认进链 | 本地预算 | `include_domains` |\n| --- | --- | --- | --- | --- |\n| Bocha 博查 | `BOCHA_API_KEY` | 是 | 每月 1000 次，1 日重置 | 不支持 |\n| Zhipu 智谱 | `ZHIPU_API_KEY` | 否（需显式列出） | 不限 | 单个域名 |\n| DeepSeek | `DEEPSEEK_API_KEY` | 否（需显式列出） | 不限 | 多个域名 |\n| Tavily | `TAVILY_API_KEY` | 是 | 每月 1000 次，2 日重置 | 多个域名 |\n| Baidu Qianfan 百度千帆 | `QIANFAN_API_KEY` | 否（需显式列出） | 每月 1500 次，1 日重置 | 多个域名 |\n| Brave Search | `BRAVE_API_KEY` | 是 | 每月 1000 次，15 日重置 | 单个域名 |\n| Serper | `SERPER_API_KEY` | 否（需显式列出） | 累计 2500 次 | 单个域名 |\n| Exa | `EXA_API_KEY` | 是 | 每月 800 次，1 日重置 | 多个域名 |\n| SearXNG (self-hosted) | `SEARXNG_URL`（实例地址） | 是 | 不限 | 单个域名 |\n| DuckDuckGo | 无需配置 | 是 | 不限 | 单个域名 |\n<!-- /generated:providers -->\n\n- 表格按默认优先级排列，由 `npm run plugin:sync` 从来源注册表生成。默认链只包含\"默认进链\"为\"是\"、且配好了凭据的来源。\n- 智谱、DeepSeek、千帆、Serper 是付费或一次性额度，**只配 key 不会进链**，要在 `SEARCH_FAILOVER_PROVIDERS` 或配置文件的 `providers` 里显式列出（见[配置](#配置)）。\n- \"本地预算\"是网关自己的保守计数，用来少发请求，不是账户余额，也不保证落在免费额度内。价格依据（2026-10-04）：[Brave](https://api-dashboard.search.brave.com/documentation/pricing) 的 1000 是月度 credits 折算出的参考值；[Exa](https://exa.ai/pricing) 按月重置 credits，800 只是保留的本地上限；智谱按次计费（GLM Coding Plan 含次数）；千帆每月 1500 次免费，超额按量；Serper 是一次性 credits，分档计费尚待实测。[DeepSeek](https://api-docs.deepseek.com/guides/search)（2026-10-18）按成功请求计费，每千次 0.75 美元，空结果和失败不计费；官方公布的免费期（至 2026-11-01）只针对 DeepSeek 模型在 Harness、Claude Code、Codex 里发起的服务端搜索，网关直接调用接口，按次计费。\n- DeepSeek 的 `include_domains` 和 `freshness` 只能写进查询词（`site:a OR site:b`、`after:YYYY-MM-DD`），和查询词合计不超过 128 个字符：超出时截断查询词，并在 `note` 里说明；运算符挤得查询词不到 32 个字符时（大约 5 个以上的域名），这一家记为 `request_error`，换下一家。另外 DeepSeek 几乎总有结果：实测一条不可能命中的查询也返回了 8 条低相关结果，排在它后面的来源很少因为\"没搜到\"被用上。\n- SearXNG 需要自建实例，并在实例的 `settings.yml` 里把 `json` 加进 `search.formats`，否则接口返回 403。\n- DuckDuckGo 靠抓取 HTML 页面，免 key 但受反爬限制；国内网络无法直连。\n\n## 工作方式\n\n```text\nweb_search(query)\n ├─ 命中缓存       → 直接返回，不请求上游\n └─ 未命中 → 按优先级依次尝试；冷却中、本地预算用完、限速等不到的来源直接跳过\n      Bocha    403 余额不足   → 屏蔽到恢复时间，换下一家\n      Tavily   429 限流       → 冷却，换下一家\n      Brave    返回结果       → 结束，返回结果和 fallback_chain\n      Exa / DuckDuckGo …      （没轮到，不会被请求）\n```\n\n第一家非空结果胜出。任何错误都不在同一家重试，**换下一家就是重试**。每个来源只记三样东西：屏蔽到什么时候、为什么屏蔽、连续失败了几次；到点自动放行，放行后的第一个请求就是试探。\n\n带 `include_domains` 时，能执行这个过滤的来源排到前面（原生支持多域名的，或只有一个域名时支持单域名的），做不到的放到最后兜底。\n\n不同错误的处理：\n\n| 类别 | 典型信号 | 处理 |\n| --- | --- | --- |\n| `rate_limited` | 429；DuckDuckGo 的异常页 | 按上游给的等待时间冷却，没有就 60 秒，上限 1 小时（DuckDuckGo 是 6 小时） |\n| `quota_exhausted` | 博查 403；Exa、Serper、DeepSeek 402；Tavily 432/433；Brave 429 + `QUOTA_LIMITED`；智谱 429 + 1113 | 屏蔽到上游给的恢复时间；没有就等 `quota_retry_s`（默认 6 小时） |\n| `auth_failure` | 401；Brave 422 + `SUBSCRIPTION_TOKEN_INVALID` | 摘除该来源，直到重启 |\n| `timeout` / `network` | 请求超时、连接失败（`detail` 带上 ENOTFOUND、ECONNREFUSED、证书错误等真实原因） | 冷却 30 秒起，连续失败翻倍，上限 1 小时 |\n| `server_error` | 5xx；响应无法解析；SearXNG 所有引擎都失败 | 冷却 60 秒起，连续失败翻倍，上限 1 小时 |\n| `request_error` | 400/422 等参数错误 | 不屏蔽该来源，换下一家 |\n| `no_results` | 合法的空结果（博查、Brave 没有结果时会省略结果字段，也按空结果处理） | 不惩罚，换下一家 |\n\n- **总预算。** 整次搜索最多 30 秒，单个来源最多 10 秒。还没轮到的来源记 `skipped:budget_exhausted`；已经发出请求、总预算先用完的记 `budget_exhausted`。两者都不算来源的错。宿主取消请求时，进行中的请求会中止，不再继续兜底。\n- **两套时间互不干扰。** 上游给了恢复时间就用它，没有就等 `quota_retry_s`；本地 `reset_day` 只管本地计数。冷却结束只表示下一次搜索可以再试，网关不会在后台探测。\n- **本地请求预算。** 每次批准向上游发请求就计一次：上游报错、批准后被取消也算；缓存命中、排队时被取消不算。计数达到 `quota.limit`，该来源记为 `skipped:quota_local`。`monthly` 按本地时区和 `reset_day` 重置，`one_time` 随进程累计。检查和计数在同一个同步步骤里完成，并发请求不会超发。\n\n<details>\n<summary>缓存与取数</summary>\n\n- 缓存 key 由归一化的查询（忽略大小写和多余空白）、`max_results`、`freshness`、`include_domains` 组成。默认 TTL 1 小时，`freshness=day` 为 15 分钟，可用 `cache.ttl_s` / `cache.ttl_fresh_s` 调整。\n- `use_cache=false` 或全局关闭缓存时，不读也不写。缓存命中会重新给出获胜来源的降级说明。\n- 每家按 `min(max_results, 来源条数上限)` 取数，不预取。不同条数分别缓存，非空的短结果也会缓存。\n- `freshness` 由各家自己执行，日期语义和粒度可能不同；千帆会忽略 `day`，并在 `note` 里说明。\n\n</details>\n\n## 接入宿主\n\n| 宿主 | 方式 |\n| --- | --- |\n| Claude Code | `claude mcp add`，或 `.mcp.json` |\n| Codex | 插件市场，或 `codex mcp add` |\n| Cursor | MCP 配置（官方插件市场仍在审核） |\n| ZCode | 插件市场，或手写 MCP 配置 |\n| VS Code（Copilot） | `.vscode/mcp.json` |\n| OpenCode | MCP 配置（依官方文档编写，尚未在客户端实测） |\n| Claude Desktop、Windsurf、Cline、Cherry Studio 等 | 通用 `mcpServers` 配置 |\n| 只支持远程 MCP 的平台（网页版客户端、Dify / n8n 等工作流平台） | [HTTP 模式](#http-模式远程平台) |\n\n本仓库按 [Agent Plugins 1.0](https://agent-plugins.org) 打包（见 `plugin/`），Codex / Cursor / ZCode 的市场清单放在仓库根目录。\n\n> [!IMPORTANT]\n> **各宿主通用的几件事：**\n> - **凭据放哪都行，推荐用户级配置文件。** 环境变量优先，其次是 `~/.config/search-failover/config.json` 的 `credentials`。这样宿主配置里不用再写 key。\n> - **从程序坞 / 开始菜单启动的 GUI 宿主（如 Claude Desktop）读不到 shell 里 `export` 的变量**，而且常常找不到用 nvm、Volta、fnm 装的 `npx`。遇到 `spawn npx ENOENT` 时，把 `command` 换成 `npx` 的绝对路径（`which npx` 查看），凭据写进配置文件。\n> - **Windows** 上如果宿主报找不到 `npx`，把 `\"command\": \"npx\"` 改成 `\"command\": \"cmd\"`，`args` 前面加上 `\"/c\", \"npx\"`。\n> - **只配 key 不等于进链。** 智谱、DeepSeek、千帆、Serper 要在 `SEARCH_FAILOVER_PROVIDERS` 或配置文件的 `providers` 里列出。\n> - 宿主没有展开的 `${...}` 模板会被当作未设置，并在 `web_search_status` 的 `config.warnings` 里提示。\n\n<details>\n<summary>Claude Code</summary>\n\n```bash\nclaude mcp add search-failover -- npx -y search-failover\n# 需要在宿主里单独给凭据或来源顺序时：\nclaude mcp add search-failover -e TAVILY_API_KEY=... -e SEARCH_FAILOVER_PROVIDERS=tavily,brave,duckduckgo -- npx -y search-failover\n```\n\n加 `--scope user` 对所有项目生效。连接 HTTP 模式：`claude mcp add --transport http search-failover http://127.0.0.1:8787/mcp --header \"Authorization: Bearer <token>\"`。\n\n</details>\n\n<details>\n<summary>Codex</summary>\n\n**插件市场**（CLI ≥ 0.117）：\n\n```bash\ncodex plugin marketplace add crisweb1994/search-failover\n```\n\n然后在 ChatGPT 桌面端 / Codex 的插件目录里安装 search-failover。凭据写进用户级配置文件即可。\n\n**MCP 配置**：\n\n```bash\ncodex mcp add search-failover -- npx -y search-failover\n```\n\n或者写 `~/.codex/config.toml`：\n\n```toml\n[mcp_servers.search-failover]\ncommand = \"npx\"\nargs = [\"-y\", \"search-failover\"]\nstartup_timeout_sec = 30   # npx 首次下载可能超过默认的 10 秒\n\n[mcp_servers.search-failover.env]\nTAVILY_API_KEY = \"...\"\n```\n\n</details>\n\n<details>\n<summary>Cursor</summary>\n\n全局 `~/.cursor/mcp.json` 或项目 `.cursor/mcp.json`：\n\n```json\n{\n  \"mcpServers\": {\n    \"search-failover\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"search-failover\"],\n      \"env\": {\n        \"TAVILY_API_KEY\": \"${env:TAVILY_API_KEY}\"\n      }\n    }\n  }\n}\n```\n\n`${env:NAME}` 是 Cursor 的变量插值，key 留在 shell 环境里。凭据都在用户级配置文件里时，`env` 整段可以删掉。仓库里也带了 Cursor 原生插件清单（`.cursor-plugin/`），官方市场审核通过前可以本地添加。\n\n</details>\n\n<details>\n<summary>ZCode</summary>\n\n**插件市场**：「发现」→ `+` → 粘贴 `https://github.com/crisweb1994/search-failover` → 安装。插件详情的「配置」里有两项，都可以留空：`config_path`（配置文件的绝对路径，默认读用户级文件）和 `providers`（来源顺序，如 `bocha,tavily,duckduckgo`）。凭据写进用户级配置文件。\n\n**MCP 配置**：用户级 `~/.zcode/cli/config.json`（项目级 `<repo>/.zcode/config.json`），注意是嵌套的 `mcp.servers`：\n\n```json\n{\n  \"mcp\": {\n    \"servers\": {\n      \"search-failover\": {\n        \"type\": \"stdio\",\n        \"command\": \"npx\",\n        \"args\": [\"-y\", \"search-failover\"],\n        \"timeoutMs\": 60000\n      }\n    }\n  }\n}\n```\n\nZCode 的 MCP schema 很严格：`command` 必须是字符串，环境变量字段必须叫 `env`，出现未知字段整条 server 会被静默丢弃；配置文件里不展开 `${...}` 模板。\n\n</details>\n\n<details>\n<summary>VS Code（Copilot）</summary>\n\n`.vscode/mcp.json`（注意顶层是 `servers`）：\n\n```json\n{\n  \"servers\": {\n    \"search-failover\": {\n      \"type\": \"stdio\",\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"search-failover\"]\n    }\n  }\n}\n```\n\n</details>\n\n<details>\n<summary>OpenCode</summary>\n\n`opencode.json`：\n\n```jsonc\n{\n  \"mcp\": {\n    \"search-failover\": {\n      \"type\": \"local\",\n      \"command\": [\"npx\", \"-y\", \"search-failover\"],\n      \"environment\": { \"TAVILY_API_KEY\": \"...\" }\n    }\n  }\n}\n```\n\n和其他宿主不同：`command` 是**数组**，环境变量字段叫 **`environment`**。这段依据 OpenCode 官方文档编写，尚未在客户端实测。\n\n</details>\n\n<details>\n<summary>Claude Desktop、Windsurf、Cline、Cherry Studio 等</summary>\n\n使用通用 `mcpServers` 格式的宿主：\n\n```json\n{\n  \"mcpServers\": {\n    \"search-failover\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"search-failover\"]\n    }\n  }\n}\n```\n\n全局安装（`npm install -g search-failover`）后可以直接写 `\"command\": \"search-failover\"`。从源码运行则用 `\"command\": \"node\"`，`args` 指向 `dist/index.js` 的绝对路径。\n\n</details>\n\n### HTTP 模式（远程平台）\n\n```bash\nSEARCH_FAILOVER_HTTP_TOKEN=$(openssl rand -hex 24) npx -y search-failover --http --port 8787\n# MCP 端点：http://127.0.0.1:8787/mcp   健康检查：/healthz\n```\n\n- 无状态的 Streamable HTTP：每个请求独立处理，所有客户端共享同一份额度计数、冷却和缓存，多个宿主同时用也不会各算各的。\n- 默认只监听 `127.0.0.1`。监听其他地址（`--host 0.0.0.0`）必须设置 `SEARCH_FAILOVER_HTTP_TOKEN`，否则拒绝启动；公网暴露请放在 HTTPS 反向代理后面。\n- 客户端用 `Authorization: Bearer <token>` 鉴权。只能填一个 URL 的平台可以写成 `https://your.host/mcp?token=<token>`，但查询参数可能出现在代理日志里。\n- 不设 token 时只接受本机的 Host / Origin，防止网页借 DNS rebinding 调用你的本地服务。\n\n## 配置\n\n不写配置也能跑。需要调整时有两种方式，可以混用：\n\n**环境变量**（每个宿主都支持，适合简单场景）：\n\n| 变量 | 作用 |\n| --- | --- |\n| `BOCHA_API_KEY` 等 | 各来源的凭据，见[搜索源](#搜索源) |\n| `SEARCH_FAILOVER_PROVIDERS` | 运行链，按优先级逗号分隔，如 `tavily,brave,duckduckgo`；列出的可选源直接启用 |\n| `SEARCH_FAILOVER_CONFIG` | 配置文件路径，默认用户级文件 |\n| `SEARCH_FAILOVER_HTTP_TOKEN` | HTTP 模式的 token |\n| `SEARCH_FAILOVER_DEBUG=1` | 给模型暴露调试参数 `provider`（强制只查一家） |\n| `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | 出站代理，见[网络与代理](#网络与代理) |\n| `LOG` | 日志级别：`error` / `warn` / `info` / `debug`（写 stderr，`debug` 会记录每一步兜底） |\n\n**配置文件**：先看 `SEARCH_FAILOVER_CONFIG`（指定了就必须存在且合法），否则读用户级文件 `$XDG_CONFIG_HOME/search-failover/config.json`，默认 `~/.config/search-failover/config.json`，Windows 为 `%APPDATA%\\search-failover\\config.json`。**不读当前目录**：宿主的工作目录各不相同，而且别人仓库里的配置文件不应该替你打开付费来源。\n\n```json\n{\n  \"credentials\": {\n    \"TAVILY_API_KEY\": \"...\",\n    \"ZHIPU_API_KEY\": \"...\",\n    \"SEARXNG_URL\": \"http://localhost:8080\"\n  },\n  \"providers\": [\n    { \"name\": \"zhipu\" },\n    { \"name\": \"tavily\" },\n    { \"name\": \"brave\", \"quota\": { \"limit\": 500 } },\n    { \"name\": \"searxng\" },\n    { \"name\": \"duckduckgo\" }\n  ]\n}\n```\n\n规则：\n\n- `providers` 的**数组顺序就是优先级**，列出即启用（`\"enabled\": false` 可以暂时停用）。不写 `providers` 用默认链，写 `[]` 就是空链。\n- 设置了 `SEARCH_FAILOVER_PROVIDERS` 时，名单和顺序以它为准；配置文件里对应来源的调优字段仍然生效。\n- `credentials` 的键是环境变量名；同名环境变量优先。\n- 条目里省略的字段继承该来源的默认值（限速、额度、冷却）。`quota` 类型不变时按字段合并；切换类型则整体替换，比如 `{ \"type\": \"unbounded\" }` 关掉本地额度限制。\n- 未知或重复的来源名、未知字段（包括拼错的字段名，比如把 `limit` 写成 `limt`）、非法数值都算配置错误。**出错时网关照常启动，但不运行任何来源**：每次搜索都返回 `config_error` 和出错位置，`web_search_status` 里也能看到，在任何宿主的对话里都能直接看到原因。\n\n可用字段：\n\n| 字段 | 说明 | 默认 |\n| --- | --- | --- |\n| `providers[].name` | 来源名：`bocha` `zhipu` `deepseek` `tavily` `qianfan` `brave` `serper` `exa` `searxng` `duckduckgo` | 必填 |\n| `providers[].enabled` | 设为 `false` 暂时停用 | `true` |\n| `providers[].quota` | 本地请求预算：`type`（`monthly` / `one_time` / `unbounded`）、`limit`、`reset_day`（1–31） | 随来源，见[搜索源](#搜索源) |\n| `providers[].quota_retry_s` | 上游配额错误没给恢复时间时的等待秒数，`0` 表示不施加这类冷却 | `21600` |\n| `providers[].local_qps` / `min_interval_ms` | 本地限速 | Brave、千帆、Serper 默认 1 QPS，DeepSeek 5 QPS，DuckDuckGo 间隔 2 秒 |\n| `providers[].cooldown_max_s` | 覆盖该来源的冷却上限 | 全局值；DuckDuckGo 为 21600 |\n| `credentials.<环境变量名>` | 凭据（key 或 SearXNG 实例地址） | – |\n| `defaults.timeout_ms` | 单个来源的请求超时 | `10000` |\n| `defaults.total_budget_ms` | 整次搜索的总预算 | `30000` |\n| `defaults.cooldown_default_s` / `cooldown_max_s` | 限流缺省冷却 / 冷却上限（秒） | `60` / `3600` |\n| `defaults.max_output_chars` | 单次返回里正文（`content`/`snippet`）的总字符预算 | `12000` |\n| `cache.enabled` | 是否启用缓存 | `true` |\n| `cache.ttl_s` / `ttl_fresh_s` | 缓存 TTL，后者用于 `freshness=day` | `3600` / `900` |\n| `cache.max_entries` | 缓存条数上限 | `512` |\n\n## 网络与代理\n\n- 设置 `HTTPS_PROXY`（或 `HTTP_PROXY`）后，所有出站请求走代理；`NO_PROXY` 按惯例列出直连的地址。没设 `NO_PROXY` 时，`localhost`、`127.0.0.1`、`::1` 默认直连，方便连本机的 SearXNG。生效的代理地址会显示在 `web_search_status` 里（账号密码已隐去）。\n- 这件事需要网关自己处理：Node 自带的 fetch 不读这些变量，Node 22 实测设了 `HTTPS_PROXY` 依然直连。\n- **国内网络：** DuckDuckGo 无法直连，所以\"零配置\"在国内需要代理才能工作；Tavily、Brave、Exa 也常需要代理。不想用代理的话，博查、智谱、DeepSeek、千帆是国内服务，自建的 SearXNG 可以作为免 key 的兜底。\n\n## 工具与返回值\n\n### `web_search`\n\n| 参数 | 默认 | 说明 |\n| --- | --- | --- |\n| `query` | 必填 | 搜索词。网关截断到 400 字符；智谱、DeepSeek、千帆还有更严的来源级截断，会在 `note` 里说明 |\n| `max_results` | 8 | 1–20；超出范围或传成字符串会自动调整 |\n| `freshness` | – | `day` / `week` / `month` / `year` |\n| `include_domains` | – | 只返回这些主机名的结果；`https://docs.rs/x`、`site:docs.rs`、`*.docs.rs` 这类写法会自动归一成 `docs.rs` |\n| `use_cache` | true | 是否读写缓存 |\n\n调试参数 `provider`（强制只查一家）只在 `SEARCH_FAILOVER_DEBUG=1` 时出现。工具描述按当前可用的来源生成。\n\n返回 `results[]` 和 `meta`。每条结果一定有 `title` 和 `url`；正文在 `content`（来源给出较长摘要时）或 `snippet`（只有短摘要时），两者不会同时出现，也可能都缺失。所有结果的正文合计超过 `defaults.max_output_chars` 时，按比例缩短较长的条目，并在 `note` 里说明（宿主普遍对工具输出有上限，比如 Claude Code 默认 25k token）。`meta` 里有 `provider_used`、完整的 `fallback_chain`、`cache_hit`、`elapsed_ms` 和可选的 `note`。\n\n怎么读 `fallback_chain`：\n\n| outcome | 含义 |\n| --- | --- |\n| `no_results` | 这一家正常返回了空结果 |\n| `rate_limited` `quota_exhausted` `auth_failure` `timeout` `network` `server_error` `request_error` | 这一家失败了，处理方式见[工作方式](#工作方式) |\n| `budget_exhausted` | 请求已经发出，但整次搜索的总预算先用完了 |\n| `skipped:<原因>` | 这次没有请求这一家：仍在冷却（`auth_failure` 的 detail 是 `until_restart`）、`quota_local`（本地预算用完）、`local_rate`（限速等不到）、`budget_exhausted`（总预算已用完） |\n| `config_error` / `no_provider` / `internal_error`（provider 为 `gateway`） | 配置错误 / 没有可用来源 / 网关自身异常 |\n\n空结果的判断：链非空且全是 `no_results`，是正常的空搜索；有任何失败或跳过，或者没有可用的来源，MCP 返回 `isError: true`。\n\n### `web_search_status`\n\n没有参数。返回每家来源的状态、配置它的环境变量、屏蔽原因和剩余时间、连续失败次数、本地用量（达到 90% 预警），以及配置文件位置与错误、代理、缓存统计。节选：\n\n```jsonc\n{\n  \"version\": \"0.4.0\",\n  \"providers\": [\n    { \"name\": \"bocha\", \"in_chain\": true, \"credential_env\": \"BOCHA_API_KEY\", \"state\": \"blocked(quota_exhausted, 21600s left)\", \"used_requests\": 1, \"quota_profile\": \"monthly:1000@day1\" },\n    { \"name\": \"tavily\", \"in_chain\": true, \"credential_env\": \"TAVILY_API_KEY\", \"state\": \"active\", \"used_requests\": 1, \"quota_profile\": \"monthly:1000@day2\" },\n    { \"name\": \"zhipu\", \"in_chain\": false, \"credential_env\": \"ZHIPU_API_KEY\", \"state\": \"unconfigured\" }\n    // …\n  ],\n  \"config\": { \"source\": \"/Users/me/.config/search-failover/config.json\", \"error\": null, \"warnings\": [] },\n  \"network\": { \"proxy\": \"http://127.0.0.1:7890\" },\n  \"cache\": { \"entries\": 1, \"hits\": 0, \"misses\": 1 },\n  \"uptime_s\": 42\n}\n```\n\n### 排错\n\n| `web_search_status` 里看到 | 含义 | 怎么办 |\n| --- | --- | --- |\n| `config.error` 不为空 | 配置文件或环境变量有错，所有搜索都不会执行 | 按提示的位置修正，重启宿主 |\n| `config.warnings` 提到 unexpanded template | 宿主没有展开 `${...}`，这个值被忽略了 | 写成真实的值，或者改用配置文件 |\n| `unconfigured` | 没读到这一家的凭据（`credential_env`） | 设置环境变量或配置文件的 `credentials`，重启宿主 |\n| `disabled` | 有凭据，但不在运行链里（可选源默认不进链） | 加进 `SEARCH_FAILOVER_PROVIDERS` 或 `providers` |\n| `disabled(auth_failure)` | key 被上游拒绝，已摘除到重启 | 换 key，重启宿主 |\n| `blocked(<原因>, Ns left)` | 冷却中 | 等待，或看 `last_error` |\n| `blocked(quota_local)` | 本地请求预算用完 | 调大 `quota.limit`，或等下个周期 |\n| `active` | 可用 | – |\n\n## 状态与局限\n\n- **状态只在进程内存里。** 计数、限速、冷却和缓存重启即清零。stdio 模式下每个宿主各有一个进程，互不共享；需要共享时用 HTTP 模式，让多个宿主连同一个进程。计数也不包含其他应用对同一个 key 的消耗。\n- **相同查询并发时不合并请求。** 两个一模一样的请求同时到达，各请求一次上游。\n- **不合并多家结果。** 第一家返回非空结果就结束；这是容灾网关，不是元搜索引擎。\n- **部分来源还没用真实 key 校准。** 智谱、千帆、Serper、SearXNG 的契约测试用的是按官方文档构造的响应；博查、Brave 的\"无结果\"响应格式依据官方实现和响应模型推断。`scripts/probe.mts` 就是用来采集真实响应的（包括必然无结果的查询），手上有 key 的话，欢迎跑一遍并把脱敏后的结果贴到 issue 里。\n- **DuckDuckGo 靠抓取 HTML 页面。** 免 key，但受网络和反爬限制，页面改版可能失效；遇到不认识的页面结构会报错，不会当成空结果。\n- **宿主兼容。** OpenCode、VS Code 的配置依官方文档编写，尚未在客户端实测；Cursor 的官方插件市场仍在审核。HTTP 模式在本地用 MCP SDK 客户端测过，还没逐个平台验证。\n\n## 开发与发布\n\n```bash\nnpm run dev         # tsx 本地起服（stdio）；加 -- --http 起 HTTP 模式\nnpm test            # 单元 / 契约 / Router 集成 / stdio 与 HTTP e2e\nnpm run typecheck   # tsc --noEmit\nnpm run build       # esbuild 打包成单文件 dist/index.js\nnode scripts/runtime-smoke.mjs dist/index.js   # 用构建产物跑一遍 stdio 冒烟（上游是模拟的）\n```\n\n仓库自带的 `.cursor/mcp.json`、`.zcode/config.json` 指向本地构建（先 `npm run build`），在 Cursor / ZCode 里打开仓库就能用到工作区里的代码；ZCode 需要以仓库根目录为工作目录。\n\n接入新来源：在 `src/providers/` 新建 adapter，导出包含凭据变量、默认值和能力的 `definition`，在 `src/providers/registry.ts` 加一行，补契约测试，再跑 `npm run plugin:sync`：Cursor 插件变量和上面的来源表格会自动更新。流程是 probe-first：先 `npx tsx scripts/probe.mts <provider>` 用真实 key 采集正常、必然无结果、错误 key、越界参数四种响应的脱敏快照，再据此校准契约测试。\n\n插件清单：`npm run plugin:check`（结构、生成内容和锁定版本校验，`prepublishOnly` 会跑）、`npm run plugin:sync`（重新生成 Cursor 变量和来源表格）、`node scripts/plugin-sync.mjs --published`（确认清单锁定的版本在 npm 上已发布，CI 会跑）。清单锁定的版本由发布 workflow 自动更新，不需要手动改。\n\n发布冒烟按插件同款命令拉起真实分发包，跑完 initialize → tools/list → status → 真实搜索：`node scripts/smoke-stdio.mjs`，支持 `--env KEY=V`、`--config /绝对路径/config.json`、`--command node -- dist/index.js`（本地构建）。\n\n<details>\n<summary>发布流程（自动化）</summary>\n\n版本号由 [release-please](https://github.com/googleapis/release-please) 根据 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 决定，不再手动改版本：\n\n1. **提交规范。** PR 用 squash 合并，PR 标题就是进入 main 的提交信息，`PR title` workflow 会检查格式：`fix:` 升 patch，`feat:` 升 minor，`feat!:` 或正文里写 `BREAKING CHANGE:` 升 major（1.0 之前升 minor）。`docs:`、`chore:`、`ci:`、`test:` 等不触发发版。\n2. **发布 PR。** 每次合并到 main，release-please 会创建或更新一个 `chore: release x.y.z` PR，里面改好 `package.json`、lockfile 和 CHANGELOG。想发版时合并它。\n3. **自动发布。** 合并发布 PR 后，`Release` workflow 依次：打 tag 并创建 GitHub Release → 用 OIDC trusted publishing 发布到 npm → 确认 npm 上能装到后，运行 `plugin-sync --to x.y.z` 把插件清单锁定的版本回写到 main。\n\n市场清单直接读 GitHub 默认分支，所以清单里锁定的始终是\"最新已发布版本\"：它可以落后于 `package.json`（发布 PR 合并后、npm 发布完成前），但不能超前；CI 的 `--published` 检查和 `plugin:check` 会拦住违反这两条的改动。\n\n需要的仓库设置：\n\n- 合并方式只开 squash，默认提交信息用 PR 标题。\n- 建议配置 `RELEASE_TOKEN` secret（GitHub App token 或细粒度 PAT，需要 contents 和 pull requests 写权限）。默认 `GITHUB_TOKEN` 创建的发布 PR 和回写提交不会触发 CI；main 开了分支保护时，回写提交也需要这个 token 有推送权限。\n- npm Trusted Publisher 绑定的是 `release.yml` 和 `release` 环境，不要改 workflow 文件名。\n\n</details>\n\n## 反馈\n\n遇到某家搜索 API 的异常行为，欢迎提 [issue](https://github.com/crisweb1994/search-failover/issues)，最好附上脱敏后的响应体，它可以直接变成一条契约测试。\n\n## 许可证\n\n[MIT](LICENSE)\n"}