main
rb 232 lines 9.38 KB
Raw
1 # frozen_string_literal: true
2
3 require "rails_helper"
4 require "tmpdir"
5 require "fileutils"
6
7 # Request specs for the repo-workflow MCP tools (issues and pull requests).
8 # Each tool is exercised end to end through POST /api/v1/mcp: the happy path,
9 # a repository the caller cannot see, and missing required arguments — the
10 # failures reported in-band as isError, never as protocol errors.
11 RSpec.describe "Api::V1::Mcp repo workflow tools", type: :request do
12 around do |example|
13 Dir.mktmpdir("mcp-tools-spec") do |tmp|
14 @repo_dir = File.join(tmp, "demo.git")
15 build_repo_with_feature_branch(@repo_dir)
16 example.run
17 end
18 end
19
20 let(:user) do
21 User.create!(smbcloud_id: 4243, email: "mcp-tools-tester@example.com", username: "mcptools")
22 end
23 let(:stranger) do
24 User.create!(smbcloud_id: 4244, email: "stranger@example.com", username: "stranger")
25 end
26
27 let!(:repository) do
28 user.repositories.create!(
29 name: "demo", kind: "code", default_branch: "main", disk_path: @repo_dir
30 )
31 end
32
33 # A private repository owned by someone else: invisible to the caller, so
34 # every tool must answer "not found or not accessible" rather than leak it.
35 let!(:private_repo) do
36 stranger.repositories.create!(
37 name: "secret", kind: "code", default_branch: "main", is_private: true,
38 disk_path: GitRepositoryService.repo_path("stranger", "secret")
39 )
40 end
41
42 let(:token) { "valid-access-token" }
43 let(:auth_headers) do
44 { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
45 end
46
47 before do
48 allow_any_instance_of(Api::V1::McpController)
49 .to receive(:resolve_user) { |_controller, presented| presented == token ? user : nil }
50 end
51
52 def call_tool(name, arguments)
53 post "/api/v1/mcp",
54 params: { "jsonrpc" => "2.0", "id" => 1, "method" => "tools/call",
55 "params" => { "name" => name, "arguments" => arguments } }.to_json,
56 headers: auth_headers
57 response.parsed_body["result"]
58 end
59
60 # Successful tool output is a JSON document in the text content block.
61 def payload(result)
62 expect(result["isError"]).to be(false)
63 JSON.parse(result["content"].first["text"])
64 end
65
66 def error_text(result)
67 expect(result["isError"]).to be(true)
68 result["content"].first["text"]
69 end
70
71 describe "list_issues" do
72 before do
73 repository.issues.create!(user: user, number: 1, title: "Auth bug", body: "Login fails on Safari")
74 repository.issues.create!(user: user, number: 2, title: "Update docs", state: "closed")
75 end
76
77 it "lists open issues by default" do
78 issues = payload(call_tool("list_issues", { "repo" => "mcptools/demo" }))
79 expect(issues.map { |i| i["number"] }).to eq([ 1 ])
80 expect(issues.first).to include("title" => "Auth bug", "state" => "open", "author" => "mcptools")
81 end
82
83 it "filters by state and by a title/body query" do
84 issues = payload(call_tool("list_issues",
85 { "repo" => "mcptools/demo", "state" => "all", "query" => "docs" }))
86 expect(issues.map { |i| i["number"] }).to eq([ 2 ])
87 end
88
89 it "rejects a repository the caller cannot see" do
90 result = call_tool("list_issues", { "repo" => "stranger/secret" })
91 expect(error_text(result)).to match(/not found or not accessible/i)
92 end
93
94 it "rejects a call missing the repo argument" do
95 result = call_tool("list_issues", {})
96 expect(error_text(result)).to match(/Missing required argument: repo/)
97 end
98 end
99
100 describe "get_issue" do
101 let!(:issue) do
102 repository.issues.create!(user: user, number: 1, title: "Auth bug", body: "Login fails")
103 end
104
105 it "returns the issue with its body and comments" do
106 issue.comments.create!(user: stranger, body: "Reproduced on my machine")
107
108 data = payload(call_tool("get_issue", { "repo" => "mcptools/demo", "number" => 1 }))
109 expect(data).to include("number" => 1, "title" => "Auth bug", "state" => "open",
110 "author" => "mcptools", "body" => "Login fails")
111 expect(data["comments"]).to contain_exactly(
112 a_hash_including("author" => "stranger", "body" => "Reproduced on my machine")
113 )
114 end
115
116 it "reports an unknown issue number in-band" do
117 result = call_tool("get_issue", { "repo" => "mcptools/demo", "number" => 99 })
118 expect(error_text(result)).to match(/No issue #99/)
119 end
120
121 it "rejects a repository the caller cannot see" do
122 result = call_tool("get_issue", { "repo" => "stranger/secret", "number" => 1 })
123 expect(error_text(result)).to match(/not found or not accessible/i)
124 end
125
126 it "rejects a call missing the number argument" do
127 result = call_tool("get_issue", { "repo" => "mcptools/demo" })
128 expect(error_text(result)).to match(/Missing required argument: number/)
129 end
130 end
131
132 describe "create_issue" do
133 it "opens an issue numbered in the shared issue/PR space" do
134 repository.pull_requests.create!(
135 user: user, number: 1, title: "PR", head_ref: "feature", base_ref: "main"
136 )
137
138 data = payload(call_tool("create_issue",
139 { "repo" => "mcptools/demo", "title" => "Flaky spec", "body" => "Fails on CI" }))
140 expect(data).to include("number" => 2, "state" => "open")
141
142 issue = repository.issues.find_by(number: 2)
143 expect(issue.title).to eq("Flaky spec")
144 expect(issue.body).to eq("Fails on CI")
145 expect(issue.author).to eq(user)
146 end
147
148 it "rejects a repository the caller cannot see" do
149 result = call_tool("create_issue", { "repo" => "stranger/secret", "title" => "X" })
150 expect(error_text(result)).to match(/not found or not accessible/i)
151 end
152
153 it "rejects a call missing the title argument" do
154 result = call_tool("create_issue", { "repo" => "mcptools/demo" })
155 expect(error_text(result)).to match(/Missing required argument: title/)
156 end
157 end
158
159 describe "get_pull_request" do
160 let!(:pull_request) do
161 repository.pull_requests.create!(
162 user: user, number: 1, title: "Add feature", body: "Adds the feature",
163 head_ref: "feature", base_ref: "main"
164 )
165 end
166
167 it "returns the pull request with its comments and unified diff" do
168 pull_request.comments.create!(user: stranger, body: "LGTM")
169
170 data = payload(call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 1 }))
171 expect(data).to include("number" => 1, "title" => "Add feature", "state" => "open",
172 "head" => "feature", "base" => "main", "author" => "mcptools")
173 expect(data["comments"]).to contain_exactly(a_hash_including("author" => "stranger", "body" => "LGTM"))
174 expect(data["diff"]).to include("feature.rb").and include("+puts 'shiny new feature'")
175 expect(data).not_to have_key("diff_truncated")
176 end
177
178 it "truncates an oversized diff and says so" do
179 big_diff = "+x" * Mcp::Tools::GetPullRequest::DIFF_MAX_BYTES
180 allow_any_instance_of(Repository).to receive(:diff_between).and_return(big_diff)
181
182 data = payload(call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 1 }))
183 expect(data["diff_truncated"]).to be(true)
184 expect(data["diff"].bytesize).to eq(Mcp::Tools::GetPullRequest::DIFF_MAX_BYTES)
185 expect(data["diff_note"]).to match(/truncated/i)
186 end
187
188 it "returns a nil diff with a note when a branch no longer exists" do
189 pull_request.update!(head_ref: "deleted-branch")
190
191 data = payload(call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 1 }))
192 expect(data["diff"]).to be_nil
193 expect(data["diff_note"]).to match(/branch no longer exists/i)
194 end
195
196 it "reports an unknown pull request number in-band" do
197 result = call_tool("get_pull_request", { "repo" => "mcptools/demo", "number" => 99 })
198 expect(error_text(result)).to match(/No pull request #99/)
199 end
200
201 it "rejects a repository the caller cannot see" do
202 result = call_tool("get_pull_request", { "repo" => "stranger/secret", "number" => 1 })
203 expect(error_text(result)).to match(/not found or not accessible/i)
204 end
205
206 it "rejects a call missing the number argument" do
207 result = call_tool("get_pull_request", { "repo" => "mcptools/demo" })
208 expect(error_text(result)).to match(/Missing required argument: number/)
209 end
210 end
211
212 # A bare repo whose `feature` branch adds one file on top of `main`, so the
213 # three-dot diff a pull request shows is small and predictable.
214 def build_repo_with_feature_branch(bare_path)
215 FileUtils.mkdir_p(bare_path)
216 system("git", "init", "--bare", bare_path, exception: true)
217 Dir.mktmpdir do |work|
218 system("git", "-C", work, "init", "-b", "main", exception: true)
219 system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
220 system("git", "-C", work, "config", "user.name", "Test", exception: true)
221 File.write(File.join(work, "README.md"), "# Demo\n")
222 system("git", "-C", work, "add", ".", exception: true)
223 system("git", "-C", work, "commit", "-m", "seed", exception: true)
224 system("git", "-C", work, "checkout", "-b", "feature", exception: true)
225 File.write(File.join(work, "feature.rb"), "puts 'shiny new feature'\n")
226 system("git", "-C", work, "add", ".", exception: true)
227 system("git", "-C", work, "commit", "-m", "add feature", exception: true)
228 system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
229 system("git", "-C", work, "push", "origin", "main", "feature", exception: true)
230 end
231 end
232 end