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() }