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}}{{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-…"
+ +
{{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}}