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