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
+17
new file mode 100644 index 0000000..42a8b7a --- /dev/null +++ b/.agents/specs/README.md @@ -0,0 +1,17 @@ +# Specs + +Behavioural specifications for features in `sigit-si`. A spec describes the +contract a feature must satisfy: inputs, outputs, error cases, authorization, +and acceptance criteria. It is the source of truth for what the code is supposed +to do, independent of how it is implemented. + +Conventions: + +- One file per feature, kebab-cased (`mcp-server.md`). +- Front-matter carries `name`, `description`, `status`, and the implementing + paths so a reader can jump from contract to code. +- `status` is one of `draft`, `implemented`, or `deprecated`. +- Write to the same rules as the rest of this repo (see `../AGENTS.md`): + technical and neutral, no AI-writing tells, no em dashes. +- When behaviour changes, update the spec in the same change as the code. +</content>
.agents/specs/mcp-server.md
+300
new file mode 100644 index 0000000..c2af613 --- /dev/null +++ b/.agents/specs/mcp-server.md @@ -0,0 +1,300 @@ +--- +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 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." +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,comment_service}.rb + - config/routes.rb +verifies: + - spec/requests/api/v1/mcp_spec.rb + - spec/services/pull_request_service_spec.rb + - spec/services/comment_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: + +```sh +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: + +```json +{ + "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 six 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: <path>@<ref>". + +### 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_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 }`. + +### 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. + +## 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 under a + repository row lock during creation. + +## Authorization + +- **Read** (list_repositories, get_file_contents, search_code, list_pull_requests, + 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** (add_issue_comment): requires read access to the repo (enforced by + repo resolution). The author is always the authenticated user. + +Mutations go through the service objects (`PullRequestService`, `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`). +- **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 six 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 six, a successful `tools/call`, a notification answered + with 202, a 401 with the challenge, and a tool returning `isError`. +- Service specs (`spec/services/pull_request_service_spec.rb`, + `comment_service_spec.rb`): authorization, branch validation, head-differs-from- + base, shared numbering, comment resolution across issues and pull requests, + 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: `get_pull_request` (with + diff), `list_branches`, `list_commits`, `create_issue`, `list_issues`. + +## 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/comment_service.rb`: + mutation services. +- `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`. +</content>