From 4d6597639e4f17d4c5cb86cd087a3a4f3b789d34 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 5 Sep 2026 11:25:41 +0200 Subject: [PATCH] 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. --- cmd/gitea-admin-mcp/main.go | 77 +++++ docs/GITEA_ADMIN_MCP.md | 92 ++++++ internal/adminbootstrap/adminbootstrap.go | 284 ++++++++++++++++++ .../adminbootstrap/adminbootstrap_test.go | 258 ++++++++++++++++ .../localtools/mint_service_account_token.go | 100 ++++++ .../mint_service_account_token_test.go | 110 +++++++ internal/localtools/opsecretstore.go | 50 +++ internal/localtools/opsecretstore_test.go | 52 ++++ internal/mcp/stdio.go | 95 ++++++ internal/mcp/stdio_test.go | 115 +++++++ 10 files changed, 1233 insertions(+) create mode 100644 cmd/gitea-admin-mcp/main.go create mode 100644 docs/GITEA_ADMIN_MCP.md create mode 100644 internal/adminbootstrap/adminbootstrap.go create mode 100644 internal/adminbootstrap/adminbootstrap_test.go create mode 100644 internal/localtools/mint_service_account_token.go create mode 100644 internal/localtools/mint_service_account_token_test.go create mode 100644 internal/localtools/opsecretstore.go create mode 100644 internal/localtools/opsecretstore_test.go create mode 100644 internal/mcp/stdio.go create mode 100644 internal/mcp/stdio_test.go diff --git a/cmd/gitea-admin-mcp/main.go b/cmd/gitea-admin-mcp/main.go new file mode 100644 index 0000000..d5a4af3 --- /dev/null +++ b/cmd/gitea-admin-mcp/main.go @@ -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) + } +} diff --git a/docs/GITEA_ADMIN_MCP.md b/docs/GITEA_ADMIN_MCP.md new file mode 100644 index 0000000..4a5a3c6 --- /dev/null +++ b/docs/GITEA_ADMIN_MCP.md @@ -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. diff --git a/internal/adminbootstrap/adminbootstrap.go b/internal/adminbootstrap/adminbootstrap.go new file mode 100644 index 0000000..cc74561 --- /dev/null +++ b/internal/adminbootstrap/adminbootstrap.go @@ -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))) +} diff --git a/internal/adminbootstrap/adminbootstrap_test.go b/internal/adminbootstrap/adminbootstrap_test.go new file mode 100644 index 0000000..2bbedc8 --- /dev/null +++ b/internal/adminbootstrap/adminbootstrap_test.go @@ -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 +} diff --git a/internal/localtools/mint_service_account_token.go b/internal/localtools/mint_service_account_token.go new file mode 100644 index 0000000..e93f3b8 --- /dev/null +++ b/internal/localtools/mint_service_account_token.go @@ -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) +} diff --git a/internal/localtools/mint_service_account_token_test.go b/internal/localtools/mint_service_account_token_test.go new file mode 100644 index 0000000..bb25499 --- /dev/null +++ b/internal/localtools/mint_service_account_token_test.go @@ -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) +} diff --git a/internal/localtools/opsecretstore.go b/internal/localtools/opsecretstore.go new file mode 100644 index 0000000..7147523 --- /dev/null +++ b/internal/localtools/opsecretstore.go @@ -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 +} diff --git a/internal/localtools/opsecretstore_test.go b/internal/localtools/opsecretstore_test.go new file mode 100644 index 0000000..4b1124c --- /dev/null +++ b/internal/localtools/opsecretstore_test.go @@ -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) +} diff --git a/internal/mcp/stdio.go b/internal/mcp/stdio.go new file mode 100644 index 0000000..cc22926 --- /dev/null +++ b/internal/mcp/stdio.go @@ -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) + } +} diff --git a/internal/mcp/stdio_test.go b/internal/mcp/stdio_test.go new file mode 100644 index 0000000..c939739 --- /dev/null +++ b/internal/mcp/stdio_test.go @@ -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) +}