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 committedJun 30, 2026 at 19:15 UTCa05a53307897a96d151aad76d097c02f8871bb16
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
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
new file mode 100644index 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 100644index 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
new file mode 100644index 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 100644index 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 100644index 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 100644index 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 100644index 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 100644index 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
new file mode 100755index 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 100644index 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 100644index 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 100644index 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 100644index 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 100644index 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