Files
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

96 lines
3.0 KiB
Go

package mcp
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// ServeStdio runs the MCP JSON-RPC protocol over stdio: one JSON-RPC message
// per line on in, one response per line on out — the standard MCP stdio
// framing, and how Claude Code spawns a LOCAL MCP server as a subprocess.
//
// Built for gitea-admin-mcp (see cmd/gitea-admin-mcp), which has to run
// locally where real admin credentials exist just-in-time, rather than
// through the hosted HTTP server (server.go) a sandboxed agent session talks
// to. The method dispatch below intentionally mirrors handlePOST in
// server.go — same three methods, same error codes — so a tool written
// against registry.Tool behaves identically over either transport; it is
// kept as a separate small loop rather than a shared refactor of the HTTP
// handler, which mixes in http.ResponseWriter specifics this transport
// doesn't have.
func ServeStdio(ctx context.Context, reg *registry.Registry, in io.Reader, out io.Writer) error {
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) // 1 MiB max line, matches server.go's request cap
enc := json.NewEncoder(out)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
if werr := enc.Encode(NewErrorResponse(nil, -32700, "parse error", nil)); werr != nil {
return werr
}
continue
}
if req.ID == nil {
continue // notification — no response, per JSON-RPC 2.0
}
resp := dispatch(ctx, reg, req)
if err := enc.Encode(resp); err != nil {
return err
}
}
return scanner.Err()
}
// dispatch handles one already-parsed request. Mirrors server.go's
// handlePOST switch exactly (initialize / tools/list / tools/call / unknown).
func dispatch(ctx context.Context, reg *registry.Registry, req Request) Response {
if req.Method == "initialize" {
return NewResponse(req.ID, map[string]any{
"protocolVersion": ProtocolVersion,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "gitea-admin-mcp", "version": "0.1.0"},
})
}
switch req.Method {
case "tools/list":
return NewResponse(req.ID, map[string]any{"tools": reg.Tools()})
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return NewErrorResponse(req.ID, -32602, "invalid params", nil)
}
out, err := reg.Dispatch(ctx, p.Name, p.Arguments)
if err != nil {
code := -32000
if errors.Is(err, registry.ErrToolNotFound) {
code = CodeNotFound
}
return NewErrorResponse(req.ID, code, err.Error(), nil)
}
return NewResponse(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(out)}},
})
default:
return NewErrorResponse(req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method), nil)
}
}