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