main
rb 160 lines 5.56 KB
Raw
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