feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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__",
|
||||||
|
|||||||
Reference in new issue
Block a user