Files
gitea-mcp/docs/GITEA_ADMIN_MCP.md
T
mathias 4d6597639e
CD / Lint / Test / Vet (push) Failing after 4s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped
feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
Gitea's own token-creation and admin-user endpoints reject token auth
outright -- POST /users/{u}/tokens returns 'auth required', POST
/admin/users returns 'token does not have at least one of required
scope(s), required=[write:admin]' -- for ANY existing token, by design. An
existing token cannot mint another token or create an account; the only
way through either endpoint is real basic auth as an actual site admin.
That is exactly what a sandboxed agent session cannot safely hold.

internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable
against a fake transport (5 tests, no network). Given real admin
username/password, it creates the target account (or resets its password
if it already exists -- deliberately unconditional, gated behind a
required ConfirmServiceAccount flag since that reset is only safe for a
dedicated bot account), mints it a scoped token by authenticating AS that
account (Gitea requires this specifically -- an admin cannot mint on
another account's behalf), grants it read-only collaborator access on
named repos, and stores the result via a SecretStore -- never returning
the token or either password to the caller.

internal/localtools: the MCP tool wrapper (mint_service_account_token) and
the real op-CLI-backed SecretStore (token passed via stdin, never argv,
per the estate's own secret-handling rule). Deliberately its OWN package,
not internal/tools -- this capability is far too dangerous to risk it ever
being wired into RegisterAll and reachable from the shared HTTP gitea-mcp
server, even behind OIDC.

internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line,
standard MCP stdio framing) for the existing registry.Tool interface --
the only transport gitea-mcp had before this was HTTP (server.go), which a
sandboxed session can reach but a real admin password never should be
piped through.

cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code
session (.mcp.json stdio server) or run by hand, via 'op run --env-file
~/.op-env' so admin credentials arrive just-in-time and never touch a
sandboxed session.

11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the
shared HTTP server) changed.
2026-09-05 11:25:41 +02:00

93 lines
3.3 KiB
Markdown

# gitea-admin-mcp
A separate, local-only stdio MCP server. One tool: `mint_service_account_token`.
## Why this exists
dispatch#67 fix #2 needed a read-only CI credential for a dedicated service
account, and hit a hard wall: Gitea's own `POST /users/{u}/tokens` and
`POST /admin/users` both reject token auth outright —
{"message":"auth required"}
{"message":"token does not have at least one of required scope(s), required=[write:admin], ..."}
— by design: an existing API token cannot mint another token, or create an
account, no matter its scope. The only way through either endpoint is real
basic auth as an actual site admin. A sandboxed agent session has no
interactive password and shouldn't be handed one — that's broader exposure
than the job needs. This binary is meant to run where real admin credentials
already exist just-in-time: your own machine, via `op run`.
## What it does
`mint_service_account_token` does the whole bootstrap in one call:
1. Creates the named account if it doesn't exist (admin basic auth), or
resets its password if it does. **This is why `confirm_service_account`
is required and checked first** — the tool unconditionally (re)sets
whatever password the target account has, which is correct for a
dedicated bot account and would be destructive pointed at a real person's
login by mistake.
2. Mints a scoped token for that account, authenticating as the account
itself (Gitea requires this specifically — an admin cannot mint a token
on another account's behalf).
3. Grants the account read-only collaborator access on any named
`owner/repo` targets (admin action, least privilege).
4. Stores the token in 1Password via `op item create`, value passed on
stdin — never as a literal argv element, matching the estate's own
secret-handling rule. Returns only the `op://` reference, never the
token value itself, to the caller.
## Setup
Deliberately **not** wired into `cmd/gitea-mcp` (the shared HTTP server) —
this capability is too dangerous to expose over the network, even behind
OIDC. `internal/localtools` is a separate package specifically so it can
never be wired into that server's `RegisterAll` by accident.
Build:
```
go build -o gitea-admin-mcp ./cmd/gitea-admin-mcp
```
Configure as a local stdio MCP server (Claude Code `.mcp.json`, run where
your real `op` session lives):
```json
{
"mcpServers": {
"gitea-admin": {
"command": "op",
"args": ["run", "--env-file", "~/.op-env", "--", "/path/to/gitea-admin-mcp"]
}
}
}
```
Required env (see `~/.op-env`):
| Var | Meaning |
|---|---|
| `GITEA_BASE_URL` | e.g. `https://git.d-ma.be` |
| `GITEA_ADMIN_USERNAME` | a real site-admin account |
| `GITEA_ADMIN_PASSWORD` | that account's real password |
| `OP_VAULT` | 1Password vault for minted tokens (default `AgentSecrets`, per the estate's agent-governed ops-secrets policy) |
## Example call
```json
{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": ["read:repository", "read:issue"],
"token_name": "dispatch-ci-readonly",
"secret_name": "gitea-dispatch-bot-ci-readonly",
"grant_read_repos": ["mathias/cobalt-dingo"],
"confirm_service_account": true
}
```
Result carries `SecretRef` (e.g. `op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential`)
and `GrantedRepos` — never the token or either password.