Files
llmgw/internal/web/admin_mcp.go
T
Ilker ManapandClaude Opus 5.5 03d3bcfd2d The company's MCP servers on a web page
/admin/mcp, for administrators only, since a server's credential reaches
whatever it guards: each server's state, who has been granted its tools,
its rate, the headers it is sent by name with the sealed ones marked, and
its address; adding one with a bearer token or another header kept in the
store; testing it with its credential and listing its tools; granting them
to a team, project, org, person or key, written into that level's limits
beside its other grants and its rate and budget; revoking; changing;
replacing the credential; turning off and on; removing; and how a person
points an agent at the gateway's /mcp with a key of their own. English and
Turkish. The secret's field is named so the audit line redacts it.

The admin API's server merge and test are package functions now,
adminapi.MCPServerFrom and adminapi.TestMCPServer, shared by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:26:51 +01:00

436 lines
14 KiB
Go

package web
import (
"context"
"errors"
"net/http"
"net/url"
"path"
"slices"
"strconv"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The company's own MCP servers, managed in the browser: what `llmgw ctl
// mcp` and the console's MCP screen do, for whoever has no terminal on a
// control plane. Agents reach every server through the gateway's /mcp, its
// tools named server.tool, seen only where they are granted.
func init() {
// Named from the grant's scope in the template.
registerKeys("mcp.scope.org", "mcp.scope.team", "mcp.scope.project", "mcp.scope.user", "mcp.scope.key")
}
// MCPAdmin is where the servers, their sealed credentials and the tree's
// tool grants are kept.
type MCPAdmin interface {
adminapi.MCPStore
AllLimits(ctx context.Context) (map[string]store.Limits, error)
LimitsFor(ctx context.Context, scope store.Scope, id string) (store.Limits, error)
PutLimits(ctx context.Context, l store.Limits) error
Tree(ctx context.Context) ([]store.TreeOrg, error)
Users(ctx context.Context, state store.UserState) ([]store.User, error)
}
type mcpPageData struct {
Nav Nav
Version string
CSRF string
Servers []mcpRow
// Gateway is this gateway's address as the browser reached it, for the
// line that points an agent at it.
Gateway string
// Tools are what a test found, for the server Tested.
Tested string
Tools []string
Notice string
Error string
}
type mcpRow struct {
adminapi.MCPServerView
// Sent are the headers it is sent, by name: Secret ones sealed in the
// store, Plain ones as written.
Secret, Plain []string
Grants []mcpGrantRow
// Local is this cluster's own read-only server, which has no address
// or credential to change.
Local bool
}
// mcpGrantRow is one level of the tree that grants some of a server's
// tools.
type mcpGrantRow struct {
Scope, ID, Label string
Tools []string
}
// RegisterAdminMCP mounts /admin/mcp, for administrators only: a server's
// credential reaches whatever it guards. local answers for this cluster's
// own server when it is tested; nil leaves that test to the control plane.
func (p *AccountPages) RegisterAdminMCP(mux *http.ServeMux, s MCPAdmin, local func() http.Handler) {
p.hasMCP = true
page := func(w http.ResponseWriter, r *http.Request, admin store.User, d mcpPageData) {
ctx, l := r.Context(), langOf(r)
d.Nav, d.Version, d.CSRF, d.Gateway = p.navFor(r, admin, "mcp"), p.version, csrfFor(r), mcpAgentURL(baseURL(r))
list, err := s.MCPServers(ctx)
if err != nil && d.Error == "" {
d.Error = l.Err(err)
}
grants := mcpGrants(ctx, s)
for _, m := range list {
row := mcpRow{MCPServerView: adminapi.MCPServerView{Name: m.Name, URL: m.URL, Description: m.Description,
Enabled: m.Enabled, TimeoutSeconds: int(m.Timeout.Seconds()), RatePerMinute: m.RatePerMinute},
Local: m.URL == adminapi.AdminMCPAddress}
for h := range m.HeaderRefs {
row.Secret = append(row.Secret, h)
}
for h := range m.Headers {
row.Plain = append(row.Plain, h)
}
slices.Sort(row.Secret)
slices.Sort(row.Plain)
for _, g := range grants {
var mine []string
for _, t := range g.Tools {
if strings.HasPrefix(t, m.Name+".") || t == "*" {
mine = append(mine, t)
}
}
if len(mine) > 0 {
row.Grants = append(row.Grants, mcpGrantRow{Scope: g.Scope, ID: g.ID, Label: g.Label, Tools: mine})
}
}
d.Servers = append(d.Servers, row)
}
noStore(w)
render(w, r, "admin_mcp", d)
}
adminsOnly := func(next func(w http.ResponseWriter, r *http.Request, admin store.User)) http.HandlerFunc {
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
if !admin.IsAdmin {
http.NotFound(w, r)
return
}
if r.Method == http.MethodPost {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
badForm(w, r)
return
}
}
next(w, r, admin)
})
}
// done renders the page with what an action came to, and notes it in
// the audit line.
done := func(w http.ResponseWriter, r *http.Request, admin store.User, subject string, notice string, err error) {
d := mcpPageData{Notice: notice}
if err != nil {
d.Notice, d.Error = "", langOf(r).Err(err)
noteAudit(r, subject, "", err.Error())
} else {
noteAudit(r, subject, notice, "")
}
page(w, r, admin, d)
}
put := func(r *http.Request, admin store.User, name string, req adminapi.MCPServerRequest) error {
m, err := adminapi.MCPServerFrom(r.Context(), s, name, req, admin.Email)
if err != nil {
return err
}
return s.PutMCPServer(r.Context(), m)
}
mux.HandleFunc("GET /admin/mcp", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
page(w, r, admin, mcpPageData{})
}))
mux.HandleFunc("POST /admin/mcp/add", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
l := langOf(r)
name := strings.TrimSpace(r.FormValue("name"))
if _, err := s.MCPServer(r.Context(), name); err == nil {
done(w, r, admin, "mcp server "+name, "", errors.New(l.T("mcp.err.exists", name)))
return
}
secrets, err := mcpSecrets(l, r.FormValue("auth"), r.FormValue("header"), r.FormValue("secret_value"))
if err != nil {
done(w, r, admin, "mcp server "+name, "", err)
return
}
timeout, rate, err := mcpBounds(l, r.FormValue("timeout"), r.FormValue("rate"))
if err != nil {
done(w, r, admin, "mcp server "+name, "", err)
return
}
on := true
err = put(r, admin, name, adminapi.MCPServerRequest{URL: strings.TrimSpace(r.FormValue("url")),
Description: strings.TrimSpace(r.FormValue("description")), Secrets: secrets, Enabled: &on,
TimeoutSeconds: timeout, RatePerMinute: rate})
done(w, r, admin, "mcp server "+name, l.T("mcp.added", name), err)
}))
mux.HandleFunc("POST /admin/mcp/{name}/edit", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
l, name := langOf(r), r.PathValue("name")
timeout, rate, err := mcpBounds(l, r.FormValue("timeout"), r.FormValue("rate"))
if err == nil && rate == 0 {
rate = -1 // an empty rate takes the bound away
}
if err == nil {
err = put(r, admin, name, adminapi.MCPServerRequest{URL: strings.TrimSpace(r.FormValue("url")),
Description: strings.TrimSpace(r.FormValue("description")), TimeoutSeconds: timeout, RatePerMinute: rate})
}
done(w, r, admin, "mcp server "+name, l.T("mcp.changed", name), err)
}))
mux.HandleFunc("POST /admin/mcp/{name}/credential", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
l, name := langOf(r), r.PathValue("name")
secrets, err := mcpSecrets(l, r.FormValue("auth"), r.FormValue("header"), r.FormValue("secret_value"))
if err == nil && len(secrets) == 0 {
err = errors.New(l.T("mcp.err.no_secret"))
}
if err == nil {
err = put(r, admin, name, adminapi.MCPServerRequest{Secrets: secrets})
}
done(w, r, admin, "mcp server "+name, l.T("mcp.credential_replaced", name), err)
}))
for _, onOff := range []struct {
path string
on bool
key string
}{{"enable", true, "mcp.enabled"}, {"disable", false, "mcp.disabled"}} {
mux.HandleFunc("POST /admin/mcp/{name}/"+onOff.path, adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
name, on := r.PathValue("name"), onOff.on
err := put(r, admin, name, adminapi.MCPServerRequest{Enabled: &on})
done(w, r, admin, "mcp server "+name, langOf(r).T(onOff.key, name), err)
}))
}
mux.HandleFunc("POST /admin/mcp/{name}/remove", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
name := r.PathValue("name")
err := s.DeleteMCPServer(r.Context(), name)
done(w, r, admin, "mcp server "+name, langOf(r).T("mcp.removed", name), err)
}))
mux.HandleFunc("POST /admin/mcp/{name}/test", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
l, name := langOf(r), r.PathValue("name")
m, err := s.MCPServer(r.Context(), name)
if err != nil {
page(w, r, admin, mcpPageData{Error: l.Err(err)})
return
}
var h http.Handler
if local != nil {
h = local()
}
v := adminapi.TestMCPServer(r.Context(), m, h)
d := mcpPageData{Tested: name, Tools: v.Tools}
switch {
case !v.OK:
d.Error = l.T("mcp.test_failed", name, v.Error)
case len(v.Tools) == 0:
d.Notice = l.T("mcp.test_no_tools", name)
default:
d.Notice = l.N("mcp.test_ok", int64(len(v.Tools)), name)
}
page(w, r, admin, d)
}))
mux.HandleFunc("POST /admin/mcp/{name}/grant", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
l, name := langOf(r), r.PathValue("name")
scope, which := store.Scope(r.FormValue("scope")), strings.TrimSpace(r.FormValue("which"))
var tools []string
for _, t := range strings.Split(r.FormValue("tools"), ",") {
if t = strings.TrimSpace(t); t != "" {
tools = append(tools, t)
}
}
err := mcpCheckTools(l, name, tools)
var id, label string
if err == nil {
id, label, err = mcpResolve(r.Context(), s, l, scope, which)
}
if err == nil {
err = mcpSetTools(r.Context(), s, name, scope, id, tools)
}
done(w, r, admin, "mcp grants "+name, l.T("mcp.granted", name, label), err)
}))
mux.HandleFunc("POST /admin/mcp/{name}/revoke", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
l, name := langOf(r), r.PathValue("name")
scope, id := store.Scope(r.FormValue("scope")), r.FormValue("id")
err := mcpSetTools(r.Context(), s, name, scope, id, nil)
done(w, r, admin, "mcp grants "+name, l.T("mcp.revoked", name), err)
}))
}
// mcpGrants are the tree's levels that grant tools, named as people know
// them, sorted.
func mcpGrants(ctx context.Context, s MCPAdmin) []mcpGrantRow {
limits, err := s.AllLimits(ctx)
if err != nil {
return nil
}
names := map[string]string{}
if tree, err := s.Tree(ctx); err == nil {
for _, o := range tree {
names[store.LimitOwner{Scope: store.ScopeOrg, ID: o.ID}.CounterKey()] = o.Name
for _, t := range o.Teams {
names[store.LimitOwner{Scope: store.ScopeTeam, ID: t.ID}.CounterKey()] = o.Name + "/" + t.Name
for _, pr := range t.Projects {
names[store.LimitOwner{Scope: store.ScopeProject, ID: pr.ID}.CounterKey()] = o.Name + "/" + t.Name + "/" + pr.Name
}
}
}
}
if users, err := s.Users(ctx, ""); err == nil {
for _, u := range users {
names[store.LimitOwner{Scope: store.ScopeUser, ID: u.ID}.CounterKey()] = u.Email
}
}
var out []mcpGrantRow
for key, lim := range limits {
if len(lim.AllowedTools) == 0 {
continue
}
label := names[key]
if label == "" {
label = lim.ScopeID
}
out = append(out, mcpGrantRow{Scope: string(lim.Scope), ID: lim.ScopeID, Label: label, Tools: lim.AllowedTools})
}
slices.SortFunc(out, func(a, b mcpGrantRow) int { return strings.Compare(a.Scope+a.Label, b.Scope+b.Label) })
return out
}
// mcpResolve is the ID of the level a form names: a path in the tree, a
// person's e-mail, or a key's ID; and how to say it.
func mcpResolve(ctx context.Context, s MCPAdmin, l *Lang, scope store.Scope, which string) (string, string, error) {
if which == "" {
return "", "", errors.New(l.T("mcp.err.which"))
}
switch scope {
case store.ScopeKey:
return which, which, nil
case store.ScopeUser:
users, err := s.Users(ctx, "")
if err != nil {
return "", "", err
}
for _, u := range users {
if strings.EqualFold(u.Email, which) || u.ID == which {
return u.ID, u.Email, nil
}
}
case store.ScopeOrg, store.ScopeTeam, store.ScopeProject:
tree, err := s.Tree(ctx)
if err != nil {
return "", "", err
}
for _, o := range tree {
if scope == store.ScopeOrg && (o.Name == which || o.ID == which) {
return o.ID, o.Name, nil
}
for _, t := range o.Teams {
tp := o.Name + "/" + t.Name
if scope == store.ScopeTeam && (tp == which || t.ID == which) {
return t.ID, tp, nil
}
for _, pr := range t.Projects {
if pp := tp + "/" + pr.Name; scope == store.ScopeProject && (pp == which || pr.ID == which) {
return pr.ID, pp, nil
}
}
}
}
default:
return "", "", errors.New(l.T("mcp.err.scope"))
}
return "", "", errors.New(l.T("mcp.err.no_such", which))
}
// mcpCheckTools refuses patterns that are not a server's tools.
func mcpCheckTools(l *Lang, server string, tools []string) error {
if len(tools) == 0 {
return errors.New(l.T("mcp.err.no_tools", server))
}
for _, t := range tools {
if !strings.HasPrefix(t, server+".") {
return errors.New(l.T("mcp.err.not_its_tool", t, server))
}
if _, err := path.Match(t, server+".x"); err != nil {
return errors.New(l.T("mcp.err.pattern", t))
}
}
return nil
}
// mcpSetTools makes a level's grants of a server's tools these, leaving its
// other grants and its other limits as they are; none revokes them.
func mcpSetTools(ctx context.Context, s MCPAdmin, server string, scope store.Scope, id string, tools []string) error {
lim, err := s.LimitsFor(ctx, scope, id)
switch {
case errors.Is(err, store.ErrNotFound):
lim = store.Limits{Scope: scope, ScopeID: id}
case err != nil:
return err
}
keep := slices.DeleteFunc(slices.Clone(lim.AllowedTools), func(t string) bool { return strings.HasPrefix(t, server+".") })
lim.AllowedTools = append(keep, tools...)
return s.PutLimits(ctx, lim)
}
// mcpSecrets turns the form's credential into what the server is sent.
func mcpSecrets(l *Lang, kind, header, value string) (map[string]string, error) {
header, value = strings.TrimSpace(header), strings.TrimSpace(value)
switch kind {
case "", "none":
return nil, nil
case "bearer":
if value == "" {
return nil, nil
}
return map[string]string{"Authorization": "Bearer " + strings.TrimPrefix(value, "Bearer ")}, nil
case "header":
if header == "" {
return nil, errors.New(l.T("mcp.err.header"))
}
if value == "" {
return nil, nil
}
return map[string]string{header: value}, nil
}
return nil, errors.New(l.T("mcp.err.auth"))
}
// mcpBounds reads the form's timeout and rate, empty being none.
func mcpBounds(l *Lang, timeout, rate string) (int, int, error) {
t, r := 0, 0
var err error
if s := strings.TrimSpace(timeout); s != "" {
if t, err = strconv.Atoi(s); err != nil || t < 0 {
return 0, 0, errors.New(l.T("mcp.err.timeout"))
}
}
if s := strings.TrimSpace(rate); s != "" {
if r, err = strconv.Atoi(s); err != nil || r < 0 {
return 0, 0, errors.New(l.T("mcp.err.rate"))
}
}
return t, r, nil
}
// mcpAgentURL is the gateway's /mcp, as a person's agent is given it.
func mcpAgentURL(base string) string {
u, err := url.Parse(base)
if err != nil {
return base + "/mcp"
}
u.Path = "/mcp"
return u.String()
}