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