/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:
1 parent
98c45ed28b
commit
03d3bcfd2d
14 files changed
+914
-4
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'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'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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
Reference in new issue
Block a user