Brave
NAME
MCP::Server::Tool::Web::Provider::Brave - Brave Search adapter for web_search
SYNOPSIS
use MCP::Server::Tool::Web::Provider::Brave;
# BRAVE_API_KEY set in the environment:
my $brave = MCP::Server::Tool::Web::Provider::Brave.new;
my @rows = $brave.search('raku json::fast api', 5);
# @rows[0]<title> / <url> / <snippet> / <age>?
# Or explicit, e.g. a key vaulted under a different name, or a
# self-hosted Brave-compatible proxy:
my $brave2 = MCP::Server::Tool::Web::Provider::Brave.new(
api-key => $vault.fetch('brave-search'),
api-url => 'https://search.internal.example.com/res/v1/web/search',
country => 'GB',
search-lang => 'en',
);
DESCRIPTION
Default MCP::Server::Tool::Web::SearchProvider implementation,
talking to the Brave Search API's web/search endpoint. This is what
MCP::Server::Tool::Web's web_search tool uses unless a different
provider is configured or an alternative
MCP::Server::Tool::Web::SearchProvider is injected for testing.
KEY RESOLUTION
The API key is resolved at call time, inside .search, never at
construction. This matters: a MCP::Server::Tool::Web instance with
no key configured anywhere must still construct and register all four
web_* tools โ web_fetch, web_crawl and web_grep don't need
a search key at all, and only web_search should fail, with a
teaching message, the moment it's actually called without one.
Resolution order, first match wins:
$.api-keyโ used verbatim when set to a non-empty string.%*ENV{$.api-key-env}โ$.api-key-envdefaults to'BRAVE_API_KEY'but names whatever environment variable the operator points it at; used when non-empty.Neither is set (or both are empty) โ
.searchdies with a message naming the environment variable actually in play (i.e. the current value of$.api-key-env, not always the literal string"BRAVE_API_KEY") and both config keys that would supply it.
SSRF GUARD โ DELIBERATELY NOT APPLIED HERE
This provider does NOT go through the pack's SSRF Guard. That
guard exists to stop a model-supplied URL (from web_fetch /
web_crawl / web_grep) from being used to probe the operator's
own network โ the URL in those cases comes from untrusted model
output. $.api-url here is the opposite: it is set once, by
whoever deploys MCP::Server::Tool::Web, in the same config that
also chooses the provider class. Refusing loopback/private addresses
on it would break the entirely legitimate case of a self-hosted
search backend (a SearXNG instance, an internal Brave-compatible
proxy, ...) running on the operator's own LAN or even localhost.
If $.api-url is ever made settable from an untrusted source, THAT
call site is responsible for guarding it โ this class intentionally
does not.
REQUEST SHAPE
GET $.api-url with query parameters q (the query), count,
safesearch, and country / search_lang only when those
attributes are set. Headers: X-Subscription-Token (the resolved
key), Accept: application/json, and Cache-Control: no-cache โ
the last of these is not cosmetic: Brave has been observed in the
field to answer a request that omits it with a 422 that has
nothing to do with the query, so it is sent on every call.
Accept-Encoding: gzip is deliberately not sent: Cro's
body-blob does not decompress a gzipped response unless the
optional Compress::Zlib module happens to be installed, and this
class hand-decodes body-blob itself (see !blob-text below) โ an
environment-dependent decompression step is not something this dist
can rely on, so the request stays identity-encoded and Brave answers
in kind.
ERRORS
Every failure mode .search can hit is turned into a die with a
teaching first line, per the MCP::Server::Tool::Web::SearchProvider
contract:
401/403โ names the environment variable / config key the key was actually read from.422โ "Brave refused the request as invalid", Brave's own error message when the body carries one, and a reminder of the 400-character / 50-word query limit (the most common cause).429โ "Brave rate-limited this search", theX-RateLimit-Resetseconds when Brave sent that header, and a note that only successful requests count against quota (so retrying after the reset is safe).Other 5xx โ described as transient; safe to retry.
Timeout โ names the seconds waited and the
timeoutconfig key that controls it.Connection failure (refused, reset, DNS, ...) โ names the host and a one-line cause.
An unparseable (non-JSON) response body โ quotes its first 200 characters, which is usually enough to recognise a captive portal or an HTML error page from an intermediary proxy.
A gzip-compressed response body (the gzip magic bytes
1f 8bat the start ofbody-blob) โ this client cannot decompress it (see REQUEST SHAPE above on whyAccept-Encoding: gzipis never sent) and dies with a diagnostic that says so plainly, rather than feeding a latin-1 mangling of the compressed bytes tofrom-jsonand reporting a confusing "not JSON" error instead.
AUTHOR
Matt Doughty
COPYRIGHT AND LICENSE
Copyright 2026 Matt Doughty
This library is free software; you can redistribute it and/or modify it under the Artistic License 2.0.