From 03d3bcfd2d4e15f9e580233458527ab2002a90c1 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Wed, 28 Oct 2026 19:26:51 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 6 + docs/console.md | 3 +- internal/control/adminapi/mcp.go | 23 +- internal/router/handlers.go | 8 + internal/web/account.go | 2 + internal/web/admin_mcp.go | 435 ++++++++++++++++++++++++++ internal/web/admin_mcp_test.go | 167 ++++++++++ internal/web/locales/en/mcp.json | 82 +++++ internal/web/locales/en/nav.json | 1 + internal/web/locales/tr/mcp.json | 81 +++++ internal/web/locales/tr/nav.json | 1 + internal/web/nav.go | 3 + internal/web/templates/admin_mcp.html | 103 ++++++ internal/web/templates/nav.html | 3 + 14 files changed, 914 insertions(+), 4 deletions(-) create mode 100644 internal/web/admin_mcp.go create mode 100644 internal/web/admin_mcp_test.go create mode 100644 internal/web/locales/en/mcp.json create mode 100644 internal/web/locales/tr/mcp.json create mode 100644 internal/web/templates/admin_mcp.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b7812e0..3ea0e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,12 @@ APIs change between minors without a deprecation period. ### Added +- **And on a web page, `/admin/mcp`.** For administrators: the same list, + adding a server with its credential sealed, testing it and listing its + tools, granting them in the tree beside that level's other grants and + limits, revoking, changing, replacing the credential, turning it off, + removing; and the line that points an agent at this gateway's `/mcp`. + In English and Turkish; every change in the audit log, the secret not. - **The company's MCP servers in the console.** A screen of its own: each server with its state, who has its tools, its rate, the names of the credentials it is sent and its address; add one with a bearer token or diff --git a/docs/console.md b/docs/console.md index 4c2d1de..7aed363 100644 --- a/docs/console.md +++ b/docs/console.md @@ -186,7 +186,8 @@ by name — beside whatever else that level is granted; **Revoke** a grant; **Turn off** (its tools are listed nowhere and every call refused) or on; **Remove…**. A server's tools are seen nowhere until granted. The screen also says how a person points an agent at the gateway, with a key of their -own from `/profile/keys`. `llmgw ctl mcp` does the same from a shell. +own from `/profile/keys`. `llmgw ctl mcp` does the same from a shell, and +the web page **MCP servers** (`/admin/mcp`, administrators only) in a browser. **Mirror** is what the model mirror holds, one row for each version of each model — a file, or the set of files a model of several is: shards, a vision diff --git a/internal/control/adminapi/mcp.go b/internal/control/adminapi/mcp.go index 22f4748..abfa1b3 100644 --- a/internal/control/adminapi/mcp.go +++ b/internal/control/adminapi/mcp.go @@ -150,11 +150,18 @@ func (a *API) registerMCP(mux Mux) { // mcpServerFrom is the server a request makes, over the one of that name if // there is one: its secrets kept in the store, and named by reference. func (a *API) mcpServerFrom(ctx context.Context, name string, req MCPServerRequest, by string) (store.MCPServer, error) { + return MCPServerFrom(ctx, a.mcp, name, req, by) +} + +// MCPServerFrom is the server a request makes, over the one of that name in +// st if there is one: its secrets kept in the store, sealed, and named by +// reference. Not stored; the caller puts it. For the API and the admin page. +func MCPServerFrom(ctx context.Context, st MCPStore, name string, req MCPServerRequest, by string) (store.MCPServer, error) { if !store.ValidMCPServerName(name) { return store.MCPServer{}, errors.New("a server's name is lowercase letters, digits, - and _, starting with a letter: " + "its tools are NAME.tool") } - m, err := a.mcp.MCPServer(ctx, name) + m, err := st.MCPServer(ctx, name) switch { case errors.Is(err, store.ErrNotFound): m = store.MCPServer{Name: name, Enabled: true, CreatedBy: by} @@ -184,7 +191,7 @@ func (a *API) mcpServerFrom(ctx context.Context, name string, req MCPServerReque return m, errors.New("a secret header needs a name and a value") } key := "mcp/" + name + "/" + header - if err := a.mcp.SetClusterSecret(ctx, key, value); err != nil { + if err := st.SetClusterSecret(ctx, key, value); err != nil { return m, err } m.HeaderRefs[header] = "store:" + key @@ -208,6 +215,13 @@ func (a *API) mcpServerFrom(ctx context.Context, name string, req MCPServerReque // testMCPServer asks a server for its tools, with its credentials, as /mcp // would. func (a *API) testMCPServer(ctx context.Context, m store.MCPServer) MCPTestView { + return TestMCPServer(ctx, m, http.HandlerFunc(a.MCP)) +} + +// TestMCPServer asks a server for its tools, with its credentials, as /mcp +// would; local answers for this cluster's own (AdminMCPAddress), and +// without it that one is not asked. +func TestMCPServer(ctx context.Context, m store.MCPServer, local http.Handler) MCPTestView { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() c := &mcpclient.Client{URL: m.URL, Header: func(ctx context.Context) (http.Header, error) { @@ -225,7 +239,10 @@ func (a *API) testMCPServer(ctx context.Context, m store.MCPServer) MCPTestView return h, nil }} if m.URL == AdminMCPAddress { - c.URL, c.HTTP = InProcessURL, &http.Client{Transport: InProcess(http.HandlerFunc(a.MCP))} + if local == nil { + return MCPTestView{Error: "this cluster's own tools are answered only by a control plane"} + } + c.URL, c.HTTP = InProcessURL, &http.Client{Transport: InProcess(local)} } tools, err := c.Tools(ctx) if err != nil { diff --git a/internal/router/handlers.go b/internal/router/handlers.go index b63ff94..069fd44 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -281,6 +281,14 @@ func (r *Router) Handler() http.Handler { if r.store != nil && r.guardLibrary != nil { pages.RegisterAdminGuard(mux, r.store, r.guardLibrary) } + if r.store != nil { + pages.RegisterAdminMCP(mux, r.store, func() http.Handler { + if r.mcpgw == nil { + return nil + } + return r.mcpgw.local + }) + } if r.store != nil && r.settingsCheck != nil { pages.RegisterAdminSettings(mux, r.store, r.settingsCheck, r.settingsLocal) } diff --git a/internal/web/account.go b/internal/web/account.go index c2686be..4f9fe82 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -97,6 +97,8 @@ type AccountPages struct { hasGuard bool // hasSettings is set when /admin/settings is registered. hasSettings bool + // hasMCP is set when /admin/mcp is registered. + hasMCP bool // images says whether some model makes pictures; nil when the // pictures page is not mounted. // licenceChoices and licencePrefs offer the choice of licences on the diff --git a/internal/web/admin_mcp.go b/internal/web/admin_mcp.go new file mode 100644 index 0000000..c111d65 --- /dev/null +++ b/internal/web/admin_mcp.go @@ -0,0 +1,435 @@ +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() +} diff --git a/internal/web/admin_mcp_test.go b/internal/web/admin_mcp_test.go new file mode 100644 index 0000000..bf024af --- /dev/null +++ b/internal/web/admin_mcp_test.go @@ -0,0 +1,167 @@ +package web + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "path/filepath" + "slices" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/mcpclient/mcptest" + "gitea.mynodes.xyz/ilker/llmgw/internal/secrets" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// mcpPage mounts /admin/mcp over a store of its own, for a person. +func mcpPage(t *testing.T, user store.User) (*http.ServeMux, *store.DB, *auditSink) { + t.Helper() + st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "llmgw.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + secrets.SetStore(st.StoredSecret) + t.Cleanup(func() { secrets.SetStore(nil) }) + p := NewAccountPages(fakeAccounts{user: user}, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil))) + sink := &auditSink{} + p.SetAudit(sink) + mux := http.NewServeMux() + p.RegisterAdminMCP(mux, st, nil) + return mux, st, sink +} + +var mcpAdmin = store.User{ID: "a", Email: "a@example.com", IsAdmin: true} + +func TestTheMCPPageIsForAdministratorsOnly(t *testing.T) { + for _, role := range []string{store.RoleViewer, store.RoleOperator, store.RoleBilling} { + mux, _, _ := mcpPage(t, store.User{ID: "s", Email: "s@example.com", Role: role}) + if w := mirrorGet(t, mux, "/admin/mcp"); w.Code != http.StatusNotFound { + t.Errorf("a %s got %d", role, w.Code) + } + } + mux, _, _ := mcpPage(t, mcpAdmin) + body := mirrorGet(t, mux, "/admin/mcp").Body.String() + if !strings.Contains(body, `href="/admin/mcp" aria-current="page"`) || !strings.Contains(body, "/mcp --header") { + t.Errorf("the menu should mark the page, and the page say how an agent is pointed at it: %s", body) + } + if w := mirrorPost(t, mux, "/admin/mcp/add", url.Values{"name": {"x"}, "url": {"https://x/mcp"}}); w.Code != http.StatusBadRequest { + t.Errorf("a form without its token got %d", w.Code) + } +} + +func TestAnMCPServerIsAddedTestedAndItsCredentialKeptSealed(t *testing.T) { + mux, st, sink := mcpPage(t, mcpAdmin) + up := mcptest.New() + defer up.Close() + // What the server is sent, every request: the fake records calls only. + var auth []string + target, _ := url.Parse(up.URL) + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = append(auth, r.Header.Get("Authorization")) + httputil.NewSingleHostReverseProxy(target).ServeHTTP(w, r) + })) + defer front.Close() + + w := mirrorPost(t, mux, "/admin/mcp/add", url.Values{"csrf": {"tok"}, "name": {"jira"}, "url": {front.URL}, + "description": {"tickets"}, "auth": {"bearer"}, "secret_value": {"s3cret-token"}, "rate": {"30"}}) + body := w.Body.String() + if !strings.Contains(body, "Added jira") || strings.Contains(body, "s3cret-token") { + t.Fatalf("not added, or the token shown: %s", body) + } + m, err := st.MCPServer(context.Background(), "jira") + if err != nil || m.HeaderRefs["Authorization"] != "store:mcp/jira/Authorization" || m.RatePerMinute != 30 || !m.Enabled { + t.Fatalf("stored %+v, %v", m, err) + } + for _, e := range sink.events { + if strings.Contains(e.Detail, "s3cret-token") || strings.Contains(e.Target, "s3cret-token") { + t.Errorf("the audit line holds the token: %+v", e) + } + } + if !strings.Contains(body, "nobody yet") || !strings.Contains(body, "sealed") { + t.Errorf("a new server is granted to nobody, its credential sealed: %s", body) + } + + // The test asks it for its tools with the sealed token. + body = mirrorPost(t, mux, "/admin/mcp/jira/test", url.Values{"csrf": {"tok"}}).Body.String() + if !strings.Contains(body, "jira answers, with 3 tools") || !strings.Contains(body, "jira.echo") { + t.Fatalf("the test's answer: %s", body) + } + if len(auth) == 0 || auth[len(auth)-1] != "Bearer s3cret-token" { + t.Error("the server was not sent its token") + } + + // Off, then removed. + mirrorPost(t, mux, "/admin/mcp/jira/disable", url.Values{"csrf": {"tok"}}) + if m, _ := st.MCPServer(context.Background(), "jira"); m.Enabled { + t.Error("still on") + } + mirrorPost(t, mux, "/admin/mcp/jira/remove", url.Values{"csrf": {"tok"}}) + if _, err := st.MCPServer(context.Background(), "jira"); err == nil { + t.Error("still there") + } +} + +func TestAnMCPServersToolsAreGrantedBesideAnotherServers(t *testing.T) { + mux, st, _ := mcpPage(t, mcpAdmin) + ctx := context.Background() + org, _ := st.CreateOrg(ctx, "Acme") + team, _ := st.CreateTeam(ctx, org, "ml") + rpm := 60 + if err := st.PutLimits(ctx, store.Limits{Scope: store.ScopeTeam, ScopeID: team, RPM: &rpm, AllowedTools: []string{"jira.*"}}); err != nil { + t.Fatal(err) + } + if err := st.PutMCPServer(ctx, store.MCPServer{Name: "hr", URL: "https://hr/mcp", Enabled: true}); err != nil { + t.Fatal(err) + } + limits := func() store.Limits { + l, _ := st.LimitsFor(ctx, store.ScopeTeam, team) + return l + } + + body := mirrorPost(t, mux, "/admin/mcp/hr/grant", url.Values{"csrf": {"tok"}, "scope": {"team"}, "which": {"Acme/ml"}, + "tools": {"hr.search, hr.get"}}).Body.String() + l := limits() + if !strings.Contains(body, "hr's tools granted to Acme/ml") || + !slices.Equal(l.AllowedTools, []string{"jira.*", "hr.search", "hr.get"}) || l.RPM == nil || *l.RPM != 60 { + t.Fatalf("the team's limits are %+v; jira's grant and its rate should have stayed: %s", l, body) + } + if !strings.Contains(body, "team Acme/ml: hr.search, hr.get") { + t.Errorf("the grant should show on hr's row: %s", body) + } + // Another server's tools, or no such team, are refused. + if body := mirrorPost(t, mux, "/admin/mcp/hr/grant", url.Values{"csrf": {"tok"}, "scope": {"team"}, "which": {"Acme/ml"}, + "tools": {"jira.delete"}}).Body.String(); !strings.Contains(body, "jira.delete is not one of hr's tools") { + t.Errorf("another server's tool was granted: %s", body) + } + if body := mirrorPost(t, mux, "/admin/mcp/hr/grant", url.Values{"csrf": {"tok"}, "scope": {"team"}, "which": {"Acme/none"}, + "tools": {"hr.*"}}).Body.String(); !strings.Contains(body, "There is no Acme/none") { + t.Errorf("a team that does not exist was granted: %s", body) + } + + mirrorPost(t, mux, "/admin/mcp/hr/revoke", url.Values{"csrf": {"tok"}, "scope": {"team"}, "id": {team}}) + if l := limits(); !slices.Equal(l.AllowedTools, []string{"jira.*"}) { + t.Fatalf("after the revoke the team grants %v", l.AllowedTools) + } +} + +func TestTheMCPPageSpeaksTurkishWhenAsked(t *testing.T) { + mux, st, _ := mcpPage(t, mcpAdmin) + _ = st.PutMCPServer(context.Background(), store.MCPServer{Name: "hr", URL: "https://hr/mcp", Enabled: true}) + body := peopleInTurkish(t, mux, http.MethodGet, "/admin/mcp", nil) + for _, want := range []string{"MCP sunucuları", "henüz kimse", "Sunucu ekle", "Araçlarına yetki ver"} { + if !strings.Contains(body, want) { + t.Errorf("missing %q", want) + } + } + for _, not := range []string{"nobody yet", "Add a server"} { + if strings.Contains(body, not) { + t.Errorf("English left: %q", not) + } + } +} diff --git a/internal/web/locales/en/mcp.json b/internal/web/locales/en/mcp.json new file mode 100644 index 0000000..1d7a754 --- /dev/null +++ b/internal/web/locales/en/mcp.json @@ -0,0 +1,82 @@ +{ + "mcp.title": "MCP servers", + "mcp.sub": "The company's own MCP servers, offered to agents through the gateway's /mcp, their tools named server.tool. A server's tools are seen only where they are granted; nothing is granted by default. Its credential stays here, sealed, and never reaches an agent.", + "mcp.col.server": "Server", + "mcp.col.state": "State", + "mcp.col.granted": "Granted to", + "mcp.col.rate": "Rate", + "mcp.col.sent": "Sent", + "mcp.col.address": "Address", + "mcp.on": "on", + "mcp.off": "off", + "mcp.nobody": "nobody yet", + "mcp.per_minute": "%d a minute", + "mcp.sealed": "sealed", + "mcp.scope.org": "organization", + "mcp.scope.team": "team", + "mcp.scope.project": "project", + "mcp.scope.user": "person", + "mcp.scope.key": "key", + "mcp.actions": "What can be done to %s", + "mcp.test": "Test, and list its tools", + "mcp.disable": "Turn off", + "mcp.enable": "Turn on", + "mcp.remove": "Remove", + "mcp.confirm_disable": "Turn %s off? Its tools are then listed nowhere and every call to them is refused.", + "mcp.confirm_remove": "Remove %s? Its tools go from every agent at once, and its sealed credential with it.", + "mcp.grant": "Grant its tools", + "mcp.to": "To", + "mcp.which": "Which", + "mcp.which_hint": "ORG/TEAM, ORG/TEAM/PROJECT, ORG, an e-mail or a key's ID", + "mcp.tools": "Tools", + "mcp.grant_button": "Grant", + "mcp.confirm_revoke": "Revoke %s from %s? It and everything below it then no longer see those tools, unless another level grants them.", + "mcp.revoke": "Revoke from %s", + "mcp.change": "Change", + "mcp.address": "Address", + "mcp.about": "What it is for", + "mcp.timeout": "Timeout, seconds", + "mcp.rate": "Calls a minute per caller", + "mcp.save": "Save", + "mcp.credential": "Replace its credential", + "mcp.auth": "Credential", + "mcp.auth.bearer": "bearer token", + "mcp.auth.header": "another header", + "mcp.auth.none": "none", + "mcp.header": "Header", + "mcp.secret": "Secret", + "mcp.replace": "Replace", + "mcp.none": "None yet. Add one below: its address, and the credential it wants.", + "mcp.add": "Add a server", + "mcp.name": "Name", + "mcp.name_hint": "lowercase; its tools' prefix, jira → jira.search", + "mcp.header_hint": "for another header, its name", + "mcp.secret_hint": "the token or key; kept sealed in the store", + "mcp.add_button": "Add", + "mcp.agents": "Pointing an agent at it", + "mcp.agents_sub": "Each person adds the gateway's /mcp to their agent with a key of their own from their profile. They see the tools granted to them, their team and project, and every call is in the audit log under their name.", + "mcp.added": "Added %s. Test it, then grant its tools: nobody sees them yet.", + "mcp.changed": "%s changed.", + "mcp.credential_replaced": "The credential %s is sent was replaced.", + "mcp.enabled": "%s is on.", + "mcp.disabled": "%s is off: its tools are listed nowhere and every call is refused.", + "mcp.removed": "%s removed.", + "mcp.test_failed": "%s did not answer: %s", + "mcp.test_no_tools": "%s answers, and offers no tools.", + "mcp.test_ok.one": "%[2]s answers, with %[1]d tool:", + "mcp.test_ok.other": "%[2]s answers, with %[1]d tools:", + "mcp.granted": "%s's tools granted to %s.", + "mcp.revoked": "Revoked %s's tools there.", + "mcp.err.exists": "There is already a server %s; change it in its row.", + "mcp.err.no_secret": "The new secret?", + "mcp.err.which": "Which one?", + "mcp.err.scope": "Grant to a team, project, organization, person or key.", + "mcp.err.no_such": "There is no %s.", + "mcp.err.no_tools": "Which tools? %s.* for all of them.", + "mcp.err.not_its_tool": "%s is not one of %s's tools.", + "mcp.err.pattern": "%s is not a pattern.", + "mcp.err.header": "Which header carries the credential?", + "mcp.err.auth": "The credential is a bearer token, another header or none.", + "mcp.err.timeout": "The timeout is whole seconds.", + "mcp.err.rate": "The rate is calls a minute." +} diff --git a/internal/web/locales/en/nav.json b/internal/web/locales/en/nav.json index 02aff68..9f49797 100644 --- a/internal/web/locales/en/nav.json +++ b/internal/web/locales/en/nav.json @@ -11,6 +11,7 @@ "nav.account": "Account", "nav.sign_out": "Sign out", "nav.docs": "Docs", + "nav.mcp": "MCP servers", "nav.settings": "Settings", "nav.images": "Pictures", "nav.meshes": "3D" diff --git a/internal/web/locales/tr/mcp.json b/internal/web/locales/tr/mcp.json new file mode 100644 index 0000000..f9b3b89 --- /dev/null +++ b/internal/web/locales/tr/mcp.json @@ -0,0 +1,81 @@ +{ + "mcp.title": "MCP sunucuları", + "mcp.sub": "Şirketin kendi MCP sunucuları; ajanlara ağ geçidinin /mcp adresinden, araçları sunucu.araç adıyla sunulur. Bir sunucunun araçlarını yalnızca yetki verilenler görür; varsayılan olarak kimseye yetki yoktur. Kimlik bilgisi burada, mühürlü olarak kalır ve hiçbir ajana gitmez.", + "mcp.col.server": "Sunucu", + "mcp.col.state": "Durum", + "mcp.col.granted": "Yetkililer", + "mcp.col.rate": "Hız", + "mcp.col.sent": "Gönderilen", + "mcp.col.address": "Adres", + "mcp.on": "açık", + "mcp.off": "kapalı", + "mcp.nobody": "henüz kimse", + "mcp.per_minute": "dakikada %d", + "mcp.sealed": "mühürlü", + "mcp.scope.org": "organizasyon", + "mcp.scope.team": "takım", + "mcp.scope.project": "proje", + "mcp.scope.user": "kişi", + "mcp.scope.key": "anahtar", + "mcp.actions": "%s için yapılabilecekler", + "mcp.test": "Dene ve araçlarını listele", + "mcp.disable": "Kapat", + "mcp.enable": "Aç", + "mcp.remove": "Kaldır", + "mcp.confirm_disable": "%s kapatılsın mı? Araçları hiçbir yerde listelenmez ve her çağrı reddedilir.", + "mcp.confirm_remove": "%s kaldırılsın mı? Araçları tüm ajanlardan hemen gider, mühürlü kimlik bilgisi de onunla birlikte.", + "mcp.grant": "Araçlarına yetki ver", + "mcp.to": "Kime", + "mcp.which": "Hangisi", + "mcp.which_hint": "ORG/TAKIM, ORG/TAKIM/PROJE, ORG, bir e-posta ya da anahtarın kimliği", + "mcp.tools": "Araçlar", + "mcp.grant_button": "Yetki ver", + "mcp.confirm_revoke": "%s yetkisi %s üzerinden geri alınsın mı? Başka bir seviye vermedikçe orası ve altındakiler bu araçları artık görmez.", + "mcp.revoke": "%s üzerinden geri al", + "mcp.change": "Değiştir", + "mcp.address": "Adres", + "mcp.about": "Ne için", + "mcp.timeout": "Zaman aşımı, saniye", + "mcp.rate": "Çağıran başına dakikada çağrı", + "mcp.save": "Kaydet", + "mcp.credential": "Kimlik bilgisini değiştir", + "mcp.auth": "Kimlik bilgisi", + "mcp.auth.bearer": "bearer token", + "mcp.auth.header": "başka bir başlık", + "mcp.auth.none": "yok", + "mcp.header": "Başlık", + "mcp.secret": "Gizli değer", + "mcp.replace": "Değiştir", + "mcp.none": "Henüz yok. Aşağıdan ekleyin: adresi ve istediği kimlik bilgisi.", + "mcp.add": "Sunucu ekle", + "mcp.name": "Ad", + "mcp.name_hint": "küçük harf; araçlarının öneki, jira → jira.search", + "mcp.header_hint": "başka bir başlık için, adı", + "mcp.secret_hint": "token ya da anahtar; depoda mühürlü saklanır", + "mcp.add_button": "Ekle", + "mcp.agents": "Bir ajanı bağlamak", + "mcp.agents_sub": "Herkes ağ geçidinin /mcp adresini, profilinden alacağı kendi anahtarıyla ajanına ekler. Kendisine, takımına ve projesine verilmiş araçları görür; her çağrı denetim kaydına onun adıyla düşer.", + "mcp.added": "%s eklendi. Deneyin, sonra araçlarına yetki verin: henüz kimse görmüyor.", + "mcp.changed": "%s değiştirildi.", + "mcp.credential_replaced": "%s için gönderilen kimlik bilgisi değiştirildi.", + "mcp.enabled": "%s açık.", + "mcp.disabled": "%s kapalı: araçları hiçbir yerde listelenmiyor ve her çağrı reddediliyor.", + "mcp.removed": "%s kaldırıldı.", + "mcp.test_failed": "%s yanıt vermedi: %s", + "mcp.test_no_tools": "%s yanıt veriyor, ama hiç aracı yok.", + "mcp.test_ok.other": "%[2]s yanıt veriyor, %[1]d aracıyla:", + "mcp.granted": "%s araçlarına %s için yetki verildi.", + "mcp.revoked": "%s araçlarının yetkisi orada geri alındı.", + "mcp.err.exists": "%s adında bir sunucu zaten var; satırından değiştirin.", + "mcp.err.no_secret": "Yeni gizli değer?", + "mcp.err.which": "Hangisi?", + "mcp.err.scope": "Yetki bir takıma, projeye, organizasyona, kişiye ya da anahtara verilir.", + "mcp.err.no_such": "%s diye bir şey yok.", + "mcp.err.no_tools": "Hangi araçlar? Hepsi için %s.*", + "mcp.err.not_its_tool": "%s, %s sunucusunun araçlarından biri değil.", + "mcp.err.pattern": "%s geçerli bir desen değil.", + "mcp.err.header": "Kimlik bilgisini hangi başlık taşıyor?", + "mcp.err.auth": "Kimlik bilgisi bir bearer token, başka bir başlık ya da hiçbiri olabilir.", + "mcp.err.timeout": "Zaman aşımı tam saniye olmalı.", + "mcp.err.rate": "Hız dakikadaki çağrı sayısıdır." +} diff --git a/internal/web/locales/tr/nav.json b/internal/web/locales/tr/nav.json index 57cff41..8f51f9c 100644 --- a/internal/web/locales/tr/nav.json +++ b/internal/web/locales/tr/nav.json @@ -11,6 +11,7 @@ "nav.account": "Hesap", "nav.sign_out": "Çıkış yap", "nav.docs": "Belgeler", + "nav.mcp": "MCP sunucuları", "nav.settings": "Ayarlar", "nav.images": "Görseller", "nav.meshes": "3D" diff --git a/internal/web/nav.go b/internal/web/nav.go index 59fc474..a0f9227 100644 --- a/internal/web/nav.go +++ b/internal/web/nav.go @@ -30,6 +30,8 @@ type Nav struct { // Settings offers the cluster's settings: mounted, and to an // administrator only. Settings bool + // MCP offers the company's MCP servers, the same way. + MCP bool // CSRF signs the sign-out form. CSRF string // Org offers the organization page: to an administrator, and to anyone @@ -52,6 +54,7 @@ func (p *AccountPages) navFor(r *http.Request, user store.User, current string) Quotas: p.hasQuotas, Guard: p.hasGuard, Settings: p.hasSettings && user.IsAdmin, + MCP: p.hasMCP && user.IsAdmin, CSRF: csrfFor(r), Images: p.images != nil && p.images(), Meshes: p.meshes != nil && p.meshes(), diff --git a/internal/web/templates/admin_mcp.html b/internal/web/templates/admin_mcp.html new file mode 100644 index 0000000..0028194 --- /dev/null +++ b/internal/web/templates/admin_mcp.html @@ -0,0 +1,103 @@ +{{define "admin_mcp"}} + + +{{t "mcp.title"}} · llmgw + +
+
+

