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.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Command gitea-admin-mcp is a LOCAL-ONLY stdio MCP server exposing exactly
|
||||
// one tool: mint_service_account_token.
|
||||
//
|
||||
// This exists because dispatch#67 fix #2 hit a hard wall: Gitea's own
|
||||
// token-creation and admin-user endpoints reject token auth outright — an
|
||||
// existing API token cannot create another token, or a new account, by
|
||||
// design (a leaked token being usable to escalate would defeat the point of
|
||||
// scoping it). The only way through is real basic auth as an actual admin,
|
||||
// which a sandboxed agent session cannot safely hold. This binary is meant
|
||||
// to be spawned by a LOCAL Claude Code session (or run by hand) on a machine
|
||||
// with real, interactive 1Password access — configured as a stdio MCP
|
||||
// server in .mcp.json, or invoked directly.
|
||||
//
|
||||
// Deliberately NOT part of cmd/gitea-mcp (the shared HTTP server): the
|
||||
// capability here (reset a target account's password, mint it a token) is
|
||||
// far too dangerous to ever 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.
|
||||
//
|
||||
// Required env (fetch admin credentials just-in-time, e.g. via
|
||||
// `op run --env-file ~/.op-env -- gitea-admin-mcp`):
|
||||
//
|
||||
// GITEA_BASE_URL e.g. https://git.d-ma.be
|
||||
// GITEA_ADMIN_USERNAME a real site-admin account's username
|
||||
// GITEA_ADMIN_PASSWORD that account's real password
|
||||
// OP_VAULT 1Password vault to store minted tokens in
|
||||
// (default: AgentSecrets, per the estate's agent-governed
|
||||
// ops-secrets policy)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Logs to stderr, never stdout — stdout is the MCP JSON-RPC channel, and
|
||||
// a stray log line there would corrupt the protocol stream.
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
||||
baseURL := os.Getenv("GITEA_BASE_URL")
|
||||
adminUser := os.Getenv("GITEA_ADMIN_USERNAME")
|
||||
adminPass := os.Getenv("GITEA_ADMIN_PASSWORD")
|
||||
if baseURL == "" || adminUser == "" || adminPass == "" {
|
||||
fmt.Fprintln(os.Stderr, "GITEA_BASE_URL, GITEA_ADMIN_USERNAME, and GITEA_ADMIN_PASSWORD are all required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
b := adminbootstrap.Bootstrapper{
|
||||
BaseURL: baseURL,
|
||||
AdminUsername: adminUser,
|
||||
AdminPassword: adminPass,
|
||||
Secrets: localtools.OpSecretStore{Vault: envOr("OP_VAULT", "AgentSecrets")},
|
||||
}
|
||||
|
||||
reg := registry.New()
|
||||
reg.Register(localtools.NewMintServiceAccountToken(b))
|
||||
|
||||
if err := mcp.ServeStdio(context.Background(), reg, os.Stdin, os.Stdout); err != nil {
|
||||
logger.Error("serve stdio", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user