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>