Skip to main content
Glama
AlimBkb

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).