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