7.7 KiB
Architecture
AegisGitea MCP turns the Gitea REST API into MCP tools. The design decisions are recorded in docs/adr; 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 is generated from it, and
CI fails when tools.json or tools.md drift from the spec or from each other (see
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_toolsreturns one tool per catalog operation, plus the hand-writtengitea_api_request(raw-api.md). The filtersTOOLSETS,READ_ONLYandDISABLED_TOOLSapply here and again when a tool is called, so a filtered tool is unknown. Unknown names inDISABLED_TOOLSare a startup error.call_toolvalidates the arguments withjsonschema(the SDK does not), resolves the identity, runs the executor, writes the audit event and returns the result. Failures of the tool areis_errorresults 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
sudovalue becomes theSudoheader; - 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,LinkandX-Total-Countbecome 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_MODEmasks or blocks secret-like strings; - the envelope (ADR 0005) carries the identity, the request, the status, pagination and the
untrusted_contentnotice.
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
Hostvalues: loopback with the port, the host ofPUBLIC_BASE_URL,ALLOWED_HOSTS); - rate limiting per client on the MCP path;
GET /healthandGET /(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.
Request flow
- The client sends
tools/call(stdio, or HTTP with a bearer token). - HTTP only: host check, rate limit, token verification (static bearer or OAuth).
- The dispatcher looks the tool up (filters applied), validates the arguments against the schema, and resolves the identity.
- The executor calls Gitea with the identity's token.
- The response is bounded, redacted and masked, and wrapped in the envelope.
- One audit result event is written and the result is returned. (A write-ahead
tool_call_startedevent 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 |