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