Files
gitea-mcp/internal/localtools/mint_service_account_token.go
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

101 lines
3.6 KiB
Go

// Package localtools holds MCP tools that must never be reachable from the
// shared HTTP gitea-mcp server (RegisterAll, cmd/gitea-mcp). Each tool here
// carries a capability too dangerous to expose network-side, even behind
// OIDC — mint_service_account_token resets whatever password its target
// account has. Only cmd/gitea-admin-mcp, a separate local-only stdio binary,
// imports this package.
package localtools
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// bootstrapper is the one method this tool needs — narrow so tests inject a
// fake instead of a real adminbootstrap.Bootstrapper.
type bootstrapper interface {
Run(ctx context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error)
}
type MintServiceAccountToken struct {
b bootstrapper
}
func NewMintServiceAccountToken(b bootstrapper) *MintServiceAccountToken {
return &MintServiceAccountToken{b: b}
}
func (t *MintServiceAccountToken) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "mint_service_account_token",
Description: "Create (or reset the password of) a dedicated Gitea " +
"service/bot account, mint it a scoped access token, optionally " +
"grant it read-only collaborator access on named repos, and store " +
"the token via 1Password. Requires confirm_service_account=true: " +
"this resets whatever password the target account currently has, " +
"which is only safe for a dedicated bot account, never a real " +
"person's login.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"username":{"type":"string"},
"email":{"type":"string"},
"full_name":{"type":"string"},
"scopes":{"type":"array","items":{"type":"string"}},
"token_name":{"type":"string"},
"secret_name":{"type":"string","description":"name to store the minted token under"},
"grant_read_repos":{"type":"array","items":{"type":"string"},"description":"owner/repo strings"},
"confirm_service_account":{"type":"boolean"}
},
"required":["username","email","scopes","token_name","secret_name","confirm_service_account"]
}`),
}
}
type mintArgs struct {
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
Scopes []string `json:"scopes"`
TokenName string `json:"token_name"`
SecretName string `json:"secret_name"`
GrantReadRepos []string `json:"grant_read_repos"`
ConfirmServiceAccount bool `json:"confirm_service_account"`
}
func (t *MintServiceAccountToken) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args mintArgs
if err := json.Unmarshal(raw, &args); err != nil {
return nil, fmt.Errorf("parse arguments: %w", err)
}
repos := make([]adminbootstrap.RepoRef, 0, len(args.GrantReadRepos))
for _, r := range args.GrantReadRepos {
owner, name, ok := strings.Cut(r, "/")
if !ok || owner == "" || name == "" {
return nil, fmt.Errorf("grant_read_repos entry %q must be owner/repo", r)
}
repos = append(repos, adminbootstrap.RepoRef{Owner: owner, Name: name})
}
result, err := t.b.Run(ctx, adminbootstrap.Request{
Username: args.Username,
Email: args.Email,
FullName: args.FullName,
Scopes: args.Scopes,
TokenName: args.TokenName,
SecretName: args.SecretName,
GrantReadRepos: repos,
ConfirmServiceAccount: args.ConfirmServiceAccount,
})
if err != nil {
return nil, err
}
return json.Marshal(result)
}