Dev #72

Merged
Latte merged 71 commits from dev into main 2026-10-14 17:52:23 +00:00
7 changed files with 66 additions and 21 deletions
Showing only changes of commit d7cac91175 - Show all commits

No files matched your search

+8 -3
View File
@@ -126,9 +126,14 @@ Both modes can run on one HTTP deployment at the same time (details in
calls then run with that user's own Gitea token, so Gitea enforces that user's permissions: calls then run with that user's own Gitea token, so Gitea enforces that user's permissions:
admin tools fail with Gitea's own 403 for non-admins. Upstream tokens are refreshed admin tools fail with Gitea's own 403 for non-admins. Upstream tokens are refreshed
automatically. Stored user tokens are encrypted (Fernet, key derived from automatically. Stored user tokens are encrypted (Fernet, key derived from
`TOKEN_ENCRYPTION_KEY`) in `DATA_DIR/oauth-tokens.json` with mode 0600; the server's own `TOKEN_ENCRYPTION_KEY`) in `DATA_DIR/oauth-store.json` with mode 0600; the server's own
tokens are stored only as SHA-256 hashes. The signed-in login is visible in the `identity` tokens are stored only as SHA-256 hashes. A store written with another key stops startup until
field of every result. the key is restored or the file is moved away. The signed-in login is visible in the
`identity` field of every result. Before the browser goes to Gitea, a consent page
(`/oauth/consent`) names the client and where the result goes and needs an explicit approval;
for public deployments set `OAUTH_REDIRECT_ALLOWLIST` to the hosted clients you use. An
OAuth-only deployment (no `MCP_BEARER_TOKEN`, no `GITEA_TOKEN`) is supported: it has no admin
identity to fall back to.
## Client configuration ## Client configuration
+6 -4
View File
@@ -102,10 +102,12 @@ compared, so length does not leak). A match runs the call as `GITEA_TOKEN`, with
resolved once from `GET /user`. stdio is always this mode. 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 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. `/oauth/callback` receives client registration, PKCE) and a confidential OAuth client of Gitea. `/authorize` parks the
Gitea's code, exchanges it, and issues the client opaque access and refresh tokens that map to request and shows the consent page `/oauth/consent` (client name, redirect URI, approval with
the user's Gitea tokens. A call with such a token runs with the user's own Gitea access token, CSRF and origin checks) before the browser goes to Gitea. `/oauth/callback` receives Gitea's
so Gitea enforces that user's permissions. The mapping lives in `DATA_DIR/oauth-tokens.json` 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 (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 `TOKEN_ENCRYPTION_KEY`); our own tokens are stored as SHA-256 hashes. Upstream refresh is
automatic. automatic.
+18 -4
View File
@@ -56,22 +56,36 @@ when OAuth is on.
| Variable | Default | Rules | | Variable | Default | Rules |
|---|---|---| |---|---|---|
| `OAUTH_ENABLED` | `false` | When true, the four settings below are required, or the server refuses to start (exit code 2, with the list of what is missing). | | `OAUTH_ENABLED` | `false` | When true, `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL` are required, or the server refuses to start (exit code 2, with the list of what is missing). |
| `GITEA_OAUTH_CLIENT_ID` | unset | Client id of the Gitea OAuth application (created by `setup`). | | `GITEA_OAUTH_CLIENT_ID` | unset | Client id of the Gitea OAuth application (created by `setup`). |
| `GITEA_OAUTH_CLIENT_SECRET` | unset | Its client secret. | | `GITEA_OAUTH_CLIENT_SECRET` | unset | Its client secret. |
| `TOKEN_ENCRYPTION_KEY` | unset | At least 32 characters. Encrypts stored user tokens. Losing it invalidates every stored login. | | `TOKEN_ENCRYPTION_KEY` | unset | At least 32 characters. Encrypts the token store. The key cannot be rotated in place: a store written with another key stops startup (exit code 2) instead of being wiped, until you restore the original key or move `DATA_DIR/oauth-store.json` away, which signs every user out. |
| `OAUTH_SCOPES` | all `write:<category>` scopes | Granular Gitea scopes only (`read:` or `write:` plus activitypub, admin, issue, misc, notification, organization, package, repository, user). Anything else is rejected, because Gitea grants full access for unknown scope strings. Must not be empty. | | `OAUTH_SCOPES` | all `write:<category>` scopes | Granular Gitea scopes only (`read:` or `write:` plus activitypub, admin, issue, misc, notification, organization, package, repository, user). Anything else is rejected, because Gitea grants full access for unknown scope strings. Must not be empty. |
| `OAUTH_REDIRECT_ALLOWLIST` | empty | Comma list of redirect URIs MCP clients may register: exact URIs (`https://client.example/cb`) or prefixes ending in `/` that allow a whole host (`https://claude.ai/`). A bare origin without a path is refused. Empty accepts any https URI (and http on `localhost`, `127.0.0.1`, `[::1]`). With a list, https URIs must match it; loopback URIs stay allowed for native clients. Checked at registration and again at `/authorize`. | | `OAUTH_REDIRECT_ALLOWLIST` | empty | Comma list of redirect URIs MCP clients may register: exact URIs (`https://client.example/cb`) or prefixes ending in `/` that allow a whole host (`https://claude.ai/`). A bare origin without a path is refused. Empty accepts any https URI (and http on `localhost`, `127.0.0.1`, `[::1]`). With a list, https URIs must match it; loopback URIs stay allowed for native clients. Checked at registration and again at `/authorize`. |
`PUBLIC_BASE_URL` is also required for OAuth. The Gitea OAuth application's redirect URI is `PUBLIC_BASE_URL` is also required for OAuth. The Gitea OAuth application's redirect URI is
`<PUBLIC_BASE_URL>/oauth/callback`. `<PUBLIC_BASE_URL>/oauth/callback`.
OAuth-only deployments are supported: with `OAUTH_ENABLED=true` and neither `MCP_BEARER_TOKEN`
nor `GITEA_TOKEN`, every user brings their own Gitea token and admin-token mode is simply not
available (nothing falls back to it). `GITEA_TOKEN` stays required whenever admin-token mode is
reachable: with a static bearer, without any HTTP authentication, and on stdio.
Sign-in flow and limits: `/authorize` sends the browser to the consent page `/oauth/consent`,
which names the client and the redirect URI and needs an explicit approval (CSRF token, cookie
and an `Origin` that matches `PUBLIC_BASE_URL`) before the browser goes to Gitea; the callback
`/oauth/callback` completes only in that same browser. Issued `agm_` access tokens live 1 hour
and refresh tokens 30 days (rotated on use, reuse revokes the grant); authorization codes live 5
minutes and pending sign-ins 10 minutes. Bounds: 500 registered clients (10 redirect URIs and 8
KiB of metadata each), 1024 pending sign-ins and codes, 50 sessions per Gitea login, and per
client address 20 pending sign-ins and 20 registrations per hour. The consent form body is
limited to 16 KiB and must be `application/x-www-form-urlencoded`.
## State ## State
| Variable | Default | Rules | | Variable | Default | Rules |
|---|---|---| |---|---|---|
| `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the token store (`oauth-tokens.json`, mode 0600) and the audit log. | | `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the encrypted token store (`oauth-store.json`, mode 0600) and the audit log. A new directory is created with mode 0700; an existing one must belong to the running user and is tightened to 0700, otherwise OAuth startup fails. |
| `AUDIT_LOG_PATH` | `DATA_DIR/audit.log` | Path of the hash-chained audit log. See [audit-logging.md](audit-logging.md). The server opens it for append at startup and exits with code 2 and a message on stderr when it cannot. | | `AUDIT_LOG_PATH` | `DATA_DIR/audit.log` | Path of the hash-chained audit log. See [audit-logging.md](audit-logging.md). The server opens it for append at startup and exits with code 2 and a message on stderr when it cannot. |
## Tool exposure ## Tool exposure
+16 -5
View File
@@ -9,8 +9,12 @@ hands the power of its Gitea token to every client that can authenticate.
- Admin-token only: set `GITEA_TOKEN` and `MCP_BEARER_TOKEN`. Every client with the bearer token - Admin-token only: set `GITEA_TOKEN` and `MCP_BEARER_TOKEN`. Every client with the bearer token
acts as the token owner. acts as the token owner.
- Per-user OAuth: additionally set `OAUTH_ENABLED=true` with the OAuth settings. Users sign in - Per-user OAuth: additionally set `OAUTH_ENABLED=true` with the OAuth settings. Users sign in
with their own Gitea account and act under their own permissions. The static bearer keeps with their own Gitea account (after a consent step on `/oauth/consent`) and act under their
working next to it as the admin identity; leave `MCP_BEARER_TOKEN` unset to turn that off. own permissions. The static bearer keeps working next to it as the admin identity; leave
`MCP_BEARER_TOKEN` unset to turn that off.
- OAuth only: `OAUTH_ENABLED=true` with neither `MCP_BEARER_TOKEN` nor `GITEA_TOKEN`. The server
starts and serves signed-in users only; admin-token mode does not exist in this deployment, so
no request can ever fall back to an admin identity. Recommended for public deployments.
## Docker ## Docker
@@ -50,7 +54,7 @@ Points that matter:
- The port is published on `127.0.0.1` only. The reverse proxy on the same host is the public - The port is published on `127.0.0.1` only. The reverse proxy on the same host is the public
entry point. entry point.
- `DATA_DIR` must be a persistent volume writable by the container user. It holds the audit log - `DATA_DIR` must be a persistent volume writable by the container user. It holds the audit log
and the encrypted OAuth token store (`oauth-tokens.json`, mode 0600). Losing the volume loses and the encrypted OAuth token store (`oauth-store.json`, mode 0600). Losing the volume loses
all OAuth logins and the audit history. Back it up, and protect the backup like the secrets it all OAuth logins and the audit history. Back it up, and protect the backup like the secrets it
protects. protects.
- Keep `.env` out of version control and out of the image (mode 0600; `setup` writes it that - Keep `.env` out of version control and out of the image (mode 0600; `setup` writes it that
@@ -104,6 +108,12 @@ Gitea has no instance-wide OAuth application API, so the application belongs to
that created it. Re-running setup reuses it; if the stored client secret is missing, Gitea has to that created it. Re-running setup reuses it; if the stored client secret is missing, Gitea has to
regenerate it, and setup asks before doing so. regenerate it, and setup asks before doing so.
Before the browser is sent to Gitea, the consent page `/oauth/consent` shows the client name and
the redirect URI, because Gitea remembers consent for this one application and any client can
register itself. For a public deployment also set `OAUTH_REDIRECT_ALLOWLIST` to the hosted
clients you use, for example `https://claude.ai/,https://chat.mistral.ai/` (see
[configuration.md](configuration.md)).
`OAUTH_SCOPES` is the scope list sent to Gitea. Keep it to granular scopes. Narrow it (for `OAUTH_SCOPES` is the scope list sent to Gitea. Keep it to granular scopes. Narrow it (for
example to `read:repository,read:issue`) to limit what signed-in users' tokens can do through this example to `read:repository,read:issue`) to limit what signed-in users' tokens can do through this
server regardless of their Gitea rights. server regardless of their Gitea rights.
@@ -111,8 +121,9 @@ server regardless of their Gitea rights.
## Upgrades ## Upgrades
- Application: pull the new image (or package) and restart. The data volume is kept. - Application: pull the new image (or package) and restart. The data volume is kept.
- `TOKEN_ENCRYPTION_KEY` must stay the same, or stored OAuth logins can no longer be decrypted - `TOKEN_ENCRYPTION_KEY` must stay the same. A store written with another key stops the server
and users have to sign in again. at startup (exit code 2, the file is kept) instead of being wiped: restore the old key, or move
`DATA_DIR/oauth-store.json` away to start over, which signs every OAuth user out.
- Upgrading Gitea: tool names and schemas come from the pinned swagger, so a Gitea upgrade does - Upgrading Gitea: tool names and schemas come from the pinned swagger, so a Gitea upgrade does
not change them. To pick up a newer API, re-pin the swagger and regenerate the catalog: not change them. To pick up a newer API, re-pin the swagger and regenerate the catalog:
+7 -1
View File
@@ -27,7 +27,13 @@ authentication), but it does not decide what is safe. This checklist is how you
header). header).
- `TOKEN_ENCRYPTION_KEY` protects the stored per-user tokens. Keep it separate from the data - `TOKEN_ENCRYPTION_KEY` protects the stored per-user tokens. Keep it separate from the data
volume backups. `GITEA_OAUTH_CLIENT_SECRET` likewise. volume backups. `GITEA_OAUTH_CLIENT_SECRET` likewise.
- Use `setup`, which writes the env file with mode 0600 and never prints secrets. - Use `setup`, which writes the env file with mode 0600 and never prints secrets. The server
loads `./.env` only while it is a regular file you own with mode 0600; keep it elsewhere with
`--env-file` or `AEGIS_ENV_FILE`.
- For a public OAuth deployment set `OAUTH_REDIRECT_ALLOWLIST` (for example
`https://claude.ai/,https://chat.mistral.ai/`), so only the hosted clients you use can
register, and leave `MCP_BEARER_TOKEN` and `GITEA_TOKEN` unset when nobody needs the admin
identity.
- Do not paste tokens into chat with the assistant, and do not commit `.env`. - Do not paste tokens into chat with the assistant, and do not commit `.env`.
## Network and transport ## Network and transport
+9 -2
View File
@@ -13,6 +13,8 @@ Gitea's sanitized message, and the audit log has one event per call (see
| Exit code 2, "env file ... does not exist" | The file named by `--env-file` or `AEGIS_ENV_FILE` is missing or not a regular file. | | Exit code 2, "env file ... does not exist" | The file named by `--env-file` or `AEGIS_ENV_FILE` is missing or not a regular file. |
| Exit code 2, refuses a non-loopback bind | `MCP_HOST` is not loopback and there is no `MCP_BEARER_TOKEN` or complete OAuth setup. Set one of them, or bind `127.0.0.1` behind a reverse proxy. | | Exit code 2, refuses a non-loopback bind | `MCP_HOST` is not loopback and there is no `MCP_BEARER_TOKEN` or complete OAuth setup. Set one of them, or bind `127.0.0.1` behind a reverse proxy. |
| Exit code 2, lists OAuth settings | `OAUTH_ENABLED=true` needs `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL`. | | Exit code 2, lists OAuth settings | `OAUTH_ENABLED=true` needs `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL`. |
| Exit code 2, "cannot decrypt .../oauth-store.json" | `TOKEN_ENCRYPTION_KEY` differs from the key the store was written with (or the file is damaged). The server does not wipe it. Restore the original key, or move `DATA_DIR/oauth-store.json` away to start over (signs every OAuth user out). |
| Exit code 2, "owned by another user" or "writable by group or others" (OAuth) | `DATA_DIR` must belong to the user the server runs as and not be group or world writable. Fix the owner or `chmod 700`. |
| Validation error naming a setting | See the rules in [configuration.md](configuration.md): URLs must be http(s) without credentials, `PUBLIC_BASE_URL` must be https, secrets need 32 characters, `OAUTH_SCOPES` accepts only granular scopes. | | Validation error naming a setting | See the rules in [configuration.md](configuration.md): URLs must be http(s) without credentials, `PUBLIC_BASE_URL` must be https, secrets need 32 characters, `OAUTH_SCOPES` accepts only granular scopes. |
| Startup error listing tool names | `DISABLED_TOOLS` contains names that are not registered. Check [tools.md](tools.md). | | Startup error listing tool names | `DISABLED_TOOLS` contains names that are not registered. Check [tools.md](tools.md). |
@@ -69,8 +71,13 @@ A tool the client cannot see is probably filtered by `TOOLSETS`, `READ_ONLY` or
- Gitea shows a redirect URI error: the OAuth application's redirect URI must be exactly - Gitea shows a redirect URI error: the OAuth application's redirect URI must be exactly
`<PUBLIC_BASE_URL>/oauth/callback`. Re-run `setup --public-base-url ...` or fix it in Gitea. `<PUBLIC_BASE_URL>/oauth/callback`. Re-run `setup --public-base-url ...` or fix it in Gitea.
- Signed in, but admin tools return 403: the user is not a site administrator. That is intended. - Signed in, but admin tools return 403: the user is not a site administrator. That is intended.
- Users are asked to sign in again after a restart: `TOKEN_ENCRYPTION_KEY` or the `DATA_DIR` - Users are asked to sign in again after a restart: the `DATA_DIR` volume changed or was not
volume changed. The key must stay stable and the volume persistent. persistent (a changed `TOKEN_ENCRYPTION_KEY` stops startup instead, see above). Keep the key
stable and the volume persistent.
- The sign-in page says the request is invalid or expired: the consent page lives 10 minutes
and a form is single use. Start the sign-in again from the client. Registration or sign-in
refused with "too many": one client address may hold 20 pending sign-ins and register 20
clients per hour.
- After fixing OAuth settings the client still fails: some clients cache tokens. Disconnect the - After fixing OAuth settings the client still fails: some clients cache tokens. Disconnect the
server in the client and connect again, which starts a fresh flow. Users can also revoke the server in the client and connect again, which starts a fresh flow. Users can also revoke the
application under Settings, Applications, Authorized OAuth2 Applications in Gitea. application under Settings, Applications, Authorized OAuth2 Applications in Gitea.
+2 -2
View File
@@ -127,7 +127,7 @@ disallow_untyped_defs = false
[tool.pytest.ini_options] [tool.pytest.ini_options]
minversion = "8.0" minversion = "8.0"
addopts = "-ra -q --strict-markers --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=80" addopts = "-ra -q --strict-markers --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=90"
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["src"] pythonpath = ["src"]
asyncio_mode = "auto" asyncio_mode = "auto"
@@ -141,7 +141,7 @@ source = ["src"]
omit = ["tests/*", "**/__pycache__/*"] omit = ["tests/*", "**/__pycache__/*"]
[tool.coverage.report] [tool.coverage.report]
fail_under = 80 fail_under = 90
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
"def __repr__", "def __repr__",