feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71

Merged
Latte merged 70 commits from feat/full-api-coverage into dev 2026-10-14 17:25:25 +00:00
8 changed files with 102 additions and 34 deletions
Showing only changes of commit 59d0e43aae - Show all commits

No files matched your search

+38 -9
View File
@@ -145,9 +145,12 @@ Gitea token, `<MCP_BEARER_TOKEN>` is the value from the env file.
Claude Code: Claude Code:
```bash ```bash
claude mcp add --transport stdio --env GITEA_URL=https://git.example.com --env GITEA_TOKEN=<your-gitea-token> aegis-gitea -- uvx aegis-gitea-mcp claude mcp add --env GITEA_URL=https://git.example.com --env GITEA_TOKEN=<your-gitea-token> --transport stdio aegis-gitea -- uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
``` ```
Keep `--transport stdio` and the server name after the `--env` options: `--env` accepts several
values, so an option placed before the name would swallow it.
Claude Desktop, Cursor and other local clients that read an `mcpServers` block: Claude Desktop, Cursor and other local clients that read an `mcpServers` block:
```json ```json
@@ -155,7 +158,7 @@ Claude Desktop, Cursor and other local clients that read an `mcpServers` block:
"mcpServers": { "mcpServers": {
"aegis-gitea": { "aegis-gitea": {
"command": "uvx", "command": "uvx",
"args": ["aegis-gitea-mcp"], "args": ["--index", "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/", "aegis-gitea-mcp"],
"env": { "env": {
"GITEA_URL": "https://git.example.com", "GITEA_URL": "https://git.example.com",
"GITEA_TOKEN": "<your-gitea-token>" "GITEA_TOKEN": "<your-gitea-token>"
@@ -165,9 +168,16 @@ Claude Desktop, Cursor and other local clients that read an `mcpServers` block:
} }
``` ```
`uvx` installs the package from your configured index (see [packaging.md](docs/packaging.md) Always pass the `--index` of the project's registry. The name `aegis-gitea-mcp` is not
for the Gitea registry). To run from a clone instead, use `uv` as the command with the registered on public PyPI, so a bare `uvx aegis-gitea-mcp` would resolve there, and whoever
arguments `--directory /path/to/AegisGitea-MCP run aegis-gitea-mcp`. registers that name would receive your Gitea token through the environment. The owner should
reserve the name on PyPI to close that gap (see [packaging.md](docs/packaging.md)). To run from
a clone instead, use `uv` as the command with the arguments
`run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve`, or on the command line:
```bash
claude mcp add --env GITEA_URL=https://git.example.com --env GITEA_TOKEN=<your-gitea-token> --transport stdio aegis-gitea -- uv run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve
```
### Remote streamable HTTP ### Remote streamable HTTP
@@ -184,7 +194,8 @@ claude.ai custom connector: Settings, Connectors, Add custom connector, URL
Claude registers itself (dynamic client registration) and sends you to Gitea to sign in. Claude registers itself (dynamic client registration) and sends you to Gitea to sign in.
Mistral Le Chat custom connector: Intelligence, Connectors, Add Connector, Custom MCP Mistral Le Chat custom connector: Intelligence, Connectors, Add Connector, Custom MCP
Connector, URL `https://gitea-mcp.example.com/mcp` (HTTPS with a valid certificate). Connector, URL `https://gitea-mcp.example.com/mcp` (HTTPS with a valid certificate). Adding
connectors in Le Chat may require workspace admin rights.
Authentication is either HTTP Bearer Token with the `MCP_BEARER_TOKEN` value, or OAuth 2.1 with Authentication is either HTTP Bearer Token with the `MCP_BEARER_TOKEN` value, or OAuth 2.1 with
dynamic client registration so users sign in with their own Gitea account. dynamic client registration so users sign in with their own Gitea account.
@@ -267,7 +278,7 @@ you with mode 0600 (what `setup` writes); any other `./.env` is ignored with a w
| `MCP_HOST`, `MCP_PORT`, `MCP_PATH` | `127.0.0.1`, `8080`, `/mcp` | HTTP bind and endpoint path | | `MCP_HOST`, `MCP_PORT`, `MCP_PATH` | `127.0.0.1`, `8080`, `/mcp` | HTTP bind and endpoint path |
| `MCP_BEARER_TOKEN` | unset | Static bearer for HTTP, at least 32 characters | | `MCP_BEARER_TOKEN` | unset | Static bearer for HTTP, at least 32 characters |
| `OAUTH_ENABLED` | `false` | Per-user OAuth on HTTP | | `OAUTH_ENABLED` | `false` | Per-user OAuth on HTTP |
| `READ_ONLY` | `false` | Expose only read-only operations | | `READ_ONLY` | `false` | Expose only operations annotated read-only |
| `TOOLSETS` | `all` | Comma separated domains to expose | | `TOOLSETS` | `all` | Comma separated domains to expose |
| `DISABLED_TOOLS` | empty | Tool names to hide | | `DISABLED_TOOLS` | empty | Tool names to hide |
| `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block` | | `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block` |
@@ -277,8 +288,10 @@ Every variable, its default and its validation rules are in
## What is config-gated, and what is impossible ## What is config-gated, and what is impossible
Config-gated, all optional: `READ_ONLY` (only GET operations, and `gitea_api_request` accepts Config-gated, all optional: `READ_ONLY` (exposes only the operations annotated read-only, which
only GET), `TOOLSETS` (domains), `DISABLED_TOOLS` (single tools, an unknown name is a startup are the GET operations plus a few POST endpoints that do not change anything, such as the
markdown and markup render endpoints and the file-contents query; `gitea_api_request` stays
listed but accepts only GET), `TOOLSETS` (domains), `DISABLED_TOOLS` (single tools, an unknown name is a startup
error), and `SECRET_DETECTION_MODE` (`mask` replaces secret-like strings in results, `block` error), and `SECRET_DETECTION_MODE` (`mask` replaces secret-like strings in results, `block`
withholds a result that contains one, `off` disables it; exact redaction of the server's own withholds a result that contains one, `off` disables it; exact redaction of the server's own
tokens always applies). A filtered tool is neither listed nor callable. tokens always applies). A filtered tool is neither listed nor callable.
@@ -288,6 +301,22 @@ writes to the instance configuration, and instance-wide OAuth applications (UI o
registers its OAuth application as a user application of the admin). `gitea_api_request` reaches registers its OAuth application as a user application of the admin). `gitea_api_request` reaches
anything newer than the pinned spec once Gitea ships it. anything newer than the pinned spec once Gitea ships it.
## Known limitations
A token with the `all` scope does not make every tool work. Check
[docs/integration-coverage.md](docs/integration-coverage.md), generated by running every tool
against a live Gitea 28.1.0, for the per-tool outcome.
- `gitea_user_create_token`, `gitea_user_get_tokens` and `gitea_user_delete_access_token` need
basic authentication with the account password (a Gitea API rule). The server always sends
token authentication, so these three return 401 through it. Create tokens in the Gitea UI.
- Some operations depend on instance state and answer 404 or an error until it exists: the
commit signing keys (`gitea_misc_get_signing_key`, `gitea_repo_signing_key` and their SSH
variants) when the instance has none, user badges (Gitea cannot create badges through the
API), unadopted repositories, and similar. The coverage report lists them with the reason.
- Tools that depend on a runner, such as workflow runs and jobs, only return data once an
Actions runner is registered.
## Result and error format ## Result and error format
A successful call returns one text item with JSON (see A successful call returns one text item with JSON (see
+1 -1
View File
@@ -95,7 +95,7 @@ listed nor callable.
| Variable | Default | Rules | | Variable | Default | Rules |
|---|---|---| |---|---|---|
| `READ_ONLY` | `false` | Expose only read-only (GET) operations. `gitea_api_request` stays listed but accepts only GET. | | `READ_ONLY` | `false` | Expose only operations annotated read-only (GET plus a few non-mutating POST endpoints such as the render endpoints). `gitea_api_request` stays listed but accepts only GET. |
| `TOOLSETS` | `all` | Comma separated domains: `admin`, `repo`, `issue`, `pull`, `release`, `wiki`, `org`, `team`, `user`, `actions`, `hook`, `package`, `notification`, `misc`, or `all`. Unknown domains are rejected. `gitea_api_request` belongs to `misc`. | | `TOOLSETS` | `all` | Comma separated domains: `admin`, `repo`, `issue`, `pull`, `release`, `wiki`, `org`, `team`, `user`, `actions`, `hook`, `package`, `notification`, `misc`, or `all`. Unknown domains are rejected. `gitea_api_request` belongs to `misc`. |
| `DISABLED_TOOLS` | empty | Exact tool names to hide, lowercased on load. An unknown name is a startup error that lists the bad names. | | `DISABLED_TOOLS` | empty | Exact tool names to hide, lowercased on load. An unknown name is a startup error that lists the bad names. |
+1 -1
View File
@@ -67,7 +67,7 @@ Check it: `curl http://127.0.0.1:8080/health`.
Terminate TLS at a reverse proxy. `PUBLIC_BASE_URL` must be the public https URL (it is required Terminate TLS at a reverse proxy. `PUBLIC_BASE_URL` must be the public https URL (it is required
for OAuth, and http is only accepted for loopback hosts). Mistral Le Chat needs a valid public for OAuth, and http is only accepted for loopback hosts). Mistral Le Chat needs a valid public
certificate. certificate, and adding a connector there may require workspace admin rights.
Caddy: Caddy:
+6 -2
View File
@@ -96,10 +96,14 @@ make validate-audit
With `uvx`, no checkout is needed: With `uvx`, no checkout is needed:
```bash ```bash
GITEA_URL=https://git.example.com GITEA_TOKEN=<your-gitea-token> uvx aegis-gitea-mcp GITEA_URL=https://git.example.com GITEA_TOKEN=<your-gitea-token> \
uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
``` ```
The package comes from the Gitea package registry, see [packaging.md](packaging.md). The package comes from the Gitea package registry, see [packaging.md](packaging.md). Always pass
the `--index`: the name is not registered on public PyPI, and a bare `uvx aegis-gitea-mcp` would
resolve there. From a clone use
`uv run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve`.
## Where state lives ## Where state lives
+30 -12
View File
@@ -44,28 +44,46 @@ Dependencies are pinned in `uv.lock`; `uv sync` reproduces the environment.
## Install from the Gitea registry ## Install from the Gitea registry
Index URL: `https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/`
Stable: Stable:
```bash ```bash
uv pip install \ # run it without installing into an environment (recommended):
--extra-index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \
aegis-gitea-mcp
# or run it without installing into an environment:
uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
# or install it into an environment:
uv pip install --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
``` ```
Dev (newest pre-release build): Dev (newest pre-release build, package `aegis-gitea-mcp-dev`):
```bash ```bash
uv pip install --pre \ uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ --prerelease allow aegis-gitea-mcp-dev
--extra-index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \
aegis-gitea-mcp-dev
``` ```
The registry holds only this project's packages and does not proxy PyPI, so it is added as an The registry holds only this project's packages and does not proxy PyPI, so it is added with
extra index and the dependencies (`mcp`, `httpx`, ...) still come from PyPI. With `pip` use `--index` and the dependencies (`mcp`, `httpx`, ...) still come from PyPI. uv's default index
`--extra-index-url` the same way. strategy (`first-index`) takes a package from the first index that has it and never mixes
versions across indexes, so with `--index` the name `aegis-gitea-mcp` is always taken from
this registry, even if somebody registers it on PyPI.
Do not use `--extra-index-url` with `pip`. It merges the indexes, and `pip` picks the highest
version from either: whoever publishes `aegis-gitea-mcp` on PyPI with a higher version number
wins (dependency confusion), and the package would run with your Gitea token in its
environment. With `pip`, fetch the wheel from this registry alone, then install it:
```bash
pip download --no-deps --index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp -d wheels/
pip install wheels/aegis_gitea_mcp-*.whl
```
The name is not registered on public PyPI. Publishing there is intentionally not configured, but
the owner should reserve both names (`aegis-gitea-mcp`, `aegis-gitea-mcp-dev`) on PyPI so nobody
else can take them. Until then every install command above must name the index.
To run from a clone instead of a registry:
`uv run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve`.
## Publishing ## Publishing
+4 -2
View File
@@ -22,8 +22,10 @@ Gitea's sanitized message, and the audit log has one event per call (see
stdout carries only JSON-RPC. If a wrapper script prints to stdout before the server starts, the stdout carries only JSON-RPC. If a wrapper script prints to stdout before the server starts, the
client fails to parse the stream. Logs go to stderr; look at the client's MCP server log. client fails to parse the stream. Logs go to stderr; look at the client's MCP server log.
`uvx` needs access to the package index that hosts `aegis-gitea-mcp`, see `uvx` needs access to the package index that hosts `aegis-gitea-mcp`: pass
[packaging.md](packaging.md). `--index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/`, see
[packaging.md](packaging.md). If you register the server with `claude mcp add`, put `--env`
before `--transport stdio` and the server name, as `setup` prints it.
## Tool calls fail ## Tool calls fail
+14 -5
View File
@@ -19,14 +19,22 @@ TOKEN_PLACEHOLDER = "<your-gitea-token>" # noqa: S105
BEARER_PLACEHOLDER = "<MCP_BEARER_TOKEN>" BEARER_PLACEHOLDER = "<MCP_BEARER_TOKEN>"
#: Loopback base URL used when no public base URL is configured. #: Loopback base URL used when no public base URL is configured.
LOCAL_BASE_URL = "http://127.0.0.1:8080" LOCAL_BASE_URL = "http://127.0.0.1:8080"
#: The project's own package index. The package name is not registered on public PyPI, so
#: ``uvx`` must always be pointed at this index (otherwise a squatted PyPI package would
#: receive the Gitea token from the environment).
PACKAGE_INDEX = "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/"
PACKAGE_NAME = "aegis-gitea-mcp"
def claude_code_stdio(gitea_url: str) -> str: def claude_code_stdio(gitea_url: str) -> str:
"""Return the ``claude mcp add`` command for the local stdio server.""" """Return the ``claude mcp add`` command for the local stdio server.
``--env`` takes several values, so the transport option must follow it: otherwise the
server name is swallowed as one more environment entry.
"""
return ( return (
"claude mcp add --transport stdio " f"claude mcp add --env GITEA_URL={gitea_url} --env GITEA_TOKEN={TOKEN_PLACEHOLDER} "
f"--env GITEA_URL={gitea_url} --env GITEA_TOKEN={TOKEN_PLACEHOLDER} " f"--transport stdio {SERVER_NAME} -- uvx --index {PACKAGE_INDEX} {PACKAGE_NAME}"
f"{SERVER_NAME} -- uvx aegis-gitea-mcp"
) )
@@ -36,7 +44,7 @@ def mcp_servers_json(gitea_url: str) -> str:
"mcpServers": { "mcpServers": {
SERVER_NAME: { SERVER_NAME: {
"command": "uvx", "command": "uvx",
"args": ["aegis-gitea-mcp"], "args": ["--index", PACKAGE_INDEX, PACKAGE_NAME],
"env": {"GITEA_URL": gitea_url, "GITEA_TOKEN": TOKEN_PLACEHOLDER}, "env": {"GITEA_URL": gitea_url, "GITEA_TOKEN": TOKEN_PLACEHOLDER},
} }
} }
@@ -88,6 +96,7 @@ def le_chat_connector(base_url: str, mcp_path: str = "/mcp", *, oauth: bool) ->
" or OAuth 2.1 with dynamic client registration (users sign in with their " " or OAuth 2.1 with dynamic client registration (users sign in with their "
"own Gitea account)." "own Gitea account)."
) )
lines.append(" Adding connectors in Le Chat may require workspace admin rights.")
return "\n".join(lines) return "\n".join(lines)
+8 -2
View File
@@ -173,8 +173,14 @@ def test_printed_configs(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[st
mock_create(httpx_mock) mock_create(httpx_mock)
assert run_setup() == 0 assert run_setup() == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert f"--env GITEA_URL={GITEA} --env GITEA_TOKEN=<your-gitea-token>" in out index = "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/"
assert "uvx aegis-gitea-mcp" in out assert (
f"claude mcp add --env GITEA_URL={GITEA} --env GITEA_TOKEN=<your-gitea-token> "
f"--transport stdio aegis-gitea -- uvx --index {index} aegis-gitea-mcp"
) in out
# The bare name would resolve from public PyPI, where it is not registered.
assert "uvx aegis-gitea-mcp" not in out
assert f'"--index",\n "{index}"' in out
assert '"mcpServers"' in out assert '"mcpServers"' in out
assert ( assert (
f"claude mcp add --transport http aegis-gitea {PUBLIC}/mcp " f"claude mcp add --transport http aegis-gitea {PUBLIC}/mcp "