The company's MCP servers on a web page
CI / test (push) Successful in 9m8s

/admin/mcp, for administrators only, since a server's credential reaches
whatever it guards: each server's state, who has been granted its tools,
its rate, the headers it is sent by name with the sealed ones marked, and
its address; adding one with a bearer token or another header kept in the
store; testing it with its credential and listing its tools; granting them
to a team, project, org, person or key, written into that level's limits
beside its other grants and its rate and budget; revoking; changing;
replacing the credential; turning off and on; removing; and how a person
points an agent at the gateway's /mcp with a key of their own. English and
Turkish. The secret's field is named so the audit line redacts it.

The admin API's server merge and test are package functions now,
adminapi.MCPServerFrom and adminapi.TestMCPServer, shared by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-28 19:26:51 +01:00
1 parent 98c45ed28b
commit 03d3bcfd2d
14 files changed
+914 -4

No files matched your search

+6
View File
@@ -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
+2 -1
View File
@@ -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
+20 -3
View File
@@ -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 {
+8
View File
@@ -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)
}
+2
View File
@@ -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
+435
View File
@@ -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()
}
+167
View File
@@ -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, "<code>jira.echo</code>") {
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&#39;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: <code>hr.search, hr.get</code>") {
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&#39;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)
}
}
}
+82
View File
@@ -0,0 +1,82 @@
{
"mcp.title": "MCP servers",
"mcp.sub": "The company's own MCP servers, offered to agents through the gateway's <code>/mcp</code>, their tools named <code>server.tool</code>. 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 <code>/mcp</code> to their agent with a key of their own from <a href=\"/profile\">their profile</a>. 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."
}
+1
View File
@@ -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"
+81
View File
@@ -0,0 +1,81 @@
{
"mcp.title": "MCP sunucuları",
"mcp.sub": "Şirketin kendi MCP sunucuları; ajanlara ağ geçidinin <code>/mcp</code> adresinden, araçları <code>sunucu.araç</code> 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 <code>/mcp</code> adresini, <a href=\"/profile\">profilinden</a> 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."
}
+1
View File
@@ -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"
+3
View File
@@ -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(),
+103
View File
@@ -0,0 +1,103 @@
{{define "admin_mcp"}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "mcp.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<script defer src="{{asset "pages.js"}}"></script>
</head><body><div class="wrap">
<header>
<h1>{{t "mcp.title"}}</h1>
<p class="sub">{{th "mcp.sub"}}</p>
{{template "nav" .Nav}}
</header>
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok" role="status">{{.Notice}}</p>{{end}}
{{if .Tools}}<ul class="mcp-tools">{{range .Tools}}<li><code>{{.}}</code></li>{{end}}</ul>{{end}}
{{if .Servers}}
<table class="rows">
<tr><th>{{t "mcp.col.server"}}</th><th>{{t "mcp.col.state"}}</th><th>{{t "mcp.col.granted"}}</th><th class="num">{{t "mcp.col.rate"}}</th><th>{{t "mcp.col.sent"}}</th><th>{{t "mcp.col.address"}}</th></tr>
{{range .Servers}}<tr>
<td><strong>{{.Name}}</strong>{{with .Description}}<br><span class="opt">{{.}}</span>{{end}}</td>
<td>{{if .Enabled}}<span class="state state-ready">{{t "mcp.on"}}</span>{{else}}<span class="state">{{t "mcp.off"}}</span>{{end}}</td>
<td>{{if .Grants}}{{range $i, $g := .Grants}}{{if $i}}<br>{{end}}{{t (printf "mcp.scope.%s" $g.Scope)}} {{$g.Label}}: <code>{{range $j, $t := $g.Tools}}{{if $j}}, {{end}}{{$t}}{{end}}</code>{{end}}{{else}}<span class="cannot">{{t "mcp.nobody"}}</span>{{end}}</td>
<td class="num">{{if .RatePerMinute}}{{t "mcp.per_minute" .RatePerMinute}}{{else}}—{{end}}</td>
<td>{{range .Secret}}<code>{{.}}</code> <span class="opt">{{t "mcp.sealed"}}</span><br>{{end}}{{range .Plain}}<code>{{.}}</code><br>{{end}}{{if and (not .Secret) (not .Plain)}}—{{end}}</td>
<td><code>{{.URL}}</code></td>
</tr>
<tr><td colspan="6">
<details><summary>{{t "mcp.actions" .Name}}</summary>
<div class="mcp-actions">
<form method="post" action="/admin/mcp/{{.Name}}/test" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}"><button type="submit">{{t "mcp.test"}}</button></form>
{{if .Enabled}}
<form method="post" action="/admin/mcp/{{.Name}}/disable" class="inline" data-confirm="{{t "mcp.confirm_disable" .Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}"><button type="submit">{{t "mcp.disable"}}</button></form>
{{else}}
<form method="post" action="/admin/mcp/{{.Name}}/enable" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}"><button type="submit">{{t "mcp.enable"}}</button></form>
{{end}}
<form method="post" action="/admin/mcp/{{.Name}}/remove" class="inline" data-confirm="{{t "mcp.confirm_remove" .Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}"><button type="submit" class="link danger">{{t "mcp.remove"}}</button></form>
<h3>{{t "mcp.grant"}}</h3>
<form method="post" action="/admin/mcp/{{.Name}}/grant">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>{{t "mcp.to"}} <select name="scope">
<option value="team">{{t "mcp.scope.team"}}</option><option value="project">{{t "mcp.scope.project"}}</option>
<option value="org">{{t "mcp.scope.org"}}</option><option value="user">{{t "mcp.scope.user"}}</option><option value="key">{{t "mcp.scope.key"}}</option>
</select></label>
<label>{{t "mcp.which"}} <input type="text" name="which" placeholder="{{t "mcp.which_hint"}}" required></label>
<label>{{t "mcp.tools"}} <input type="text" name="tools" value="{{.Name}}.*"></label>
<button type="submit">{{t "mcp.grant_button"}}</button>
</form>
{{$name := .Name}}
{{range .Grants}}
<form method="post" action="/admin/mcp/{{$name}}/revoke" class="inline" data-confirm="{{t "mcp.confirm_revoke" $name .Label}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="scope" value="{{.Scope}}"><input type="hidden" name="id" value="{{.ID}}">
<button type="submit">{{t "mcp.revoke" .Label}}</button></form>
{{end}}
{{if not .Local}}
<h3>{{t "mcp.change"}}</h3>
<form method="post" action="/admin/mcp/{{.Name}}/edit">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>{{t "mcp.address"}} <input type="text" inputmode="url" name="url" value="{{.URL}}" required></label>
<label>{{t "mcp.about"}} <input type="text" name="description" value="{{.Description}}"></label>
<label>{{t "mcp.timeout"}} <input type="number" name="timeout" min="0" value="{{if .TimeoutSeconds}}{{.TimeoutSeconds}}{{end}}"></label>
<label>{{t "mcp.rate"}} <input type="number" name="rate" min="0" value="{{if .RatePerMinute}}{{.RatePerMinute}}{{end}}"></label>
<button type="submit">{{t "mcp.save"}}</button>
</form>
<h3>{{t "mcp.credential"}}</h3>
<form method="post" action="/admin/mcp/{{.Name}}/credential">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>{{t "mcp.auth"}} <select name="auth"><option value="bearer">{{t "mcp.auth.bearer"}}</option><option value="header">{{t "mcp.auth.header"}}</option></select></label>
<label>{{t "mcp.header"}} <input type="text" name="header"></label>
<label>{{t "mcp.secret"}} <input type="password" name="secret_value" autocomplete="new-password" required></label>
<button type="submit">{{t "mcp.replace"}}</button>
</form>
{{end}}
</div></details>
</td></tr>
{{end}}
</table>
{{else}}<p class="sub">{{t "mcp.none"}}</p>{{end}}
<h2>{{t "mcp.add"}}</h2>
<form method="post" action="/admin/mcp/add">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>{{t "mcp.name"}} <input type="text" name="name" pattern="[a-z][a-z0-9_\-]{0,31}" required> <span class="opt">{{t "mcp.name_hint"}}</span></label>
<label>{{t "mcp.address"}} <input type="text" inputmode="url" name="url" required></label>
<label>{{t "mcp.about"}} <input type="text" name="description"></label>
<label>{{t "mcp.auth"}} <select name="auth"><option value="bearer">{{t "mcp.auth.bearer"}}</option><option value="header">{{t "mcp.auth.header"}}</option><option value="none">{{t "mcp.auth.none"}}</option></select></label>
<label>{{t "mcp.header"}} <input type="text" name="header"> <span class="opt">{{t "mcp.header_hint"}}</span></label>
<label>{{t "mcp.secret"}} <input type="password" name="secret_value" autocomplete="new-password"> <span class="opt">{{t "mcp.secret_hint"}}</span></label>
<label>{{t "mcp.timeout"}} <input type="number" name="timeout" min="0"></label>
<label>{{t "mcp.rate"}} <input type="number" name="rate" min="0"></label>
<button type="submit">{{t "mcp.add_button"}}</button>
</form>
<h2>{{t "mcp.agents"}}</h2>
<p class="sub">{{th "mcp.agents_sub"}}</p>
<pre translate="no">claude mcp add --transport http company {{.Gateway}} --header "Authorization: Bearer sk-llmgw-…"</pre>
<footer>llmgw {{.Version}}</footer>
</div></body></html>{{end}}
+3
View File
@@ -20,6 +20,9 @@
{{- if .Guard}}
<a href="/admin/guard"{{if eq .Current "guard"}} aria-current="page"{{end}}>{{t "nav.guard"}}</a>
{{- end}}
{{- if .MCP}}
<a href="/admin/mcp"{{if eq .Current "mcp"}} aria-current="page"{{end}}>{{t "nav.mcp"}}</a>
{{- end}}
{{- if .Settings}}
<a href="/admin/settings"{{if eq .Current "settings"}} aria-current="page"{{end}}>{{t "nav.settings"}}</a>
{{- end}}