385b442b6f
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>
207 lines
10 KiB
Markdown
207 lines
10 KiB
Markdown
# 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)](https://modelcontextprotocol.io).
|
|
|
|
```
|
|
┌──────────────────────── 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:
|
|
|
|
- `APIKeyValidator` class: 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_FAILURES` within `AUTH_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_KEYS` into 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.AsyncClient` with 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 `structlog` logger writing JSON to the configured log file
|
|
- `log_tool_invocation()`: records tool calls with result and correlation ID
|
|
- `log_access_denied()`: records failed authentication
|
|
- `log_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_TOOLS` list: the canonical list of tools exposed to clients
|
|
- `get_tool_by_name()`: lookup helper used by the dispatcher
|
|
|
|
### `tools/repository.py`
|
|
|
|
Concrete tool handler functions. Responsibilities:
|
|
|
|
- `list_repositories_tool()`: calls `GiteaClient.list_repositories()`, formats the result
|
|
- `get_repository_info_tool()`: calls `GiteaClient.get_repository()`, formats metadata
|
|
- `get_file_tree_tool()`: calls `GiteaClient.get_tree()`, flattens to a list of paths
|
|
- `get_file_contents_tool()`: calls `GiteaClient.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.
|