- 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>
488 lines
16 KiB
Go
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()
|
|
}
|