main
rb 430 lines 16.9 KB
Raw
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 ListIssues,
23 GetIssue,
24 CreateIssue,
25 ListPullRequests,
26 GetPullRequest,
27 CreatePullRequest,
28 AddIssueComment,
29 WebSearch
30 ].freeze
31 end
32
33 def find(name) = all.find { |t| t.tool_name == name }
34 def specs = all.map(&:spec)
35
36 # ----------------------------------------------------------------------- #
37
38 class Base
39 class << self
40 attr_reader :tool_name, :description, :schema
41
42 def name!(value) = (@tool_name = value)
43 def describe(value) = (@description = value)
44 def input_schema(value) = (@schema = value)
45
46 def spec
47 { "name" => tool_name, "description" => description,
48 "inputSchema" => schema || { "type" => "object", "properties" => {} } }
49 end
50
51 def call(args, current_user:) = new(current_user).call(args)
52 end
53
54 def initialize(current_user) = (@current_user = current_user)
55 attr_reader :current_user
56
57 def call(_args) = raise(NotImplementedError)
58
59 private
60
61 def require_arg(args, key)
62 value = args[key]
63 value = value.strip if value.is_a?(String)
64 value.presence || raise(Mcp::ToolError, "Missing required argument: #{key}")
65 end
66
67 # Resolves "owner/name" to a Repository the current user may read.
68 # Repository.visible_to is siGit's read-authorization scope (public repos
69 # plus the user's own), so this never leaks private repos.
70 def find_repo!(full_name)
71 owner, name = full_name.to_s.split("/", 2)
72 raise Mcp::ToolError, "repo must be in 'owner/name' form" unless owner.present? && name.present?
73
74 repo = Repository.visible_to(current_user)
75 .joins(:user)
76 .find_by(users: { username: owner }, repositories: { name: name })
77 repo || raise(Mcp::ToolError, "Repository not found or not accessible: #{full_name}")
78 end
79
80 # Comments serialized oldest-first — the shape both get_issue and
81 # get_pull_request return.
82 def comments_for(commentable)
83 commentable.comments.includes(:user).order(:created_at).map do |c|
84 { "author" => c.author.username, "body" => c.body, "created_at" => c.created_at.iso8601 }
85 end
86 end
87 end
88
89 # ----------------------------------------------------------------------- #
90
91 class ListRepositories < Base
92 name! "list_repositories"
93 describe "List git repositories the authenticated user can access. " \
94 "Use this first to discover the 'owner/name' to pass to other tools."
95 input_schema(
96 "type" => "object",
97 "properties" => {
98 "query" => { "type" => "string", "description" => "Optional case-insensitive substring of the owner or repo name." },
99 "limit" => { "type" => "integer", "description" => "Max repos to return (default 30, max 100).", "default" => 30 }
100 }
101 )
102
103 def call(args)
104 limit = (args["limit"] || 30).to_i.clamp(1, 100)
105 repos = Repository.visible_to(current_user).includes(:user)
106 if args["query"].present?
107 repos = repos.joins(:user).where(
108 "repositories.name ILIKE :q OR users.username ILIKE :q", q: "%#{args['query']}%"
109 )
110 end
111 repos.order(updated_at: :desc).limit(limit).map do |r|
112 { "full_name" => r.full_name, "description" => r.description,
113 "default_branch" => r.default_branch, "private" => r.is_private, "url" => r.html_url }
114 end
115 end
116 end
117
118 class GetFileContents < Base
119 name! "get_file_contents"
120 describe "Read a file's contents in a repository at a given ref (branch, tag, or commit SHA)."
121 input_schema(
122 "type" => "object",
123 "required" => %w[repo path],
124 "properties" => {
125 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
126 "path" => { "type" => "string", "description" => "File path relative to the repo root." },
127 "ref" => { "type" => "string", "description" => "Branch, tag, or commit SHA. Defaults to the default branch." }
128 }
129 )
130
131 def call(args)
132 repo = find_repo!(require_arg(args, "repo"))
133 path = require_arg(args, "path")
134 ref = args["ref"].presence || repo.default_branch
135
136 content = repo.blob_at(ref, path)
137 raise Mcp::ToolError, "File not found: #{path}@#{ref}" if content.nil?
138
139 { "repo" => repo.full_name, "path" => path, "ref" => ref,
140 "size" => content.bytesize, "content" => content }
141 end
142 end
143
144 class SearchCode < Base
145 name! "search_code"
146 describe "Search file contents across a repository and return matching files with line snippets."
147 input_schema(
148 "type" => "object",
149 "required" => %w[repo query],
150 "properties" => {
151 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
152 "query" => { "type" => "string", "description" => "Text to search for (fixed string, case-insensitive)." },
153 "ref" => { "type" => "string", "description" => "Branch, tag, or SHA to search. Defaults to the default branch." },
154 "limit" => { "type" => "integer", "description" => "Max matching lines (default 20, max 100).", "default" => 20 }
155 }
156 )
157
158 def call(args)
159 repo = find_repo!(require_arg(args, "repo"))
160 query = require_arg(args, "query")
161 limit = (args["limit"] || 20).to_i.clamp(1, 100)
162 ref = args["ref"].presence
163
164 repo.search_code(query, limit: limit, ref: ref).map do |hit|
165 { "path" => hit[:path], "line" => hit[:line], "snippet" => hit[:snippet] }
166 end
167 end
168 end
169
170 class ListIssues < Base
171 name! "list_issues"
172 describe "List issues in a repository (requires repo), optionally filtered by state or a title/body search."
173 input_schema(
174 "type" => "object",
175 "required" => %w[repo],
176 "properties" => {
177 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
178 "query" => { "type" => "string", "description" => "Optional case-insensitive substring of the issue title or body." },
179 "state" => { "type" => "string", "enum" => %w[open closed all], "default" => "open" }
180 }
181 )
182
183 def call(args)
184 repo = find_repo!(require_arg(args, "repo"))
185 state = args["state"].presence || "open"
186 scope = state == "all" ? repo.issues : repo.issues.where(state: state)
187 if args["query"].present?
188 scope = scope.where("issues.title ILIKE :q OR issues.body ILIKE :q", q: "%#{args['query']}%")
189 end
190 scope.includes(:user).order(number: :desc).limit(50).map do |issue|
191 { "number" => issue.number, "title" => issue.title, "state" => issue.state,
192 "author" => issue.author.username, "url" => issue.html_url }
193 end
194 end
195 end
196
197 class GetIssue < Base
198 name! "get_issue"
199 describe "Fetch one issue by number (requires repo and number), including its body and comments."
200 input_schema(
201 "type" => "object",
202 "required" => %w[repo number],
203 "properties" => {
204 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
205 "number" => { "type" => "integer", "description" => "Issue number." }
206 }
207 )
208
209 def call(args)
210 repo = find_repo!(require_arg(args, "repo"))
211 number = require_arg(args, "number")
212 issue = repo.issues.find_by(number: number)
213 raise Mcp::ToolError, "No issue ##{number} in #{repo.full_name}." unless issue
214
215 { "number" => issue.number, "title" => issue.title, "state" => issue.state,
216 "author" => issue.author.username, "body" => issue.body, "url" => issue.html_url,
217 "comments" => comments_for(issue) }
218 end
219 end
220
221 class CreateIssue < Base
222 name! "create_issue"
223 describe "Open a new issue in a repository (requires repo and title)."
224 input_schema(
225 "type" => "object",
226 "required" => %w[repo title],
227 "properties" => {
228 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
229 "title" => { "type" => "string" },
230 "body" => { "type" => "string", "description" => "Issue description (Markdown)." }
231 }
232 )
233
234 def call(args)
235 repo = find_repo!(require_arg(args, "repo"))
236 # Route through the service so per-repo numbering (shared with pull
237 # requests) runs exactly as it does for any other caller.
238 issue = IssueService.create(
239 repository: repo,
240 author: current_user,
241 title: require_arg(args, "title"),
242 body: args["body"]
243 )
244 { "number" => issue.number, "state" => issue.state, "url" => issue.html_url }
245 rescue IssueService::Error => e
246 raise Mcp::ToolError, e.message
247 rescue ActiveRecord::RecordInvalid => e
248 raise Mcp::ToolError, e.record.errors.full_messages.to_sentence
249 end
250 end
251
252 class ListPullRequests < Base
253 name! "list_pull_requests"
254 describe "List pull requests in a repository, optionally filtered by state."
255 input_schema(
256 "type" => "object",
257 "required" => %w[repo],
258 "properties" => {
259 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
260 "state" => { "type" => "string", "enum" => %w[open closed merged all], "default" => "open" }
261 }
262 )
263
264 def call(args)
265 repo = find_repo!(require_arg(args, "repo"))
266 state = args["state"].presence || "open"
267 scope = state == "all" ? repo.pull_requests : repo.pull_requests.where(state: state)
268 scope.order(number: :desc).limit(50).map do |pr|
269 { "number" => pr.number, "title" => pr.title, "state" => pr.state,
270 "head" => pr.head_ref, "base" => pr.base_ref, "url" => pr.html_url }
271 end
272 end
273 end
274
275 class GetPullRequest < Base
276 # Unified diffs are included up to this many bytes so one large pull
277 # request cannot blow out the model's context window.
278 DIFF_MAX_BYTES = 100_000
279
280 name! "get_pull_request"
281 describe "Fetch one pull request by number (requires repo and number), " \
282 "including its body, comments, and unified diff (truncated past #{DIFF_MAX_BYTES / 1000} kB)."
283 input_schema(
284 "type" => "object",
285 "required" => %w[repo number],
286 "properties" => {
287 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
288 "number" => { "type" => "integer", "description" => "Pull request number." }
289 }
290 )
291
292 def call(args)
293 repo = find_repo!(require_arg(args, "repo"))
294 number = require_arg(args, "number")
295 pr = repo.pull_requests.find_by(number: number)
296 raise Mcp::ToolError, "No pull request ##{number} in #{repo.full_name}." unless pr
297
298 { "number" => pr.number, "title" => pr.title, "state" => pr.state,
299 "author" => pr.author.username, "head" => pr.head_ref, "base" => pr.base_ref,
300 "body" => pr.body, "url" => pr.html_url,
301 "comments" => comments_for(pr) }.merge(diff_fields(repo, pr))
302 end
303
304 private
305
306 # The three-dot diff of head against base, capped at DIFF_MAX_BYTES.
307 # nil with a note when a ref no longer exists (e.g. the head branch of a
308 # merged pull request was deleted).
309 def diff_fields(repo, pr)
310 diff = repo.diff_between(pr.base_ref, pr.head_ref)
311 return { "diff" => nil, "diff_note" => "Diff unavailable: a branch no longer exists." } if diff.nil?
312 return { "diff" => diff } if diff.bytesize <= DIFF_MAX_BYTES
313
314 { "diff" => diff.byteslice(0, DIFF_MAX_BYTES).scrub(""),
315 "diff_truncated" => true,
316 "diff_note" => "Diff truncated to the first #{DIFF_MAX_BYTES} bytes." }
317 end
318 end
319
320 class CreatePullRequest < Base
321 name! "create_pull_request"
322 describe "Open a pull request from a head branch into a base branch."
323 input_schema(
324 "type" => "object",
325 "required" => %w[repo title head base],
326 "properties" => {
327 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
328 "title" => { "type" => "string" },
329 "head" => { "type" => "string", "description" => "Source branch containing your changes." },
330 "base" => { "type" => "string", "description" => "Target branch to merge into." },
331 "body" => { "type" => "string", "description" => "PR description (Markdown)." }
332 }
333 )
334
335 def call(args)
336 repo = find_repo!(require_arg(args, "repo"))
337 # Route through the service so validations, authorization, and per-repo
338 # numbering run exactly as they do for the web UI.
339 pr = PullRequestService.create(
340 repository: repo,
341 author: current_user,
342 title: require_arg(args, "title"),
343 head: require_arg(args, "head"),
344 base: require_arg(args, "base"),
345 body: args["body"]
346 )
347 { "number" => pr.number, "state" => pr.state, "url" => pr.html_url }
348 rescue PullRequestService::Error => e
349 raise Mcp::ToolError, e.message
350 rescue ActiveRecord::RecordInvalid => e
351 raise Mcp::ToolError, e.record.errors.full_messages.to_sentence
352 end
353 end
354
355 class AddIssueComment < Base
356 name! "add_issue_comment"
357 describe "Post a comment on an issue or pull request by its number."
358 input_schema(
359 "type" => "object",
360 "required" => %w[repo number body],
361 "properties" => {
362 "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." },
363 "number" => { "type" => "integer", "description" => "Issue or PR number." },
364 "body" => { "type" => "string", "description" => "Comment body (Markdown)." }
365 }
366 )
367
368 def call(args)
369 repo = find_repo!(require_arg(args, "repo"))
370 number = require_arg(args, "number")
371 comment = CommentService.create(
372 repository: repo,
373 author: current_user,
374 number: number,
375 body: require_arg(args, "body")
376 )
377 { "id" => comment.id, "url" => comment.html_url }
378 rescue CommentService::Error => e
379 raise Mcp::ToolError, e.message
380 rescue ActiveRecord::RecordInvalid => e
381 raise Mcp::ToolError, e.record.errors.full_messages.to_sentence
382 end
383 end
384
385 class WebSearch < Base
386 # Per-user sliding window: at most RATE_LIMIT searches per RATE_WINDOW.
387 # Backed by Rails.cache (solid_cache in production) because the app has
388 # no rack-attack or Redis counter infrastructure to reuse.
389 RATE_LIMIT = 60
390 RATE_WINDOW = 1.hour
391
392 name! "web_search"
393 describe "Search the web and return matching pages as title, URL, and snippet. " \
394 "Use this to find pages to read when you don't already know the URL."
395 input_schema(
396 "type" => "object",
397 "required" => %w[query],
398 "properties" => {
399 "query" => { "type" => "string", "description" => "The search query." },
400 "count" => { "type" => "integer", "description" => "Number of results (default 5, max 10).", "default" => 5 }
401 }
402 )
403
404 def call(args)
405 query = require_arg(args, "query")
406 count = (args["count"] || 5).to_i.clamp(1, 10)
407 check_rate_limit!
408
409 WebSearchService.search(query, count: count)
410 rescue WebSearchService::Error => e
411 raise Mcp::ToolError, e.message
412 end
413
414 private
415
416 def check_rate_limit!
417 key = "mcp:web_search:user:#{current_user.id}"
418 cutoff = Time.current.to_i - RATE_WINDOW.to_i
419 stamps = Array(Rails.cache.read(key)).select { |t| t > cutoff }
420
421 if stamps.size >= RATE_LIMIT
422 raise Mcp::ToolError,
423 "Rate limit exceeded: at most #{RATE_LIMIT} web searches per hour per user. Try again later."
424 end
425
426 Rails.cache.write(key, stamps + [ Time.current.to_i ], expires_in: RATE_WINDOW)
427 end
428 end
429 end
430 end