@souyo/brave-mcp
v2.2.2
Published
Brave Search MCP Server (multi-key fork): round-robins multiple BRAVE_API_KEYs over stdio or HTTP to lift the 1 RPS limit.
Readme
Brave Search MCP Server (multi-key fork)
Fork of brave/brave-search-mcp-server
that round-robins multiple Brave Search API keys to lift the 1-RPS ceiling.
The upstream server accepts exactly one key (BRAVE_API_KEY) and is hard-limited
to ~1 request/second; this fork accepts a comma-separated list and spreads every
search across keys, so N keys ≈ N×RPS with no single key tripping 429.
It also adds Bearer token auth on the HTTP transport (the upstream HTTP endpoint is unauthenticated), so you can safely expose it over a network.
Everything else (all 8 tools, the multi-key/rate-limit behavior, STDIO/HTTP transports, DNS-rebinding guard, and 2020-12 JSON Schema advertising) matches upstream, apart from the argument-tolerance changes documented under Tools.
How the rate-limit lift works
BRAVE_API_KEYmay contain multiple keys, comma-separated:KEY1,KEY2,KEY3.- Every request picks the next key round-robin (single shared cursor), so load is spread evenly.
- A per-key sliding window refuses to send more than
BRAVE_MCP_KEY_RPSrequests/second on any one key (default1). - If Brave still answers
429for a key (bursts, per-key quota), the request transparently retries on the next key. Only when every configured key has failed does the call error out. - Aggregate throughput is therefore roughly
N keys × per-key RPS.
BRAVE_API_KEY_FILEstill works and takes precedence, but it holds a single key (it cannot hold a list).
BRAVE_MCP_KEY_RPStunes per-key ceiling. Keep it at1unless your plan's per-key limit differs.
When api.search.brave.com is unreachable
If your client cannot connect to api.search.brave.com directly (e.g. fetch failed),
pick one of:
1. Proxy — the package honors HTTPS_PROXY / HTTP_PROXY env vars
(Node's global fetch normally ignores them; here they are applied via undici's
ProxyAgent):
{
"mcpServers": {
"brave-search": {
"command": "npx",
"args": ["-y", "@souyo/brave-mcp"],
"env": {
"BRAVE_API_KEY": "key1,key2",
"HTTPS_PROXY": "http://127.0.0.1:7890"
}
}
}
}2. Reverse proxy — point BRAVE_API_BASE_URL at any reverse proxy of
api.search.brave.com (e.g. a Cloudflare Worker). All endpoints are routed
through it; key rotation and rate-limit handling are unchanged. The proxy must
forward the X-Subscription-Token header and query string verbatim:
{
"mcpServers": {
"brave-search": {
"command": "npx",
"args": ["-y", "@souyo/brave-mcp"],
"env": {
"BRAVE_API_KEY": "key1,key2",
"BRAVE_API_BASE_URL": "https://your-brave-proxy.example.workers.dev"
}
}
}
}Deploy with Docker
Build once, then run with your keys and a server token:
cp .env.example .env # fill in BRAVE_API_KEY list + MCP_SERVER_TOKEN
docker compose up -d --buildThis publishes http://<host>:8080/mcp. Every client request must carry
Authorization: Bearer <MCP_SERVER_TOKEN>.
Verify it's up:
curl -H "Authorization: Bearer $MCP_SERVER_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
http://localhost:8080/mcpWithout the token you get 401:
curl -i http://localhost:8080/mcp -X POST -H 'Content-Type: application/json' \
-d '{}' | head -1 # HTTP/1.1 401 UnauthorizedUsage from MCP clients (Streamable HTTP)
Point your client at http://<host>:8080/mcp (URL** only** — note the path),
with the Authorization header set to your token:
{
"mcpServers": {
"brave-search-multi": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer YOUR_MCP_SERVER_TOKEN"
}
}
}
}Claude Desktop claude_desktop_config.json:
{
"mcpServers": {
"brave-search-multi": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer YOUR_MCP_SERVER_TOKEN"
}
}
}
}Local development
npm install
npm run build
npm testRun in HTTP mode with Bearer auth:
BRAVE_API_KEY=key1,key2 BRAVE_MCP_TRANSPORT=http MCP_SERVER_TOKEN=secret \
BRAVE_MCP_HOST=0.0.0.0 node dist/index.jsConfiguration reference
| Variable | Default | Purpose |
|---|---|---|
| BRAVE_API_KEY | – | Comma-separated Brave API keys (required) |
| BRAVE_API_KEY_FILE | – | Path to a file holding a single key (takes precedence) |
| BRAVE_MCP_KEY_RPS | 1 | Per-key requests-per-second ceiling |
| BRAVE_API_BASE_URL | https://api.search.brave.com | Reverse-proxy base URL for the Brave API — for clients that cannot reach Brave directly and have no proxy. Invalid values fall back to the official endpoint |
| HTTPS_PROXY / HTTP_PROXY | – | Proxy for Brave API requests (Node's global fetch ignores these by default; this package honors them via undici ProxyAgent) |
| MCP_SERVER_TOKEN | – | Bearer token required on every HTTP request (leave unset to disable auth for local testing) |
| BRAVE_MCP_TRANSPORT | stdio | stdio or http |
| BRAVE_MCP_PORT | 8080 | HTTP port |
| BRAVE_MCP_HOST | 127.0.0.1 | Bind host (0.0.0.0 inside Docker) |
| BRAVE_MCP_STATELESS | true | Stateless HTTP mode |
| BRAVE_MCP_LOG_LEVEL | info | Log level |
| BRAVE_MCP_ENABLED_TOOLS | – | Whitelist tool names |
| BRAVE_MCP_DISABLED_TOOLS | – | Blacklist tool names |
| BRAVE_MCP_ALLOWED_ORIGINS | – | DNS-rebinding origin allowlist |
| BRAVE_MCP_ALLOWED_HOSTS | – | DNS-rebinding host allowlist |
CLI flags --brave-api-key, --brave-api-key-file, --transport, --port,
--host, --enabled-tools, --disabled-tools, --logging-level,
--stateless, --allowed-origins, --allowed-hosts behave as upstream;
--mcp-server-token was added for the Bearer token.
Tools
Same tool set as upstream: brave_web_search, brave_local_search,
brave_video_search, brave_image_search, brave_news_search,
brave_summarizer, brave_place_search, brave_llm_context.
See the upstream README
for full parameter docs.
Argument tolerance
Upstream is strict in ways that trip up LLM callers, which surface as
-32602 Input validation error. This fork is deliberately lenient where the
intent is unambiguous:
nullfor an optional argument means "not supplied". Models routinely send every advertised property, usingnullfor the ones they have no value for. Optional properties now acceptnulland fall back to the documented default (or omit the parameter).nullfor a required property still fails.freshness: "any"means "no time filter". The API expresses that by omitting the parameter, but every documented value is a discovery window, so"any"is accepted and normalized away.pd/pw/pm/pyare matched case-insensitively and trimmed. Other unknown values still fail.- **
country: "ALL"(no country restriction) is accepted bybrave_web_search,brave_place_searchandbrave_llm_context, but not bybrave_news_search/brave_video_search/brave_image_search, whosecountryis a free-form string. This asymmetry is pre-existing upstream behaviour and was left as-is, but it is a real trap: a model that learns"ALL"on one tool will have it rejected on another. - Fixed a typo'd parameter name in the
brave_web_searchdescription: it advertisedresults_filterinstead ofresult_filter. Zod strips unknown keys, so that call used to succeed while silently dropping the filter. If you see successful-but-unfiltered results on an older build, this was why.
Genuine mistakes (empty or over-long query, count/offset outside the
per-tool range, unknown enum values for country / search_lang / ui_lang /
safesearch) are still rejected with a message naming the offending field.
Note that count limits differ per tool: 1-20 for web/local, 1-50 for
video/news/place/llm_context, 1-200 for images.
License
MIT (same as upstream).
