Files
gitea-mcp/docs/GITEA_ADMIN_MCP.md
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

3.3 KiB

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):

{
  "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

{
  "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.