153 lines
7.8 KiB
Markdown
153 lines
7.8 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 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](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` |
|