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