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"}}
+
{{th "mcp.sub"}}
+ {{template "nav" .Nav}} +{{.Error}}
{{end}} +{{if .Notice}}{{.Notice}}
{{end}} +{{if .Tools}}{{.}}| {{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}} + | |||||
{{t "mcp.none"}}
{{end}} + +{{th "mcp.agents_sub"}}
+claude mcp add --transport http company {{.Gateway}} --header "Authorization: Bearer sk-llmgw-…"
+
+