Files
AegisGitea-MCP/docs/architecture.md
T

7.8 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 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>_<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_tools returns one tool per catalog operation, plus the hand-written gitea_api_request (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.

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