| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | require "rails_helper" |
| 4 | |
| 5 | RSpec.describe "Api::V1::Mcp", type: :request do |
| 6 | let(:user) do |
| 7 | User.create!(smbcloud_id: 4242, email: "mcp-tester@example.com", username: "mcptester") |
| 8 | end |
| 9 | |
| 10 | let!(:repository) do |
| 11 | user.repositories.create!( |
| 12 | name: "demo", |
| 13 | kind: "code", |
| 14 | default_branch: "main", |
| 15 | disk_path: GitRepositoryService.repo_path("mcptester", "demo") |
| 16 | ) |
| 17 | end |
| 18 | |
| 19 | let(:token) { "valid-access-token" } |
| 20 | let(:auth_headers) do |
| 21 | { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" } |
| 22 | end |
| 23 | |
| 24 | # Bearer auth is validated against smbCloud (the same path the rest of the API |
| 25 | # uses). We stub the controller's auth seam so a valid token resolves to our |
| 26 | # test user — this also keeps specs off the smbcloud-auth native extension, |
| 27 | # which can't be dlopen'd in every dev environment. |
| 28 | before do |
| 29 | allow_any_instance_of(Api::V1::McpController) |
| 30 | .to receive(:resolve_user) { |_controller, presented| presented == token ? user : nil } |
| 31 | end |
| 32 | |
| 33 | def rpc(body) |
| 34 | post "/api/v1/mcp", params: body.to_json, headers: auth_headers |
| 35 | response.parsed_body |
| 36 | end |
| 37 | |
| 38 | describe "initialize handshake" do |
| 39 | it "echoes the protocol version and advertises the server" do |
| 40 | result = rpc({ |
| 41 | "jsonrpc" => "2.0", "id" => 1, "method" => "initialize", |
| 42 | "params" => { "protocolVersion" => "2025-06-18", "capabilities" => {}, |
| 43 | "clientInfo" => { "name" => "rspec", "version" => "0" } } |
| 44 | })["result"] |
| 45 | |
| 46 | expect(response).to have_http_status(:ok) |
| 47 | expect(result["protocolVersion"]).to eq("2025-06-18") |
| 48 | expect(result.dig("serverInfo", "name")).to eq("sigit") |
| 49 | expect(result.dig("capabilities", "tools")).to include("listChanged" => false) |
| 50 | end |
| 51 | end |
| 52 | |
| 53 | describe "notifications/initialized" do |
| 54 | it "is acknowledged with an empty 202 (no body)" do |
| 55 | post "/api/v1/mcp", |
| 56 | params: { "jsonrpc" => "2.0", "method" => "notifications/initialized" }.to_json, |
| 57 | headers: auth_headers |
| 58 | expect(response).to have_http_status(:accepted) |
| 59 | expect(response.body).to be_blank |
| 60 | end |
| 61 | end |
| 62 | |
| 63 | describe "tools/list" do |
| 64 | it "lists all eleven 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_issues", "get_issue", "create_issue", |
| 71 | "list_pull_requests", "get_pull_request", "create_pull_request", "add_issue_comment", |
| 72 | "web_search" |
| 73 | ) |
| 74 | expect(tools).to all(include("description", "inputSchema")) |
| 75 | end |
| 76 | end |
| 77 | |
| 78 | describe "tools/call" do |
| 79 | it "runs a tool successfully (list_repositories)" do |
| 80 | result = rpc({ |
| 81 | "jsonrpc" => "2.0", "id" => 3, "method" => "tools/call", |
| 82 | "params" => { "name" => "list_repositories", "arguments" => { "limit" => 5 } } |
| 83 | })["result"] |
| 84 | |
| 85 | expect(result["isError"]).to be(false) |
| 86 | text = result["content"].first["text"] |
| 87 | expect(text).to include("mcptester/demo") |
| 88 | end |
| 89 | |
| 90 | it "reports a tool failure in-band as isError (not a protocol error)" do |
| 91 | result = rpc({ |
| 92 | "jsonrpc" => "2.0", "id" => 4, "method" => "tools/call", |
| 93 | "params" => { "name" => "get_file_contents", |
| 94 | "arguments" => { "repo" => "nobody/missing", "path" => "README.md" } } |
| 95 | })["result"] |
| 96 | |
| 97 | expect(response).to have_http_status(:ok) |
| 98 | expect(result["isError"]).to be(true) |
| 99 | expect(result["content"].first["text"]).to match(/not found or not accessible/i) |
| 100 | end |
| 101 | end |
| 102 | |
| 103 | describe "authentication" do |
| 104 | it "returns a JSON-RPC 401 with a WWW-Authenticate challenge when the token is missing" do |
| 105 | post "/api/v1/mcp", |
| 106 | params: { "jsonrpc" => "2.0", "id" => 5, "method" => "tools/list" }.to_json, |
| 107 | headers: { "Content-Type" => "application/json" } |
| 108 | |
| 109 | expect(response).to have_http_status(:unauthorized) |
| 110 | expect(response.parsed_body.dig("error", "code")).to eq(-32_001) |
| 111 | expect(response.headers["WWW-Authenticate"]).to include("resource_metadata=") |
| 112 | end |
| 113 | end |
| 114 | end |