{{t "mcp.title"}}

+

{{th "mcp.sub"}}

+ {{template "nav" .Nav}} +
+{{if .Error}}{{end}} +{{if .Notice}}

{{.Notice}}

{{end}} +{{if .Tools}}
    {{range .Tools}}
  • {{.}}
  • {{end}}
{{end}} + +{{if .Servers}} + + +{{range .Servers}} + + + + + + + + +{{end}} +
{{t "mcp.col.server"}}{{t "mcp.col.state"}}{{t "mcp.col.granted"}}{{t "mcp.col.rate"}}{{t "mcp.col.sent"}}{{t "mcp.col.address"}}
{{.Name}}{{with .Description}}
{{.}}{{end}}
{{if .Enabled}}{{t "mcp.on"}}{{else}}{{t "mcp.off"}}{{end}}{{if .Grants}}{{range $i, $g := .Grants}}{{if $i}}
{{end}}{{t (printf "mcp.scope.%s" $g.Scope)}} {{$g.Label}}: {{range $j, $t := $g.Tools}}{{if $j}}, {{end}}{{$t}}{{end}}{{end}}{{else}}{{t "mcp.nobody"}}{{end}}
{{if .RatePerMinute}}{{t "mcp.per_minute" .RatePerMinute}}{{else}}—{{end}}{{range .Secret}}{{.}} {{t "mcp.sealed"}}
{{end}}{{range .Plain}}{{.}}
{{end}}{{if and (not .Secret) (not .Plain)}}—{{end}}
{{.URL}}
+
{{t "mcp.actions" .Name}} +
+
+
+ {{if .Enabled}} +
+
+ {{else}} +
+
+ {{end}} +
+
+ +

{{t "mcp.grant"}}

+
+ + + + + +
+ {{$name := .Name}} + {{range .Grants}} +
+ +
+ {{end}} + + {{if not .Local}} +

{{t "mcp.change"}}

+
+ + + + + + +
+

{{t "mcp.credential"}}

+
+ + + + + +
+ {{end}} +
+
+{{else}}

{{t "mcp.none"}}

{{end}} + +

{{t "mcp.add"}}

+
+ + + + + + + + + + +
+ +

{{t "mcp.agents"}}

+

{{th "mcp.agents_sub"}}

+
claude mcp add --transport http company {{.Gateway}} --header "Authorization: Bearer sk-llmgw-…"
+
llmgw {{.Version}}
+
{{end}} diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 4237a77..ced3c2b 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -20,6 +20,9 @@ {{- if .Guard}} {{t "nav.guard"}} {{- end}} + {{- if .MCP}} + {{t "nav.mcp"}} + {{- end}} {{- if .Settings}} {{t "nav.settings"}} {{- end}}