main
md 387 lines 18.6 KB
Rendered Raw
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>