docs(specs): add .agents/specs with reverse-engineered MCP server spec

Establish .agents/specs/ for behavioural specifications and document the MCP server feature: transport, auth, protocol method set, the six tool contracts, the pull request / issue / comment data model, shared per-repo numbering, authorization, acceptance criteria, and verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 19:34 UTC 08af9cb9cd63aee113e77068d1a027fe9cfdd78c
2 files changed +317
.agents/specs/README.md new
+17
@@ -0,0 +1,17 @@
1 +# Specs
2 +
3 +Behavioural specifications for features in `sigit-si`. A spec describes the
4 +contract a feature must satisfy: inputs, outputs, error cases, authorization,
5 +and acceptance criteria. It is the source of truth for what the code is supposed
6 +to do, independent of how it is implemented.
7 +
8 +Conventions:
9 +
10 +- One file per feature, kebab-cased (`mcp-server.md`).
11 +- Front-matter carries `name`, `description`, `status`, and the implementing
12 + paths so a reader can jump from contract to code.
13 +- `status` is one of `draft`, `implemented`, or `deprecated`.
14 +- Write to the same rules as the rest of this repo (see `../AGENTS.md`):
15 + technical and neutral, no AI-writing tells, no em dashes.
16 +- When behaviour changes, update the spec in the same change as the code.
17 +</content>
.agents/specs/mcp-server.md new
+300
@@ -0,0 +1,300 @@
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 six tools: list_repositories, get_file_contents, search_code, list_pull_requests, create_pull_request, add_issue_comment. 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,comment_service}.rb
12 + - config/routes.rb
13 +verifies:
14 + - spec/requests/api/v1/mcp_spec.rb
15 + - spec/services/pull_request_service_spec.rb
16 + - spec/services/comment_service_spec.rb
17 +---
18 +
19 +# siGit MCP server
20 +
21 +## Purpose
22 +
23 +Expose siGit-hosted git repositories to Model Context Protocol clients as tools,
24 +so an MCP client (Claude Code, the desktop app, any conforming client) can list
25 +repositories, read files, search code, and open or comment on pull requests and
26 +issues. The server lives inside the Rails monolith and reuses the existing
27 +models, auth, and authorization rather than running as a separate service.
28 +
29 +Endpoint: `POST https://sigit.si/api/v1/mcp`.
30 +
31 +## Status
32 +
33 +Implemented and deployed to production. Connected and verified end to end with:
34 +
35 +```sh
36 +claude mcp add --transport http sigit https://sigit.si/api/v1/mcp \
37 + --header "Authorization: Bearer <token>"
38 +```
39 +
40 +## Transport
41 +
42 +- **Streamable HTTP, stateless.** One JSON-RPC 2.0 message (or a batch array)
43 + per `POST`. The body is `application/json`.
44 +- A request (message with an `id`) gets a JSON-RPC response body with HTTP 200.
45 +- A notification or response (message with no `id`) produces no reply: the
46 + server returns HTTP 202 with an empty body. A batch containing only
47 + notifications also returns 202.
48 +- The server does not issue an `Mcp-Session-Id` and does not serve a
49 + server-to-client SSE stream, because it has no server-initiated traffic.
50 + `GET /api/v1/mcp` returns HTTP 405 (method not allowed) to advertise this.
51 +- Malformed JSON is rejected with HTTP 400 by the Rails JSON middleware before
52 + the controller runs. The controller also defends the same case and would
53 + return a JSON-RPC `-32700` parse error for bodies it parses itself.
54 +
55 +## Authentication
56 +
57 +- Bearer token in the `Authorization` header: `Authorization: Bearer <token>`,
58 + where `<token>` is a siGit access token (the same smbCloud-issued token the
59 + rest of `/api/v1` accepts).
60 +- The token is validated against smbCloud via `SmbcloudAuthService.me` and the
61 + local `User` mirror is resolved or created with
62 + `User.find_or_create_from_smbcloud`. This is the same flow as
63 + `Api::BaseController#authenticate_token!`; the MCP controller performs it in a
64 + private `resolve_user` method so the smbCloud dependency is touched only at
65 + request time, not at class load.
66 +- A missing, malformed, or invalid token returns HTTP 401 with a JSON-RPC error
67 + body `{ code: -32001, message: "Unauthorized" }` and a `WWW-Authenticate`
68 + header that points at the protected-resource metadata URL:
69 +
70 + ```
71 + WWW-Authenticate: Bearer realm="sigit", error="invalid_token",
72 + resource_metadata="<base_url>/.well-known/oauth-protected-resource"
73 + ```
74 +
75 +- A token obtained from `POST /api/v1/auth/sign_in` (`{email, password}` ->
76 + `{"Ready":{"access_token": ...}}`) is a valid bearer token here.
77 +
78 +### Future: OAuth 2.1 (not yet implemented)
79 +
80 +The bearer token is the front door today. The intended upgrade is OAuth 2.1 so a
81 +client can authorize through the standard `/mcp` browser flow without pasting a
82 +token: serve `/.well-known/oauth-protected-resource` and
83 +`/.well-known/oauth-authorization-server`, support PKCE and dynamic client
84 +registration, validate the `Origin` header against DNS rebinding, and rate-limit
85 +per token. The 401 challenge already advertises the protected-resource URL.
86 +
87 +## Protocol methods
88 +
89 +`Mcp::Server#handle` dispatches one parsed JSON-RPC message:
90 +
91 +| Method | Result |
92 +|--------|--------|
93 +| `initialize` | Handshake (see below). |
94 +| `ping` | `{}`. |
95 +| `tools/list` | `{ "tools": [ <spec>, ... ] }` for all registered tools. |
96 +| `tools/call` | Runs the named tool (see Tool dispatch). |
97 +| `resources/list` | `{ "resources": [] }` (none offered). |
98 +| `prompts/list` | `{ "prompts": [] }` (none offered). |
99 +| `notifications/*` | No reply (returns nil; the controller answers 202). |
100 +| anything else | JSON-RPC error `-32601` "Method not found". |
101 +
102 +Any unhandled exception during dispatch is logged and returned as JSON-RPC
103 +`-32603` "Internal error"; the underlying message is not leaked to the client.
104 +
105 +### initialize
106 +
107 +Request params may include `protocolVersion`. The server echoes the requested
108 +version when it is one of the supported versions
109 +(`2025-06-18`, `2025-03-26`, `2024-11-05`), otherwise it falls back to the latest
110 +it supports (`2025-06-18`). Result:
111 +
112 +```json
113 +{
114 + "protocolVersion": "2025-06-18",
115 + "capabilities": { "tools": { "listChanged": false } },
116 + "serverInfo": { "name": "sigit", "title": "siGit Code", "version": "0.1.0" },
117 + "instructions": "Tools for siGit-hosted git repositories: ..."
118 +}
119 +```
120 +
121 +## Error model
122 +
123 +Two distinct failure channels, and they must not be confused:
124 +
125 +1. **Protocol errors** (bad or unknown method, parse error, unknown tool name)
126 + are JSON-RPC errors: an `error` object with a negative `code`. Codes used:
127 + `-32700` parse error, `-32601` method not found, `-32602` invalid params /
128 + unknown tool, `-32603` internal error, `-32001` unauthorized.
129 +2. **Tool failures** (bad arguments, not found, not authorized, validation
130 + failure) are returned in band as a successful `tools/call` result with
131 + `isError: true` and a human-readable message in the text content. The model
132 + sees the message and can recover. A tool failure is never a protocol error.
133 +
134 +A tool signals an in-band failure by raising `Mcp::ToolError`; the server catches
135 +it and renders the `isError: true` result.
136 +
137 +## Tool registry
138 +
139 +Tools are a data-driven registry in `Mcp::Tools.all`: an ordered array of
140 +`Mcp::Tools::Base` subclasses. `tools/list` and `tools/call` both read from it,
141 +so adding a tool is a one-line change (append the class to `all`). Each tool
142 +declares `tool_name`, `description`, `input_schema` (JSON Schema surfaced to the
143 +model), and a `call`.
144 +
145 +The six tools, in registry order:
146 +
147 +### list_repositories
148 +
149 +- **Purpose:** list repositories the authenticated user can access. The model
150 + uses this first to discover the `owner/name` for other tools.
151 +- **Input:** `query` (optional substring of owner or repo name, case
152 + insensitive), `limit` (optional, default 30, clamped 1..100).
153 +- **Behaviour:** `Repository.visible_to(current_user)` (public repos plus the
154 + user's own), optional `ILIKE` filter on repo name or owner username, ordered by
155 + `updated_at` descending, limited.
156 +- **Output:** array of `{ full_name, description, default_branch, private, url }`.
157 +
158 +### get_file_contents
159 +
160 +- **Purpose:** read a file at a ref.
161 +- **Input (required):** `repo` (`owner/name`), `path`. Optional `ref` (branch,
162 + tag, or SHA; defaults to the repo default branch).
163 +- **Behaviour:** resolve the repo (read scope), then
164 + `Repository#blob_at(ref, path)` (git read layer).
165 +- **Output:** `{ repo, path, ref, size, content }` where `size` is the content
166 + byte size.
167 +- **Errors:** missing repo -> isError "Repository not found or not accessible";
168 + missing file -> isError "File not found: <path>@<ref>".
169 +
170 +### search_code
171 +
172 +- **Purpose:** search file contents and return matching lines.
173 +- **Input (required):** `repo`, `query`. Optional `ref` (defaults to default
174 + branch), `limit` (default 20, clamped 1..100).
175 +- **Behaviour:** `Repository#search_code` -> `GitRepositoryService.search_code`,
176 + which runs `git grep` (fixed string, case insensitive, skips binary files) and
177 + parses `<ref>:<path>:<line>:<text>`.
178 +- **Output:** array of `{ path, line, snippet }`, capped at `limit`.
179 +
180 +### list_pull_requests
181 +
182 +- **Purpose:** list pull requests, optionally by state.
183 +- **Input (required):** `repo`. Optional `state` in
184 + `open|closed|merged|all` (default `open`).
185 +- **Behaviour:** `repo.pull_requests`, filtered by state unless `all`, ordered by
186 + `number` descending, limited to 50.
187 +- **Output:** array of `{ number, title, state, head, base, url }`.
188 +
189 +### create_pull_request
190 +
191 +- **Purpose:** open a pull request from a head branch into a base branch.
192 +- **Input (required):** `repo`, `title`, `head`, `base`. Optional `body`
193 + (Markdown).
194 +- **Behaviour:** routed through `PullRequestService.create` so authorization,
195 + branch validation, and per-repo numbering run identically to any other caller.
196 +- **Output:** `{ number, state, url }`.
197 +- **Errors (in band):** not authorized to write the repo; head or base branch
198 + not found; head equals base; any model validation failure.
199 +
200 +### add_issue_comment
201 +
202 +- **Purpose:** post a comment on an issue or pull request by its number.
203 +- **Input (required):** `repo`, `number`, `body` (Markdown).
204 +- **Behaviour:** routed through `CommentService.create`. Issues and pull requests
205 + share one per-repo number space, so a number resolves to exactly one of them.
206 +- **Output:** `{ id, url }`.
207 +- **Errors (in band):** no issue or PR with that number; blank body.
208 +
209 +## Data model
210 +
211 +The app had no pull request, issue, or comment concept before this feature; the
212 +following were added to back the tools.
213 +
214 +- **PullRequest** belongs to a repository and a user (author), has many comments
215 + (polymorphic). Columns: `number`, `title`, `body`, `state`
216 + (`open|closed|merged`, default `open`), `head_ref`, `base_ref`, `merged_at`.
217 + Validations: number present and unique per repository, title present, head and
218 + base present, head differs from base, state in the allowed set.
219 +- **Issue** belongs to a repository and a user (author), has many comments
220 + (polymorphic). Columns: `number`, `title`, `body`, `state`
221 + (`open|closed`, default `open`), `closed_at`. Validations: number present and
222 + unique per repository, title present, state in the allowed set.
223 +- **Comment** belongs to a polymorphic `commentable` (a PullRequest or an Issue)
224 + and a user (author). Column: `body` (present). `html_url` anchors to the parent.
225 +- **Shared numbering.** Pull requests and issues draw from one per-repository
226 + number space (as on GitHub), so a number maps to exactly one record. The next
227 + number is `max(pull_requests.number, issues.number) + 1`, computed under a
228 + repository row lock during creation.
229 +
230 +## Authorization
231 +
232 +- **Read** (list_repositories, get_file_contents, search_code, list_pull_requests,
233 + resolving the repo for any tool): `Repository.visible_to(user)`, which returns
234 + public repositories plus the caller's own. Private repositories of other users
235 + are never resolvable, so they cannot be read or mutated.
236 +- **Write** (create_pull_request): `Repository#writable_by?(user)`. siGit repos
237 + have a single owner and no collaborators yet, so write equals ownership. A
238 + non-owner attempt returns an in-band `isError`.
239 +- **Comment** (add_issue_comment): requires read access to the repo (enforced by
240 + repo resolution). The author is always the authenticated user.
241 +
242 +Mutations go through the service objects (`PullRequestService`, `CommentService`),
243 +never directly through `create!` from the tool, so that the same validations and
244 +authorization run for the MCP path as for any future web UI.
245 +
246 +## Supporting layers
247 +
248 +- **Git read layer.** `Repository#blob_at(ref, path)` delegates to
249 + `GitRepositoryService.file_content` (a `git show <ref>:<path>`).
250 + `Repository#search_code` delegates to `GitRepositoryService.search_code`
251 + (`git grep`).
252 +- **URLs.** `Repository#html_url`, `PullRequest#html_url`, `Issue#html_url`, and
253 + `Comment#html_url` build absolute links from `Repository.site_url`, which reads
254 + `SIGITSI_URL` (default `https://sigit.si`). This is needed because tools have no
255 + HTTP request context to derive a base URL from.
256 +
257 +## Acceptance criteria
258 +
259 +1. `claude mcp add --transport http sigit https://sigit.si/api/v1/mcp --header
260 + "Authorization: Bearer <token>"` connects.
261 +2. `tools/list` returns all six tools.
262 +3. `list_repositories` and `get_file_contents` work end to end against a real
263 + repository.
264 +4. An unauthenticated request returns a JSON-RPC 401 with a `WWW-Authenticate`
265 + challenge.
266 +5. A failing tool returns `isError: true` with a message, not a protocol error.
267 +
268 +## Verification
269 +
270 +- Request specs (`spec/requests/api/v1/mcp_spec.rb`): initialize handshake,
271 + `tools/list` shows all six, a successful `tools/call`, a notification answered
272 + with 202, a 401 with the challenge, and a tool returning `isError`.
273 +- Service specs (`spec/services/pull_request_service_spec.rb`,
274 + `comment_service_spec.rb`): authorization, branch validation, head-differs-from-
275 + base, shared numbering, comment resolution across issues and pull requests,
276 + blank-body rejection.
277 +
278 +## Out of scope (current) and natural extensions
279 +
280 +- Server-to-client streaming and sessions (`Mcp-Session-Id`, GET SSE). Add both
281 + if the server later pushes events.
282 +- OAuth 2.1 authorization (see Authentication).
283 +- Webhooks on pull request / issue / comment creation. None exist in the app yet;
284 + when added, they run automatically because mutations go through the service
285 + objects.
286 +- Further tools, each a one-line registry addition: `get_pull_request` (with
287 + diff), `list_branches`, `list_commits`, `create_issue`, `list_issues`.
288 +
289 +## Key files
290 +
291 +- `app/controllers/api/v1/mcp_controller.rb`: HTTP transport, auth, batch handling.
292 +- `app/services/mcp/server.rb`: protocol dispatch.
293 +- `app/services/mcp/tools.rb`: tool registry and tool implementations.
294 +- `app/services/mcp/tool_error.rb`: in-band tool failure type.
295 +- `app/services/pull_request_service.rb`, `app/services/comment_service.rb`:
296 + mutation services.
297 +- `app/models/pull_request.rb`, `issue.rb`, `comment.rb`, and the additions to
298 + `repository.rb` and `git_repository_service.rb`.
299 +- `config/routes.rb`: `post`/`get api/v1/mcp`.
300 +</content>