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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# gitea-admin-mcp
|
||||
|
||||
A separate, local-only stdio MCP server. One tool: `mint_service_account_token`.
|
||||
|
||||
## Why this exists
|
||||
|
||||
dispatch#67 fix #2 needed a read-only CI credential for a dedicated service
|
||||
account, and hit a hard wall: Gitea's own `POST /users/{u}/tokens` and
|
||||
`POST /admin/users` both reject token auth outright —
|
||||
|
||||
{"message":"auth required"}
|
||||
{"message":"token does not have at least one of required scope(s), required=[write:admin], ..."}
|
||||
|
||||
— by design: an existing API token cannot mint another token, or create an
|
||||
account, no matter its scope. The only way through either endpoint is real
|
||||
basic auth as an actual site admin. A sandboxed agent session has no
|
||||
interactive password and shouldn't be handed one — that's broader exposure
|
||||
than the job needs. This binary is meant to run where real admin credentials
|
||||
already exist just-in-time: your own machine, via `op run`.
|
||||
|
||||
## What it does
|
||||
|
||||
`mint_service_account_token` does the whole bootstrap in one call:
|
||||
|
||||
1. Creates the named account if it doesn't exist (admin basic auth), or
|
||||
resets its password if it does. **This is why `confirm_service_account`
|
||||
is required and checked first** — the tool unconditionally (re)sets
|
||||
whatever password the target account has, which is correct for a
|
||||
dedicated bot account and would be destructive pointed at a real person's
|
||||
login by mistake.
|
||||
2. Mints a scoped token for that account, authenticating as the account
|
||||
itself (Gitea requires this specifically — an admin cannot mint a token
|
||||
on another account's behalf).
|
||||
3. Grants the account read-only collaborator access on any named
|
||||
`owner/repo` targets (admin action, least privilege).
|
||||
4. Stores the token in 1Password via `op item create`, value passed on
|
||||
stdin — never as a literal argv element, matching the estate's own
|
||||
secret-handling rule. Returns only the `op://` reference, never the
|
||||
token value itself, to the caller.
|
||||
|
||||
## Setup
|
||||
|
||||
Deliberately **not** wired into `cmd/gitea-mcp` (the shared HTTP server) —
|
||||
this capability is too dangerous to 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.
|
||||
|
||||
Build:
|
||||
|
||||
```
|
||||
go build -o gitea-admin-mcp ./cmd/gitea-admin-mcp
|
||||
```
|
||||
|
||||
Configure as a local stdio MCP server (Claude Code `.mcp.json`, run where
|
||||
your real `op` session lives):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea-admin": {
|
||||
"command": "op",
|
||||
"args": ["run", "--env-file", "~/.op-env", "--", "/path/to/gitea-admin-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required env (see `~/.op-env`):
|
||||
|
||||
| Var | Meaning |
|
||||
|---|---|
|
||||
| `GITEA_BASE_URL` | e.g. `https://git.d-ma.be` |
|
||||
| `GITEA_ADMIN_USERNAME` | a real site-admin account |
|
||||
| `GITEA_ADMIN_PASSWORD` | that account's real password |
|
||||
| `OP_VAULT` | 1Password vault for minted tokens (default `AgentSecrets`, per the estate's agent-governed ops-secrets policy) |
|
||||
|
||||
## Example call
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "dispatch-bot",
|
||||
"email": "dispatch@d-ma.be",
|
||||
"scopes": ["read:repository", "read:issue"],
|
||||
"token_name": "dispatch-ci-readonly",
|
||||
"secret_name": "gitea-dispatch-bot-ci-readonly",
|
||||
"grant_read_repos": ["mathias/cobalt-dingo"],
|
||||
"confirm_service_account": true
|
||||
}
|
||||
```
|
||||
|
||||
Result carries `SecretRef` (e.g. `op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential`)
|
||||
and `GrantedRepos` — never the token or either password.
|
||||
@@ -0,0 +1,284 @@
|
||||
// Package adminbootstrap mints a scoped Gitea access token for a dedicated
|
||||
// service/bot account, end to end, in one auditable call.
|
||||
//
|
||||
// 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 and POST /admin/users both return
|
||||
// "auth required" / a required-scope error for an existing token, by
|
||||
// design — a leaked token being usable to mint further tokens would be a
|
||||
// privilege-escalation footgun). Neither endpoint has a token-scoped
|
||||
// workaround. The only way through is real basic auth as an actual admin.
|
||||
//
|
||||
// That is exactly what a sandboxed agent session cannot safely have — no
|
||||
// interactive password, and handing one to a session would be broader
|
||||
// exposure than the job needs. This package is meant to run LOCALLY, where
|
||||
// real admin credentials exist just-in-time (1Password, `op run`), as a
|
||||
// small stdio MCP tool (see cmd/gitea-admin-mcp) rather than ad hoc curl.
|
||||
package adminbootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTTPDoer is the one method this package needs from *http.Client — narrow
|
||||
// on purpose, so tests inject a fake transport instead of hitting a network.
|
||||
type HTTPDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// SecretStore persists the minted token somewhere durable and returns a
|
||||
// reference safe to log or display — never the value itself. The real
|
||||
// implementation (cmd/gitea-admin-mcp) shells out to the local `op` CLI;
|
||||
// tests use an in-memory fake.
|
||||
type SecretStore interface {
|
||||
Store(ctx context.Context, name, value string) (ref string, err error)
|
||||
}
|
||||
|
||||
// RepoRef names a repo to grant the new account read-only collaborator
|
||||
// access to — least privilege: a service account should see only the repos
|
||||
// it actually operates against, not every private repo on the instance.
|
||||
type RepoRef struct {
|
||||
Owner string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Request describes one bootstrap. ConfirmServiceAccount is required and
|
||||
// checked first: this package (re)sets whatever password Username already
|
||||
// has, which is correct and safe for a dedicated bot account and dangerous
|
||||
// for anything else.
|
||||
type Request struct {
|
||||
Username string
|
||||
Email string
|
||||
FullName string
|
||||
Scopes []string
|
||||
TokenName string
|
||||
SecretName string
|
||||
GrantReadRepos []RepoRef
|
||||
ConfirmServiceAccount bool
|
||||
}
|
||||
|
||||
// Result reports what happened. SecretRef, never the token value, is what a
|
||||
// caller (or an MCP tool's text response) should ever surface.
|
||||
type Result struct {
|
||||
Username string
|
||||
Created bool // false if the account already existed (password was reset, not created)
|
||||
SecretRef string
|
||||
GrantedRepos []string
|
||||
}
|
||||
|
||||
// Bootstrapper holds the real admin credentials and where to reach Gitea —
|
||||
// constructed once per invocation, never persisted.
|
||||
type Bootstrapper struct {
|
||||
BaseURL string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
HTTP HTTPDoer // nil defaults to http.DefaultClient
|
||||
Secrets SecretStore
|
||||
}
|
||||
|
||||
func (b Bootstrapper) httpClient() HTTPDoer {
|
||||
if b.HTTP != nil {
|
||||
return b.HTTP
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// Run creates (or resets the password of) Username, mints it a scoped
|
||||
// token, grants it read-only access to GrantReadRepos, stores the token via
|
||||
// Secrets, and returns without ever exposing the token value or either
|
||||
// password to the caller.
|
||||
func (b Bootstrapper) Run(ctx context.Context, req Request) (Result, error) {
|
||||
if !req.ConfirmServiceAccount {
|
||||
return Result{}, fmt.Errorf("refusing: ConfirmServiceAccount must be true — this resets %q's password unconditionally, which is only safe for a dedicated service/bot account", req.Username)
|
||||
}
|
||||
|
||||
created, err := b.userExists(ctx, req.Username)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("check existing account: %w", err)
|
||||
}
|
||||
created = !created // userExists returns true if found; Result.Created means "we created it"
|
||||
|
||||
botPassword, err := randomPassword()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("generate account password: %w", err)
|
||||
}
|
||||
|
||||
if created {
|
||||
if err := b.createUser(ctx, req, botPassword); err != nil {
|
||||
return Result{}, fmt.Errorf("create account: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := b.resetPassword(ctx, req.Username, botPassword); err != nil {
|
||||
return Result{}, fmt.Errorf("reset existing account's password: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
token, err := b.mintToken(ctx, req.Username, botPassword, req.TokenName, req.Scopes)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("mint token: %w", err)
|
||||
}
|
||||
|
||||
var granted []string
|
||||
for _, r := range req.GrantReadRepos {
|
||||
if err := b.grantReadAccess(ctx, r, req.Username); err != nil {
|
||||
return Result{}, fmt.Errorf("grant read access on %s/%s: %w", r.Owner, r.Name, err)
|
||||
}
|
||||
granted = append(granted, r.Owner+"/"+r.Name)
|
||||
}
|
||||
|
||||
ref, err := b.Secrets.Store(ctx, req.SecretName, token)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("store token: %w", err)
|
||||
}
|
||||
|
||||
return Result{
|
||||
Username: req.Username,
|
||||
Created: created,
|
||||
SecretRef: ref,
|
||||
GrantedRepos: granted,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomPassword() (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func (b Bootstrapper) userExists(ctx context.Context, username string) (bool, error) {
|
||||
resp, err := b.doAdmin(ctx, http.MethodGet, "/api/v1/users/"+username, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unexpected status %d checking account existence", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (b Bootstrapper) createUser(ctx context.Context, req Request, password string) error {
|
||||
body := map[string]any{
|
||||
"username": req.Username,
|
||||
"email": req.Email,
|
||||
"password": password,
|
||||
"must_change_password": false,
|
||||
}
|
||||
if req.FullName != "" {
|
||||
body["full_name"] = req.FullName
|
||||
}
|
||||
resp, err := b.doAdmin(ctx, http.MethodPost, "/api/v1/admin/users", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return statusErr(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b Bootstrapper) resetPassword(ctx context.Context, username, password string) error {
|
||||
resp, err := b.doAdmin(ctx, http.MethodPatch, "/api/v1/admin/users/"+username, map[string]any{
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return statusErr(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mintToken authenticates as the SERVICE ACCOUNT itself (not the admin) —
|
||||
// Gitea's token-creation endpoint requires basic auth as the token's owner,
|
||||
// and an admin's own credentials cannot mint on another account's behalf.
|
||||
func (b Bootstrapper) mintToken(ctx context.Context, username, password, tokenName string, scopes []string) (string, error) {
|
||||
raw, err := json.Marshal(map[string]any{"name": tokenName, "scopes": scopes})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(b.BaseURL, "/")+"/api/v1/users/"+username+"/tokens", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := b.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return "", statusErr(resp)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
SHA1 string `json:"sha1"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return "", fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
if parsed.SHA1 == "" {
|
||||
return "", fmt.Errorf("token response carried no sha1 value")
|
||||
}
|
||||
return parsed.SHA1, nil
|
||||
}
|
||||
|
||||
// grantReadAccess is an admin action (adding a collaborator to a repo the
|
||||
// admin owns), so it uses admin auth, not the new account's own.
|
||||
func (b Bootstrapper) grantReadAccess(ctx context.Context, r RepoRef, username string) error {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", r.Owner, r.Name, username)
|
||||
resp, err := b.doAdmin(ctx, http.MethodPut, path, map[string]any{"permission": "read"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return statusErr(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b Bootstrapper) doAdmin(ctx context.Context, method, path string, body map[string]any) (*http.Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(b.BaseURL, "/")+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(b.AdminUsername, b.AdminPassword)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return b.httpClient().Do(req)
|
||||
}
|
||||
|
||||
func statusErr(resp *http.Response) error {
|
||||
msg, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("gitea returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package adminbootstrap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// This exists to close dispatch#67 fix #2's blocker: minting a scoped Gitea
|
||||
// token needs basic auth (POST /users/{u}/tokens and POST /admin/users both
|
||||
// reject token auth outright, confirmed live against git.d-ma.be — an
|
||||
// existing token cannot mint another token, by design). A sandboxed agent
|
||||
// session has no interactive password, so this package is meant to run
|
||||
// locally, where a real admin password is available just-in-time (1Password,
|
||||
// `op run`), and to do the whole bootstrap in one auditable call rather than
|
||||
// leaving it as ad hoc curl.
|
||||
//
|
||||
// Design choice, made explicit rather than silently assumed: this tool
|
||||
// ALWAYS (re)sets the target account's password via admin basic auth before
|
||||
// minting, whether the account is new or already existed. That is safe and
|
||||
// expected for a dedicated service/bot account (its password is a mechanism
|
||||
// to reach the token endpoint, never a human login credential) and would be
|
||||
// dangerous pointed at a real person's account by mistake -- which is why
|
||||
// Request.ConfirmServiceAccount exists and Run refuses to proceed without it.
|
||||
|
||||
type call struct {
|
||||
method, path string
|
||||
body map[string]any
|
||||
authUser string // basic-auth username used for this call, "" if token/none
|
||||
authPass string
|
||||
}
|
||||
|
||||
type fakeTransport struct {
|
||||
t *testing.T
|
||||
responses map[string]fakeResponse // key: "METHOD path"
|
||||
calls []call
|
||||
}
|
||||
|
||||
type fakeResponse struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var body map[string]any
|
||||
if req.Body != nil {
|
||||
raw, _ := io.ReadAll(req.Body)
|
||||
if len(raw) > 0 {
|
||||
require.NoError(f.t, json.Unmarshal(raw, &body))
|
||||
}
|
||||
}
|
||||
user, pass, _ := req.BasicAuth()
|
||||
f.calls = append(f.calls, call{method: req.Method, path: req.URL.Path, body: body, authUser: user, authPass: pass})
|
||||
|
||||
key := req.Method + " " + req.URL.Path
|
||||
r, ok := f.responses[key]
|
||||
if !ok {
|
||||
f.t.Fatalf("unscripted request: %s (have: %v)", key, keys(f.responses))
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: r.status,
|
||||
Body: io.NopCloser(strings.NewReader(r.body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func keys(m map[string]fakeResponse) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeSecretStore records what would have been written to 1Password, without
|
||||
// ever touching it -- and, critically, asserts the token value itself never
|
||||
// leaks into anything but this one call.
|
||||
type fakeSecretStore struct {
|
||||
stored map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeSecretStore) Store(_ context.Context, name, value string) (string, error) {
|
||||
if f.stored == nil {
|
||||
f.stored = map[string]string{}
|
||||
}
|
||||
f.stored[name] = value
|
||||
return "op://AgentSecrets/" + name + "/token", nil
|
||||
}
|
||||
|
||||
func newBootstrapper(t *testing.T, responses map[string]fakeResponse) (adminbootstrap.Bootstrapper, *fakeTransport, *fakeSecretStore) {
|
||||
ft := &fakeTransport{t: t, responses: responses}
|
||||
fs := &fakeSecretStore{}
|
||||
return adminbootstrap.Bootstrapper{
|
||||
BaseURL: "http://fake.gitea",
|
||||
AdminUsername: "mathias",
|
||||
AdminPassword: "admin-pass-not-a-real-secret",
|
||||
HTTP: &http.Client{Transport: ft},
|
||||
Secrets: fs,
|
||||
}, ft, fs
|
||||
}
|
||||
|
||||
func TestRun_CreatesNewAccountMintsTokenAndStoresIt(t *testing.T) {
|
||||
b, ft, fs := newBootstrapper(t, map[string]fakeResponse{
|
||||
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
||||
"POST /api/v1/admin/users": {status: 201, body: `{"login":"dispatch-bot"}`},
|
||||
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_abc123"}`},
|
||||
})
|
||||
|
||||
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||
Username: "dispatch-bot",
|
||||
Email: "dispatch@d-ma.be",
|
||||
Scopes: []string{"read:repository", "read:issue"},
|
||||
TokenName: "dispatch-ci-readonly",
|
||||
SecretName: "gitea-dispatch-bot-ci-readonly",
|
||||
ConfirmServiceAccount: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Created)
|
||||
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result.SecretRef)
|
||||
assert.Equal(t, "tok_abc123", fs.stored["gitea-dispatch-bot-ci-readonly"])
|
||||
|
||||
// The account-creation call must use the ADMIN's basic auth, never the
|
||||
// fresh account's own (unknown at that point) password.
|
||||
create := findCall(t, ft.calls, "POST", "/api/v1/admin/users")
|
||||
assert.Equal(t, "mathias", create.authUser)
|
||||
assert.Equal(t, "dispatch-bot", create.body["username"])
|
||||
assert.Equal(t, "dispatch@d-ma.be", create.body["email"])
|
||||
assert.Equal(t, false, create.body["must_change_password"])
|
||||
|
||||
// The token-mint call must use basic auth AS THE NEW ACCOUNT -- Gitea
|
||||
// requires this specifically (an admin token cannot mint on someone
|
||||
// else's behalf) -- with the password this package generated and set,
|
||||
// never the admin's own password.
|
||||
mint := findCall(t, ft.calls, "POST", "/api/v1/users/dispatch-bot/tokens")
|
||||
assert.Equal(t, "dispatch-bot", mint.authUser)
|
||||
assert.NotEqual(t, "admin-pass-not-a-real-secret", mint.authPass)
|
||||
assert.NotEmpty(t, mint.authPass)
|
||||
assert.Equal(t, "dispatch-ci-readonly", mint.body["name"])
|
||||
assert.ElementsMatch(t, []any{"read:repository", "read:issue"}, mint.body["scopes"])
|
||||
}
|
||||
|
||||
func TestRun_ExistingAccountGetsPasswordResetNotRecreated(t *testing.T) {
|
||||
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
|
||||
"GET /api/v1/users/dispatch-bot": {status: 200, body: `{"login":"dispatch-bot"}`},
|
||||
"PATCH /api/v1/admin/users/dispatch-bot": {status: 200, body: `{}`},
|
||||
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_new"}`},
|
||||
})
|
||||
|
||||
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||
Username: "dispatch-bot",
|
||||
Email: "dispatch@d-ma.be",
|
||||
Scopes: []string{"read:repository"},
|
||||
TokenName: "dispatch-ci-readonly",
|
||||
SecretName: "gitea-dispatch-bot-ci-readonly",
|
||||
ConfirmServiceAccount: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Created, "must report the account as pre-existing, not newly created")
|
||||
|
||||
// Must PATCH (reset password), never POST (create) for an existing account.
|
||||
assert.Empty(t, filterCalls(ft.calls, "POST", "/api/v1/admin/users"))
|
||||
patch := findCall(t, ft.calls, "PATCH", "/api/v1/admin/users/dispatch-bot")
|
||||
assert.Equal(t, "mathias", patch.authUser, "the reset must be done as the real admin")
|
||||
assert.NotEmpty(t, patch.body["password"])
|
||||
}
|
||||
|
||||
func TestRun_RefusesWithoutExplicitServiceAccountConfirmation(t *testing.T) {
|
||||
// Guard against pointing this at a real person's account by accident --
|
||||
// this tool resets whatever password the target account has.
|
||||
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{})
|
||||
|
||||
_, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||
Username: "mathias",
|
||||
Email: "mthbqv@gmail.com",
|
||||
Scopes: []string{"read:repository"},
|
||||
TokenName: "whatever",
|
||||
SecretName: "whatever",
|
||||
// ConfirmServiceAccount left false/unset
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ConfirmServiceAccount")
|
||||
assert.Empty(t, ft.calls, "must not make any request at all without confirmation")
|
||||
}
|
||||
|
||||
func TestRun_GrantsReadOnlyCollaboratorAccessViaAdminAuth(t *testing.T) {
|
||||
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
|
||||
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
||||
"POST /api/v1/admin/users": {status: 201, body: `{}`},
|
||||
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok"}`},
|
||||
"PUT /api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot": {status: 204, body: ``},
|
||||
})
|
||||
|
||||
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||
Username: "dispatch-bot",
|
||||
Email: "dispatch@d-ma.be",
|
||||
Scopes: []string{"read:repository"},
|
||||
TokenName: "dispatch-ci-readonly",
|
||||
SecretName: "gitea-dispatch-bot-ci-readonly",
|
||||
GrantReadRepos: []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}},
|
||||
ConfirmServiceAccount: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"mathias/cobalt-dingo"}, result.GrantedRepos)
|
||||
grant := findCall(t, ft.calls, "PUT", "/api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot")
|
||||
assert.Equal(t, "mathias", grant.authUser, "granting collaborator access is an admin action, not the new account's own")
|
||||
assert.Equal(t, "read", grant.body["permission"])
|
||||
}
|
||||
|
||||
func TestRun_FailsClosedOnUpstreamError(t *testing.T) {
|
||||
b, _, fs := newBootstrapper(t, map[string]fakeResponse{
|
||||
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
||||
"POST /api/v1/admin/users": {status: 500, body: `{"message":"internal error"}`},
|
||||
})
|
||||
|
||||
_, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||
Username: "dispatch-bot",
|
||||
Email: "dispatch@d-ma.be",
|
||||
Scopes: []string{"read:repository"},
|
||||
TokenName: "x",
|
||||
SecretName: "x",
|
||||
ConfirmServiceAccount: true,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, fs.stored, "nothing must be written to the secret store if the flow didn't complete")
|
||||
}
|
||||
|
||||
func findCall(t *testing.T, calls []call, method, path string) call {
|
||||
t.Helper()
|
||||
for _, c := range calls {
|
||||
if c.method == method && c.path == path {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("no call found: %s %s (have: %+v)", method, path, calls)
|
||||
return call{}
|
||||
}
|
||||
|
||||
func filterCalls(calls []call, method, path string) []call {
|
||||
var out []call
|
||||
for _, c := range calls {
|
||||
if c.method == method && c.path == path {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package localtools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// This tool lives in its own package, never internal/tools, on purpose: it
|
||||
// resets whatever password its target account has, which is far too
|
||||
// dangerous a capability to risk it ever being wired into RegisterAll and
|
||||
// exposed on the shared HTTP gitea-mcp server. Only cmd/gitea-admin-mcp (a
|
||||
// separate, local-only, stdio binary) imports this package.
|
||||
|
||||
type fakeBootstrapper struct {
|
||||
gotReq adminbootstrap.Request
|
||||
result adminbootstrap.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBootstrapper) Run(_ context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error) {
|
||||
f.gotReq = req
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_ParsesArgsAndCallsBootstrapper(t *testing.T) {
|
||||
fb := &fakeBootstrapper{result: adminbootstrap.Result{
|
||||
Username: "dispatch-bot", Created: true,
|
||||
SecretRef: "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token",
|
||||
}}
|
||||
tool := localtools.NewMintServiceAccountToken(fb)
|
||||
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"username": "dispatch-bot",
|
||||
"email": "dispatch@d-ma.be",
|
||||
"scopes": []string{"read:repository", "read:issue"},
|
||||
"token_name": "dispatch-ci-readonly",
|
||||
"secret_name": "gitea-dispatch-bot-ci-readonly",
|
||||
"grant_read_repos": []string{"mathias/cobalt-dingo"},
|
||||
"confirm_service_account": true,
|
||||
})
|
||||
|
||||
out, err := tool.Call(context.Background(), args)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dispatch-bot", fb.gotReq.Username)
|
||||
assert.Equal(t, []string{"read:repository", "read:issue"}, fb.gotReq.Scopes)
|
||||
assert.Equal(t, true, fb.gotReq.ConfirmServiceAccount)
|
||||
require.Equal(t, []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}}, fb.gotReq.GrantReadRepos)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result["SecretRef"])
|
||||
// The tool's own output must never carry a raw token or password field --
|
||||
// adminbootstrap.Result structurally can't (it has none), which this pins.
|
||||
_, hasToken := result["Token"]
|
||||
_, hasPassword := result["Password"]
|
||||
assert.False(t, hasToken)
|
||||
assert.False(t, hasPassword)
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_RejectsMalformedRepoRef(t *testing.T) {
|
||||
fb := &fakeBootstrapper{}
|
||||
tool := localtools.NewMintServiceAccountToken(fb)
|
||||
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"username": "dispatch-bot",
|
||||
"email": "dispatch@d-ma.be",
|
||||
"scopes": []string{"read:repository"},
|
||||
"token_name": "x",
|
||||
"secret_name": "x",
|
||||
"grant_read_repos": []string{"not-owner-slash-repo"},
|
||||
"confirm_service_account": true,
|
||||
})
|
||||
|
||||
_, err := tool.Call(context.Background(), args)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "owner/repo")
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_PropagatesBootstrapperError(t *testing.T) {
|
||||
fb := &fakeBootstrapper{err: assert.AnError}
|
||||
tool := localtools.NewMintServiceAccountToken(fb)
|
||||
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"username": "dispatch-bot",
|
||||
"email": "dispatch@d-ma.be",
|
||||
"scopes": []string{"read:repository"},
|
||||
"token_name": "x",
|
||||
"secret_name": "x",
|
||||
"confirm_service_account": true,
|
||||
})
|
||||
|
||||
_, err := tool.Call(context.Background(), args)
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_Descriptor(t *testing.T) {
|
||||
tool := localtools.NewMintServiceAccountToken(&fakeBootstrapper{})
|
||||
d := tool.Descriptor()
|
||||
assert.Equal(t, "mint_service_account_token", d.Name)
|
||||
assert.NotEmpty(t, d.Description)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package localtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpSecretStore implements adminbootstrap.SecretStore against the local
|
||||
// `op` (1Password) CLI. Run is injectable for tests; nil defaults to
|
||||
// actually invoking `op`.
|
||||
//
|
||||
// The token value is passed via the child process's stdin, never as a
|
||||
// literal argv element — matching the estate's own secret-handling rule
|
||||
// (a value in argv lands in process listings and any transcript of the
|
||||
// command). `credential[password]=-` is `op item create`'s documented way
|
||||
// to read one field's value from stdin instead of the command line.
|
||||
type OpSecretStore struct {
|
||||
Vault string
|
||||
Run func(ctx context.Context, stdin string, args ...string) error
|
||||
}
|
||||
|
||||
func (s OpSecretStore) Store(ctx context.Context, name, value string) (string, error) {
|
||||
run := s.Run
|
||||
if run == nil {
|
||||
run = runOp
|
||||
}
|
||||
args := []string{
|
||||
"item", "create",
|
||||
"--category=login",
|
||||
"--vault=" + s.Vault,
|
||||
"--title=" + name,
|
||||
"credential[password]=-",
|
||||
}
|
||||
if err := run(ctx, value, args...); err != nil {
|
||||
return "", fmt.Errorf("op item create: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("op://%s/%s/credential", s.Vault, name), nil
|
||||
}
|
||||
|
||||
func runOp(ctx context.Context, stdin string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, "op", args...)
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package localtools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The estate's own secret-handling rule: never place a literal secret in a
|
||||
// command's argv — it lands in process listings and any transcript of the
|
||||
// command. OpSecretStore feeds the token to `op` via stdin instead.
|
||||
|
||||
func TestOpSecretStore_PassesValueViaStdinNeverArgv(t *testing.T) {
|
||||
var gotStdin string
|
||||
var gotArgs []string
|
||||
s := localtools.OpSecretStore{
|
||||
Vault: "AgentSecrets",
|
||||
Run: func(_ context.Context, stdin string, args ...string) error {
|
||||
gotStdin = stdin
|
||||
gotArgs = args
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
ref, err := s.Store(context.Background(), "gitea-dispatch-bot-ci-readonly", "tok_the_actual_secret_value")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential", ref)
|
||||
assert.Equal(t, "tok_the_actual_secret_value", gotStdin)
|
||||
for _, a := range gotArgs {
|
||||
assert.NotContains(t, a, "tok_the_actual_secret_value",
|
||||
"the secret value must never appear as a literal argv element")
|
||||
}
|
||||
assert.Contains(t, gotArgs, "--vault=AgentSecrets")
|
||||
assert.Contains(t, gotArgs, "--title=gitea-dispatch-bot-ci-readonly")
|
||||
}
|
||||
|
||||
func TestOpSecretStore_PropagatesRunError(t *testing.T) {
|
||||
s := localtools.OpSecretStore{
|
||||
Vault: "AgentSecrets",
|
||||
Run: func(_ context.Context, _ string, _ ...string) error {
|
||||
return assert.AnError
|
||||
},
|
||||
}
|
||||
|
||||
_, err := s.Store(context.Background(), "x", "y")
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -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