docs: local vs server quickstart, authz model, packaging
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>
This commit is contained in:
+32
-1
@@ -2,7 +2,38 @@
|
||||
|
||||
## Overview
|
||||
|
||||
AegisGitea MCP is a Python 3.10+ application built on **FastAPI**. It acts as a bridge between an AI client (such as Claude, Claude Code, or Cowork) and a self-hosted Gitea instance, implementing the [Model Context Protocol (MCP)](https://modelcontextprotocol.io).
|
||||
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)
|
||||
|
||||
+33
-1
@@ -6,6 +6,37 @@ Copy `.env.example` to `.env` and set values before starting:
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Local stdio transport (`aegis-gitea-mcp`)
|
||||
|
||||
The local single-user server reads only two variables; a local `.env` file is
|
||||
supported via python-dotenv.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `GITEA_URL` | Yes | - | Base URL of your Gitea instance |
|
||||
| `GITEA_TOKEN` | Yes | - | Your Gitea Personal Access Token (the local identity) |
|
||||
| `AUDIT_LOG_PATH` | No | per-user state path | Audit log location (see below) |
|
||||
|
||||
The local adapter forces `OAUTH_MODE=false` and defaults `AUTH_ENABLED=false`
|
||||
(no API-key requirement) — the operator is the trusted PAT owner. `WRITE_MODE`,
|
||||
`WRITE_REPOSITORY_WHITELIST`, `POLICY_FILE_PATH`, `SECRET_DETECTION_MODE`,
|
||||
`RAW_API_ENABLED`, and `RAW_API_ALLOW_SENSITIVE` all behave exactly as on the
|
||||
server.
|
||||
|
||||
**Audit-log fallback.** When `AUDIT_LOG_PATH` is unset, the container default
|
||||
(`/var/log/aegis-mcp/audit.log`) is replaced with a writable per-user path:
|
||||
|
||||
- Windows: `%LOCALAPPDATA%\aegis-gitea-mcp\audit.log`
|
||||
- Linux/macOS: `$XDG_STATE_HOME/aegis-gitea-mcp/audit.log`, else
|
||||
`~/.local/state/aegis-gitea-mcp/audit.log`
|
||||
|
||||
## Raw API dispatch (`gitea_request`)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `RAW_API_ENABLED` | No | `true` | Enable the generic `gitea_request` escape hatch |
|
||||
| `RAW_API_ALLOW_SENSITIVE` | No | `false` | Opt in to the admin/credential surface (`/admin`, `*tokens*`, `*secrets*`, `*hooks*`, `*keys*`, `applications/oauth2`, runner registration). Admin calls additionally require a verified site administrator. |
|
||||
|
||||
## OAuth/OIDC Settings (Primary)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
@@ -67,6 +98,7 @@ cp .env.example .env
|
||||
|
||||
These are retained for compatibility but not used for OAuth-protected MCP tool execution:
|
||||
|
||||
- `GITEA_TOKEN`
|
||||
- `GITEA_TOKEN` — note: in **service-PAT** server mode and in the **local stdio**
|
||||
transport this is required and is the API identity (see above).
|
||||
- `MCP_API_KEYS`
|
||||
- `AUTH_ENABLED`
|
||||
|
||||
+22
-1
@@ -8,7 +8,20 @@
|
||||
- Policy checks run before tool execution.
|
||||
- OAuth-protected MCP challenge responses are enabled by default for tool calls.
|
||||
|
||||
## Local Development
|
||||
## Local stdio install (single user)
|
||||
|
||||
The local transport needs only the core package (no web stack):
|
||||
|
||||
```bash
|
||||
pip install aegis-gitea-mcp # or: uvx aegis-gitea-mcp
|
||||
GITEA_URL=https://git.hiddenden.cafe GITEA_TOKEN=<pat> aegis-gitea-mcp
|
||||
```
|
||||
|
||||
It authenticates with your Gitea PAT, runs policy + `WRITE_MODE` + audit, and
|
||||
serves over stdio for Claude Desktop / Claude Code. See
|
||||
[local-quickstart.md](local-quickstart.md).
|
||||
|
||||
## Local Development (HTTP server)
|
||||
|
||||
```bash
|
||||
make install-dev
|
||||
@@ -16,6 +29,14 @@ cp .env.example .env
|
||||
make run
|
||||
```
|
||||
|
||||
The HTTP server requires the web stack. From a published package that is the
|
||||
`[server]` extra:
|
||||
|
||||
```bash
|
||||
pip install 'aegis-gitea-mcp[server]'
|
||||
aegis-gitea-mcp-server
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Use `docker/Dockerfile`:
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Local quickstart (stdio)
|
||||
|
||||
The local transport runs AegisGitea-MCP on your own machine as a single-user MCP
|
||||
server over stdio. It authenticates with **your** Gitea Personal Access Token
|
||||
(PAT) — there is no OAuth, no public endpoint, and no web stack to install.
|
||||
|
||||
## What you need
|
||||
|
||||
- A Gitea instance URL (`GITEA_URL`).
|
||||
- A Gitea Personal Access Token (`GITEA_TOKEN`) with least privilege:
|
||||
- `read:repository`
|
||||
- `write:repository` only if you intend to enable `WRITE_MODE`.
|
||||
- [`uv`](https://docs.astral.sh/uv/) (for `uvx`) or `pip`.
|
||||
|
||||
## Run it
|
||||
|
||||
With `uvx` (no install):
|
||||
|
||||
```bash
|
||||
GITEA_URL=https://git.hiddenden.cafe \
|
||||
GITEA_TOKEN=<pat> \
|
||||
uvx aegis-gitea-mcp
|
||||
```
|
||||
|
||||
With pip:
|
||||
|
||||
```bash
|
||||
pip install aegis-gitea-mcp
|
||||
GITEA_URL=https://git.hiddenden.cafe GITEA_TOKEN=<pat> aegis-gitea-mcp
|
||||
```
|
||||
|
||||
A local `.env` file is also supported — drop `GITEA_URL` and `GITEA_TOKEN` in it
|
||||
and just run `aegis-gitea-mcp`.
|
||||
|
||||
If a required variable is missing the server exits with a clear message instead
|
||||
of a traceback.
|
||||
|
||||
## Wire it into a client
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add aegis-gitea \
|
||||
-e GITEA_URL=https://git.hiddenden.cafe \
|
||||
-e GITEA_TOKEN=<pat> \
|
||||
-- uvx aegis-gitea-mcp
|
||||
```
|
||||
|
||||
Claude Desktop (`claude_desktop_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"aegis-gitea": {
|
||||
"command": "uvx",
|
||||
"args": ["aegis-gitea-mcp"],
|
||||
"env": {
|
||||
"GITEA_URL": "https://git.hiddenden.cafe",
|
||||
"GITEA_TOKEN": "<pat>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What still applies locally
|
||||
|
||||
The local adapter is single-user and trusts the PAT owner, so it skips the
|
||||
per-user repository-permission probe used by the public server. Everything else
|
||||
is identical to the server:
|
||||
|
||||
- **Policy engine** (`policy.yaml`) — same allow/deny rules.
|
||||
- **`WRITE_MODE`** — off by default; writes are denied unless explicitly enabled
|
||||
and whitelisted.
|
||||
- **`gitea_request`** full-API escape hatch — same write classifier, known-path
|
||||
gate, and admin/credential denylist.
|
||||
- **Secret sanitization** of tool output.
|
||||
- **Tamper-evident audit log** — written to a per-user path when the container
|
||||
default is not writable:
|
||||
- Windows: `%LOCALAPPDATA%\aegis-gitea-mcp\audit.log`
|
||||
- Linux/macOS: `$XDG_STATE_HOME/aegis-gitea-mcp/audit.log` or
|
||||
`~/.local/state/aegis-gitea-mcp/audit.log`
|
||||
- Override with `AUDIT_LOG_PATH`.
|
||||
|
||||
## Enabling writes locally
|
||||
|
||||
Writes are opt-in, exactly as on the server:
|
||||
|
||||
```bash
|
||||
GITEA_URL=https://git.hiddenden.cafe \
|
||||
GITEA_TOKEN=<pat-with-write-repository> \
|
||||
WRITE_MODE=true \
|
||||
WRITE_REPOSITORY_WHITELIST=acme/app,acme/docs \
|
||||
uvx aegis-gitea-mcp
|
||||
```
|
||||
|
||||
See [configuration.md](configuration.md) for the full variable reference and
|
||||
[write-mode.md](write-mode.md) for the write-mode model.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Packaging & publishing
|
||||
|
||||
AegisGitea-MCP is distributed as a single Python package, `aegis-gitea-mcp`,
|
||||
built with [`uv`](https://docs.astral.sh/uv/) and published to the self-hosted
|
||||
Gitea package registry.
|
||||
|
||||
## Distribution layout
|
||||
|
||||
One package, two console scripts, one optional extra:
|
||||
|
||||
| Console script | Entry point | Requires |
|
||||
|----------------|-------------|----------|
|
||||
| `aegis-gitea-mcp` | `aegis_gitea_mcp.stdio_app:main` | core only |
|
||||
| `aegis-gitea-mcp-server` | `aegis_gitea_mcp.server_entry:main` | `[server]` extra |
|
||||
|
||||
- **Core** (default install): `httpx`, `pydantic`, `pydantic-settings`, `PyYAML`,
|
||||
`python-dotenv`, `structlog`, `mcp`. Enough to run the local stdio server.
|
||||
- **`[server]` extra**: `fastapi`, `uvicorn[standard]`, `PyJWT[crypto]`,
|
||||
`python-multipart`. The public HTTP/OAuth server.
|
||||
|
||||
The `aegis-gitea-mcp-server` entry point degrades gracefully: invoked without
|
||||
the web stack it prints `install 'aegis-gitea-mcp[server]'` instead of a
|
||||
`ModuleNotFoundError` traceback.
|
||||
|
||||
## Build locally
|
||||
|
||||
```bash
|
||||
uv build
|
||||
# -> dist/aegis_gitea_mcp-<version>-py3-none-any.whl
|
||||
# -> dist/aegis_gitea_mcp-<version>.tar.gz
|
||||
```
|
||||
|
||||
Smoke-test the local stdio server from the built wheel:
|
||||
|
||||
```bash
|
||||
GITEA_URL=https://git.hiddenden.cafe GITEA_TOKEN=<pat> \
|
||||
uvx --from ./dist/aegis_gitea_mcp-*.whl aegis-gitea-mcp
|
||||
```
|
||||
|
||||
## Install from the Gitea registry
|
||||
|
||||
```bash
|
||||
uv pip install \
|
||||
--index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple \
|
||||
aegis-gitea-mcp
|
||||
```
|
||||
|
||||
(With `pip`, use `--index-url` the same way.)
|
||||
|
||||
## Cutting a release
|
||||
|
||||
Releases are tag-driven. The publish workflow
|
||||
(`.gitea/workflows/publish.yml`) triggers on a `v*` tag, runs lint + tests
|
||||
first, builds with `uv`, and publishes to the Gitea PyPI registry.
|
||||
|
||||
1. Bump `version` in `pyproject.toml` (e.g. `0.2.0`).
|
||||
2. Open a PR into `dev`, merge `dev` into `main`.
|
||||
3. Tag the release commit and push the tag:
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
4. The workflow publishes the wheel + sdist and attaches them to the run.
|
||||
|
||||
### Required CI secrets
|
||||
|
||||
The publish job uses Gitea Actions secrets — never hardcode credentials:
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| `GITEA_PACKAGE_USER` | Gitea username that owns the package |
|
||||
| `GITEA_PACKAGE_TOKEN` | least-privilege PAT with `write:package` |
|
||||
|
||||
If either secret is absent the job fails loudly rather than publishing
|
||||
anonymously.
|
||||
|
||||
> Publishing to public PyPI is intentionally **not** configured. A second,
|
||||
> separately-gated `uv publish` step would be required and is left as a
|
||||
> commented stub in the workflow.
|
||||
@@ -7,6 +7,8 @@
|
||||
3. Controlled write-mode rollout.
|
||||
4. Automation and event-driven workflows.
|
||||
5. Continuous hardening and enterprise controls.
|
||||
6. Dual transport (HTTP/OAuth + local stdio) on a shared core, with safe
|
||||
full-API coverage and resource-type-aware authorization (0.2.0).
|
||||
|
||||
## Threat Model Updates
|
||||
|
||||
|
||||
+51
-1
@@ -32,7 +32,57 @@
|
||||
|
||||
- Each MCP request executes with the signed-in user token.
|
||||
- Gitea authorization stays source-of-truth for repository visibility.
|
||||
- A compromised token is limited to that user’s permissions.
|
||||
- A compromised token is limited to that user�s permissions.
|
||||
|
||||
## Resource-type-aware authorization
|
||||
|
||||
The public server runs in *service-PAT mode*: a privileged bot token makes the
|
||||
actual Gitea calls while the per-user OAuth identity decides what the user may
|
||||
reach. Repository calls are gated by the user's collaborator permission on
|
||||
`owner/repo`. The rest of the Gitea surface — reachable through the
|
||||
`gitea_request` escape hatch — is gated by **resource-type-aware authorization**
|
||||
(`authz.py`). Every call is classified by `(method, path)` and enforced against
|
||||
a type-specific rule. **Every decision fails closed**: a call that cannot be
|
||||
classified, or whose permission cannot be positively verified against Gitea, is
|
||||
denied and audited.
|
||||
|
||||
| Resource type | Rule (service-PAT mode) |
|
||||
|---------------|--------------------------|
|
||||
| `repository` | Per-user collaborator permission on `owner/repo` (existing check). A repo path that cannot be parsed to `owner/repo` is denied. |
|
||||
| `org` | The signed-in user must be a **verified member** of the target org (checked against Gitea, fail closed). |
|
||||
| `user_owned` | A resource owned by a named user/org (`/users/{name}`, `/packages/{owner}`): allowed only when the owner is the caller, or the caller is a verified member of the owning org. |
|
||||
| `user_self` | Token-owner-scoped endpoints (`/user`, `/notifications`): **denied** — in service-PAT mode the data belongs to the bot, not the caller. |
|
||||
| `misc_global` | Instance-wide read-only utilities (markdown render, version, gitignore templates): reads allowed; writes denied. |
|
||||
| `admin` | **Default deny.** Allowed only when the operator opts in (`RAW_API_ALLOW_SENSITIVE=true`) **and** the signed-in user is a verified Gitea site administrator. |
|
||||
| `unknown` | Denied. |
|
||||
|
||||
This gate runs *in addition to* the policy engine and the `WRITE_MODE` gate — a
|
||||
write call is denied unless write mode is on, policy allows it, and the
|
||||
resource-type rule passes. In pure-OAuth mode (no service PAT) the user's own
|
||||
token already scopes every call at Gitea, so the extra gate is unnecessary.
|
||||
|
||||
Positive verification results (org membership, site-admin) are cached briefly
|
||||
and bounded; only successful checks are cached, so a transient failure never
|
||||
grants access.
|
||||
|
||||
## Full-API coverage: classified `gitea_request`
|
||||
|
||||
`gitea_request` exposes the long tail of the Gitea API that the curated typed
|
||||
tools do not cover, safely:
|
||||
|
||||
- **Deterministic read/write classifier.** `GET`/`HEAD` are reads; everything
|
||||
else is a write. A small, explicit override table may only *downgrade*
|
||||
provably side-effect-free render endpoints (markdown/markup) to reads — never
|
||||
the reverse — so a mutating call can never be misclassified as a read and slip
|
||||
past the `WRITE_MODE` gate.
|
||||
- **Known-path gate.** A request whose top path segment is not a recognized
|
||||
Gitea `/api/v1` route prefix is denied (fail closed): unknown paths are never
|
||||
passed straight through.
|
||||
- **Admin/credential denylist.** `/admin`, `*tokens*`, `*secrets*`, `*hooks*`,
|
||||
`*keys*`, `applications/oauth2`, and runner registration tokens are blocked for
|
||||
every method (including `GET`) and cannot be re-opened from `policy.yaml` —
|
||||
only `RAW_API_ALLOW_SENSITIVE=true` overrides them, and admin then still
|
||||
requires a verified site administrator (see above).
|
||||
|
||||
## Prompt Injection Hardening
|
||||
|
||||
|
||||
@@ -83,6 +83,19 @@
|
||||
- [ ] Final security review sign-off.
|
||||
- [ ] Release checklist execution.
|
||||
|
||||
## Phase 10 Local Package & Safe Full Coverage (0.2.0)
|
||||
|
||||
- [x] Extract transport-agnostic core + shared tool registry.
|
||||
- [x] Lock the core/web boundary with a no-fastapi import test.
|
||||
- [x] Add local stdio adapter (`stdio_app.py`) over the `mcp` SDK.
|
||||
- [x] Restructure packaging: core install + `[server]` extra + console scripts.
|
||||
- [x] Resource-type-aware authorization (repo/org/user/admin/misc), fail-closed.
|
||||
- [x] Classified `gitea_request`: write classifier + known-path gate + denylist.
|
||||
- [x] Authz matrix, write-mode bypass, classifier, and stdio adapter tests.
|
||||
- [x] `.gitea/workflows/publish.yml` (uv build + publish to Gitea registry on tag).
|
||||
- [ ] Make `list_organizations` user-scoped in service-PAT mode (`/users/{login}/orgs`)
|
||||
so it can be allowed instead of denied. (TODO(authz))
|
||||
|
||||
## Release Checklist
|
||||
|
||||
- [ ] `make lint`
|
||||
|
||||
Reference in New Issue
Block a user