main
rb 114 lines 4.13 KB
Raw
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