1
+# frozen_string_literal: true
2
+
3
+module Mcp
4
+ # Tool registry + the tool implementations.
5
+ #
6
+ # Each tool is a Base subclass that declares a `tool_name`, a `description`
7
+ # (this is what the model reads to decide when to use it — keep it concrete),
8
+ # an `input_schema` (JSON Schema, surfaced to the model), and a `call`.
9
+ #
10
+ # The tools are wired to siGit's real domain: Repository.visible_to as the
11
+ # read-authorization scope, Repository#blob_at / #search_code as the git read
12
+ # layer, and PullRequestService / CommentService for mutations so the same
13
+ # validations and authorization run regardless of caller (web UI or MCP).
14
+ module Tools
15
+ module_function
16
+
17
+ def all
18
+ @all ||= [
19
+ ListRepositories,
20
+ GetFileContents,
21
+ SearchCode,
22
+ ListPullRequests,
23
+ CreatePullRequest,
24
+ AddIssueComment
25
+ ].freeze
26
+ end
27
+
28
+ def find(name) = all.find { |t| t.tool_name == name }
29
+ def specs = all.map(&:spec)
30
+
31
+ # ----------------------------------------------------------------------- #
32
+
33
+ class Base
34
+ class << self
35
+ attr_reader :tool_name, :description, :schema
36
+
37
+ def name!(value) = (@tool_name = value)
38
+ def describe(value) = (@description = value)
39
+ def input_schema(value) = (@schema = value)
40
+
41
+ def spec
42
+ { "name" => tool_name, "description" => description,
43
+ "inputSchema" => schema || { "type" => "object", "properties" => {} } }
44
+ end
45
+
46
+ def call(args, current_user:) = new(current_user).call(args)
47
+ end
48
+
49
+ def initialize(current_user) = (@current_user = current_user)
50
+ attr_reader :current_user
51
+
52
+ def call(_args) = raise(NotImplementedError)
53
+
54
+ private
55
+
56
+ def require_arg(args, key)
57
+ value = args[key]
58
+ value = value.strip if value.is_a?(String)
59
+ value.presence || raise(Mcp::ToolError, "Missing required argument: #{key}")
60
+ end
61
+
62
+ # Resolves "owner/name" to a Repository the current user may read.
63
+ # Repository.visible_to is siGit's read-authorization scope (public repos
64
+ # plus the user's own), so this never leaks private repos.
65
+ def find_repo!(full_name)
66
+ owner, name = full_name.to_s.split("/", 2)
67
+ raise Mcp::ToolError, "repo must be in 'owner/name' form" unless owner.present? && name.present?
68
+
69
+ repo = Repository.visible_to(current_user)
70
+ .joins(:user)
71
+ .find_by(users: { username: owner }, repositories: { name: name })
72
+ repo || raise(Mcp::ToolError, "Repository not found or not accessible: #{full_name}")
73
+ end
74
+ end
75
+
76
+ # ----------------------------------------------------------------------- #
77
+
78
+ class ListRepositories < Base
79
+ name! "list_repositories"
80
+ describe "List git repositories the authenticated user can access. " \
81
+ "Use this first to discover the 'owner/name' to pass to other tools."
82
+ input_schema(
83
+ "type" => "object",
84
+ "properties" => {
85
+ "query" => { "type" => "string", "description" => "Optional case-insensitive substring of the owner or repo name." },
86
+ "limit" => { "type" => "integer", "description" => "Max repos to return (default 30, max 100).", "default" => 30 }
87
+ }
88
+ )
89
+
90
+ def call(args)
91
+ limit = (args["limit"] || 30).to_i.clamp(1, 100)
92
+ repos = Repository.visible_to(current_user).includes(:user)
93
+ if args["query"].present?
94
+ repos = repos.joins(:user).where(
95
+ "repositories.name ILIKE :q OR users.username ILIKE :q", q: "%#{args['query']}%"
96
+ )
97
+ end
98
+ repos.order(updated_at: :desc).limit(limit).map do |r|
99
+ { "full_name" => r.full_name, "description" => r.description,
100
+ "default_branch" => r.default_branch, "private" => r.is_private, "url" => r.html_url }
101
+ end
102
+ end
103
+ end
104
+
105
+ class GetFileContents < Base
106
+ name! "get_file_contents"
107
+ describe "Read a file's contents in a repository at a given ref (branch, tag, or commit SHA)."
108
+ input_schema(
109
+ "type" => "object",
110
+ "required" => %w[repo path],
111
+ "properties" => {
112
+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
113
+ "path" => { "type" => "string", "description" => "File path relative to the repo root." },
114
+ "ref" => { "type" => "string", "description" => "Branch, tag, or commit SHA. Defaults to the default branch." }
115
+ }
116
+ )
117
+
118
+ def call(args)
119
+ repo = find_repo!(require_arg(args, "repo"))
120
+ path = require_arg(args, "path")
121
+ ref = args["ref"].presence || repo.default_branch
122
+
123
+ content = repo.blob_at(ref, path)
124
+ raise Mcp::ToolError, "File not found: #{path}@#{ref}" if content.nil?
125
+
126
+ { "repo" => repo.full_name, "path" => path, "ref" => ref,
127
+ "size" => content.bytesize, "content" => content }
128
+ end
129
+ end
130
+
131
+ class SearchCode < Base
132
+ name! "search_code"
133
+ describe "Search file contents across a repository and return matching files with line snippets."
134
+ input_schema(
135
+ "type" => "object",
136
+ "required" => %w[repo query],
137
+ "properties" => {
138
+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
139
+ "query" => { "type" => "string", "description" => "Text to search for (fixed string, case-insensitive)." },
140
+ "ref" => { "type" => "string", "description" => "Branch, tag, or SHA to search. Defaults to the default branch." },
141
+ "limit" => { "type" => "integer", "description" => "Max matching lines (default 20, max 100).", "default" => 20 }
142
+ }
143
+ )
144
+
145
+ def call(args)
146
+ repo = find_repo!(require_arg(args, "repo"))
147
+ query = require_arg(args, "query")
148
+ limit = (args["limit"] || 20).to_i.clamp(1, 100)
149
+ ref = args["ref"].presence
150
+
151
+ repo.search_code(query, limit: limit, ref: ref).map do |hit|
152
+ { "path" => hit[:path], "line" => hit[:line], "snippet" => hit[:snippet] }
153
+ end
154
+ end
155
+ end
156
+
157
+ class ListPullRequests < Base
158
+ name! "list_pull_requests"
159
+ describe "List pull requests in a repository, optionally filtered by state."
160
+ input_schema(
161
+ "type" => "object",
162
+ "required" => %w[repo],
163
+ "properties" => {
164
+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
165
+ "state" => { "type" => "string", "enum" => %w[open closed merged all], "default" => "open" }
166
+ }
167
+ )
168
+
169
+ def call(args)
170
+ repo = find_repo!(require_arg(args, "repo"))
171
+ state = args["state"].presence || "open"
172
+ scope = state == "all" ? repo.pull_requests : repo.pull_requests.where(state: state)
173
+ scope.order(number: :desc).limit(50).map do |pr|
174
+ { "number" => pr.number, "title" => pr.title, "state" => pr.state,
175
+ "head" => pr.head_ref, "base" => pr.base_ref, "url" => pr.html_url }
176
+ end
177
+ end
178
+ end
179
+
180
+ class CreatePullRequest < Base
181
+ name! "create_pull_request"
182
+ describe "Open a pull request from a head branch into a base branch."
183
+ input_schema(
184
+ "type" => "object",
185
+ "required" => %w[repo title head base],
186
+ "properties" => {
187
+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
188
+ "title" => { "type" => "string" },
189
+ "head" => { "type" => "string", "description" => "Source branch containing your changes." },
190
+ "base" => { "type" => "string", "description" => "Target branch to merge into." },
191
+ "body" => { "type" => "string", "description" => "PR description (Markdown)." }
192
+ }
193
+ )
194
+
195
+ def call(args)
196
+ repo = find_repo!(require_arg(args, "repo"))
197
+ # Route through the service so validations, authorization, and per-repo
198
+ # numbering run exactly as they do for the web UI.
199
+ pr = PullRequestService.create(
200
+ repository: repo,
201
+ author: current_user,
202
+ title: require_arg(args, "title"),
203
+ head: require_arg(args, "head"),
204
+ base: require_arg(args, "base"),
205
+ body: args["body"]
206
+ )
207
+ { "number" => pr.number, "state" => pr.state, "url" => pr.html_url }
208
+ rescue PullRequestService::Error => e
209
+ raise Mcp::ToolError, e.message
210
+ rescue ActiveRecord::RecordInvalid => e
211
+ raise Mcp::ToolError, e.record.errors.full_messages.to_sentence
212
+ end
213
+ end
214
+
215
+ class AddIssueComment < Base
216
+ name! "add_issue_comment"
217
+ describe "Post a comment on an issue or pull request by its number."
218
+ input_schema(
219
+ "type" => "object",
220
+ "required" => %w[repo number body],
221
+ "properties" => {
222
+ "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
223
+ "number" => { "type" => "integer", "description" => "Issue or PR number." },
224
+ "body" => { "type" => "string", "description" => "Comment body (Markdown)." }
225
+ }
226
+ )
227
+
228
+ def call(args)
229
+ repo = find_repo!(require_arg(args, "repo"))
230
+ number = require_arg(args, "number")
231
+ comment = CommentService.create(
232
+ repository: repo,
233
+ author: current_user,
234
+ number: number,
235
+ body: require_arg(args, "body")
236
+ )
237
+ { "id" => comment.id, "url" => comment.html_url }
238
+ rescue CommentService::Error => e
239
+ raise Mcp::ToolError, e.message
240
+ rescue ActiveRecord::RecordInvalid => e
241
+ raise Mcp::ToolError, e.record.errors.full_messages.to_sentence
242
+ end
243
+ end
244
+ end
245
+end