name: mcp-server description: "Specification for the siGit MCP server at /api/v1/mcp. A stateless Streamable-HTTP JSON-RPC endpoint that exposes siGit repositories to MCP clients (Claude Code and others) as eleven tools: list_repositories, get_file_contents, search_code, list_issues, get_issue, create_issue, list_pull_requests, get_pull_request, create_pull_request, add_issue_comment, web_search. Covers transport, auth, the protocol method set, the tool contracts, the pull request / issue / comment data model, authorization, and acceptance criteria." status: implemented implements: - app/controllers/api/v1/mcp_controller.rb - app/services/mcp/server.rb - app/services/mcp/tools.rb - app/services/mcp/tool_error.rb - app/models/{pull_request,issue,comment,repository}.rb - app/services/{pull_request_service,issue_service,comment_service}.rb - app/services/web_search_service.rb - config/routes.rb verifies: - spec/requests/api/v1/mcp_spec.rb - spec/requests/api/v1/mcp_tools_spec.rb - spec/requests/api/v1/mcp_web_search_spec.rb - spec/services/pull_request_service_spec.rb - spec/services/issue_service_spec.rb - spec/services/comment_service_spec.rb
- spec/services/web_search_service_spec.rb
siGit MCP server
Purpose
Expose siGit-hosted git repositories to Model Context Protocol clients as tools, so an MCP client (Claude Code, the desktop app, any conforming client) can list repositories, read files, search code, and open or comment on pull requests and issues. The server lives inside the Rails monolith and reuses the existing models, auth, and authorization rather than running as a separate service.
Endpoint: POST https://sigit.si/api/v1/mcp.
Status
Implemented and deployed to production. Connected and verified end to end with:
claude mcp add --transport http sigit https://sigit.si/api/v1/mcp \
--header "Authorization: Bearer <token>"
Transport
- Streamable HTTP, stateless. One JSON-RPC 2.0 message (or a batch array)
per
POST. The body isapplication/json. - A request (message with an
id) gets a JSON-RPC response body with HTTP 200. - A notification or response (message with no
id) produces no reply: the server returns HTTP 202 with an empty body. A batch containing only notifications also returns 202. - The server does not issue an
Mcp-Session-Idand does not serve a server-to-client SSE stream, because it has no server-initiated traffic.GET /api/v1/mcpreturns HTTP 405 (method not allowed) to advertise this. - Malformed JSON is rejected with HTTP 400 by the Rails JSON middleware before
the controller runs. The controller also defends the same case and would
return a JSON-RPC
-32700parse error for bodies it parses itself.
Authentication
- Bearer token in the
Authorizationheader:Authorization: Bearer <token>, where<token>is a siGit access token (the same smbCloud-issued token the rest of/api/v1accepts). - The token is validated against smbCloud via
SmbcloudAuthService.meand the localUsermirror is resolved or created withUser.find_or_create_from_smbcloud. This is the same flow asApi::BaseController#authenticate_token!; the MCP controller performs it in a privateresolve_usermethod so the smbCloud dependency is touched only at request time, not at class load. - A missing, malformed, or invalid token returns HTTP 401 with a JSON-RPC error
body
{ code: -32001, message: "Unauthorized" }and aWWW-Authenticateheader that points at the protected-resource metadata URL:
WWW-Authenticate: Bearer realm="sigit", error="invalid_token",
resource_metadata="<base_url>/.well-known/oauth-protected-resource"
- A token obtained from
POST /api/v1/auth/sign_in({email, password}->{"Ready":{"access_token": ...}}) is a valid bearer token here.
Future: OAuth 2.1 (not yet implemented)
The bearer token is the front door today. The intended upgrade is OAuth 2.1 so a
client can authorize through the standard /mcp browser flow without pasting a
token: serve /.well-known/oauth-protected-resource and
/.well-known/oauth-authorization-server, support PKCE and dynamic client
registration, validate the Origin header against DNS rebinding, and rate-limit
per token. The 401 challenge already advertises the protected-resource URL.
Protocol methods
Mcp::Server#handle dispatches one parsed JSON-RPC message:
| Method | Result |
|---|---|
initialize |
Handshake (see below). |
ping |
{}. |
tools/list |
{ "tools": [ <spec>, ... ] } for all registered tools. |
tools/call |
Runs the named tool (see Tool dispatch). |
resources/list |
{ "resources": [] } (none offered). |
prompts/list |
{ "prompts": [] } (none offered). |
notifications/* |
No reply (returns nil; the controller answers 202). |
| anything else | JSON-RPC error -32601 "Method not found". |
Any unhandled exception during dispatch is logged and returned as JSON-RPC
-32603 "Internal error"; the underlying message is not leaked to the client.
initialize
Request params may include protocolVersion. The server echoes the requested
version when it is one of the supported versions
(2025-06-18, 2025-03-26, 2024-11-05), otherwise it falls back to the latest
it supports (2025-06-18). Result:
{
"protocolVersion": "2025-06-18",
"capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "sigit", "title": "siGit Code", "version": "0.1.0" },
"instructions": "Tools for siGit-hosted git repositories: ..."
}
Error model
Two distinct failure channels, and they must not be confused:
- Protocol errors (bad or unknown method, parse error, unknown tool name)
are JSON-RPC errors: an
errorobject with a negativecode. Codes used:-32700parse error,-32601method not found,-32602invalid params / unknown tool,-32603internal error,-32001unauthorized. - Tool failures (bad arguments, not found, not authorized, validation
failure) are returned in band as a successful
tools/callresult withisError: trueand a human-readable message in the text content. The model sees the message and can recover. A tool failure is never a protocol error.
A tool signals an in-band failure by raising Mcp::ToolError; the server catches
it and renders the isError: true result.
Tool registry
Tools are a data-driven registry in Mcp::Tools.all: an ordered array of
Mcp::Tools::Base subclasses. tools/list and tools/call both read from it,
so adding a tool is a one-line change (append the class to all). Each tool
declares tool_name, description, input_schema (JSON Schema surfaced to the
model), and a call.
The eleven tools, in registry order:
list_repositories
- Purpose: list repositories the authenticated user can access. The model
uses this first to discover the
owner/namefor other tools. - Input:
query(optional substring of owner or repo name, case insensitive),limit(optional, default 30, clamped 1..100). - Behaviour:
Repository.visible_to(current_user)(public repos plus the user's own), optionalILIKEfilter on repo name or owner username, ordered byupdated_atdescending, limited. - Output: array of
{ full_name, description, default_branch, private, url }.
get_file_contents
- Purpose: read a file at a ref.
- Input (required):
repo(owner/name),path. Optionalref(branch, tag, or SHA; defaults to the repo default branch). - Behaviour: resolve the repo (read scope), then
Repository#blob_at(ref, path)(git read layer). - Output:
{ repo, path, ref, size, content }wheresizeis the content byte size. - Errors: missing repo -> isError "Repository not found or not accessible"; missing file -> isError "File not found: @".
search_code
- Purpose: search file contents and return matching lines.
- Input (required):
repo,query. Optionalref(defaults to default branch),limit(default 20, clamped 1..100). - Behaviour:
Repository#search_code->GitRepositoryService.search_code, which runsgit grep(fixed string, case insensitive, skips binary files) and parses<ref>:<path>:<line>:<text>. - Output: array of
{ path, line, snippet }, capped atlimit.
list_issues
- Purpose: list issues, optionally by state or a text query.
- Input (required):
repo. Optionalstateinopen|closed|all(defaultopen),query(case-insensitive substring of the issue title or body). - Behaviour:
repo.issues, filtered by state unlessall,ILIKEfilter on title or body whenqueryis given, ordered bynumberdescending, limited to 50. - Output: array of
{ number, title, state, author, url }.
get_issue
- Purpose: read one issue in full, including its comments.
- Input (required):
repo,number. - Behaviour: resolve the repo (read scope), find the issue by number.
- Output:
{ number, title, state, author, body, url, comments }wherecommentsis an array of{ author, body, created_at }, oldest first. - Errors (in band): no issue with that number.
create_issue
- Purpose: open a new issue.
- Input (required):
repo,title. Optionalbody(Markdown). - Behaviour: routed through
IssueService.createso per-repo numbering (shared with pull requests) runs identically to any other caller. Anyone who can read the repo may open an issue on it, as with comments. - Output:
{ number, state, url }. - Errors (in band): blank title; any model validation failure.
list_pull_requests
- Purpose: list pull requests, optionally by state.
- Input (required):
repo. Optionalstateinopen|closed|merged|all(defaultopen). - Behaviour:
repo.pull_requests, filtered by state unlessall, ordered bynumberdescending, limited to 50. - Output: array of
{ number, title, state, head, base, url }.
get_pull_request
- Purpose: read one pull request in full, including its comments and diff.
- Input (required):
repo,number. - Behaviour: resolve the repo (read scope), find the PR by number, then
Repository#diff_between(base_ref, head_ref)->GitRepositoryService.diff(three-dotgit diff base...head, the range a pull request shows). - Output:
{ number, title, state, author, head, base, body, url, comments, diff }. The diff is capped at 100 kB; past the cap the result carriesdiff_truncated: trueand adiff_note. When a ref no longer exists (e.g. the head branch of a merged PR was deleted),diffis null with adiff_notesaying so. - Errors (in band): no pull request with that number.
create_pull_request
- Purpose: open a pull request from a head branch into a base branch.
- Input (required):
repo,title,head,base. Optionalbody(Markdown). - Behaviour: routed through
PullRequestService.createso authorization, branch validation, and per-repo numbering run identically to any other caller. - Output:
{ number, state, url }. - Errors (in band): not authorized to write the repo; head or base branch not found; head equals base; any model validation failure.
add_issue_comment
- Purpose: post a comment on an issue or pull request by its number.
- Input (required):
repo,number,body(Markdown). - Behaviour: routed through
CommentService.create. Issues and pull requests share one per-repo number space, so a number resolves to exactly one of them. - Output:
{ id, url }. - Errors (in band): no issue or PR with that number; blank body.
web_search
- Purpose: search the public web and return result links with snippets, so the agent can find pages to read (it can already fetch a known URL). Search is a signed-in cloud feature of the official MCP server, not a client-side API key.
- Input (required):
query. Optionalcount(default 5, clamped 1..10 — out-of-range values are clamped, never an error). - Behaviour: delegates to
WebSearchService, which selects a provider adapter viaSEARCH_PROVIDER(defaultbrave) and returns a uniform result shape regardless of provider. The Brave adapter calls the Brave Search API (GET https://api.search.brave.com/res/v1/web/search, auth via theX-Subscription-Tokenheader set fromBRAVE_SEARCH_API_KEY, paramsqandcount) and mapsweb.results[]title/url/descriptionto the output. - Rate limit: at most 60 searches per user per hour, a sliding window
counted in
Rails.cache(solid_cache in production). Exceeding it is an in-bandisError, not a protocol error. - Output: JSON array of
{ title, url, snippet }. - Errors (in band): missing query; unconfigured provider (the message names
the env var the operator must set — never a 500); unknown
SEARCH_PROVIDER; provider HTTP failure (the message carries the upstream status); rate limit exceeded.
Data model
The app had no pull request, issue, or comment concept before this feature; the following were added to back the tools.
- PullRequest belongs to a repository and a user (author), has many comments
(polymorphic). Columns:
number,title,body,state(open|closed|merged, defaultopen),head_ref,base_ref,merged_at. Validations: number present and unique per repository, title present, head and base present, head differs from base, state in the allowed set. - Issue belongs to a repository and a user (author), has many comments
(polymorphic). Columns:
number,title,body,state(open|closed, defaultopen),closed_at. Validations: number present and unique per repository, title present, state in the allowed set. - Comment belongs to a polymorphic
commentable(a PullRequest or an Issue) and a user (author). Column:body(present).html_urlanchors to the parent. - Shared numbering. Pull requests and issues draw from one per-repository
number space (as on GitHub), so a number maps to exactly one record. The next
number is
max(pull_requests.number, issues.number) + 1, computed byRepository#next_issue_numberunder a repository row lock during creation; bothPullRequestServiceandIssueServiceallocate through it.
Authorization
- Read (list_repositories, get_file_contents, search_code, list_issues,
get_issue, list_pull_requests, get_pull_request, resolving the repo for any
tool):
Repository.visible_to(user), which returns public repositories plus the caller's own. Private repositories of other users are never resolvable, so they cannot be read or mutated. - Write (create_pull_request):
Repository#writable_by?(user). siGit repos have a single owner and no collaborators yet, so write equals ownership. A non-owner attempt returns an in-bandisError. - Comment and issue creation (add_issue_comment, create_issue): require read access to the repo (enforced by repo resolution). The author is always the authenticated user.
Mutations go through the service objects (PullRequestService, IssueService,
CommentService), never directly through create! from the tool, so that the
same validations and authorization run for the MCP path as for any future web UI.
Supporting layers
- Git read layer.
Repository#blob_at(ref, path)delegates toGitRepositoryService.file_content(agit show <ref>:<path>).Repository#search_codedelegates toGitRepositoryService.search_code(git grep).Repository#diff_between(base, head)delegates toGitRepositoryService.diff(git diff base...head). - URLs.
Repository#html_url,PullRequest#html_url,Issue#html_url, andComment#html_urlbuild absolute links fromRepository.site_url, which readsSIGITSI_URL(defaulthttps://sigit.si). This is needed because tools have no HTTP request context to derive a base URL from.
Acceptance criteria
claude mcp add --transport http sigit https://sigit.si/api/v1/mcp --header "Authorization: Bearer <token>"connects.tools/listreturns all eleven tools.list_repositoriesandget_file_contentswork end to end against a real repository.- An unauthenticated request returns a JSON-RPC 401 with a
WWW-Authenticatechallenge. - A failing tool returns
isError: truewith a message, not a protocol error.
Verification
- Request specs (
spec/requests/api/v1/mcp_spec.rb): initialize handshake,tools/listshows all eleven, a successfultools/call, a notification answered with 202, a 401 with the challenge, and a tool returningisError. - Request specs (
spec/requests/api/v1/mcp_tools_spec.rb): the issue and pull request tools end to end against a real bare repository — happy paths (including the real three-dot diff), diff truncation, missing-branch diffs, unknown numbers, an invisible repository, and missing required arguments. - Web search request specs (
spec/requests/api/v1/mcp_web_search_spec.rb): happy path with the provider HTTP call stubbed, count clamping, missing query, unconfigured and unknown provider reported in-band, provider 4xx/5xx surfaced in-band, and the rate limit. Service specs (spec/services/web_search_service_spec.rb): provider selection and the Brave adapter's request shape, parsing, and error mapping. - Service specs (
spec/services/pull_request_service_spec.rb,issue_service_spec.rb,comment_service_spec.rb): authorization, branch validation, head-differs-from-base, shared numbering, comment resolution across issues and pull requests, blank-title and blank-body rejection.
Out of scope (current) and natural extensions
- Server-to-client streaming and sessions (
Mcp-Session-Id, GET SSE). Add both if the server later pushes events. - OAuth 2.1 authorization (see Authentication).
- Webhooks on pull request / issue / comment creation. None exist in the app yet; when added, they run automatically because mutations go through the service objects.
- Further tools, each a one-line registry addition:
list_branches,list_commits,close_issue,merge_pull_request.
Key files
app/controllers/api/v1/mcp_controller.rb: HTTP transport, auth, batch handling.app/services/mcp/server.rb: protocol dispatch.app/services/mcp/tools.rb: tool registry and tool implementations.app/services/mcp/tool_error.rb: in-band tool failure type.app/services/pull_request_service.rb,app/services/issue_service.rb,app/services/comment_service.rb: mutation services.app/services/web_search_service.rb: web search with the provider adapter boundary (Brave first).app/models/pull_request.rb,issue.rb,comment.rb, and the additions torepository.rbandgit_repository_service.rb.config/routes.rb:post/get api/v1/mcp.