Add repo workflow tools to the MCP server

siGit Code bakes in this MCP server but could not touch repository workflows through it. Four new tools close that: list_issues (state filter plus a title/body search), get_issue (body and comments), create_issue (through a new IssueService that mirrors PullRequestService and shares the per-repo number space), and get_pull_request (body, comments, and the three-dot diff against the base branch, capped at 100 kB with an explicit truncation note, and a graceful note when a branch is gone). comment_on_pull_request was deliberately not added: issues and pull requests share one number space and the existing add_issue_comment already covers both, so a duplicate tool would only confuse the model consumer. Authorization reuses the existing visibility scopes; the registry, result envelope, and in-band error style follow the current tools exactly, and the internal MCP spec doc is updated to match.

Seto Elkahfi committed Jul 5, 2026 at 08:38 UTC 941b556723a9f72e4158551526e6514ac0c7da22
9 files changed +545 -35
.agents/specs/mcp-server.md
+77 -24
@@ -1,6 +1,6 @@
1 ---
2 name: mcp-server
3 -description: "Specification for the siGit MCP server at /api/v1/mcp. A stateless Streamable-HTTP JSON-RPC endpoint that exposes siGit repositories to MCP clients (Claude Code and others) as six tools: list_repositories, get_file_contents, search_code, list_pull_requests, create_pull_request, add_issue_comment. Covers transport, auth, the protocol method set, the tool contracts, the pull request / issue / comment data model, authorization, and acceptance criteria."
3 +description: "Specification for the siGit MCP server at /api/v1/mcp. A stateless Streamable-HTTP JSON-RPC endpoint that exposes siGit repositories to MCP clients (Claude Code and others) as ten tools: list_repositories, get_file_contents, search_code, list_issues, get_issue, create_issue, list_pull_requests, get_pull_request, create_pull_request, add_issue_comment. Covers transport, auth, the protocol method set, the tool contracts, the pull request / issue / comment data model, authorization, and acceptance criteria."
4 status: implemented
5 implements:
6 - app/controllers/api/v1/mcp_controller.rb
@@ -8,11 +8,13 @@ implements:
8 - app/services/mcp/tools.rb
9 - app/services/mcp/tool_error.rb
10 - app/models/{pull_request,issue,comment,repository}.rb
11 - - app/services/{pull_request_service,comment_service}.rb
11 + - app/services/{pull_request_service,issue_service,comment_service}.rb
12 - config/routes.rb
13 verifies:
14 - spec/requests/api/v1/mcp_spec.rb
15 + - spec/requests/api/v1/mcp_tools_spec.rb
16 - spec/services/pull_request_service_spec.rb
17 + - spec/services/issue_service_spec.rb
18 - spec/services/comment_service_spec.rb
19 ---
20
@@ -142,7 +144,7 @@ so adding a tool is a one-line change (append the class to `all`). Each tool
144 declares `tool_name`, `description`, `input_schema` (JSON Schema surfaced to the
145 model), and a `call`.
146
145 -The six tools, in registry order:
147 +The ten tools, in registry order:
148
149 ### list_repositories
150
@@ -177,6 +179,35 @@ The six tools, in registry order:
179 parses `<ref>:<path>:<line>:<text>`.
180 - **Output:** array of `{ path, line, snippet }`, capped at `limit`.
181
182 +### list_issues
183 +
184 +- **Purpose:** list issues, optionally by state or a text query.
185 +- **Input (required):** `repo`. Optional `state` in `open|closed|all` (default
186 + `open`), `query` (case-insensitive substring of the issue title or body).
187 +- **Behaviour:** `repo.issues`, filtered by state unless `all`, `ILIKE` filter
188 + on title or body when `query` is given, ordered by `number` descending,
189 + limited to 50.
190 +- **Output:** array of `{ number, title, state, author, url }`.
191 +
192 +### get_issue
193 +
194 +- **Purpose:** read one issue in full, including its comments.
195 +- **Input (required):** `repo`, `number`.
196 +- **Behaviour:** resolve the repo (read scope), find the issue by number.
197 +- **Output:** `{ number, title, state, author, body, url, comments }` where
198 + `comments` is an array of `{ author, body, created_at }`, oldest first.
199 +- **Errors (in band):** no issue with that number.
200 +
201 +### create_issue
202 +
203 +- **Purpose:** open a new issue.
204 +- **Input (required):** `repo`, `title`. Optional `body` (Markdown).
205 +- **Behaviour:** routed through `IssueService.create` so per-repo numbering
206 + (shared with pull requests) runs identically to any other caller. Anyone who
207 + can read the repo may open an issue on it, as with comments.
208 +- **Output:** `{ number, state, url }`.
209 +- **Errors (in band):** blank title; any model validation failure.
210 +
211 ### list_pull_requests
212
213 - **Purpose:** list pull requests, optionally by state.
@@ -186,6 +217,20 @@ The six tools, in registry order:
217 `number` descending, limited to 50.
218 - **Output:** array of `{ number, title, state, head, base, url }`.
219
220 +### get_pull_request
221 +
222 +- **Purpose:** read one pull request in full, including its comments and diff.
223 +- **Input (required):** `repo`, `number`.
224 +- **Behaviour:** resolve the repo (read scope), find the PR by number, then
225 + `Repository#diff_between(base_ref, head_ref)` -> `GitRepositoryService.diff`
226 + (three-dot `git diff base...head`, the range a pull request shows).
227 +- **Output:** `{ number, title, state, author, head, base, body, url, comments,
228 + diff }`. The diff is capped at 100 kB; past the cap the result carries
229 + `diff_truncated: true` and a `diff_note`. When a ref no longer exists (e.g.
230 + the head branch of a merged PR was deleted), `diff` is null with a
231 + `diff_note` saying so.
232 +- **Errors (in band):** no pull request with that number.
233 +
234 ### create_pull_request
235
236 - **Purpose:** open a pull request from a head branch into a base branch.
@@ -224,31 +269,35 @@ following were added to back the tools.
269 and a user (author). Column: `body` (present). `html_url` anchors to the parent.
270 - **Shared numbering.** Pull requests and issues draw from one per-repository
271 number space (as on GitHub), so a number maps to exactly one record. The next
227 - number is `max(pull_requests.number, issues.number) + 1`, computed under a
228 - repository row lock during creation.
272 + number is `max(pull_requests.number, issues.number) + 1`, computed by
273 + `Repository#next_issue_number` under a repository row lock during creation;
274 + both `PullRequestService` and `IssueService` allocate through it.
275
276 ## Authorization
277
232 -- **Read** (list_repositories, get_file_contents, search_code, list_pull_requests,
233 - resolving the repo for any tool): `Repository.visible_to(user)`, which returns
234 - public repositories plus the caller's own. Private repositories of other users
235 - are never resolvable, so they cannot be read or mutated.
278 +- **Read** (list_repositories, get_file_contents, search_code, list_issues,
279 + get_issue, list_pull_requests, get_pull_request, resolving the repo for any
280 + tool): `Repository.visible_to(user)`, which returns public repositories plus
281 + the caller's own. Private repositories of other users are never resolvable,
282 + so they cannot be read or mutated.
283 - **Write** (create_pull_request): `Repository#writable_by?(user)`. siGit repos
284 have a single owner and no collaborators yet, so write equals ownership. A
285 non-owner attempt returns an in-band `isError`.
239 -- **Comment** (add_issue_comment): requires read access to the repo (enforced by
240 - repo resolution). The author is always the authenticated user.
286 +- **Comment and issue creation** (add_issue_comment, create_issue): require read
287 + access to the repo (enforced by repo resolution). The author is always the
288 + authenticated user.
289
242 -Mutations go through the service objects (`PullRequestService`, `CommentService`),
243 -never directly through `create!` from the tool, so that the same validations and
244 -authorization run for the MCP path as for any future web UI.
290 +Mutations go through the service objects (`PullRequestService`, `IssueService`,
291 +`CommentService`), never directly through `create!` from the tool, so that the
292 +same validations and authorization run for the MCP path as for any future web UI.
293
294 ## Supporting layers
295
296 - **Git read layer.** `Repository#blob_at(ref, path)` delegates to
297 `GitRepositoryService.file_content` (a `git show <ref>:<path>`).
298 `Repository#search_code` delegates to `GitRepositoryService.search_code`
251 - (`git grep`).
299 + (`git grep`). `Repository#diff_between(base, head)` delegates to
300 + `GitRepositoryService.diff` (`git diff base...head`).
301 - **URLs.** `Repository#html_url`, `PullRequest#html_url`, `Issue#html_url`, and
302 `Comment#html_url` build absolute links from `Repository.site_url`, which reads
303 `SIGITSI_URL` (default `https://sigit.si`). This is needed because tools have no
@@ -258,7 +307,7 @@ authorization run for the MCP path as for any future web UI.
307
308 1. `claude mcp add --transport http sigit https://sigit.si/api/v1/mcp --header
309 "Authorization: Bearer <token>"` connects.
261 -2. `tools/list` returns all six tools.
310 +2. `tools/list` returns all ten tools.
311 3. `list_repositories` and `get_file_contents` work end to end against a real
312 repository.
313 4. An unauthenticated request returns a JSON-RPC 401 with a `WWW-Authenticate`
@@ -268,12 +317,16 @@ authorization run for the MCP path as for any future web UI.
317 ## Verification
318
319 - Request specs (`spec/requests/api/v1/mcp_spec.rb`): initialize handshake,
271 - `tools/list` shows all six, a successful `tools/call`, a notification answered
320 + `tools/list` shows all ten, a successful `tools/call`, a notification answered
321 with 202, a 401 with the challenge, and a tool returning `isError`.
322 +- Request specs (`spec/requests/api/v1/mcp_tools_spec.rb`): the issue and pull
323 + request tools end to end against a real bare repository — happy paths
324 + (including the real three-dot diff), diff truncation, missing-branch diffs,
325 + unknown numbers, an invisible repository, and missing required arguments.
326 - Service specs (`spec/services/pull_request_service_spec.rb`,
274 - `comment_service_spec.rb`): authorization, branch validation, head-differs-from-
275 - base, shared numbering, comment resolution across issues and pull requests,
276 - blank-body rejection.
327 + `issue_service_spec.rb`, `comment_service_spec.rb`): authorization, branch
328 + validation, head-differs-from-base, shared numbering, comment resolution
329 + across issues and pull requests, blank-title and blank-body rejection.
330
331 ## Out of scope (current) and natural extensions
332
@@ -283,8 +336,8 @@ authorization run for the MCP path as for any future web UI.
336 - Webhooks on pull request / issue / comment creation. None exist in the app yet;
337 when added, they run automatically because mutations go through the service
338 objects.
286 -- Further tools, each a one-line registry addition: `get_pull_request` (with
287 - diff), `list_branches`, `list_commits`, `create_issue`, `list_issues`.
339 +- Further tools, each a one-line registry addition: `list_branches`,
340 + `list_commits`, `close_issue`, `merge_pull_request`.
341
342 ## Key files
343
@@ -292,8 +345,8 @@ authorization run for the MCP path as for any future web UI.
345 - `app/services/mcp/server.rb`: protocol dispatch.
346 - `app/services/mcp/tools.rb`: tool registry and tool implementations.
347 - `app/services/mcp/tool_error.rb`: in-band tool failure type.
295 -- `app/services/pull_request_service.rb`, `app/services/comment_service.rb`:
296 - mutation services.
348 +- `app/services/pull_request_service.rb`, `app/services/issue_service.rb`,
349 + `app/services/comment_service.rb`: mutation services.
350 - `app/models/pull_request.rb`, `issue.rb`, `comment.rb`, and the additions to
351 `repository.rb` and `git_repository_service.rb`.
352 - `config/routes.rb`: `post`/`get api/v1/mcp`.
app/models/repository.rb
+16
@@ -62,6 +62,22 @@ class Repository < ApplicationRecord
62 GitRepositoryService.search_code(disk_path, ref || default_branch, query, limit: limit)
63 end
64
65 + # Unified diff of +head+ against its merge base with +base+ (what a pull
66 + # request shows). Returns the diff String ("" when the refs are identical),
67 + # or nil when either ref is unknown — e.g. the head branch of a merged pull
68 + # request has been deleted.
69 + def diff_between(base, head)
70 + GitRepositoryService.diff(disk_path, base, head)
71 + end
72 +
73 + # Next number in the shared issue / pull request number space. Issues and
74 + # pull requests draw from one per-repository sequence (as on GitHub), so a
75 + # number resolves to exactly one record. Call inside a `with_lock` block
76 + # when allocating.
77 + def next_issue_number
78 + [ pull_requests.maximum(:number) || 0, issues.maximum(:number) || 0 ].max + 1
79 + end
80 +
81 def git_path
82 disk_path
83 end
app/services/git_repository_service.rb
+10
@@ -198,6 +198,16 @@ class GitRepositoryService
198 end
199 end
200
201 + # Unified diff of +head+ against its merge base with +base+ (three-dot
202 + # `git diff base...head`, the same range a pull request shows). Returns the
203 + # diff String ("" when the refs are identical), or nil when either ref is
204 + # unknown or the repository is missing.
205 + def self.diff(path, base, head)
206 + out, _err, status = Open3.capture3("git", "--git-dir", path, "diff", "#{base}...#{head}")
207 + return nil unless status.success?
208 + out
209 + end
210 +
211 def self.branch_exists?(path, branch)
212 _out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch)
213 status.success?
app/services/issue_service.rb new
+24
@@ -0,0 +1,24 @@
1 +# frozen_string_literal: true
2 +
3 +# Creates issues. All issue creation — web UI or MCP — goes through here so
4 +# per-repo numbering (shared with pull requests) behaves identically across
5 +# callers.
6 +class IssueService
7 + class Error < StandardError; end
8 +
9 + # Opens an issue on +repository+ authored by +author+. Anyone who can read a
10 + # repository may open an issue on it, so read access is enforced by the
11 + # caller (the MCP layer only resolves repos the user can read), as with
12 + # comments. Raises ActiveRecord::RecordInvalid on validation failure.
13 + def self.create(repository:, author:, title:, body: nil)
14 + repository.with_lock do
15 + repository.issues.create!(
16 + user: author,
17 + number: repository.next_issue_number,
18 + title: title,
19 + body: body.to_s,
20 + state: "open"
21 + )
22 + end
23 + end
24 +end
app/services/mcp/tools.rb
+139
@@ -19,7 +19,11 @@ module Mcp
19 ListRepositories,
20 GetFileContents,
21 SearchCode,
22 + ListIssues,
23 + GetIssue,
24 + CreateIssue,
25 ListPullRequests,
26 + GetPullRequest,
27 CreatePullRequest,
28 AddIssueComment
29 ].freeze
@@ -71,6 +75,14 @@ module Mcp
75 .find_by(users: { username: owner }, repositories: { name: name })
76 repo || raise(Mcp::ToolError, "Repository not found or not accessible: #{full_name}")
77 end
78 +
79 + # Comments serialized oldest-first — the shape both get_issue and
80 + # get_pull_request return.
81 + def comments_for(commentable)
82 + commentable.comments.includes(:user).order(:created_at).map do |c|
83 + { "author" => c.author.username, "body" => c.body, "created_at" => c.created_at.iso8601 }
84 + end
85 + end
86 end
87
88 # ----------------------------------------------------------------------- #
@@ -154,6 +166,88 @@ module Mcp
166 end
167 end
168
169 + class ListIssues < Base
170 + name! "list_issues"
171 + describe "List issues in a repository (requires repo), optionally filtered by state or a title/body search."
172 + input_schema(
173 + "type" => "object",
174 + "required" => %w[repo],
175 + "properties" => {
176 + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
177 + "query" => { "type" => "string", "description" => "Optional case-insensitive substring of the issue title or body." },
178 + "state" => { "type" => "string", "enum" => %w[open closed all], "default" => "open" }
179 + }
180 + )
181 +
182 + def call(args)
183 + repo = find_repo!(require_arg(args, "repo"))
184 + state = args["state"].presence || "open"
185 + scope = state == "all" ? repo.issues : repo.issues.where(state: state)
186 + if args["query"].present?
187 + scope = scope.where("issues.title ILIKE :q OR issues.body ILIKE :q", q: "%#{args['query']}%")
188 + end
189 + scope.includes(:user).order(number: :desc).limit(50).map do |issue|
190 + { "number" => issue.number, "title" => issue.title, "state" => issue.state,
191 + "author" => issue.author.username, "url" => issue.html_url }
192 + end
193 + end
194 + end
195 +
196 + class GetIssue < Base
197 + name! "get_issue"
198 + describe "Fetch one issue by number (requires repo and number), including its body and comments."
199 + input_schema(
200 + "type" => "object",
201 + "required" => %w[repo number],
202 + "properties" => {
203 + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
204 + "number" => { "type" => "integer", "description" => "Issue number." }
205 + }
206 + )
207 +
208 + def call(args)
209 + repo = find_repo!(require_arg(args, "repo"))
210 + number = require_arg(args, "number")
211 + issue = repo.issues.find_by(number: number)
212 + raise Mcp::ToolError, "No issue ##{number} in #{repo.full_name}." unless issue
213 +
214 + { "number" => issue.number, "title" => issue.title, "state" => issue.state,
215 + "author" => issue.author.username, "body" => issue.body, "url" => issue.html_url,
216 + "comments" => comments_for(issue) }
217 + end
218 + end
219 +
220 + class CreateIssue < Base
221 + name! "create_issue"
222 + describe "Open a new issue in a repository (requires repo and title)."
223 + input_schema(
224 + "type" => "object",
225 + "required" => %w[repo title],
226 + "properties" => {
227 + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
228 + "title" => { "type" => "string" },
229 + "body" => { "type" => "string", "description" => "Issue description (Markdown)." }
230 + }
231 + )
232 +
233 + def call(args)
234 + repo = find_repo!(require_arg(args, "repo"))
235 + # Route through the service so per-repo numbering (shared with pull
236 + # requests) runs exactly as it does for any other caller.
237 + issue = IssueService.create(
238 + repository: repo,
239 + author: current_user,
240 + title: require_arg(args, "title"),
241 + body: args["body"]
242 + )
243 + { "number" => issue.number, "state" => issue.state, "url" => issue.html_url }
244 + rescue IssueService::Error => e
245 + raise Mcp::ToolError, e.message
246 + rescue ActiveRecord::RecordInvalid => e
247 + raise Mcp::ToolError, e.record.errors.full_messages.to_sentence
248 + end
249 + end
250 +
251 class ListPullRequests < Base
252 name! "list_pull_requests"
253 describe "List pull requests in a repository, optionally filtered by state."
@@ -177,6 +271,51 @@ module Mcp
271 end
272 end
273
274 + class GetPullRequest < Base
275 + # Unified diffs are included up to this many bytes so one large pull
276 + # request cannot blow out the model's context window.
277 + DIFF_MAX_BYTES = 100_000
278 +
279 + name! "get_pull_request"
280 + describe "Fetch one pull request by number (requires repo and number), " \
281 + "including its body, comments, and unified diff (truncated past #{DIFF_MAX_BYTES / 1000} kB)."
282 + input_schema(
283 + "type" => "object",
284 + "required" => %w[repo number],
285 + "properties" => {
286 + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
287 + "number" => { "type" => "integer", "description" => "Pull request number." }
288 + }
289 + )
290 +
291 + def call(args)
292 + repo = find_repo!(require_arg(args, "repo"))
293 + number = require_arg(args, "number")
294 + pr = repo.pull_requests.find_by(number: number)
295 + raise Mcp::ToolError, "No pull request ##{number} in #{repo.full_name}." unless pr
296 +
297 + { "number" => pr.number, "title" => pr.title, "state" => pr.state,
298 + "author" => pr.author.username, "head" => pr.head_ref, "base" => pr.base_ref,
299 + "body" => pr.body, "url" => pr.html_url,
300 + "comments" => comments_for(pr) }.merge(diff_fields(repo, pr))
301 + end
302 +
303 + private
304 +
305 + # The three-dot diff of head against base, capped at DIFF_MAX_BYTES.
306 + # nil with a note when a ref no longer exists (e.g. the head branch of a
307 + # merged pull request was deleted).
308 + def diff_fields(repo, pr)
309 + diff = repo.diff_between(pr.base_ref, pr.head_ref)
310 + return { "diff" => nil, "diff_note" => "Diff unavailable: a branch no longer exists." } if diff.nil?
311 + return { "diff" => diff } if diff.bytesize <= DIFF_MAX_BYTES
312 +
313 + { "diff" => diff.byteslice(0, DIFF_MAX_BYTES).scrub(""),
314 + "diff_truncated" => true,
315 + "diff_note" => "Diff truncated to the first #{DIFF_MAX_BYTES} bytes." }
316 + end
317 + end
318 +
319 class CreatePullRequest < Base
320 name! "create_pull_request"
321 describe "Open a pull request from a head branch into a base branch."
app/services/pull_request_service.rb
+3 -9
@@ -22,7 +22,9 @@ class PullRequestService
22 repository.with_lock do
23 repository.pull_requests.create!(
24 user: author,
25 - number: next_number(repository),
25 + # Issues and pull requests share one per-repo number space (as on
26 + # GitHub); Repository#next_issue_number is the single allocator.
27 + number: repository.next_issue_number,
28 title: title,
29 head_ref: head,
30 base_ref: base,
@@ -31,12 +33,4 @@ class PullRequestService
33 )
34 end
35 end
34 -
35 - # Issues and pull requests share one per-repo number space (as on GitHub), so a
36 - # number resolves to exactly one of them. Call inside a repository row lock.
37 - def self.next_number(repository)
38 - [ repository.pull_requests.maximum(:number) || 0,
39 - repository.issues.maximum(:number) || 0 ].max + 1
40 - end
41 - private_class_method :next_number
36 end
spec/requests/api/v1/mcp_spec.rb
+3 -2
@@ -61,13 +61,14 @@ RSpec.describe "Api::V1::Mcp", type: :request do
61 end
62
63 describe "tools/list" do
64 - it "lists all six tools" do
64 + it "lists all ten 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_pull_requests", "create_pull_request", "add_issue_comment"
70 + "list_issues", "get_issue", "create_issue",
71 + "list_pull_requests", "get_pull_request", "create_pull_request", "add_issue_comment"
72 )
73 expect(tools).to all(include("description", "inputSchema"))
74 end
spec/requests/api/v1/mcp_tools_spec.rb new
+232
@@ -0,0 +1,232 @@
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
spec/services/issue_service_spec.rb new
+41
@@ -0,0 +1,41 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe IssueService do
6 + let(:owner) { User.create!(smbcloud_id: 9001, email: "owner@example.com", username: "owner") }
7 + let(:reader) { User.create!(smbcloud_id: 9002, email: "reader@example.com", username: "reader") }
8 + let(:repo) do
9 + owner.repositories.create!(
10 + name: "app", kind: "code", default_branch: "main",
11 + disk_path: GitRepositoryService.repo_path("owner", "app")
12 + )
13 + end
14 +
15 + it "opens an issue authored by the given user" do
16 + issue = described_class.create(repository: repo, author: owner, title: "Bug", body: "Details")
17 +
18 + expect(issue).to be_persisted
19 + expect(issue.number).to eq(1)
20 + expect(issue.state).to eq("open")
21 + expect(issue.author).to eq(owner)
22 + expect(issue.body).to eq("Details")
23 + end
24 +
25 + it "lets any reader open an issue, not only the owner" do
26 + issue = described_class.create(repository: repo, author: reader, title: "Feature request")
27 + expect(issue.author).to eq(reader)
28 + end
29 +
30 + it "shares one number space with pull requests" do
31 + repo.pull_requests.create!(user: owner, number: 3, title: "PR", head_ref: "feature", base_ref: "main")
32 + issue = described_class.create(repository: repo, author: owner, title: "Bug")
33 + expect(issue.number).to eq(4)
34 + end
35 +
36 + it "rejects a blank title via model validation" do
37 + expect do
38 + described_class.create(repository: repo, author: owner, title: "")
39 + end.to raise_error(ActiveRecord::RecordInvalid)
40 + end
41 +end