feat(mcp): admin token CRUD HTTP handler (POST/GET/DELETE) + TokenService.List/GetByID

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:04:37 +08:00
parent 2d6521e610
commit d0efe2bdd4
3 changed files with 207 additions and 0 deletions
+4
View File
@@ -272,6 +272,10 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
}
mcpServer := mcp.NewMCPServer(mcpDeps)
mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter)
mcp.MountAdmin(r, mcp.AdminHandlerDeps{
Tokens: mcpTokenSvc,
Users: userSvc,
})
// ===== ACP module wiring (Part 2: full SessionService + Supervisor) =====
acpRepo := acp.NewPostgresRepository(pool)
+193
View File
@@ -1,10 +1,20 @@
package mcp
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
"github.com/yan1h/agent-coding-workflow/internal/user"
)
// MountTransports 在 chi router 上挂载 MCP transport 端点:
@@ -56,3 +66,186 @@ func injectTokenIDMiddleware() func(http.Handler) http.Handler {
})
}
}
// ===== Admin Token CRUD HTTP Handler =====
// AdminHandlerDeps 装配 admin token CRUD HTTP handler 所需依赖。
type AdminHandlerDeps struct {
Tokens TokenService
Users user.Service
}
// MountAdmin 把 admin token CRUD 挂到 /api/v1/mcp/tokens。
func MountAdmin(r chi.Router, deps AdminHandlerDeps) {
r.Route("/api/v1/mcp/tokens", func(r chi.Router) {
r.Post("/", postToken(deps))
r.Get("/", listTokens(deps))
r.Delete("/{id}", deleteToken(deps))
})
}
type postTokenReq struct {
UserID string `json:"user_id,omitempty"`
Name string `json:"name"`
Scope Scope `json:"scope"`
ExpiresAt time.Time `json:"expires_at"`
}
type postTokenResp struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
func postToken(deps AdminHandlerDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rid := middleware.RequestIDFromContext(r.Context())
caller, isAdmin, err := resolveSessionUser(r.Context(), deps.Users, r)
if err != nil {
httpx.WriteError(w, rid, err)
return
}
if !isAdmin {
httpx.WriteError(w, rid, errs.New(errs.CodeForbidden, "admin only"))
return
}
var req postTokenReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.WriteError(w, rid, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
return
}
var targetUID uuid.UUID
if req.UserID == "" {
targetUID = caller
} else {
tid, err := uuid.Parse(req.UserID)
if err != nil {
httpx.WriteError(w, rid, errs.Wrap(err, errs.CodeInvalidInput, "user_id parse"))
return
}
targetUID = tid
}
res, err := deps.Tokens.IssueAdmin(r.Context(), IssueAdminInput{
UserID: targetUID,
Name: req.Name,
Scope: req.Scope,
ExpiresAt: req.ExpiresAt,
CreatedBy: caller,
})
if err != nil {
httpx.WriteError(w, rid, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, postTokenResp{
ID: res.ID.String(),
Token: res.Plaintext,
ExpiresAt: derefTime(res.ExpiresAt),
})
}
}
type tokenRow struct {
ID string `json:"id"`
Name string `json:"name"`
Issuer string `json:"issuer"`
Scope Scope `json:"scope"`
ExpiresAt *string `json:"expires_at,omitempty"`
RevokedAt *string `json:"revoked_at,omitempty"`
LastUsedAt *string `json:"last_used_at,omitempty"`
CreatedAt string `json:"created_at"`
}
func listTokens(deps AdminHandlerDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rid := middleware.RequestIDFromContext(r.Context())
caller, isAdmin, err := resolveSessionUser(r.Context(), deps.Users, r)
if err != nil {
httpx.WriteError(w, rid, err)
return
}
var callerFilter *uuid.UUID
if !isAdmin {
callerFilter = &caller
}
activeOnly := r.URL.Query().Get("active_only") == "true"
tokens, err := deps.Tokens.List(r.Context(), callerFilter, activeOnly)
if err != nil {
httpx.WriteError(w, rid, err)
return
}
out := make([]tokenRow, 0, len(tokens))
for _, t := range tokens {
row := tokenRow{
ID: t.ID.String(), Name: t.Name,
Issuer: string(t.Issuer), Scope: t.Scope,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
}
if t.ExpiresAt != nil {
s := t.ExpiresAt.Format(time.RFC3339)
row.ExpiresAt = &s
}
if t.RevokedAt != nil {
s := t.RevokedAt.Format(time.RFC3339)
row.RevokedAt = &s
}
if t.LastUsedAt != nil {
s := t.LastUsedAt.Format(time.RFC3339)
row.LastUsedAt = &s
}
out = append(out, row)
}
httpx.WriteJSON(w, http.StatusOK, out)
}
}
func deleteToken(deps AdminHandlerDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rid := middleware.RequestIDFromContext(r.Context())
caller, _, err := resolveSessionUser(r.Context(), deps.Users, r)
if err != nil {
httpx.WriteError(w, rid, err)
return
}
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
httpx.WriteError(w, rid, errs.Wrap(err, errs.CodeInvalidInput, "id parse"))
return
}
if err := deps.Tokens.Revoke(r.Context(), id, caller); err != nil {
httpx.WriteError(w, rid, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func resolveSessionUser(ctx context.Context, us user.Service, r *http.Request) (uuid.UUID, bool, error) {
tok := extractBearer(r)
uid, ok, err := us.ResolveSession(ctx, tok)
if err != nil {
return uuid.Nil, false, errs.Wrap(err, errs.CodeInternal, "resolve session")
}
if !ok {
return uuid.Nil, false, errs.New(errs.CodeUnauthorized, "session invalid")
}
u, err := us.Get(ctx, uid)
if err != nil {
return uuid.Nil, false, err
}
return uid, u.IsAdmin, nil
}
func extractBearer(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
func derefTime(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
+10
View File
@@ -23,6 +23,8 @@ type TokenService interface {
Authenticate(ctx context.Context, plaintext string) (*Token, error)
Revoke(ctx context.Context, id, byUserID uuid.UUID) error
RevokeBySession(ctx context.Context, sessionID uuid.UUID) error
List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*Token, error)
GetByID(ctx context.Context, id uuid.UUID) (*Token, error)
}
// IssueAdminInput 是 admin 签发时的入参。UserID 缺省 = 当前 admin 自己(由调用方填)。
@@ -278,3 +280,11 @@ func (s *tokenService) RevokeBySession(ctx context.Context, sessionID uuid.UUID)
}
return nil
}
func (s *tokenService) List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*Token, error) {
return s.repo.List(ctx, callerUserID, activeOnly)
}
func (s *tokenService) GetByID(ctx context.Context, id uuid.UUID) (*Token, error) {
return s.repo.GetByID(ctx, id)
}