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