/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>
436 lines
14 KiB
Go
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()
|
|
}
|