mcp-search-failover
by AlimBkb
README.md
# mcp-search-failover
An MCP server for web search that does not die when an API key runs out.
[Русская версия](README.ru.md)
## The problem
Standard search MCP servers (`tavily-mcp`, `firecrawl-mcp`, and friends) read a single
API key from the environment at startup. When that key hits its quota, the server is
dead until you restart it with a new key. On free tiers that happens constantly, and it
always happens in the middle of something.
## What this does
Every request pulls a key from a local store at call time instead of at boot. When a key
is exhausted, the server rotates to the next key of the same provider. When every key of
that provider is spent, it falls through to the next provider entirely:
```
tavily -> serper -> exa -> firecrawl
```
The working key index is persisted, so subsequent calls do not waste a round trip
poking at a key that is already known to be dead.
### Detecting an exhausted key is the tricky part
Providers do not agree on how to say "you are out of credits". Tavily returns `432`,
which is not a real HTTP status code at all - that single detail is why naive rotation
logic silently fails. So detection works on two levels:
1. **Status codes** - `401, 402, 403, 429, 430, 432`
2. **Response body** - on *any* 4xx, the body is scanned for markers like
`usage limit`, `quota`, `exceed`, `credits`, `rate limit`, `insufficient`,
`upgrade your plan`, `out of credit`, `limit reached`
Anything else is treated as a genuine error and is *not* rotated on - a malformed query
should surface as a malformed query, not burn through four keys.
## Tools
| Tool | What it does |
|---|---|
| `web_search` | Search the web. `provider="auto"` walks the whole chain; or pin one of `tavily`, `serper`, `exa`, `firecrawl` |
| `web_extract` | Fetch a page as markdown (Firecrawl) |
| `web_map` | List the URLs of a site (Firecrawl) |
| `keys_status` | How many keys each provider has and which one is currently active |
## Install
```bash
git clone https://github.com/AlimBkb/mcp-search-failover.git
cd mcp-search-failover
pip install -r requirements.txt
```
Create the key store. The default location is `~/.claude/keys/keys.json`; override it
with the `KEYRING_FILE` environment variable.
```bash
cp keys.example.json ~/.claude/keys/keys.json
# then put your real keys in it
```
Format - a list per provider plus the index of the currently active key:
```json
{
"tavily": { "keys": ["key-one", "key-two"], "current": 0 }
}
```
You only need keys for the providers you actually intend to use. Missing providers are
skipped rather than treated as failures.
## Register with Claude Code
```bash
claude mcp add search -- python /absolute/path/to/mcp_search_server.py
```
Or add it to your MCP config by hand:
```json
{
"mcpServers": {
"search": {
"command": "python",
"args": ["/absolute/path/to/mcp_search_server.py"]
}
}
}
```
## CLI
The same rotation logic is available without an MCP client, which is handy for scripts
and for debugging:
```bash
python search.py "your query"
python search.py "your query" --provider exa --limit 10
python keyring.py status
```
## Reusing the rotation in your own code
`keyring.py` is standalone - it has no dependency on the MCP layer:
```python
from keyring import call_with_rotation, RotateKey
def do(key):
resp = requests.get(url, headers={"Authorization": f"Bearer {key}"})
if resp.status_code in (401, 402, 429):
raise RotateKey(resp.status_code)
return resp.json()
result = call_with_rotation("tavily", do)
```
`call_with_rotation` returns `None` once every key for that service is spent, so the
caller can decide whether to fall through to another provider or give up.
## Tests
```bash
python -m pytest tests/ -v
```
The tests cover the exhaustion-detection logic, which is where the actual complexity
lives and the one part that must not regress.
## Security
The key store is never committed - `keys.json` is in `.gitignore`, and only
`keys.example.json` with placeholder values ships with the repository. Keys are read
from disk at call time and are never logged: diagnostics print the key *index*
(`#3/5`), never the value.
## License
MIT - see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive