feat(mcp): MCP server at /api/v1/mcp wired to real models

Implements the Model Context Protocol endpoint as a stateless Streamable HTTP JSON-RPC transport in the monolith, reusing siGit's auth and authorization. Six tools over a data-driven registry: list_repositories, get_file_contents, search_code, list_pull_requests, create_pull_request, add_issue_comment. - Transport/dispatch: Api::V1::McpController + Mcp::Server/Tools/ToolError. Tool failures are returned in-band (isError: true), not protocol errors. Bearer auth validated against smbCloud (same path as Api::BaseController), with an OAuth-2.1 upgrade TODO. - Read layer: Repository.visible_to scope, Repository#blob_at (git show), Repository#search_code + GitRepositoryService.search_code (git grep). - Pull requests / issues / comments built from scratch (the app had none): models, migrations, and PullRequestService / CommentService so all mutations run the same validations, authorization, and per-repo numbering. - Specs (RSpec, added to the project): MCP request specs (handshake, tools/list, successful call, 401, in-band isError) + service specs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 19:15 UTC a05a53307897a96d151aad76d097c02f8871bb16
26 files changed +1149 -1
.env.example
+5
index 59514a8..e623caf 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,11 @@ ONDE_CLOUD_APP_SECRET=your-onde-app-secret-here # SIGITSI_REPOS_PATH=/var/sigitsi/repos +# Public base URL of this siGit install. Used to build absolute links (repo, +# pull request, and comment URLs) for API/MCP clients that have no request +# context of their own. Defaults to https://sigit.si when not set. +# SIGITSI_URL=https://sigit.si + # ----------------------------------------------------------------------------- # Rails
.rspec
+1
new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper
Gemfile
+3
index 47b3d71..411f0ac 100644 --- a/Gemfile +++ b/Gemfile @@ -74,6 +74,9 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false + + # RSpec for request/model specs [https://github.com/rspec/rspec-rails] + gem "rspec-rails", "~> 8.0" end group :development do
Gemfile.lock
+19
index 2f041e0..fae165b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -99,6 +99,7 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) dotenv (3.2.0) dotenv-rails (3.2.0) dotenv (= 3.2.0) @@ -262,6 +263,23 @@ GEM reline (0.6.3) io-console (~> 0.5) rouge (4.7.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) rubocop (1.88.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -373,6 +391,7 @@ DEPENDENCIES rails (~> 8.1.3) redcarpet (~> 3.6) rouge (~> 4.5) + rspec-rails (~> 8.0) rubocop-rails-omakase smbcloud-auth (~> 0.4.5) solid_cable
app/controllers/api/v1/mcp_controller.rb
+81
new file mode 100644 index 0000000..aa45270 --- /dev/null +++ b/app/controllers/api/v1/mcp_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Api + module V1 + # Streamable-HTTP MCP transport for siGit, mounted at /api/v1/mcp. + # + # The Model Context Protocol is JSON-RPC 2.0. A client POSTs one message + # (or a batch) per request; we answer requests with a JSON-RPC response and + # acknowledge notifications/responses with 202. This server is stateless + # (tool-only, no server->client streaming), which is a valid MCP transport + # mode, so we don't issue Mcp-Session-Id or implement the GET SSE stream. + class McpController < ActionController::API + before_action :authenticate! + + # POST /api/v1/mcp + def handle + payload = parse_body + server = ::Mcp::Server.new(current_user: @current_user) + + case payload + when Array # JSON-RPC batch + responses = payload.map { |msg| server.handle(msg) }.compact + responses.empty? ? head(:accepted) : render(json: responses) + when Hash + response = server.handle(payload) + response.nil? ? head(:accepted) : render(json: response) + else + render json: rpc_error(nil, -32700, "Parse error"), status: :bad_request + end + rescue JSON::ParserError + render json: rpc_error(nil, -32700, "Parse error"), status: :bad_request + end + + # GET /api/v1/mcp — server->client SSE stream. Unused by this stateless + # server; advertise that we don't offer one. + def stream + head :method_not_allowed + end + + private + + def parse_body + raw = request.body.read + raw.present? ? JSON.parse(raw) : nil + end + + # Bearer-token auth. siGit's API is token-based against smbCloud: the same + # `Authorization: Bearer <smbCloud access token>` the desktop app uses + # (see Api::BaseController#authenticate_token!). We validate it against + # smbCloud and resolve/mirror the local User. See README for the OAuth 2.1 + # upgrade path that lets clients authorize via the standard /mcp flow. + def authenticate! + token = request.authorization.to_s[/\ABearer (.+)\z/, 1] + @current_user = token && resolve_user(token) + return if @current_user + + # Per MCP/OAuth, a protected resource signals how to authenticate. + response.set_header( + "WWW-Authenticate", + %(Bearer realm="sigit", error="invalid_token", ) + + %(resource_metadata="#{request.base_url}/.well-known/oauth-protected-resource") + ) + render json: rpc_error(nil, -32001, "Unauthorized"), status: :unauthorized + end + + # Validates the smbCloud access token and upserts the local mirror, exactly + # as the rest of the API does. Returns the User or nil when the token is + # missing/invalid/unverifiable. + def resolve_user(token) + profile = SmbcloudAuthService.me(access_token: token) + User.find_or_create_from_smbcloud(profile, access_token: token) + rescue SmbcloudAuthService::AuthenticationError + nil + end + + def rpc_error(id, code, message) + { jsonrpc: "2.0", id: id, error: { code: code, message: message } } + end + end + end +end
app/models/comment.rb
+17
new file mode 100644 index 0000000..31c2b53 --- /dev/null +++ b/app/models/comment.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# A comment on an issue or pull request (polymorphic `commentable`). +class Comment < ApplicationRecord + belongs_to :commentable, polymorphic: true + belongs_to :user # author + + validates :body, presence: true + + def author + user + end + + def html_url + "#{commentable.html_url}#comment-#{id}" + end +end
app/models/issue.rb
+26
new file mode 100644 index 0000000..3beb490 --- /dev/null +++ b/app/models/issue.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# A repository issue, numbered per-repository. Comments attach polymorphically +# so the same machinery serves issues and pull requests. +class Issue < ApplicationRecord + STATES = %w[open closed].freeze + + belongs_to :repository + belongs_to :user # author + has_many :comments, as: :commentable, dependent: :destroy + + scope :open, -> { where(state: "open") } + scope :closed, -> { where(state: "closed") } + + validates :number, presence: true, uniqueness: { scope: :repository_id } + validates :title, presence: true + validates :state, inclusion: { in: STATES } + + def author + user + end + + def html_url + "#{repository.html_url}/issues/#{number}" + end +end
app/models/pull_request.rb
+38
new file mode 100644 index 0000000..0b6ffb6 --- /dev/null +++ b/app/models/pull_request.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# A request to merge one branch (head) into another (base) within a repository. +# Numbered per-repository, like GitHub. Created through PullRequestService so the +# same validations and authorization run regardless of caller (web UI or MCP). +class PullRequest < ApplicationRecord + STATES = %w[open closed merged].freeze + + belongs_to :repository + belongs_to :user # author + has_many :comments, as: :commentable, dependent: :destroy + + scope :open, -> { where(state: "open") } + scope :closed, -> { where(state: "closed") } + scope :merged, -> { where(state: "merged") } + + validates :number, presence: true, uniqueness: { scope: :repository_id } + validates :title, presence: true + validates :head_ref, presence: true + validates :base_ref, presence: true + validates :state, inclusion: { in: STATES } + validate :head_differs_from_base + + def author + user + end + + def html_url + "#{repository.html_url}/pull/#{number}" + end + + private + + def head_differs_from_base + return if head_ref.blank? || base_ref.blank? + errors.add(:head_ref, "must be different from the base branch") if head_ref == base_ref + end +end
app/models/repository.rb
+31
index 00e941e..08a0d55 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -4,6 +4,8 @@ class Repository < ApplicationRecord belongs_to :user has_many :stars, dependent: :destroy has_many :stargazers, through: :stars, source: :user + has_many :pull_requests, dependent: :destroy + has_many :issues, dependent: :destroy scope :models, -> { where(kind: "model") } scope :code, -> { where(kind: "code") } @@ -31,6 +33,35 @@ class Repository < ApplicationRecord "#{user.username}/#{name}" end + # Absolute web URL for this repository, used when building links for API/MCP + # clients that have no request context of their own. + def self.site_url + ENV.fetch("SIGITSI_URL", "https://sigit.si") + end + + def html_url + "#{self.class.site_url}/#{full_name}" + end + + # True if +user+ may push to / open changes against this repo. siGit repos + # have a single owner and no collaborators yet, so write == ownership. + def writable_by?(user) + user.present? && user_id == user.id + end + + # Read a file's contents at +ref+ (branch, tag, or SHA). Returns the content + # String, or nil when the path doesn't exist at that ref. This is the git read + # layer the MCP `get_file_contents` tool sits on. + def blob_at(ref, path) + GitRepositoryService.file_content(disk_path, ref, path) + end + + # Search file contents at +ref+ (default branch when omitted). Returns an array + # of { path:, line:, snippet: } hashes, capped at +limit+. + def search_code(query, limit: 20, ref: nil) + GitRepositoryService.search_code(disk_path, ref || default_branch, query, limit: limit) + end + def git_path disk_path end
app/services/comment_service.rb
+21
new file mode 100644 index 0000000..f03dd36 --- /dev/null +++ b/app/services/comment_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Posts comments on issues or pull requests. Routing all comment creation through +# here keeps authorization and validation consistent across callers. +class CommentService + class Error < StandardError; end + class NotFound < Error; end + + # Posts a comment authored by +author+ on the issue or pull request numbered + # +number+ in +repository+. Issues and PRs share a number space, so the number + # resolves to exactly one. Raises NotFound when nothing matches and + # ActiveRecord::RecordInvalid on validation failure. Read access to the repo is + # enforced by the caller (the MCP layer only resolves repos the user can read). + def self.create(repository:, author:, number:, body:) + target = repository.issues.find_by(number: number) || + repository.pull_requests.find_by(number: number) + raise NotFound, "No issue or pull request ##{number} in #{repository.full_name}." unless target + + target.comments.create!(user: author, body: body) + end +end
app/services/git_repository_service.rb
+27
index edb4479..03ec44c 100644 --- a/app/services/git_repository_service.rb +++ b/app/services/git_repository_service.rb @@ -150,6 +150,33 @@ class GitRepositoryService nil end + # Search file contents on +branch+ for +query+ using `git grep`. Returns an + # array of { path:, line:, snippet: } hashes, one per matching line, capped at + # +limit+. The search is fixed-string and case-insensitive; matching binary + # files are skipped. Returns [] when nothing matches or the branch is unknown. + def self.search_code(path, branch, query, limit: 20) + return [] if query.to_s.empty? + + out, _err, status = Open3.capture3( + "git", "--git-dir", path, "grep", + "-I", # skip binary files + "--fixed-strings", # treat query literally, not as a regex + "--ignore-case", + "--line-number", + "--no-color", + "-e", query.to_s, + branch + ) + # git grep exits 1 with no output when there are no matches — not an error. + return [] unless status.success? || status.exitstatus == 1 + + out.each_line.first(limit).map do |line| + # Format with a tree-ish is "<branch>:<path>:<lineno>:<text>". + _ref, file_path, lineno, snippet = line.chomp.split(":", 4) + { path: file_path, line: lineno.to_i, snippet: snippet.to_s.strip } + end + end + def self.branch_exists?(path, branch) _out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch) status.success?
app/services/mcp/server.rb
+76
new file mode 100644 index 0000000..6e7f1eb --- /dev/null +++ b/app/services/mcp/server.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Mcp + # Protocol-level dispatcher: maps a single JSON-RPC message to a result. + # Knows nothing about HTTP — that's the controller's job. + class Server + PROTOCOL_VERSION = "2025-06-18" + SUPPORTED_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze + + def initialize(current_user:) + @current_user = current_user + end + + # message: a parsed JSON-RPC object. Returns a response Hash, or nil for + # notifications (no id) which must not produce a reply. + def handle(message) + id = message["id"] + method = message["method"] + params = message["params"] || {} + + case method + when "initialize" then result(id, initialize_result(params)) + when "ping" then result(id, {}) + when "tools/list" then result(id, { "tools" => Tools.specs }) + when "tools/call" then call_tool(id, params) + when "resources/list" then result(id, { "resources" => [] }) + when "prompts/list" then result(id, { "prompts" => [] }) + when %r{\Anotifications/} then nil # initialized, cancelled, etc. + else error(id, -32601, "Method not found: #{method}") + end + rescue => e + Rails.logger.error("[mcp] #{e.class}: #{e.message}") + error(id, -32603, "Internal error") + end + + private + + def initialize_result(params) + requested = params["protocolVersion"] + version = SUPPORTED_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSION + { + "protocolVersion" => version, + "capabilities" => { "tools" => { "listChanged" => false } }, + "serverInfo" => { "name" => "sigit", "title" => "siGit Code", "version" => "0.1.0" }, + "instructions" => "Tools for siGit-hosted git repositories: list repos, " \ + "read files, search code, and open or comment on pull requests and issues." + } + end + + def call_tool(id, params) + tool = Tools.find(params["name"]) + return error(id, -32602, "Unknown tool: #{params['name']}") unless tool + + output = tool.call(params["arguments"] || {}, current_user: @current_user) + result(id, { "content" => [ text_block(output) ], "isError" => false }) + rescue Mcp::ToolError => e + # Tool failures are reported in-band (isError: true) so the model can see + # and recover from them, not as JSON-RPC protocol errors. + result(id, { "content" => [ text_block(e.message) ], "isError" => true }) + end + + def text_block(value) + text = value.is_a?(String) ? value : JSON.pretty_generate(value) + { "type" => "text", "text" => text } + end + + def result(id, value) + return nil if id.nil? # was a notification + { "jsonrpc" => "2.0", "id" => id, "result" => value } + end + + def error(id, code, message) + { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } } + end + end +end
app/services/mcp/tool_error.rb
+7
new file mode 100644 index 0000000..23c3042 --- /dev/null +++ b/app/services/mcp/tool_error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Mcp + # Raised by a tool when it can't complete (bad args, not found, not authorized). + # The server reports these to the model in-band as an isError tool result. + class ToolError < StandardError; end +end
app/services/mcp/tools.rb
+245
new file mode 100644 index 0000000..cc5d2a4 --- /dev/null +++ b/app/services/mcp/tools.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +module Mcp + # Tool registry + the tool implementations. + # + # Each tool is a Base subclass that declares a `tool_name`, a `description` + # (this is what the model reads to decide when to use it — keep it concrete), + # an `input_schema` (JSON Schema, surfaced to the model), and a `call`. + # + # The tools are wired to siGit's real domain: Repository.visible_to as the + # read-authorization scope, Repository#blob_at / #search_code as the git read + # layer, and PullRequestService / CommentService for mutations so the same + # validations and authorization run regardless of caller (web UI or MCP). + module Tools + module_function + + def all + @all ||= [ + ListRepositories, + GetFileContents, + SearchCode, + ListPullRequests, + CreatePullRequest, + AddIssueComment + ].freeze + end + + def find(name) = all.find { |t| t.tool_name == name } + def specs = all.map(&:spec) + + # ----------------------------------------------------------------------- # + + class Base + class << self + attr_reader :tool_name, :description, :schema + + def name!(value) = (@tool_name = value) + def describe(value) = (@description = value) + def input_schema(value) = (@schema = value) + + def spec + { "name" => tool_name, "description" => description, + "inputSchema" => schema || { "type" => "object", "properties" => {} } } + end + + def call(args, current_user:) = new(current_user).call(args) + end + + def initialize(current_user) = (@current_user = current_user) + attr_reader :current_user + + def call(_args) = raise(NotImplementedError) + + private + + def require_arg(args, key) + value = args[key] + value = value.strip if value.is_a?(String) + value.presence || raise(Mcp::ToolError, "Missing required argument: #{key}") + end + + # Resolves "owner/name" to a Repository the current user may read. + # Repository.visible_to is siGit's read-authorization scope (public repos + # plus the user's own), so this never leaks private repos. + def find_repo!(full_name) + owner, name = full_name.to_s.split("/", 2) + raise Mcp::ToolError, "repo must be in 'owner/name' form" unless owner.present? && name.present? + + repo = Repository.visible_to(current_user) + .joins(:user) + .find_by(users: { username: owner }, repositories: { name: name }) + repo || raise(Mcp::ToolError, "Repository not found or not accessible: #{full_name}") + end + end + + # ----------------------------------------------------------------------- # + + class ListRepositories < Base + name! "list_repositories" + describe "List git repositories the authenticated user can access. " \ + "Use this first to discover the 'owner/name' to pass to other tools." + input_schema( + "type" => "object", + "properties" => { + "query" => { "type" => "string", "description" => "Optional case-insensitive substring of the owner or repo name." }, + "limit" => { "type" => "integer", "description" => "Max repos to return (default 30, max 100).", "default" => 30 } + } + ) + + def call(args) + limit = (args["limit"] || 30).to_i.clamp(1, 100) + repos = Repository.visible_to(current_user).includes(:user) + if args["query"].present? + repos = repos.joins(:user).where( + "repositories.name ILIKE :q OR users.username ILIKE :q", q: "%#{args['query']}%" + ) + end + repos.order(updated_at: :desc).limit(limit).map do |r| + { "full_name" => r.full_name, "description" => r.description, + "default_branch" => r.default_branch, "private" => r.is_private, "url" => r.html_url } + end + end + end + + class GetFileContents < Base + name! "get_file_contents" + describe "Read a file's contents in a repository at a given ref (branch, tag, or commit SHA)." + input_schema( + "type" => "object", + "required" => %w[repo path], + "properties" => { + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." }, + "path" => { "type" => "string", "description" => "File path relative to the repo root." }, + "ref" => { "type" => "string", "description" => "Branch, tag, or commit SHA. Defaults to the default branch." } + } + ) + + def call(args) + repo = find_repo!(require_arg(args, "repo")) + path = require_arg(args, "path") + ref = args["ref"].presence || repo.default_branch + + content = repo.blob_at(ref, path) + raise Mcp::ToolError, "File not found: #{path}@#{ref}" if content.nil? + + { "repo" => repo.full_name, "path" => path, "ref" => ref, + "size" => content.bytesize, "content" => content } + end + end + + class SearchCode < Base + name! "search_code" + describe "Search file contents across a repository and return matching files with line snippets." + input_schema( + "type" => "object", + "required" => %w[repo query], + "properties" => { + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." }, + "query" => { "type" => "string", "description" => "Text to search for (fixed string, case-insensitive)." }, + "ref" => { "type" => "string", "description" => "Branch, tag, or SHA to search. Defaults to the default branch." }, + "limit" => { "type" => "integer", "description" => "Max matching lines (default 20, max 100).", "default" => 20 } + } + ) + + def call(args) + repo = find_repo!(require_arg(args, "repo")) + query = require_arg(args, "query") + limit = (args["limit"] || 20).to_i.clamp(1, 100) + ref = args["ref"].presence + + repo.search_code(query, limit: limit, ref: ref).map do |hit| + { "path" => hit[:path], "line" => hit[:line], "snippet" => hit[:snippet] } + end + end + end + + class ListPullRequests < Base + name! "list_pull_requests" + describe "List pull requests in a repository, optionally filtered by state." + input_schema( + "type" => "object", + "required" => %w[repo], + "properties" => { + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." }, + "state" => { "type" => "string", "enum" => %w[open closed merged all], "default" => "open" } + } + ) + + def call(args) + repo = find_repo!(require_arg(args, "repo")) + state = args["state"].presence || "open" + scope = state == "all" ? repo.pull_requests : repo.pull_requests.where(state: state) + scope.order(number: :desc).limit(50).map do |pr| + { "number" => pr.number, "title" => pr.title, "state" => pr.state, + "head" => pr.head_ref, "base" => pr.base_ref, "url" => pr.html_url } + end + end + end + + class CreatePullRequest < Base + name! "create_pull_request" + describe "Open a pull request from a head branch into a base branch." + input_schema( + "type" => "object", + "required" => %w[repo title head base], + "properties" => { + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." }, + "title" => { "type" => "string" }, + "head" => { "type" => "string", "description" => "Source branch containing your changes." }, + "base" => { "type" => "string", "description" => "Target branch to merge into." }, + "body" => { "type" => "string", "description" => "PR description (Markdown)." } + } + ) + + def call(args) + repo = find_repo!(require_arg(args, "repo")) + # Route through the service so validations, authorization, and per-repo + # numbering run exactly as they do for the web UI. + pr = PullRequestService.create( + repository: repo, + author: current_user, + title: require_arg(args, "title"), + head: require_arg(args, "head"), + base: require_arg(args, "base"), + body: args["body"] + ) + { "number" => pr.number, "state" => pr.state, "url" => pr.html_url } + rescue PullRequestService::Error => e + raise Mcp::ToolError, e.message + rescue ActiveRecord::RecordInvalid => e + raise Mcp::ToolError, e.record.errors.full_messages.to_sentence + end + end + + class AddIssueComment < Base + name! "add_issue_comment" + describe "Post a comment on an issue or pull request by its number." + input_schema( + "type" => "object", + "required" => %w[repo number body], + "properties" => { + "repo" => { "type" => "string", "description" => "Repository in 'owner/name' form." }, + "number" => { "type" => "integer", "description" => "Issue or PR number." }, + "body" => { "type" => "string", "description" => "Comment body (Markdown)." } + } + ) + + def call(args) + repo = find_repo!(require_arg(args, "repo")) + number = require_arg(args, "number") + comment = CommentService.create( + repository: repo, + author: current_user, + number: number, + body: require_arg(args, "body") + ) + { "id" => comment.id, "url" => comment.html_url } + rescue CommentService::Error => e + raise Mcp::ToolError, e.message + rescue ActiveRecord::RecordInvalid => e + raise Mcp::ToolError, e.record.errors.full_messages.to_sentence + end + end + end +end
app/services/pull_request_service.rb
+42
new file mode 100644 index 0000000..39fdc0c --- /dev/null +++ b/app/services/pull_request_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Creates pull requests. All PR creation — web UI or MCP — goes through here so +# authorization, branch validation, and per-repo numbering behave identically. +class PullRequestService + class Error < StandardError; end + class NotAuthorized < Error; end + + # Opens a pull request from +head+ into +base+ on +repository+, authored by + # +author+. Raises NotAuthorized if the author can't write the repo, Error if a + # branch is missing, and ActiveRecord::RecordInvalid on validation failure. + def self.create(repository:, author:, title:, head:, base:, body: nil) + unless repository.writable_by?(author) + raise NotAuthorized, "You don't have permission to open a pull request on #{repository.full_name}." + end + + [ head, base ].each do |ref| + next if GitRepositoryService.branch_exists?(repository.disk_path, ref) + raise Error, "Branch not found: #{ref}" + end + + repository.with_lock do + repository.pull_requests.create!( + user: author, + number: next_number(repository), + title: title, + head_ref: head, + base_ref: base, + body: body.to_s, + state: "open" + ) + end + end + + # Issues and pull requests share one per-repo number space (as on GitHub), so a + # number resolves to exactly one of them. Call inside a repository row lock. + def self.next_number(repository) + [ repository.pull_requests.maximum(:number) || 0, + repository.issues.maximum(:number) || 0 ].max + 1 + end + private_class_method :next_number +end
config/routes.rb
+4
index 3dcd90f..8c828ce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,6 +84,10 @@ Rails.application.routes.draw do post "billing/checkout", to: "billing#checkout" post "billing/portal", to: "billing#portal" + # Model Context Protocol endpoint (Streamable HTTP, stateless JSON-RPC). + post "mcp", to: "mcp#handle" # JSON-RPC messages from the client + get "mcp", to: "mcp#stream" # optional server->client SSE (returns 405 here) + # siGit Code Cloud Sessions — persisted, resumable conversations. resources :cloud_sessions, path: "sessions", only: %i[index create show update destroy] do
db/migrate/20250101000008_create_pull_requests.rb
+19
new file mode 100644 index 0000000..3cf4be9 --- /dev/null +++ b/db/migrate/20250101000008_create_pull_requests.rb @@ -0,0 +1,19 @@ +class CreatePullRequests < ActiveRecord::Migration[8.1] + def change + create_table :pull_requests do |t| + t.references :repository, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true # author + t.integer :number, null: false + t.string :title, null: false + t.text :body + t.string :state, null: false, default: "open" + t.string :head_ref, null: false + t.string :base_ref, null: false + t.datetime :merged_at + + t.timestamps + end + + add_index :pull_requests, [ :repository_id, :number ], unique: true + end +end
db/migrate/20250101000009_create_issues.rb
+17
new file mode 100644 index 0000000..dc599bc --- /dev/null +++ b/db/migrate/20250101000009_create_issues.rb @@ -0,0 +1,17 @@ +class CreateIssues < ActiveRecord::Migration[8.1] + def change + create_table :issues do |t| + t.references :repository, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true # author + t.integer :number, null: false + t.string :title, null: false + t.text :body + t.string :state, null: false, default: "open" + t.datetime :closed_at + + t.timestamps + end + + add_index :issues, [ :repository_id, :number ], unique: true + end +end
db/migrate/20250101000010_create_comments.rb
+11
new file mode 100644 index 0000000..67404cb --- /dev/null +++ b/db/migrate/20250101000010_create_comments.rb @@ -0,0 +1,11 @@ +class CreateComments < ActiveRecord::Migration[8.1] + def change + create_table :comments do |t| + t.references :commentable, polymorphic: true, null: false + t.references :user, null: false, foreign_key: true # author + t.text :body, null: false + + t.timestamps + end + end +end
db/schema.rb
+49 -1
index 127f5cb..182a008 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_01_01_000007) do +ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -45,6 +45,49 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000007) do t.index ["user_id"], name: "index_cloud_usages_on_user_id" end + create_table "comments", force: :cascade do |t| + t.text "body", null: false + t.bigint "commentable_id", null: false + t.string "commentable_type", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable" + t.index ["user_id"], name: "index_comments_on_user_id" + end + + create_table "issues", force: :cascade do |t| + t.text "body" + t.datetime "closed_at" + t.datetime "created_at", null: false + t.integer "number", null: false + t.bigint "repository_id", null: false + t.string "state", default: "open", null: false + t.string "title", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["repository_id", "number"], name: "index_issues_on_repository_id_and_number", unique: true + t.index ["repository_id"], name: "index_issues_on_repository_id" + t.index ["user_id"], name: "index_issues_on_user_id" + end + + create_table "pull_requests", force: :cascade do |t| + t.string "base_ref", null: false + t.text "body" + t.datetime "created_at", null: false + t.string "head_ref", null: false + t.datetime "merged_at" + t.integer "number", null: false + t.bigint "repository_id", null: false + t.string "state", default: "open", null: false + t.string "title", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["repository_id", "number"], name: "index_pull_requests_on_repository_id_and_number", unique: true + t.index ["repository_id"], name: "index_pull_requests_on_repository_id" + t.index ["user_id"], name: "index_pull_requests_on_user_id" + end + create_table "repositories", force: :cascade do |t| t.datetime "created_at", null: false t.string "default_branch", default: "main", null: false @@ -113,6 +156,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000007) do add_foreign_key "cloud_messages", "cloud_sessions" add_foreign_key "cloud_sessions", "users" add_foreign_key "cloud_usages", "users" + add_foreign_key "comments", "users" + add_foreign_key "issues", "repositories" + add_foreign_key "issues", "users" + add_foreign_key "pull_requests", "repositories" + add_foreign_key "pull_requests", "users" add_foreign_key "repositories", "users" add_foreign_key "ssh_keys", "users" add_foreign_key "stars", "repositories"
script/smoke.sh
+31
new file mode 100755 index 0000000..4005a19 --- /dev/null +++ b/script/smoke.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Smoke-test the siGit MCP endpoint with raw JSON-RPC over curl. +# Usage: BASE=https://sigit.si TOKEN=<pat> ./script/smoke.sh +set -euo pipefail + +BASE="${BASE:-http://localhost:3000}" +URL="$BASE/api/v1/mcp" +TOKEN="${TOKEN:?set TOKEN to a siGit personal access token}" + +post() { + curl -sS -X POST "$URL" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d "$1" + echo +} + +echo "== initialize ==" +post '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' + +echo "== notifications/initialized (expect empty 202) ==" +curl -sS -o /dev/null -w "HTTP %{http_code}\n" -X POST "$URL" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +echo "== tools/list ==" +post '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + +echo "== tools/call list_repositories ==" +post '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_repositories","arguments":{"limit":5}}}'
spec/rails_helper.rb
+72
new file mode 100644 index 0000000..ef75d46 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,72 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [ + Rails.root.join('spec/fixtures') + ] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end
spec/requests/api/v1/mcp_spec.rb
+112
new file mode 100644 index 0000000..56628a5 --- /dev/null +++ b/spec/requests/api/v1/mcp_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Api::V1::Mcp", type: :request do + let(:user) do + User.create!(smbcloud_id: 4242, email: "mcp-tester@example.com", username: "mcptester") + end + + let!(:repository) do + user.repositories.create!( + name: "demo", + kind: "code", + default_branch: "main", + disk_path: GitRepositoryService.repo_path("mcptester", "demo") + ) + end + + let(:token) { "valid-access-token" } + let(:auth_headers) do + { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" } + end + + # Bearer auth is validated against smbCloud (the same path the rest of the API + # uses). We stub the controller's auth seam so a valid token resolves to our + # test user — this also keeps specs off the smbcloud-auth native extension, + # which can't be dlopen'd in every dev environment. + before do + allow_any_instance_of(Api::V1::McpController) + .to receive(:resolve_user) { |_controller, presented| presented == token ? user : nil } + end + + def rpc(body) + post "/api/v1/mcp", params: body.to_json, headers: auth_headers + response.parsed_body + end + + describe "initialize handshake" do + it "echoes the protocol version and advertises the server" do + result = rpc({ + "jsonrpc" => "2.0", "id" => 1, "method" => "initialize", + "params" => { "protocolVersion" => "2025-06-18", "capabilities" => {}, + "clientInfo" => { "name" => "rspec", "version" => "0" } } + })["result"] + + expect(response).to have_http_status(:ok) + expect(result["protocolVersion"]).to eq("2025-06-18") + expect(result.dig("serverInfo", "name")).to eq("sigit") + expect(result.dig("capabilities", "tools")).to include("listChanged" => false) + end + end + + describe "notifications/initialized" do + it "is acknowledged with an empty 202 (no body)" do + post "/api/v1/mcp", + params: { "jsonrpc" => "2.0", "method" => "notifications/initialized" }.to_json, + headers: auth_headers + expect(response).to have_http_status(:accepted) + expect(response.body).to be_blank + end + end + + describe "tools/list" do + it "lists all six tools" do + tools = rpc({ "jsonrpc" => "2.0", "id" => 2, "method" => "tools/list" })["result"]["tools"] + names = tools.map { |t| t["name"] } + + expect(names).to contain_exactly( + "list_repositories", "get_file_contents", "search_code", + "list_pull_requests", "create_pull_request", "add_issue_comment" + ) + expect(tools).to all(include("description", "inputSchema")) + end + end + + describe "tools/call" do + it "runs a tool successfully (list_repositories)" do + result = rpc({ + "jsonrpc" => "2.0", "id" => 3, "method" => "tools/call", + "params" => { "name" => "list_repositories", "arguments" => { "limit" => 5 } } + })["result"] + + expect(result["isError"]).to be(false) + text = result["content"].first["text"] + expect(text).to include("mcptester/demo") + end + + it "reports a tool failure in-band as isError (not a protocol error)" do + result = rpc({ + "jsonrpc" => "2.0", "id" => 4, "method" => "tools/call", + "params" => { "name" => "get_file_contents", + "arguments" => { "repo" => "nobody/missing", "path" => "README.md" } } + })["result"] + + expect(response).to have_http_status(:ok) + expect(result["isError"]).to be(true) + expect(result["content"].first["text"]).to match(/not found or not accessible/i) + end + end + + describe "authentication" do + it "returns a JSON-RPC 401 with a WWW-Authenticate challenge when the token is missing" do + post "/api/v1/mcp", + params: { "jsonrpc" => "2.0", "id" => 5, "method" => "tools/list" }.to_json, + headers: { "Content-Type" => "application/json" } + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body.dig("error", "code")).to eq(-32_001) + expect(response.headers["WWW-Authenticate"]).to include("resource_metadata=") + end + end +end
spec/services/comment_service_spec.rb
+43
new file mode 100644 index 0000000..e06be13 --- /dev/null +++ b/spec/services/comment_service_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CommentService do + let(:owner) { User.create!(smbcloud_id: 8001, email: "owner@example.com", username: "owner") } + let(:author) { User.create!(smbcloud_id: 8002, email: "author@example.com", username: "author") } + let(:repo) do + owner.repositories.create!( + name: "app", kind: "code", default_branch: "main", + disk_path: GitRepositoryService.repo_path("owner", "app") + ) + end + + it "comments on an issue by number" do + issue = repo.issues.create!(user: owner, number: 1, title: "Bug") + comment = described_class.create(repository: repo, author: author, number: 1, body: "On it") + + expect(comment).to be_persisted + expect(comment.commentable).to eq(issue) + expect(comment.author).to eq(author) + end + + it "comments on a pull request that shares the number space" do + pr = repo.pull_requests.create!(user: owner, number: 2, title: "PR", head_ref: "feature", base_ref: "main") + comment = described_class.create(repository: repo, author: author, number: 2, body: "LGTM") + + expect(comment.commentable).to eq(pr) + end + + it "raises NotFound when no issue or PR has that number" do + expect do + described_class.create(repository: repo, author: author, number: 99, body: "?") + end.to raise_error(CommentService::NotFound, /No issue or pull request #99/) + end + + it "rejects a blank body via model validation" do + repo.issues.create!(user: owner, number: 1, title: "Bug") + expect do + described_class.create(repository: repo, author: author, number: 1, body: "") + end.to raise_error(ActiveRecord::RecordInvalid) + end +end
spec/services/pull_request_service_spec.rb
+58
new file mode 100644 index 0000000..98c9ca3 --- /dev/null +++ b/spec/services/pull_request_service_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PullRequestService do + let(:owner) { User.create!(smbcloud_id: 7001, email: "owner@example.com", username: "owner") } + let(:outsider) { User.create!(smbcloud_id: 7002, email: "outsider@example.com", username: "outsider") } + let(:repo) do + owner.repositories.create!( + name: "app", kind: "code", default_branch: "main", + disk_path: GitRepositoryService.repo_path("owner", "app") + ) + end + + before do + # Branch existence is a git-layer concern; stub it so the service logic is + # tested without an on-disk repo. + allow(GitRepositoryService).to receive(:branch_exists?).and_return(true) + end + + it "opens a pull request authored by the repo owner" do + pr = described_class.create( + repository: repo, author: owner, title: "Add feature", head: "feature", base: "main" + ) + + expect(pr).to be_persisted + expect(pr.number).to eq(1) + expect(pr.state).to eq("open") + expect(pr.author).to eq(owner) + end + + it "refuses a user who cannot write the repo" do + expect do + described_class.create(repository: repo, author: outsider, title: "X", head: "feature", base: "main") + end.to raise_error(PullRequestService::NotAuthorized) + end + + it "rejects a missing branch" do + allow(GitRepositoryService).to receive(:branch_exists?).with(repo.disk_path, "feature").and_return(false) + allow(GitRepositoryService).to receive(:branch_exists?).with(repo.disk_path, "main").and_return(true) + + expect do + described_class.create(repository: repo, author: owner, title: "X", head: "feature", base: "main") + end.to raise_error(PullRequestService::Error, /Branch not found: feature/) + end + + it "rejects identical head and base via model validation" do + expect do + described_class.create(repository: repo, author: owner, title: "X", head: "main", base: "main") + end.to raise_error(ActiveRecord::RecordInvalid, /different from the base/) + end + + it "shares one number space with issues" do + repo.issues.create!(user: owner, number: 1, title: "Existing issue") + pr = described_class.create(repository: repo, author: owner, title: "PR", head: "feature", base: "main") + expect(pr.number).to eq(2) + end +end
spec/spec_helper.rb
+94
new file mode 100644 index 0000000..327b58e --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end