feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
CD / Lint / Test / Vet (push) Failing after 4s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped

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:
2026-09-05 11:25:41 +02:00
parent 419ae19cdb
commit 4d6597639e
10 changed files with 1233 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
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)
}
}
+115
View File
@@ -0,0 +1,115 @@
package mcp_test
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The existing MCP server (server.go) only speaks HTTP. gitea-admin-mcp needs
// to run as a LOCAL stdio server (Claude Code's local .mcp.json stdio server
// type, spawned as a subprocess with real 1Password/admin auth already in its
// environment) -- reusing the exact same registry.Tool interface and
// jsonrpc.go message types, one JSON-RPC message per line on stdin/stdout,
// which is the standard MCP stdio framing.
type echoTool struct{}
func (echoTool) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{Name: "echo", Description: "echoes its input"}
}
func (echoTool) Call(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
return args, nil
}
func TestServeStdio_Initialize(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
assert.EqualValues(t, 1, resp.ID)
assert.Nil(t, resp.Error)
}
func TestServeStdio_ToolsListAndCall(t *testing.T) {
reg := registry.New()
reg.Register(echoTool{})
in := strings.NewReader(
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n" +
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"hello":"world"}}}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 2, "one JSON-RPC response per line, in request order")
var listResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[0]), &listResp))
assert.EqualValues(t, 1, listResp.ID)
var callResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[1]), &callResp))
assert.EqualValues(t, 2, callResp.ID)
require.NotNil(t, callResp.Result)
}
func TestServeStdio_NotificationGetsNoResponse(t *testing.T) {
// A request with no "id" is a notification per JSON-RPC 2.0 -- must not
// write anything back, and must not stop the loop from processing what
// follows it.
reg := registry.New()
in := strings.NewReader(
`{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 1, "the notification must not produce a response line")
}
func TestServeStdio_UnknownMethodReturnsJSONRPCError(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"not/a/real/method"}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32601, resp.Error.Code)
}
func TestServeStdio_UnparseableLineReturnsParseError(t *testing.T) {
reg := registry.New()
in := strings.NewReader("not json at all\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err, "a malformed line must not crash the loop")
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32700, resp.Error.Code)
}