Reframe the README around two transports and add a local stdio quickstart with uvx/pip and Claude Desktop / Claude Code wiring. New docs: local-quickstart.md and packaging.md (uv build/publish). Document resource-type-aware authorization and classified gitea_request in security.md; stdio env vars + audit-log fallback in configuration.md; local install in deployment.md; core+adapters in architecture.md. Add the missing root AGENTS.md contract, update CLAUDE.md with the core/adapter layout, fail-closed invariants, and the branching flow (HEAD -> feature -> dev -> main). Update roadmap/todo and .env.example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
Architecture
Overview
AegisGitea MCP is a Python 3.10+ application split into a transport-agnostic core and two thin transport adapters that consume it. It bridges an AI client (Claude, Claude Code, Cowork) and a self-hosted Gitea instance, implementing the Model Context Protocol (MCP).
┌──────────────────────── shared core ────────────────────────┐
│ registry.py (name -> handler; single source of truth) │
│ tools/* (async handlers: gitea, arguments, raw) │
│ policy.py (allow/deny, WRITE_MODE gate) │
│ authz.py (resource-type-aware authorization) │
│ gitea_client · audit · security · response_limits · config │
│ errors.ToolError (transport-agnostic error type) │
│ NO fastapi / uvicorn imports (locked by a boundary test) │
└───────▲───────────────────────────────────────▲─────────────┘
│ │
┌────────────────┴───────────┐ ┌──────────────┴──────────────┐
│ HTTP / OAuth adapter │ │ Local stdio adapter │
│ server.py (FastAPI) │ │ stdio_app.py (mcp SDK) │
│ per-user OAuth2/OIDC, DCR, │ │ single PAT owner, no OAuth, │
│ rate limit, per-user repo │ │ policy + WRITE_MODE + audit │
│ authz + resource-type gate │ │ over stdio │
│ [server] extra │ │ core install │
└─────────────────────────────┘ └──────────────────────────────┘
Where the security layers sit on a dispatched call: scope check → policy
(policy.py) → resource-type authorization (authz.py) → handler → response
limits + secret sanitization → audit. For gitea_request, the handler adds a
deterministic write classifier, a known-path gate, and the admin/credential
denylist. The HTTP adapter runs the per-user repository-permission probe and the
resource-type gate; the stdio adapter trusts the PAT owner and skips the
per-user probe while keeping policy, WRITE_MODE, and audit.
The legacy single-process view below still describes the HTTP adapter:
AI Client (Claude / Claude Code / Cowork)
│
│ HTTP (Authorization: Bearer <key>)
▼
┌────────────────────────────────────────────┐
│ FastAPI Server │
│ server.py │
│ - Route: GET/POST /mcp │
│ - Route: POST /mcp/tool/call │
│ - Route: GET /mcp/tools │
│ - Route: GET /health │
│ - Streamable HTTP transport │
│ - Legacy SSE alias (GET/POST /mcp/sse) │
└───────┬───────────────────┬────────────────┘
│ │
┌────▼────┐ ┌────▼──────────────┐
│ auth │ │ Tool dispatcher │
│ auth.py│ │ (server.py) │
└────┬────┘ └────────┬──────────┘
│ │
│ ┌────────▼──────────┐
│ │ Tool handlers │
│ │ tools/repo.py │
│ └────────┬──────────┘
│ │
│ ┌────────▼──────────┐
│ │ GiteaClient │
│ │ gitea_client.py │
│ └────────┬──────────┘
│ │ HTTPS
│ ▼
│ Gitea instance
│
┌────▼────────────────────┐
│ AuditLogger │
│ audit.py │
│ /var/log/aegis-mcp/ │
│ audit.log │
└─────────────────────────┘
Source Modules
server.py
The entry point and FastAPI application. Responsibilities:
- Defines all HTTP routes
- Reads configuration on startup and initialises
GiteaClient - Applies authentication middleware to protected routes
- Dispatches tool calls to the appropriate handler function
- Handles CORS
auth.py
API key validation. Responsibilities:
APIKeyValidatorclass: holds the set of valid keys, tracks failed attempts per IP- Constant-time comparison to prevent timing side-channels
- Rate limiting: blocks IPs that exceed
MAX_AUTH_FAILURESwithinAUTH_FAILURE_WINDOW - Helper functions for key generation and hashing
- Singleton pattern (
get_validator()) with test-friendly reset (reset_validator())
config.py
Pydantic BaseSettings model. Responsibilities:
- Loads all configuration from environment variables or
.env - Validates values (log level enum, token format, key minimum length)
- Parses comma-separated
MCP_API_KEYSinto a list - Exposes computed properties (e.g. base URL for Gitea API)
gitea_client.py
Async HTTP client for the Gitea API. Responsibilities:
- Wraps
httpx.AsyncClientwith bearer token authentication - Maps HTTP status codes to typed exceptions (
GiteaAuthenticationError,GiteaNotFoundError, etc.) - Enforces file size limit before returning file contents
- Logs all API calls to the audit logger
Key methods:
| Method | Gitea endpoint |
|---|---|
get_current_user() |
GET /api/v1/user |
list_repositories() |
GET /api/v1/user/repos |
get_repository() |
GET /api/v1/repos/{owner}/{repo} |
get_file_contents() |
GET /api/v1/repos/{owner}/{repo}/contents/{path} |
get_tree() |
GET /api/v1/repos/{owner}/{repo}/git/trees/{ref} |
audit.py
Structured audit logging using structlog. Responsibilities:
- Initialises a
structloglogger writing JSON to the configured log file log_tool_invocation(): records tool calls with result and correlation IDlog_access_denied(): records failed authenticationlog_security_event(): records rate limit triggers and other security events- Auto-generates UUID correlation IDs when none is provided
mcp_protocol.py
MCP data models and tool registry. Responsibilities:
- Pydantic models:
MCPTool,MCPToolCallRequest,MCPToolCallResponse,MCPListToolsResponse AVAILABLE_TOOLSlist: the canonical list of tools exposed to clientsget_tool_by_name(): lookup helper used by the dispatcher
tools/repository.py
Concrete tool handler functions. Responsibilities:
list_repositories_tool(): callsGiteaClient.list_repositories(), formats the resultget_repository_info_tool(): callsGiteaClient.get_repository(), formats metadataget_file_tree_tool(): callsGiteaClient.get_tree(), flattens to a list of pathsget_file_contents_tool(): callsGiteaClient.get_file_contents(), decodes base64
All handlers return a plain string. server.py wraps this in an MCPToolCallResponse.
Request Lifecycle
1. Client sends POST /mcp/tool/call
│
2. FastAPI routes the request to the tool-call handler in server.py
│
3. OAuth middleware validates the Bearer token via Gitea OIDC/JWKS or userinfo
├── Fail → AuditLogger.log_access_denied() → HTTP 401 / 429
└── Pass → continue
│
4. AuditLogger.log_tool_invocation(status="pending")
│
5. Tool dispatcher looks up the tool by name (mcp_protocol.get_tool_by_name)
│
6. Policy engine checks read/write mode and repository/path policy
│
7. If GITEA_TOKEN is configured, service-PAT authz checks
GET /repos/{owner}/{repo}/collaborators/{user}/permission
│
8. Tool handler function (tools/repository.py) is called
│
9. GiteaClient makes an async HTTP call to the Gitea API
│
10. Result (or error) is returned to server.py
│
11. AuditLogger.log_tool_invocation(status="success" | "error")
│
12. MCPToolCallResponse is returned to the client
Key Design Decisions
Read by default, writes opt-in. Read tools are available by default. Write-capable tools require WRITE_MODE=true, repository write policy/whitelist approval, and write:repository authorization.
Gitea controls repository access. Without GITEA_TOKEN, Gitea enforces repository permissions on API calls made with the user's token. With GITEA_TOKEN, the service PAT can only execute after the server verifies the requesting user's actual repository permission through Gitea and writes an audit denial if the check fails.
Public tool discovery. GET /mcp/tools requires no authentication so that MCP clients can discover the available tools without credentials. All other endpoints require authentication.
Minimal persisted state. The audit log is persisted for tamper-evident review. Dynamic OAuth client registrations are persisted when DCR is enabled. Rate limit counters and short-lived authz caches are in-memory and reset on restart.
Async throughout. FastAPI + httpx.AsyncClient means all Gitea API calls are non-blocking, allowing the server to handle concurrent requests efficiently.