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>