Files
llmgw/internal/web/admin_mcp.go
T
Ilker ManapandClaude Opus 5.5 1948a787ae
CI / test (push) Successful in 9m15s
Fixes from the second review: grants, flags, updates, setup
- Revoking a server's tools takes them from every level beneath too, and a
  level left naming no tool stays closed ([]) while any level above or
  beneath names tools: an organization's revoke left its teams the copies
  their first grants took, and going back to no list opened the level to
  what was above it. A closed level is shown on the page and in the
  console with an action to open it (store.OpenTools). A key's owner's
  list is among the levels above a key.
- The flag check matches short flags as written again (-m, -hf, -mu were
  let through), takes a long flag's beginning for it only for the engines
  that parse with argparse (vLLM, SGLang, ComfyUI: --li is --listen), so
  llama.cpp's own --chat-template is not refused, and refuses llama.cpp's
  short spellings (-hfr, -hff, -mm, -jf, …).
- A stored model with an adapter by Hugging Face id can be changed again:
  what it already had is not asked about anew.
- The catalog reads a Hugging Face id as the agent does (engines.IsHubID).
- The page's form cannot move llmgw:admin to another address.
- server init skips the memory check only where the gateway already runs,
  not wherever a half-finished first run left server.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:31:07 +01:00

488 lines
16 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",
"mcp.scope.org-member", "mcp.scope.team-member", "mcp.scope.project-member")
}
// 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
GrantTools(ctx context.Context, scope store.Scope, id, server string, tools []string) (store.ToolGrant, error)
RevokeTools(ctx context.Context, scope store.Scope, id, server string) error
KeyByID(ctx context.Context, id string) (store.Key, error)
OpenTools(ctx context.Context, scope store.Scope, id string) 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
// Closed are the levels that name no tool at all, and so see none
// whatever is granted above them.
Closed []mcpGrantRow
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
// Revocable is false for a grant of every tool, "*", which taking this
// server's away would not change: it is changed with the level's limits.
Revocable bool
}
// 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 _, g := range grants {
if len(g.Tools) == 0 {
d.Closed = append(d.Closed, g)
}
}
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
revocable := false
for _, t := range g.Tools {
if strings.HasPrefix(t, m.Name+".") || t == "*" {
mine = append(mine, t)
revocable = revocable || t != "*"
}
}
if len(mine) > 0 {
row.Grants = append(row.Grants, mcpGrantRow{Scope: g.Scope, ID: g.ID, Label: g.Label, Tools: mine,
Revocable: revocable})
}
}
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"))
addr := strings.TrimSpace(r.FormValue("url"))
// This cluster's own stays its own, whatever a form sends.
if m, gerr := s.MCPServer(r.Context(), name); gerr == nil && m.URL == adminapi.AdminMCPAddress {
addr = adminapi.AdminMCPAddress
}
if err == nil {
req := adminapi.MCPServerRequest{URL: addr,
Description: strings.TrimSpace(r.FormValue("description")), TimeoutSeconds: timeout, RatePerMinute: rate}
// An emptied field empties it, rather than leaving it as it was.
if req.Description == "" {
req.Clear = append(req.Clear, "description")
}
if timeout == 0 {
req.Clear = append(req.Clear, "timeout")
}
if rate == 0 {
req.Clear = append(req.Clear, "rate")
}
err = put(r, admin, name, req)
}
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 {
// In place of every credential it was sent, not beside them: a
// bearer token given where an X-Api-Key was must not leave the
// key still sent.
err = put(r, admin, name, adminapi.MCPServerRequest{Secrets: secrets, HeaderRefs: map[string]string{}})
}
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)
}
notice := l.T("mcp.granted", name, label)
if err == nil {
var g store.ToolGrant
if g, err = s.GrantTools(r.Context(), scope, id, name, tools); err == nil && len(g.BlockedBy) > 0 {
names := mcpNames(r.Context(), s)
var by []string
for _, o := range g.BlockedBy {
by = append(by, l.T("mcp.scope."+string(o.Scope))+" "+or(names[o.CounterKey()], o.ID))
}
notice += " " + l.T("mcp.blocked_above", strings.Join(by, ", "), name)
}
}
done(w, r, admin, "mcp grants "+name, notice, err)
}))
mux.HandleFunc("POST /admin/mcp/open", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
err := s.OpenTools(r.Context(), store.Scope(r.FormValue("scope")), r.FormValue("id"))
done(w, r, admin, "mcp grants", langOf(r).T("mcp.opened"), 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 := s.RevokeTools(r.Context(), scope, id, name)
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 := mcpNames(ctx, s)
var out []mcpGrantRow
for key, lim := range limits {
if lim.AllowedTools == nil {
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
}
// mcpNames are the tree's levels and the people by what they are called,
// keyed as limits are; a member scope is named as the level it is set on.
func mcpNames(ctx context.Context, s MCPAdmin) map[string]string {
names := map[string]string{}
name := func(scope store.Scope, id, label string) {
names[store.LimitOwner{Scope: scope, ID: id}.CounterKey()] = label
if m := store.MemberScope(scope); m != "" {
names[store.LimitOwner{Scope: m, ID: id}.CounterKey()] = label
}
}
if tree, err := s.Tree(ctx); err == nil {
for _, o := range tree {
name(store.ScopeOrg, o.ID, o.Name)
for _, t := range o.Teams {
name(store.ScopeTeam, t.ID, o.Name+"/"+t.Name)
for _, pr := range t.Projects {
name(store.ScopeProject, pr.ID, o.Name+"/"+t.Name+"/"+pr.Name)
}
}
}
}
if users, err := s.Users(ctx, ""); err == nil {
for _, u := range users {
name(store.ScopeUser, u.ID, u.Email)
}
}
return names
}
// 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:
// A key that is not there would be granted nothing, and said granted.
k, err := s.KeyByID(ctx, which)
if err != nil {
return "", "", errors.New(l.T("mcp.err.no_such", which))
}
return k.ID, k.Name + " (" + k.Prefix + "…)", 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
}
// 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()
}