diff --git a/README.md b/README.md index 1be22f9..897549f 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,12 @@ Gitea token, `` is the value from the env file. Claude Code: ```bash -claude mcp add --transport stdio --env GITEA_URL=https://git.example.com --env GITEA_TOKEN= aegis-gitea -- uvx aegis-gitea-mcp +claude mcp add --env GITEA_URL=https://git.example.com --env 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: ```json @@ -155,7 +158,7 @@ Claude Desktop, Cursor and other local clients that read an `mcpServers` block: "mcpServers": { "aegis-gitea": { "command": "uvx", - "args": ["aegis-gitea-mcp"], + "args": ["--index", "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/", "aegis-gitea-mcp"], "env": { "GITEA_URL": "https://git.example.com", "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) -for the Gitea registry). To run from a clone instead, use `uv` as the command with the -arguments `--directory /path/to/AegisGitea-MCP run aegis-gitea-mcp`. +Always pass the `--index` of the project's registry. The name `aegis-gitea-mcp` is not +registered on public PyPI, so a bare `uvx aegis-gitea-mcp` would resolve there, and whoever +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= --transport stdio aegis-gitea -- uv run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve +``` ### 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. 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 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_BEARER_TOKEN` | unset | Static bearer for HTTP, at least 32 characters | | `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 | | `DISABLED_TOOLS` | empty | Tool names to hide | | `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 -Config-gated, all optional: `READ_ONLY` (only GET operations, and `gitea_api_request` accepts -only GET), `TOOLSETS` (domains), `DISABLED_TOOLS` (single tools, an unknown name is a startup +Config-gated, all optional: `READ_ONLY` (exposes only the operations annotated read-only, which +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` 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. @@ -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 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 A successful call returns one text item with JSON (see diff --git a/docs/configuration.md b/docs/configuration.md index f4a5838..f9eea3e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -95,7 +95,7 @@ listed nor callable. | 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`. | | `DISABLED_TOOLS` | empty | Exact tool names to hide, lowercased on load. An unknown name is a startup error that lists the bad names. | diff --git a/docs/deployment.md b/docs/deployment.md index b026792..6155178 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 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: diff --git a/docs/getting-started.md b/docs/getting-started.md index 9fd4fe3..23e499f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -96,10 +96,14 @@ make validate-audit With `uvx`, no checkout is needed: ```bash -GITEA_URL=https://git.example.com GITEA_TOKEN= uvx aegis-gitea-mcp +GITEA_URL=https://git.example.com 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 diff --git a/docs/packaging.md b/docs/packaging.md index cee9575..bb53214 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -44,28 +44,46 @@ Dependencies are pinned in `uv.lock`; `uv sync` reproduces the environment. ## Install from the Gitea registry +Index URL: `https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/` + Stable: ```bash -uv pip install \ - --extra-index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \ - aegis-gitea-mcp - -# or run it without installing into an environment: +# run it without installing into an environment (recommended): 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 -uv pip install --pre \ - --extra-index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \ - aegis-gitea-mcp-dev +uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ --prerelease allow aegis-gitea-mcp-dev ``` -The registry holds only this project's packages and does not proxy PyPI, so it is added as an -extra index and the dependencies (`mcp`, `httpx`, ...) still come from PyPI. With `pip` use -`--extra-index-url` the same way. +The registry holds only this project's packages and does not proxy PyPI, so it is added with +`--index` and the dependencies (`mcp`, `httpx`, ...) still come from PyPI. uv's default index +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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4db1e2c..55114b4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 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 -[packaging.md](packaging.md). +`uvx` needs access to the package index that hosts `aegis-gitea-mcp`: pass +`--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 diff --git a/src/aegis_gitea_mcp/setup_snippets.py b/src/aegis_gitea_mcp/setup_snippets.py index 6a0a0f6..ff328b6 100644 --- a/src/aegis_gitea_mcp/setup_snippets.py +++ b/src/aegis_gitea_mcp/setup_snippets.py @@ -19,14 +19,22 @@ TOKEN_PLACEHOLDER = "" # noqa: S105 BEARER_PLACEHOLDER = "" #: Loopback base URL used when no public base URL is configured. 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: - """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 ( - "claude mcp add --transport stdio " - f"--env GITEA_URL={gitea_url} --env GITEA_TOKEN={TOKEN_PLACEHOLDER} " - f"{SERVER_NAME} -- uvx aegis-gitea-mcp" + f"claude mcp add --env GITEA_URL={gitea_url} --env GITEA_TOKEN={TOKEN_PLACEHOLDER} " + f"--transport stdio {SERVER_NAME} -- uvx --index {PACKAGE_INDEX} {PACKAGE_NAME}" ) @@ -36,7 +44,7 @@ def mcp_servers_json(gitea_url: str) -> str: "mcpServers": { SERVER_NAME: { "command": "uvx", - "args": ["aegis-gitea-mcp"], + "args": ["--index", PACKAGE_INDEX, PACKAGE_NAME], "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 " "own Gitea account)." ) + lines.append(" Adding connectors in Le Chat may require workspace admin rights.") return "\n".join(lines) diff --git a/tests/test_setup.py b/tests/test_setup.py index f76b2bc..52b96c9 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -173,8 +173,14 @@ def test_printed_configs(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[st mock_create(httpx_mock) assert run_setup() == 0 out = capsys.readouterr().out - assert f"--env GITEA_URL={GITEA} --env GITEA_TOKEN=" in out - assert "uvx aegis-gitea-mcp" in out + index = "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/" + assert ( + f"claude mcp add --env GITEA_URL={GITEA} --env 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 ( f"claude mcp add --transport http aegis-gitea {PUBLIC}/mcp "