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,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user