# 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 is audited (a write-ahead `tool_call_started` entry, then one `tool_invocation` entry with the outcome), 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, 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. `/authorize` parks the request and shows the consent page `/oauth/consent` (client name, redirect URI, approval with CSRF and origin checks) before the browser goes to Gitea. `/oauth/callback` receives Gitea's code in the same browser, 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-store.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 result event in the append-only, hash-chained audit log, after a write-ahead event that makes a call fail closed when the log stops accepting writes. 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 result event is written and the result is returned. (A write-ahead `tool_call_started` event is written right after the tool lookup in step 3; when it cannot be written the call is refused.) ## 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` |