Files
AegisGitea-MCP/docs/architecture.md
T

148 lines
7.3 KiB
Markdown

# Architecture
AegisGitea MCP turns the Gitea REST API into MCP tools. The design decisions are recorded in
[docs/adr](adr/0001-full-access-by-default.md); this page describes how the pieces fit.
## Overview
```
MCP client
| stdio, or streamable HTTP (/mcp)
v
SDK transport + auth verifier (transports.py, auth.py)
| access token -> Identity
v
Server (low-level mcp Server) (server.py)
| list_tools / call_tool, filters, argument validation
v
GiteaExecutor (gitea/executor.py)
| path templating, HTTP call with the identity's token, optional Sudo header
v
Gitea REST API (/api/v1)
```
Every tool call ends in one audit event, and every result is wrapped in the same envelope.
## Catalog
`spec/gitea-swagger.v1.json` is the Gitea 28.1.0 swagger, pinned (ADR 0003).
`scripts/generate_catalog.py` turns it into `src/aegis_gitea_mcp/catalog/tools.json`, which is
committed. At runtime only the JSON is read, through `load_catalog()`.
Each `Operation` carries the tool name (`gitea_<domain>_<action>`), domain, HTTP method, path
template, parameters (path, query, body, form data, file), the complete JSON input schema,
content types, and the flags that become MCP annotations: read-only, destructive, idempotent,
paginated. Input schemas set `additionalProperties: false`, give owner, repository and username
parameters name patterns, take multipart files as base64 strings, and add an optional `sudo`.
The catalog has 537 operations in 14 domains. [tools.md](tools.md) is generated from it, and
CI fails when `tools.json` or `tools.md` drift from the spec or from each other (see
[deployment.md](deployment.md) for upgrading).
## Server
`server.py` builds a low-level `mcp` SDK `Server` (ADR 0002) because the high-level server cannot
register tools from raw JSON schemas.
- `list_tools` returns one tool per catalog operation, plus the hand-written
`gitea_api_request` ([raw-api.md](raw-api.md)). The filters `TOOLSETS`, `READ_ONLY` and
`DISABLED_TOOLS` apply here and again when a tool is called, so a filtered tool is unknown.
Unknown names in `DISABLED_TOOLS` are a startup error.
- `call_tool` validates the arguments with `jsonschema` (the SDK does not), resolves the
identity, runs the executor, writes the audit event and returns the result. Failures of the
tool are `is_error` results the model can read; only protocol problems (an unknown tool) are
protocol errors. Validation messages never contain the offending value, so a secret typed into
the wrong field is not echoed.
- Unexpected exceptions are caught, audited as internal errors and returned as a generic
message. A stack trace never leaves the process.
## Executor
`GiteaExecutor.execute(operation, arguments, identity)` builds one HTTP request:
- path parameters are validated and percent-encoded; query and JSON or multipart bodies are
built from the schema; the `sudo` value becomes the `Sudo` header;
- the request carries the identity's Gitea token. Redirects are never followed and cookies are
never kept, so one identity cannot leak into another call;
- responses: JSON and text are decoded, binary bodies become base64 up to `MAX_BINARY_BYTES`,
`Link` and `X-Total-Count` become pagination metadata;
- errors: Gitea's message is extracted, sanitized and truncated, then mapped to a stable `kind`;
- output: items and characters are bounded, the identity's tokens, the configured secrets and
secret values submitted in the request are redacted by exact match, and
`SECRET_DETECTION_MODE` masks or blocks secret-like strings;
- the envelope (ADR 0005) carries the identity, the request, the status, pagination and the
`untrusted_content` notice.
The executor never raises for upstream or argument problems; each is a result with `is_error`.
## Transports
stdio (`MCP_TRANSPORT=stdio`, the default) serves one client over stdin and stdout. stdout is
reserved for JSON-RPC, all logging goes to stderr. It always runs as admin-token mode with
`GITEA_TOKEN`.
streamable HTTP (`MCP_TRANSPORT=http`) is the SDK's Starlette app served by uvicorn at
`MCP_PATH`, stateless, wrapped with:
- bearer authentication and, with OAuth on, the OAuth endpoints of the SDK;
- DNS-rebinding protection (allowed `Host` values: loopback with the port, the host of
`PUBLIC_BASE_URL`, `ALLOWED_HOSTS`);
- rate limiting per client on the MCP path;
- `GET /health` and `GET /` (name, version, MCP path).
Startup is fail-closed: a non-loopback bind without authentication, or OAuth enabled with
missing settings, exits with code 2 before listening.
## Authentication and identity
ADR 0004 has the full decision. Two modes can run together on HTTP.
Admin-token mode: the static `MCP_BEARER_TOKEN` is compared in constant time (digests are
compared, so length does not leak). A match runs the call as `GITEA_TOKEN`, with the admin login
resolved once from `GET /user`. stdio is always this mode.
Per-user OAuth mode: the server is an OAuth 2.1 authorization server for MCP clients (dynamic
client registration, PKCE) and a confidential OAuth client of Gitea. `/oauth/callback` receives
Gitea's code, exchanges it, and issues the client opaque access and refresh tokens that map to
the user's Gitea tokens. A call with such a token runs with the user's own Gitea access token,
so Gitea enforces that user's permissions. The mapping lives in `DATA_DIR/oauth-tokens.json`
(mode 0600). Gitea tokens in it are encrypted with Fernet (key derived from
`TOKEN_ENCRYPTION_KEY`); our own tokens are stored as SHA-256 hashes. Upstream refresh is
automatic.
One token verifier accepts either kind. The dispatcher turns the verified token into an
`Identity` (login, mode, token). The token is kept out of `repr` and out of every result and log.
## Audit
Every tool call, including failed and invalid ones, produces exactly one event in the
append-only, hash-chained audit log. Only argument names are recorded, never values. Details and
integrity validation are in [audit-logging.md](audit-logging.md).
## Request flow
1. The client sends `tools/call` (stdio, or HTTP with a bearer token).
2. HTTP only: host check, rate limit, token verification (static bearer or OAuth).
3. The dispatcher looks the tool up (filters applied), validates the arguments against the
schema, and resolves the identity.
4. The executor calls Gitea with the identity's token.
5. The response is bounded, redacted and masked, and wrapped in the envelope.
6. One audit event is written and the result is returned.
## Module map
| Path | Role |
|---|---|
| `catalog/` | model, naming, schema building, generator (`build.py`), `tools.json`, loader |
| `gitea/` | executor, path and query encoding, pagination, redaction |
| `server.py` | SDK server factory, tool list, call dispatch, filters |
| `transports.py` | stdio and HTTP runners, health route, startup checks |
| `auth.py` | static bearer verifier, identity resolution |
| `oauth/` | per-user OAuth provider, token store |
| `setup_cmd.py`, `setup_snippets.py`, `setup_template.py` | the `setup` command |
| `cli.py` | `serve`, `setup`, `version` |
| `config.py` | settings and validation |
| `identity.py`, `errors.py` | identity and error types |
| `audit.py`, `security.py`, `response_limits.py`, `rate_limit.py`, `request_context.py`, `logging_utils.py` | cross-cutting safety |
| `scripts/` | `generate_catalog.py`, `validate_audit_log.py`, `check_docs.py` |