| 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 |