main
md 18.6 KB

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 is application/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-Id and does not serve a server-to-client SSE stream, because it has no server-initiated traffic. GET /api/v1/mcp returns 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 -32700 parse error for bodies it parses itself.

Authentication

  • Bearer token in the Authorization header: Authorization: Bearer <token>, where <token> is a siGit access token (the same smbCloud-issued token the rest of /api/v1 accepts).
  • The token is validated against smbCloud via SmbcloudAuthService.me and the local User mirror is resolved or created with User.find_or_create_from_smbcloud. This is the same flow as Api::BaseController#authenticate_token!; the MCP controller performs it in a private resolve_user method 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 a WWW-Authenticate header 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:

  1. Protocol errors (bad or unknown method, parse error, unknown tool name) are JSON-RPC errors: an error object with a negative code. Codes used: -32700 parse error, -32601 method not found, -32602 invalid params / unknown tool, -32603 internal error, -32001 unauthorized.
  2. Tool failures (bad arguments, not found, not authorized, validation failure) are returned in band as a successful tools/call result with isError: true and 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/name for 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), optional ILIKE filter on repo name or owner username, ordered by updated_at descending, 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. Optional ref (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 } where size is 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. Optional ref (defaults to default branch), limit (default 20, clamped 1..100).
  • Behaviour: Repository#search_code -> GitRepositoryService.search_code, which runs git grep (fixed string, case insensitive, skips binary files) and parses <ref>:<path>:<line>:<text>.
  • Output: array of { path, line, snippet }, capped at limit.

list_issues

  • Purpose: list issues, optionally by state or a text query.
  • Input (required): repo. Optional state in open|closed|all (default open), query (case-insensitive substring of the issue title or body).
  • Behaviour: repo.issues, filtered by state unless all, ILIKE filter on title or body when query is given, ordered by number descending, 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 } where comments is 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. Optional body (Markdown).
  • Behaviour: routed through IssueService.create so 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. Optional state in open|closed|merged|all (default open).
  • Behaviour: repo.pull_requests, filtered by state unless all, ordered by number descending, 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-dot git 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 carries diff_truncated: true and a diff_note. When a ref no longer exists (e.g. the head branch of a merged PR was deleted), diff is null with a diff_note saying 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. Optional body (Markdown).
  • Behaviour: routed through PullRequestService.create so 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.
  • 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. Optional count (default 5, clamped 1..10 — out-of-range values are clamped, never an error).
  • Behaviour: delegates to WebSearchService, which selects a provider adapter via SEARCH_PROVIDER (default brave) 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 the X-Subscription-Token header set from BRAVE_SEARCH_API_KEY, params q and count) and maps web.results[] title/url/description to 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-band isError, 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, default open), 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, default open), 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_url anchors 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 by Repository#next_issue_number under a repository row lock during creation; both PullRequestService and IssueService allocate 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-band isError.
  • 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 to GitRepositoryService.file_content (a git show <ref>:<path>). Repository#search_code delegates to GitRepositoryService.search_code (git grep). Repository#diff_between(base, head) delegates to GitRepositoryService.diff (git diff base...head).
  • URLs. Repository#html_url, PullRequest#html_url, Issue#html_url, and Comment#html_url build absolute links from Repository.site_url, which reads SIGITSI_URL (default https://sigit.si). This is needed because tools have no HTTP request context to derive a base URL from.

Acceptance criteria

  1. claude mcp add --transport http sigit https://sigit.si/api/v1/mcp --header "Authorization: Bearer <token>" connects.
  2. tools/list returns all eleven tools.
  3. list_repositories and get_file_contents work end to end against a real repository.
  4. An unauthenticated request returns a JSON-RPC 401 with a WWW-Authenticate challenge.
  5. A failing tool returns isError: true with a message, not a protocol error.

Verification

  • Request specs (spec/requests/api/v1/mcp_spec.rb): initialize handshake, tools/list shows all eleven, a successful tools/call, a notification answered with 202, a 401 with the challenge, and a tool returning isError.
  • 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 to repository.rb and git_repository_service.rb.
  • config/routes.rb: post/get api/v1/mcp.