Add web_search MCP tool with a provider adapter

siGit Code can fetch a page but cannot find one. Search now lives on the official MCP server as a signed-in cloud feature instead of a client-side API key: web_search takes a query and an optional count (clamped to 10) and returns title, url, and snippet triples as a compact JSON block. WebSearchService selects a provider from a registry via SEARCH_PROVIDER, Brave first, with net/http and hard timeouts like the other outbound services in this app. Every failure mode comes back in-band as a ToolError the model can react to: unconfigured key (the message names BRAVE_SEARCH_API_KEY), unknown provider, upstream 4xx and 5xx with the status, and network errors. Never a 500. Searches are rate limited per user with a sliding one-hour window of sixty, counted in the Rails cache, since the repo has no throttle infrastructure to reuse. The MCP spec doc and .env.example document the new tool and its configuration.

Seto Elkahfi committed Jul 5, 2026 at 09:07 UTC db33bd576751ae2993b31a7da5b89612f71e1ffb
8 files changed +489 -8
.agents/specs/mcp-server.md
+38 -4
@@ -1,6 +1,6 @@
1 ---
2 name: mcp-server
3 -description: "Specification for the siGit MCP server at /api/v1/mcp. A stateless Streamable-HTTP JSON-RPC endpoint that exposes siGit repositories to MCP clients (Claude Code and others) as six tools: list_repositories, get_file_contents, search_code, list_pull_requests, create_pull_request, add_issue_comment. Covers transport, auth, the protocol method set, the tool contracts, the pull request / issue / comment data model, authorization, and acceptance criteria."
3 +description: "Specification for the siGit MCP server at /api/v1/mcp. A stateless Streamable-HTTP JSON-RPC endpoint that exposes siGit repositories to MCP clients (Claude Code and others) as seven tools: list_repositories, get_file_contents, search_code, list_pull_requests, create_pull_request, add_issue_comment, web_search. Covers transport, auth, the protocol method set, the tool contracts, the pull request / issue / comment data model, authorization, and acceptance criteria."
4 status: implemented
5 implements:
6 - app/controllers/api/v1/mcp_controller.rb
@@ -9,11 +9,14 @@ implements:
9 - app/services/mcp/tool_error.rb
10 - app/models/{pull_request,issue,comment,repository}.rb
11 - app/services/{pull_request_service,comment_service}.rb
12 + - app/services/web_search_service.rb
13 - config/routes.rb
14 verifies:
15 - spec/requests/api/v1/mcp_spec.rb
16 + - spec/requests/api/v1/mcp_web_search_spec.rb
17 - spec/services/pull_request_service_spec.rb
18 - spec/services/comment_service_spec.rb
19 + - spec/services/web_search_service_spec.rb
20 ---
21
22 # siGit MCP server
@@ -142,7 +145,7 @@ so adding a tool is a one-line change (append the class to `all`). Each tool
145 declares `tool_name`, `description`, `input_schema` (JSON Schema surfaced to the
146 model), and a `call`.
147
145 -The six tools, in registry order:
148 +The seven tools, in registry order:
149
150 ### list_repositories
151
@@ -206,6 +209,29 @@ The six tools, in registry order:
209 - **Output:** `{ id, url }`.
210 - **Errors (in band):** no issue or PR with that number; blank body.
211
212 +### web_search
213 +
214 +- **Purpose:** search the public web and return result links with snippets, so
215 + the agent can find pages to read (it can already fetch a known URL). Search is
216 + a signed-in cloud feature of the official MCP server, not a client-side API
217 + key.
218 +- **Input (required):** `query`. Optional `count` (default 5, clamped 1..10 —
219 + out-of-range values are clamped, never an error).
220 +- **Behaviour:** delegates to `WebSearchService`, which selects a provider
221 + adapter via `SEARCH_PROVIDER` (default `brave`) and returns a uniform result
222 + shape regardless of provider. The Brave adapter calls the Brave Search API
223 + (`GET https://api.search.brave.com/res/v1/web/search`, auth via the
224 + `X-Subscription-Token` header set from `BRAVE_SEARCH_API_KEY`, params `q` and
225 + `count`) and maps `web.results[]` `title`/`url`/`description` to the output.
226 +- **Rate limit:** at most 60 searches per user per hour, a sliding window
227 + counted in `Rails.cache` (solid_cache in production). Exceeding it is an
228 + in-band `isError`, not a protocol error.
229 +- **Output:** JSON array of `{ title, url, snippet }`.
230 +- **Errors (in band):** missing query; unconfigured provider (the message names
231 + the env var the operator must set — never a 500); unknown `SEARCH_PROVIDER`;
232 + provider HTTP failure (the message carries the upstream status); rate limit
233 + exceeded.
234 +
235 ## Data model
236
237 The app had no pull request, issue, or comment concept before this feature; the
@@ -258,7 +284,7 @@ authorization run for the MCP path as for any future web UI.
284
285 1. `claude mcp add --transport http sigit https://sigit.si/api/v1/mcp --header
286 "Authorization: Bearer <token>"` connects.
261 -2. `tools/list` returns all six tools.
287 +2. `tools/list` returns all seven tools.
288 3. `list_repositories` and `get_file_contents` work end to end against a real
289 repository.
290 4. An unauthenticated request returns a JSON-RPC 401 with a `WWW-Authenticate`
@@ -268,8 +294,14 @@ authorization run for the MCP path as for any future web UI.
294 ## Verification
295
296 - Request specs (`spec/requests/api/v1/mcp_spec.rb`): initialize handshake,
271 - `tools/list` shows all six, a successful `tools/call`, a notification answered
297 + `tools/list` shows all seven, a successful `tools/call`, a notification answered
298 with 202, a 401 with the challenge, and a tool returning `isError`.
299 +- Web search request specs (`spec/requests/api/v1/mcp_web_search_spec.rb`):
300 + happy path with the provider HTTP call stubbed, count clamping, missing query,
301 + unconfigured and unknown provider reported in-band, provider 4xx/5xx surfaced
302 + in-band, and the rate limit. Service specs
303 + (`spec/services/web_search_service_spec.rb`): provider selection and the Brave
304 + adapter's request shape, parsing, and error mapping.
305 - Service specs (`spec/services/pull_request_service_spec.rb`,
306 `comment_service_spec.rb`): authorization, branch validation, head-differs-from-
307 base, shared numbering, comment resolution across issues and pull requests,
@@ -294,6 +326,8 @@ authorization run for the MCP path as for any future web UI.
326 - `app/services/mcp/tool_error.rb`: in-band tool failure type.
327 - `app/services/pull_request_service.rb`, `app/services/comment_service.rb`:
328 mutation services.
329 +- `app/services/web_search_service.rb`: web search with the provider adapter
330 + boundary (Brave first).
331 - `app/models/pull_request.rb`, `issue.rb`, `comment.rb`, and the additions to
332 `repository.rb` and `git_repository_service.rb`.
333 - `config/routes.rb`: `post`/`get api/v1/mcp`.
.env.example
+14
@@ -43,6 +43,20 @@ ONDE_CLOUD_APP_SECRET=your-onde-app-secret-here
43 # ONDE_CLOUD_BASE_URL=https://cloud.ondeinference.com/v1
44
45
46 +# -----------------------------------------------------------------------------
47 +# Web search (the MCP web_search tool)
48 +# Search runs server-side so signed-in siGit Code users need no search API key
49 +# of their own. Without a key the tool answers with an in-band error telling
50 +# the operator to set this; nothing else breaks.
51 +# -----------------------------------------------------------------------------
52 +
53 +# Which provider adapter WebSearchService uses. Optional; defaults to "brave".
54 +# SEARCH_PROVIDER=brave
55 +
56 +# Brave Search API subscription token (https://api-dashboard.search.brave.com/)
57 +# BRAVE_SEARCH_API_KEY=your-brave-search-api-key
58 +
59 +
60 # -----------------------------------------------------------------------------
61 # Stripe — siGit Code Pro/Team billing (the /billing page + Checkout/Portal).
62 # Separate from Onde Cloud billing. Billing is a no-op unless STRIPE_SECRET_KEY
app/services/mcp/server.rb
+2 -1
@@ -43,7 +43,8 @@ module Mcp
43 "capabilities" => { "tools" => { "listChanged" => false } },
44 "serverInfo" => { "name" => "sigit", "title" => "siGit Code", "version" => "0.1.0" },
45 "instructions" => "Tools for siGit-hosted git repositories: list repos, " \
46 - "read files, search code, and open or comment on pull requests and issues."
46 + "read files, search code, and open or comment on pull requests and issues. " \
47 + "Also web_search, for finding pages on the public web."
48 }
49 end
50
app/services/mcp/tools.rb
+47 -1
@@ -21,7 +21,8 @@ module Mcp
21 SearchCode,
22 ListPullRequests,
23 CreatePullRequest,
24 - AddIssueComment
24 + AddIssueComment,
25 + WebSearch
26 ].freeze
27 end
28
@@ -241,5 +242,50 @@ module Mcp
242 raise Mcp::ToolError, e.record.errors.full_messages.to_sentence
243 end
244 end
245 +
246 + class WebSearch < Base
247 + # Per-user sliding window: at most RATE_LIMIT searches per RATE_WINDOW.
248 + # Backed by Rails.cache (solid_cache in production) because the app has
249 + # no rack-attack or Redis counter infrastructure to reuse.
250 + RATE_LIMIT = 60
251 + RATE_WINDOW = 1.hour
252 +
253 + name! "web_search"
254 + describe "Search the web and return matching pages as title, URL, and snippet. " \
255 + "Use this to find pages to read when you don't already know the URL."
256 + input_schema(
257 + "type" => "object",
258 + "required" => %w[query],
259 + "properties" => {
260 + "query" => { "type" => "string", "description" => "The search query." },
261 + "count" => { "type" => "integer", "description" => "Number of results (default 5, max 10).", "default" => 5 }
262 + }
263 + )
264 +
265 + def call(args)
266 + query = require_arg(args, "query")
267 + count = (args["count"] || 5).to_i.clamp(1, 10)
268 + check_rate_limit!
269 +
270 + WebSearchService.search(query, count: count)
271 + rescue WebSearchService::Error => e
272 + raise Mcp::ToolError, e.message
273 + end
274 +
275 + private
276 +
277 + def check_rate_limit!
278 + key = "mcp:web_search:user:#{current_user.id}"
279 + cutoff = Time.current.to_i - RATE_WINDOW.to_i
280 + stamps = Array(Rails.cache.read(key)).select { |t| t > cutoff }
281 +
282 + if stamps.size >= RATE_LIMIT
283 + raise Mcp::ToolError,
284 + "Rate limit exceeded: at most #{RATE_LIMIT} web searches per hour per user. Try again later."
285 + end
286 +
287 + Rails.cache.write(key, stamps + [ Time.current.to_i ], expires_in: RATE_WINDOW)
288 + end
289 + end
290 end
291 end
app/services/web_search_service.rb new
+114
@@ -0,0 +1,114 @@
1 +# frozen_string_literal: true
2 +
3 +require "net/http"
4 +require "json"
5 +
6 +# Web search for the MCP server, behind a provider adapter boundary.
7 +#
8 +# `WebSearchService.search` picks a provider adapter by ENV and returns a
9 +# uniform result shape — an array of `{ "title", "url", "snippet" }` hashes —
10 +# so the MCP tool (and any future caller) never sees provider-specific JSON.
11 +# Adding a provider means adding an adapter class with a
12 +# `search(query, count:)` method and registering it in PROVIDERS.
13 +#
14 +# Failures (unconfigured or unknown provider, upstream HTTP errors, network
15 +# errors) raise WebSearchService::Error with an actionable message; callers
16 +# convert that to their own error channel (the MCP tool re-raises it as an
17 +# in-band Mcp::ToolError, never a 500).
18 +#
19 +# Env vars:
20 +# SEARCH_PROVIDER — which adapter to use (default "brave")
21 +# BRAVE_SEARCH_API_KEY — Brave Search API subscription token
22 +class WebSearchService
23 + # Raised when a search can't be performed (missing configuration, provider
24 + # HTTP failure, unusable response). The message is safe to show the caller.
25 + class Error < StandardError; end
26 +
27 + DEFAULT_COUNT = 5
28 + MAX_COUNT = 10
29 +
30 + def self.search(query, count: DEFAULT_COUNT)
31 + provider.search(query, count: count.to_i.clamp(1, MAX_COUNT))
32 + end
33 +
34 + def self.provider
35 + name = ENV.fetch("SEARCH_PROVIDER", "brave")
36 + klass = PROVIDERS[name]
37 + unless klass
38 + raise Error, "Unknown search provider #{name.inspect}. " \
39 + "Set SEARCH_PROVIDER to one of: #{PROVIDERS.keys.join(', ')}."
40 + end
41 + klass.new
42 + end
43 +
44 + # ── providers ──────────────────────────────────────────────────────────────
45 +
46 + # Brave Search API (https://api-dashboard.search.brave.com/).
47 + # GET /res/v1/web/search with `q` and `count`; auth is the
48 + # X-Subscription-Token header; results live under web.results[].
49 + class Brave
50 + ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
51 +
52 + # Bound how long a search can pin a puma thread (same rationale as
53 + # OndeCloudService: a slow upstream must never exhaust the web pool).
54 + OPEN_TIMEOUT = 5
55 + READ_TIMEOUT = 15
56 +
57 + def search(query, count:)
58 + uri = URI(ENDPOINT)
59 + uri.query = URI.encode_www_form(q: query, count: count)
60 +
61 + response = http_get(uri, api_key!)
62 + unless response.is_a?(Net::HTTPSuccess)
63 + Rails.logger.error("[web_search] Brave upstream #{response.code}: " \
64 + "#{response.body.to_s.byteslice(0, 500)}")
65 + raise Error, "Search provider request failed (HTTP #{response.code})"
66 + end
67 +
68 + parse(response.body)
69 + end
70 +
71 + # Maps Brave's response to the service's uniform result shape.
72 + def parse(body)
73 + results = JSON.parse(body).dig("web", "results") || []
74 + results.map do |r|
75 + { "title" => r["title"], "url" => r["url"], "snippet" => r["description"] }
76 + end
77 + rescue JSON::ParserError
78 + raise Error, "Search provider returned an unreadable response"
79 + end
80 +
81 + private
82 +
83 + def api_key!
84 + ENV["BRAVE_SEARCH_API_KEY"].presence || raise(
85 + Error,
86 + "Web search is not configured. Set BRAVE_SEARCH_API_KEY " \
87 + "(Brave Search API subscription token) on the server, " \
88 + "or point SEARCH_PROVIDER at a configured provider."
89 + )
90 + end
91 +
92 + # The one HTTP seam — specs stub this method directly (the repo carries no
93 + # webmock/vcr) and hand back a real Net::HTTPResponse.
94 + def http_get(uri, api_key)
95 + Net::HTTP.start(
96 + uri.hostname, uri.port,
97 + use_ssl: uri.scheme == "https",
98 + open_timeout: OPEN_TIMEOUT,
99 + read_timeout: READ_TIMEOUT
100 + ) do |http|
101 + request = Net::HTTP::Get.new(uri)
102 + request["Accept"] = "application/json"
103 + request["X-Subscription-Token"] = api_key
104 + http.request(request)
105 + end
106 + rescue StandardError => e
107 + Rails.logger.error("[web_search] connection error: #{e.class}: #{e.message}")
108 + raise Error, "Search provider is temporarily unavailable"
109 + end
110 + end
111 +
112 + # Adapter registry, keyed by SEARCH_PROVIDER.
113 + PROVIDERS = { "brave" => Brave }.freeze
114 +end
spec/requests/api/v1/mcp_spec.rb
+3 -2
@@ -61,13 +61,14 @@ RSpec.describe "Api::V1::Mcp", type: :request do
61 end
62
63 describe "tools/list" do
64 - it "lists all six tools" do
64 + it "lists all seven tools" do
65 tools = rpc({ "jsonrpc" => "2.0", "id" => 2, "method" => "tools/list" })["result"]["tools"]
66 names = tools.map { |t| t["name"] }
67
68 expect(names).to contain_exactly(
69 "list_repositories", "get_file_contents", "search_code",
70 - "list_pull_requests", "create_pull_request", "add_issue_comment"
70 + "list_pull_requests", "create_pull_request", "add_issue_comment",
71 + "web_search"
72 )
73 expect(tools).to all(include("description", "inputSchema"))
74 end
spec/requests/api/v1/mcp_web_search_spec.rb new
+160
@@ -0,0 +1,160 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe "Api::V1::Mcp web_search tool", type: :request do
6 + let(:user) do
7 + User.create!(smbcloud_id: 4243, email: "mcp-searcher@example.com", username: "mcpsearcher")
8 + end
9 +
10 + let(:token) { "valid-access-token" }
11 + let(:auth_headers) do
12 + { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
13 + end
14 +
15 + before do
16 + allow_any_instance_of(Api::V1::McpController)
17 + .to receive(:resolve_user) { |_controller, presented| presented == token ? user : nil }
18 +
19 + # The provider adapter reads its key from ENV; default each example to a
20 + # configured provider and override per-context below.
21 + allow(ENV).to receive(:[]).and_call_original
22 + allow(ENV).to receive(:fetch).and_call_original
23 + allow(ENV).to receive(:[]).with("BRAVE_SEARCH_API_KEY").and_return("brave-key")
24 + end
25 +
26 + def rpc(body)
27 + post "/api/v1/mcp", params: body.to_json, headers: auth_headers
28 + response.parsed_body
29 + end
30 +
31 + def call_web_search(arguments)
32 + rpc({
33 + "jsonrpc" => "2.0", "id" => 1, "method" => "tools/call",
34 + "params" => { "name" => "web_search", "arguments" => arguments }
35 + })["result"]
36 + end
37 +
38 + # The repo has no webmock/vcr, so specs stub the adapter's single HTTP seam
39 + # (WebSearchService::Brave#http_get) with a real Net::HTTPResponse.
40 + def http_response(code, body)
41 + klass = Net::HTTPResponse::CODE_TO_OBJ.fetch(code.to_s)
42 + response = klass.new("1.1", code.to_s, nil)
43 + response.instance_variable_set(:@read, true)
44 + response.instance_variable_set(:@body, body)
45 + response
46 + end
47 +
48 + def brave_body(results)
49 + { "web" => { "results" => results } }.to_json
50 + end
51 +
52 + def stub_brave(code, body)
53 + allow_any_instance_of(WebSearchService::Brave)
54 + .to receive(:http_get).and_return(http_response(code, body))
55 + end
56 +
57 + describe "tools/list" do
58 + it "advertises web_search with its schema" do
59 + tools = rpc({ "jsonrpc" => "2.0", "id" => 1, "method" => "tools/list" })["result"]["tools"]
60 + spec = tools.find { |t| t["name"] == "web_search" }
61 +
62 + expect(spec).to be_present
63 + expect(spec["description"]).to include("Search the web")
64 + expect(spec.dig("inputSchema", "required")).to eq([ "query" ])
65 + expect(spec.dig("inputSchema", "properties")).to include("query", "count")
66 + end
67 + end
68 +
69 + describe "tools/call web_search" do
70 + it "returns a JSON list of {title, url, snippet}" do
71 + stub_brave(200, brave_body([
72 + { "title" => "Ruby", "url" => "https://ruby-lang.org", "description" => "A language." }
73 + ]))
74 +
75 + result = call_web_search({ "query" => "ruby" })
76 +
77 + expect(response).to have_http_status(:ok)
78 + expect(result["isError"]).to be(false)
79 + parsed = JSON.parse(result["content"].first["text"])
80 + expect(parsed).to eq([
81 + { "title" => "Ruby", "url" => "https://ruby-lang.org", "snippet" => "A language." }
82 + ])
83 + end
84 +
85 + it "clamps count to at most 10 instead of erroring" do
86 + requested_uri = nil
87 + allow_any_instance_of(WebSearchService::Brave).to receive(:http_get) do |_adapter, uri, _key|
88 + requested_uri = uri
89 + http_response(200, brave_body([]))
90 + end
91 +
92 + result = call_web_search({ "query" => "ruby", "count" => 50 })
93 +
94 + expect(result["isError"]).to be(false)
95 + expect(URI.decode_www_form(requested_uri.query).to_h["count"]).to eq("10")
96 + end
97 +
98 + it "reports a missing query in-band" do
99 + result = call_web_search({})
100 +
101 + expect(response).to have_http_status(:ok)
102 + expect(result["isError"]).to be(true)
103 + expect(result["content"].first["text"]).to include("Missing required argument: query")
104 + end
105 +
106 + it "reports an unconfigured provider in-band, naming the env var (not a 500)" do
107 + allow(ENV).to receive(:[]).with("BRAVE_SEARCH_API_KEY").and_return(nil)
108 +
109 + result = call_web_search({ "query" => "ruby" })
110 +
111 + expect(response).to have_http_status(:ok)
112 + expect(result["isError"]).to be(true)
113 + expect(result["content"].first["text"]).to include("BRAVE_SEARCH_API_KEY")
114 + end
115 +
116 + it "reports an unknown SEARCH_PROVIDER in-band (not a 500)" do
117 + allow(ENV).to receive(:fetch).with("SEARCH_PROVIDER", "brave").and_return("altavista")
118 +
119 + result = call_web_search({ "query" => "ruby" })
120 +
121 + expect(response).to have_http_status(:ok)
122 + expect(result["isError"]).to be(true)
123 + expect(result["content"].first["text"]).to include("SEARCH_PROVIDER")
124 + end
125 +
126 + it "surfaces a provider 4xx in-band with the status" do
127 + stub_brave(429, "slow down")
128 +
129 + result = call_web_search({ "query" => "ruby" })
130 +
131 + expect(result["isError"]).to be(true)
132 + expect(result["content"].first["text"]).to include("HTTP 429")
133 + end
134 +
135 + it "surfaces a provider 5xx in-band with the status" do
136 + stub_brave(500, "boom")
137 +
138 + result = call_web_search({ "query" => "ruby" })
139 +
140 + expect(result["isError"]).to be(true)
141 + expect(result["content"].first["text"]).to include("HTTP 500")
142 + end
143 +
144 + it "rejects a user over the hourly rate limit in-band" do
145 + # The test env cache is :null_store; give the limiter a real store.
146 + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
147 + Rails.cache.write(
148 + "mcp:web_search:user:#{user.id}",
149 + Array.new(Mcp::Tools::WebSearch::RATE_LIMIT) { Time.current.to_i }
150 + )
151 + expect_any_instance_of(WebSearchService::Brave).not_to receive(:http_get)
152 +
153 + result = call_web_search({ "query" => "ruby" })
154 +
155 + expect(response).to have_http_status(:ok)
156 + expect(result["isError"]).to be(true)
157 + expect(result["content"].first["text"]).to match(/rate limit/i)
158 + end
159 + end
160 +end
spec/services/web_search_service_spec.rb new
+111
@@ -0,0 +1,111 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe WebSearchService do
6 + # Builds a real Net::HTTPResponse (the class the adapter checks with
7 + # Net::HTTPSuccess) carrying a canned body. The repo has no webmock/vcr, so
8 + # specs stub the adapter's http_get seam with one of these.
9 + def http_response(code, body)
10 + klass = Net::HTTPResponse::CODE_TO_OBJ.fetch(code.to_s)
11 + response = klass.new("1.1", code.to_s, nil)
12 + response.instance_variable_set(:@read, true)
13 + response.instance_variable_set(:@body, body)
14 + response
15 + end
16 +
17 + def brave_body(results)
18 + { "web" => { "results" => results } }.to_json
19 + end
20 +
21 + before do
22 + allow(ENV).to receive(:[]).and_call_original
23 + allow(ENV).to receive(:fetch).and_call_original
24 + allow(ENV).to receive(:[]).with("BRAVE_SEARCH_API_KEY").and_return("brave-key")
25 + end
26 +
27 + describe ".provider" do
28 + it "defaults to the Brave adapter" do
29 + expect(described_class.provider).to be_a(WebSearchService::Brave)
30 + end
31 +
32 + it "raises a configuration error for an unknown SEARCH_PROVIDER" do
33 + allow(ENV).to receive(:fetch).with("SEARCH_PROVIDER", "brave").and_return("bing")
34 +
35 + expect { described_class.provider }
36 + .to raise_error(WebSearchService::Error, /unknown search provider "bing".*SEARCH_PROVIDER.*brave/im)
37 + end
38 + end
39 +
40 + describe ".search" do
41 + it "clamps count to at most 10 before hitting the provider" do
42 + adapter = instance_double(WebSearchService::Brave)
43 + allow(described_class).to receive(:provider).and_return(adapter)
44 + expect(adapter).to receive(:search).with("rust", count: 10).and_return([])
45 +
46 + described_class.search("rust", count: 50)
47 + end
48 + end
49 +
50 + describe WebSearchService::Brave do
51 + subject(:adapter) { described_class.new }
52 +
53 + it "requests the Brave endpoint with q, count, and the subscription token" do
54 + captured_uri = nil
55 + captured_key = nil
56 + allow(adapter).to receive(:http_get) do |uri, api_key|
57 + captured_uri = uri
58 + captured_key = api_key
59 + http_response(200, brave_body([]))
60 + end
61 +
62 + adapter.search("ruby net/http", count: 3)
63 +
64 + expect(captured_uri.to_s).to start_with("https://api.search.brave.com/res/v1/web/search?")
65 + expect(URI.decode_www_form(captured_uri.query).to_h)
66 + .to eq("q" => "ruby net/http", "count" => "3")
67 + expect(captured_key).to eq("brave-key")
68 + end
69 +
70 + it "parses web.results[] into {title, url, snippet}" do
71 + body = brave_body([
72 + { "title" => "Ruby", "url" => "https://ruby-lang.org", "description" => "A language.",
73 + "extra" => "ignored" },
74 + { "title" => "Rails", "url" => "https://rubyonrails.org", "description" => "A framework." }
75 + ])
76 + allow(adapter).to receive(:http_get).and_return(http_response(200, body))
77 +
78 + expect(adapter.search("ruby", count: 5)).to eq([
79 + { "title" => "Ruby", "url" => "https://ruby-lang.org", "snippet" => "A language." },
80 + { "title" => "Rails", "url" => "https://rubyonrails.org", "snippet" => "A framework." }
81 + ])
82 + end
83 +
84 + it "returns an empty list when the response has no web.results" do
85 + allow(adapter).to receive(:http_get).and_return(http_response(200, "{}"))
86 +
87 + expect(adapter.search("nothing", count: 5)).to eq([])
88 + end
89 +
90 + it "raises when BRAVE_SEARCH_API_KEY is not set, naming the env var" do
91 + allow(ENV).to receive(:[]).with("BRAVE_SEARCH_API_KEY").and_return(nil)
92 +
93 + expect { adapter.search("ruby", count: 5) }
94 + .to raise_error(WebSearchService::Error, /BRAVE_SEARCH_API_KEY/)
95 + end
96 +
97 + it "surfaces an upstream HTTP failure with its status" do
98 + allow(adapter).to receive(:http_get).and_return(http_response(429, "slow down"))
99 +
100 + expect { adapter.search("ruby", count: 5) }
101 + .to raise_error(WebSearchService::Error, /HTTP 429/)
102 + end
103 +
104 + it "raises a readable error for an unparseable body" do
105 + allow(adapter).to receive(:http_get).and_return(http_response(200, "<html>nope</html>"))
106 +
107 + expect { adapter.search("ruby", count: 5) }
108 + .to raise_error(WebSearchService::Error, /unreadable response/)
109 + end
110 + end
111 +end