| 1 | --- |
| 2 | name: mcp-server |
| 3 | 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." |
| 4 | status: implemented |
| 5 | implements: |
| 6 | - app/controllers/api/v1/mcp_controller.rb |
| 7 | - app/services/mcp/server.rb |
| 8 | - app/services/mcp/tools.rb |
| 9 | - app/services/mcp/tool_error.rb |
| 10 | - app/models/{pull_request,issue,comment,repository}.rb |
| 11 | - app/services/{pull_request_service,issue_service,comment_service}.rb |
| 12 | - app/services/web_search_service.rb |
| 13 | - config/routes.rb |
| 14 | verifies: |
| 15 | - spec/requests/api/v1/mcp_spec.rb |
| 16 | - spec/requests/api/v1/mcp_tools_spec.rb |
| 17 | - spec/requests/api/v1/mcp_web_search_spec.rb |
| 18 | - spec/services/pull_request_service_spec.rb |
| 19 | - spec/services/issue_service_spec.rb |
| 20 | - spec/services/comment_service_spec.rb |
| 21 | - spec/services/web_search_service_spec.rb |
| 22 | --- |
| 23 | |
| 24 | # siGit MCP server |
| 25 | |
| 26 | ## Purpose |
| 27 | |
| 28 | Expose siGit-hosted git repositories to Model Context Protocol clients as tools, |
| 29 | so an MCP client (Claude Code, the desktop app, any conforming client) can list |
| 30 | repositories, read files, search code, and open or comment on pull requests and |
| 31 | issues. The server lives inside the Rails monolith and reuses the existing |
| 32 | models, auth, and authorization rather than running as a separate service. |
| 33 | |
| 34 | Endpoint: `POST https://sigit.si/api/v1/mcp`. |
| 35 | |
| 36 | ## Status |
| 37 | |
| 38 | Implemented and deployed to production. Connected and verified end to end with: |
| 39 | |
| 40 | ```sh |
| 41 | claude mcp add --transport http sigit https://sigit.si/api/v1/mcp \ |
| 42 | --header "Authorization: Bearer <token>" |
| 43 | ``` |
| 44 | |
| 45 | ## Transport |
| 46 | |
| 47 | - **Streamable HTTP, stateless.** One JSON-RPC 2.0 message (or a batch array) |
| 48 | per `POST`. The body is `application/json`. |
| 49 | - A request (message with an `id`) gets a JSON-RPC response body with HTTP 200. |
| 50 | - A notification or response (message with no `id`) produces no reply: the |
| 51 | server returns HTTP 202 with an empty body. A batch containing only |
| 52 | notifications also returns 202. |
| 53 | - The server does not issue an `Mcp-Session-Id` and does not serve a |
| 54 | server-to-client SSE stream, because it has no server-initiated traffic. |
| 55 | `GET /api/v1/mcp` returns HTTP 405 (method not allowed) to advertise this. |
| 56 | - Malformed JSON is rejected with HTTP 400 by the Rails JSON middleware before |
| 57 | the controller runs. The controller also defends the same case and would |
| 58 | return a JSON-RPC `-32700` parse error for bodies it parses itself. |
| 59 | |
| 60 | ## Authentication |
| 61 | |
| 62 | - Bearer token in the `Authorization` header: `Authorization: Bearer <token>`, |
| 63 | where `<token>` is a siGit access token (the same smbCloud-issued token the |
| 64 | rest of `/api/v1` accepts). |
| 65 | - The token is validated against smbCloud via `SmbcloudAuthService.me` and the |
| 66 | local `User` mirror is resolved or created with |
| 67 | `User.find_or_create_from_smbcloud`. This is the same flow as |
| 68 | `Api::BaseController#authenticate_token!`; the MCP controller performs it in a |
| 69 | private `resolve_user` method so the smbCloud dependency is touched only at |
| 70 | request time, not at class load. |
| 71 | - A missing, malformed, or invalid token returns HTTP 401 with a JSON-RPC error |
| 72 | body `{ code: -32001, message: "Unauthorized" }` and a `WWW-Authenticate` |
| 73 | header that points at the protected-resource metadata URL: |
| 74 | |
| 75 | ``` |
| 76 | WWW-Authenticate: Bearer realm="sigit", error="invalid_token", |
| 77 | resource_metadata="<base_url>/.well-known/oauth-protected-resource" |
| 78 | ``` |
| 79 | |
| 80 | - A token obtained from `POST /api/v1/auth/sign_in` (`{email, password}` -> |
| 81 | `{"Ready":{"access_token": ...}}`) is a valid bearer token here. |
| 82 | |
| 83 | ### Future: OAuth 2.1 (not yet implemented) |
| 84 | |
| 85 | The bearer token is the front door today. The intended upgrade is OAuth 2.1 so a |
| 86 | client can authorize through the standard `/mcp` browser flow without pasting a |
| 87 | token: serve `/.well-known/oauth-protected-resource` and |
| 88 | `/.well-known/oauth-authorization-server`, support PKCE and dynamic client |
| 89 | registration, validate the `Origin` header against DNS rebinding, and rate-limit |
| 90 | per token. The 401 challenge already advertises the protected-resource URL. |
| 91 | |
| 92 | ## Protocol methods |
| 93 | |
| 94 | `Mcp::Server#handle` dispatches one parsed JSON-RPC message: |
| 95 | |
| 96 | | Method | Result | |
| 97 | |--------|--------| |
| 98 | | `initialize` | Handshake (see below). | |
| 99 | | `ping` | `{}`. | |
| 100 | | `tools/list` | `{ "tools": [ <spec>, ... ] }` for all registered tools. | |
| 101 | | `tools/call` | Runs the named tool (see Tool dispatch). | |
| 102 | | `resources/list` | `{ "resources": [] }` (none offered). | |
| 103 | | `prompts/list` | `{ "prompts": [] }` (none offered). | |
| 104 | | `notifications/*` | No reply (returns nil; the controller answers 202). | |
| 105 | | anything else | JSON-RPC error `-32601` "Method not found". | |
| 106 | |
| 107 | Any unhandled exception during dispatch is logged and returned as JSON-RPC |
| 108 | `-32603` "Internal error"; the underlying message is not leaked to the client. |
| 109 | |
| 110 | ### initialize |
| 111 | |
| 112 | Request params may include `protocolVersion`. The server echoes the requested |
| 113 | version when it is one of the supported versions |
| 114 | (`2025-06-18`, `2025-03-26`, `2024-11-05`), otherwise it falls back to the latest |
| 115 | it supports (`2025-06-18`). Result: |
| 116 | |
| 117 | ```json |
| 118 | { |
| 119 | "protocolVersion": "2025-06-18", |
| 120 | "capabilities": { "tools": { "listChanged": false } }, |
| 121 | "serverInfo": { "name": "sigit", "title": "siGit Code", "version": "0.1.0" }, |
| 122 | "instructions": "Tools for siGit-hosted git repositories: ..." |
| 123 | } |
| 124 | ``` |
| 125 | |
| 126 | ## Error model |
| 127 | |
| 128 | Two distinct failure channels, and they must not be confused: |
| 129 | |
| 130 | 1. **Protocol errors** (bad or unknown method, parse error, unknown tool name) |
| 131 | are JSON-RPC errors: an `error` object with a negative `code`. Codes used: |
| 132 | `-32700` parse error, `-32601` method not found, `-32602` invalid params / |
| 133 | unknown tool, `-32603` internal error, `-32001` unauthorized. |
| 134 | 2. **Tool failures** (bad arguments, not found, not authorized, validation |
| 135 | failure) are returned in band as a successful `tools/call` result with |
| 136 | `isError: true` and a human-readable message in the text content. The model |
| 137 | sees the message and can recover. A tool failure is never a protocol error. |
| 138 | |
| 139 | A tool signals an in-band failure by raising `Mcp::ToolError`; the server catches |
| 140 | it and renders the `isError: true` result. |
| 141 | |
| 142 | ## Tool registry |
| 143 | |
| 144 | Tools are a data-driven registry in `Mcp::Tools.all`: an ordered array of |
| 145 | `Mcp::Tools::Base` subclasses. `tools/list` and `tools/call` both read from it, |
| 146 | so adding a tool is a one-line change (append the class to `all`). Each tool |
| 147 | declares `tool_name`, `description`, `input_schema` (JSON Schema surfaced to the |
| 148 | model), and a `call`. |
| 149 | |
| 150 | The eleven tools, in registry order: |
| 151 | |
| 152 | ### list_repositories |
| 153 | |
| 154 | - **Purpose:** list repositories the authenticated user can access. The model |
| 155 | uses this first to discover the `owner/name` for other tools. |
| 156 | - **Input:** `query` (optional substring of owner or repo name, case |
| 157 | insensitive), `limit` (optional, default 30, clamped 1..100). |
| 158 | - **Behaviour:** `Repository.visible_to(current_user)` (public repos plus the |
| 159 | user's own), optional `ILIKE` filter on repo name or owner username, ordered by |
| 160 | `updated_at` descending, limited. |
| 161 | - **Output:** array of `{ full_name, description, default_branch, private, url }`. |
| 162 | |
| 163 | ### get_file_contents |
| 164 | |
| 165 | - **Purpose:** read a file at a ref. |
| 166 | - **Input (required):** `repo` (`owner/name`), `path`. Optional `ref` (branch, |
| 167 | tag, or SHA; defaults to the repo default branch). |
| 168 | - **Behaviour:** resolve the repo (read scope), then |
| 169 | `Repository#blob_at(ref, path)` (git read layer). |
| 170 | - **Output:** `{ repo, path, ref, size, content }` where `size` is the content |
| 171 | byte size. |
| 172 | - **Errors:** missing repo -> isError "Repository not found or not accessible"; |
| 173 | missing file -> isError "File not found: <path>@<ref>". |
| 174 | |
| 175 | ### search_code |
| 176 | |
| 177 | - **Purpose:** search file contents and return matching lines. |
| 178 | - **Input (required):** `repo`, `query`. Optional `ref` (defaults to default |
| 179 | branch), `limit` (default 20, clamped 1..100). |
| 180 | - **Behaviour:** `Repository#search_code` -> `GitRepositoryService.search_code`, |
| 181 | which runs `git grep` (fixed string, case insensitive, skips binary files) and |
| 182 | parses `<ref>:<path>:<line>:<text>`. |
| 183 | - **Output:** array of `{ path, line, snippet }`, capped at `limit`. |
| 184 | |
| 185 | ### list_issues |
| 186 | |
| 187 | - **Purpose:** list issues, optionally by state or a text query. |
| 188 | - **Input (required):** `repo`. Optional `state` in `open|closed|all` (default |
| 189 | `open`), `query` (case-insensitive substring of the issue title or body). |
| 190 | - **Behaviour:** `repo.issues`, filtered by state unless `all`, `ILIKE` filter |
| 191 | on title or body when `query` is given, ordered by `number` descending, |
| 192 | limited to 50. |
| 193 | - **Output:** array of `{ number, title, state, author, url }`. |
| 194 | |
| 195 | ### get_issue |
| 196 | |
| 197 | - **Purpose:** read one issue in full, including its comments. |
| 198 | - **Input (required):** `repo`, `number`. |
| 199 | - **Behaviour:** resolve the repo (read scope), find the issue by number. |
| 200 | - **Output:** `{ number, title, state, author, body, url, comments }` where |
| 201 | `comments` is an array of `{ author, body, created_at }`, oldest first. |
| 202 | - **Errors (in band):** no issue with that number. |
| 203 | |
| 204 | ### create_issue |
| 205 | |
| 206 | - **Purpose:** open a new issue. |
| 207 | - **Input (required):** `repo`, `title`. Optional `body` (Markdown). |
| 208 | - **Behaviour:** routed through `IssueService.create` so per-repo numbering |
| 209 | (shared with pull requests) runs identically to any other caller. Anyone who |
| 210 | can read the repo may open an issue on it, as with comments. |
| 211 | - **Output:** `{ number, state, url }`. |
| 212 | - **Errors (in band):** blank title; any model validation failure. |
| 213 | |
| 214 | ### list_pull_requests |
| 215 | |
| 216 | - **Purpose:** list pull requests, optionally by state. |
| 217 | - **Input (required):** `repo`. Optional `state` in |
| 218 | `open|closed|merged|all` (default `open`). |
| 219 | - **Behaviour:** `repo.pull_requests`, filtered by state unless `all`, ordered by |
| 220 | `number` descending, limited to 50. |
| 221 | - **Output:** array of `{ number, title, state, head, base, url }`. |
| 222 | |
| 223 | ### get_pull_request |
| 224 | |
| 225 | - **Purpose:** read one pull request in full, including its comments and diff. |
| 226 | - **Input (required):** `repo`, `number`. |
| 227 | - **Behaviour:** resolve the repo (read scope), find the PR by number, then |
| 228 | `Repository#diff_between(base_ref, head_ref)` -> `GitRepositoryService.diff` |
| 229 | (three-dot `git diff base...head`, the range a pull request shows). |
| 230 | - **Output:** `{ number, title, state, author, head, base, body, url, comments, |
| 231 | diff }`. The diff is capped at 100 kB; past the cap the result carries |
| 232 | `diff_truncated: true` and a `diff_note`. When a ref no longer exists (e.g. |
| 233 | the head branch of a merged PR was deleted), `diff` is null with a |
| 234 | `diff_note` saying so. |
| 235 | - **Errors (in band):** no pull request with that number. |
| 236 | |
| 237 | ### create_pull_request |
| 238 | |
| 239 | - **Purpose:** open a pull request from a head branch into a base branch. |
| 240 | - **Input (required):** `repo`, `title`, `head`, `base`. Optional `body` |
| 241 | (Markdown). |
| 242 | - **Behaviour:** routed through `PullRequestService.create` so authorization, |
| 243 | branch validation, and per-repo numbering run identically to any other caller. |
| 244 | - **Output:** `{ number, state, url }`. |
| 245 | - **Errors (in band):** not authorized to write the repo; head or base branch |
| 246 | not found; head equals base; any model validation failure. |
| 247 | |
| 248 | ### add_issue_comment |
| 249 | |
| 250 | - **Purpose:** post a comment on an issue or pull request by its number. |
| 251 | - **Input (required):** `repo`, `number`, `body` (Markdown). |
| 252 | - **Behaviour:** routed through `CommentService.create`. Issues and pull requests |
| 253 | share one per-repo number space, so a number resolves to exactly one of them. |
| 254 | - **Output:** `{ id, url }`. |
| 255 | - **Errors (in band):** no issue or PR with that number; blank body. |
| 256 | |
| 257 | ### web_search |
| 258 | |
| 259 | - **Purpose:** search the public web and return result links with snippets, so |
| 260 | the agent can find pages to read (it can already fetch a known URL). Search is |
| 261 | a signed-in cloud feature of the official MCP server, not a client-side API |
| 262 | key. |
| 263 | - **Input (required):** `query`. Optional `count` (default 5, clamped 1..10 — |
| 264 | out-of-range values are clamped, never an error). |
| 265 | - **Behaviour:** delegates to `WebSearchService`, which selects a provider |
| 266 | adapter via `SEARCH_PROVIDER` (default `brave`) and returns a uniform result |
| 267 | shape regardless of provider. The Brave adapter calls the Brave Search API |
| 268 | (`GET https://api.search.brave.com/res/v1/web/search`, auth via the |
| 269 | `X-Subscription-Token` header set from `BRAVE_SEARCH_API_KEY`, params `q` and |
| 270 | `count`) and maps `web.results[]` `title`/`url`/`description` to the output. |
| 271 | - **Rate limit:** at most 60 searches per user per hour, a sliding window |
| 272 | counted in `Rails.cache` (solid_cache in production). Exceeding it is an |
| 273 | in-band `isError`, not a protocol error. |
| 274 | - **Output:** JSON array of `{ title, url, snippet }`. |
| 275 | - **Errors (in band):** missing query; unconfigured provider (the message names |
| 276 | the env var the operator must set — never a 500); unknown `SEARCH_PROVIDER`; |
| 277 | provider HTTP failure (the message carries the upstream status); rate limit |
| 278 | exceeded. |
| 279 | |
| 280 | ## Data model |
| 281 | |
| 282 | The app had no pull request, issue, or comment concept before this feature; the |
| 283 | following were added to back the tools. |
| 284 | |
| 285 | - **PullRequest** belongs to a repository and a user (author), has many comments |
| 286 | (polymorphic). Columns: `number`, `title`, `body`, `state` |
| 287 | (`open|closed|merged`, default `open`), `head_ref`, `base_ref`, `merged_at`. |
| 288 | Validations: number present and unique per repository, title present, head and |
| 289 | base present, head differs from base, state in the allowed set. |
| 290 | - **Issue** belongs to a repository and a user (author), has many comments |
| 291 | (polymorphic). Columns: `number`, `title`, `body`, `state` |
| 292 | (`open|closed`, default `open`), `closed_at`. Validations: number present and |
| 293 | unique per repository, title present, state in the allowed set. |
| 294 | - **Comment** belongs to a polymorphic `commentable` (a PullRequest or an Issue) |
| 295 | and a user (author). Column: `body` (present). `html_url` anchors to the parent. |
| 296 | - **Shared numbering.** Pull requests and issues draw from one per-repository |
| 297 | number space (as on GitHub), so a number maps to exactly one record. The next |
| 298 | number is `max(pull_requests.number, issues.number) + 1`, computed by |
| 299 | `Repository#next_issue_number` under a repository row lock during creation; |
| 300 | both `PullRequestService` and `IssueService` allocate through it. |
| 301 | |
| 302 | ## Authorization |
| 303 | |
| 304 | - **Read** (list_repositories, get_file_contents, search_code, list_issues, |
| 305 | get_issue, list_pull_requests, get_pull_request, resolving the repo for any |
| 306 | tool): `Repository.visible_to(user)`, which returns public repositories plus |
| 307 | the caller's own. Private repositories of other users are never resolvable, |
| 308 | so they cannot be read or mutated. |
| 309 | - **Write** (create_pull_request): `Repository#writable_by?(user)`. siGit repos |
| 310 | have a single owner and no collaborators yet, so write equals ownership. A |
| 311 | non-owner attempt returns an in-band `isError`. |
| 312 | - **Comment and issue creation** (add_issue_comment, create_issue): require read |
| 313 | access to the repo (enforced by repo resolution). The author is always the |
| 314 | authenticated user. |
| 315 | |
| 316 | Mutations go through the service objects (`PullRequestService`, `IssueService`, |
| 317 | `CommentService`), never directly through `create!` from the tool, so that the |
| 318 | same validations and authorization run for the MCP path as for any future web UI. |
| 319 | |
| 320 | ## Supporting layers |
| 321 | |
| 322 | - **Git read layer.** `Repository#blob_at(ref, path)` delegates to |
| 323 | `GitRepositoryService.file_content` (a `git show <ref>:<path>`). |
| 324 | `Repository#search_code` delegates to `GitRepositoryService.search_code` |
| 325 | (`git grep`). `Repository#diff_between(base, head)` delegates to |
| 326 | `GitRepositoryService.diff` (`git diff base...head`). |
| 327 | - **URLs.** `Repository#html_url`, `PullRequest#html_url`, `Issue#html_url`, and |
| 328 | `Comment#html_url` build absolute links from `Repository.site_url`, which reads |
| 329 | `SIGITSI_URL` (default `https://sigit.si`). This is needed because tools have no |
| 330 | HTTP request context to derive a base URL from. |
| 331 | |
| 332 | ## Acceptance criteria |
| 333 | |
| 334 | 1. `claude mcp add --transport http sigit https://sigit.si/api/v1/mcp --header |
| 335 | "Authorization: Bearer <token>"` connects. |
| 336 | 2. `tools/list` returns all eleven tools. |
| 337 | 3. `list_repositories` and `get_file_contents` work end to end against a real |
| 338 | repository. |
| 339 | 4. An unauthenticated request returns a JSON-RPC 401 with a `WWW-Authenticate` |
| 340 | challenge. |
| 341 | 5. A failing tool returns `isError: true` with a message, not a protocol error. |
| 342 | |
| 343 | ## Verification |
| 344 | |
| 345 | - Request specs (`spec/requests/api/v1/mcp_spec.rb`): initialize handshake, |
| 346 | `tools/list` shows all eleven, a successful `tools/call`, a notification |
| 347 | answered with 202, a 401 with the challenge, and a tool returning `isError`. |
| 348 | - Request specs (`spec/requests/api/v1/mcp_tools_spec.rb`): the issue and pull |
| 349 | request tools end to end against a real bare repository — happy paths |
| 350 | (including the real three-dot diff), diff truncation, missing-branch diffs, |
| 351 | unknown numbers, an invisible repository, and missing required arguments. |
| 352 | - Web search request specs (`spec/requests/api/v1/mcp_web_search_spec.rb`): |
| 353 | happy path with the provider HTTP call stubbed, count clamping, missing query, |
| 354 | unconfigured and unknown provider reported in-band, provider 4xx/5xx surfaced |
| 355 | in-band, and the rate limit. Service specs |
| 356 | (`spec/services/web_search_service_spec.rb`): provider selection and the Brave |
| 357 | adapter's request shape, parsing, and error mapping. |
| 358 | - Service specs (`spec/services/pull_request_service_spec.rb`, |
| 359 | `issue_service_spec.rb`, `comment_service_spec.rb`): authorization, branch |
| 360 | validation, head-differs-from-base, shared numbering, comment resolution |
| 361 | across issues and pull requests, blank-title and blank-body rejection. |
| 362 | |
| 363 | ## Out of scope (current) and natural extensions |
| 364 | |
| 365 | - Server-to-client streaming and sessions (`Mcp-Session-Id`, GET SSE). Add both |
| 366 | if the server later pushes events. |
| 367 | - OAuth 2.1 authorization (see Authentication). |
| 368 | - Webhooks on pull request / issue / comment creation. None exist in the app yet; |
| 369 | when added, they run automatically because mutations go through the service |
| 370 | objects. |
| 371 | - Further tools, each a one-line registry addition: `list_branches`, |
| 372 | `list_commits`, `close_issue`, `merge_pull_request`. |
| 373 | |
| 374 | ## Key files |
| 375 | |
| 376 | - `app/controllers/api/v1/mcp_controller.rb`: HTTP transport, auth, batch handling. |
| 377 | - `app/services/mcp/server.rb`: protocol dispatch. |
| 378 | - `app/services/mcp/tools.rb`: tool registry and tool implementations. |
| 379 | - `app/services/mcp/tool_error.rb`: in-band tool failure type. |
| 380 | - `app/services/pull_request_service.rb`, `app/services/issue_service.rb`, |
| 381 | `app/services/comment_service.rb`: mutation services. |
| 382 | - `app/services/web_search_service.rb`: web search with the provider adapter |
| 383 | boundary (Brave first). |
| 384 | - `app/models/pull_request.rb`, `issue.rb`, `comment.rb`, and the additions to |
| 385 | `repository.rb` and `git_repository_service.rb`. |
| 386 | - `config/routes.rb`: `post`/`get api/v1/mcp`. |
| 387 | </content> |