A screen beside Knowledge: each server's state, who has been granted its tools (read from the store's limits), its rate, the names of the headers it is sent but never their values, and its address. A adds one, with a bearer token or another header sent once and kept sealed. On a row: test it and list its tools; grant them to a team, project, org, person or key, merged with what else that level is granted, since keys limits --tools writes the list whole; revoke; change; replace the credential; turn off or on; remove. The screen also says how an agent is pointed at /mcp with a person's own key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89435735de
commit
98c45ed28b
5 files changed
+631
-1
No files matched your search
@@ -39,6 +39,12 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
a header kept sealed, test it and list its tools, grant them to a team,
|
||||
project, organization, person or key beside that level's other grants,
|
||||
revoke, change, replace the credential, turn off, remove.
|
||||
- **And on the cards, as this cluster would lay it out.** The explorer's
|
||||
**GPU tok/s** and a repository's files give one reply's speed for each
|
||||
layout the planner offers here — one card, tensor parallel across a
|
||||
|
||||
@@ -173,6 +173,21 @@ search…** as an administrator, or **Delete…** it. People
|
||||
fill collections from their own machines with `llmgw-kb`
|
||||
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
|
||||
|
||||
**MCP servers** lists the company's own MCP servers that agents reach through
|
||||
the gateway's `/mcp`, their tools named `server.tool`: whether each is on,
|
||||
who has been granted its tools, its rate per caller, the names of the
|
||||
credentials it is sent (never their values) and its address. **A** adds one:
|
||||
its name, which is its tools' prefix, its address, and a bearer token or
|
||||
another header, kept sealed in the store. On a row: **Test, and list its
|
||||
tools**, with its credential, as a call would; **Grant its tools to…** a
|
||||
team, project, organization, person or key — all of them, `jira.*`, or some
|
||||
by name — beside whatever else that level is granted; **Revoke** a grant;
|
||||
**Change…** its address, timeout or rate; **Replace its credential…**;
|
||||
**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.
|
||||
|
||||
**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
|
||||
projector, a config and tokenizer — with how far its download has got and
|
||||
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
cQuotas
|
||||
cGuard
|
||||
cKB
|
||||
cMCP
|
||||
cFederation
|
||||
cMirror
|
||||
cJobs
|
||||
@@ -70,7 +71,7 @@ const (
|
||||
cCount
|
||||
)
|
||||
|
||||
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Knowledge", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Sign-in", "Audit", "Manual",
|
||||
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Knowledge", "MCP servers", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Sign-in", "Audit", "Manual",
|
||||
"server.yaml", "Add a node", "Add a control plane", "Replicate the store", "Serve a model", "Serve the guardrails' judge", "Upgrade the cluster",
|
||||
"Restart all replicas", "Back up now", "Wipe this machine", "Leave"}
|
||||
|
||||
@@ -184,6 +185,7 @@ type consoleModel struct {
|
||||
guard guardState
|
||||
signIn signInState
|
||||
kb kbState
|
||||
mcp mcpState
|
||||
|
||||
st *storeHolder
|
||||
people peopleData
|
||||
@@ -342,6 +344,9 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.menu == cKB {
|
||||
cmds = append(cmds, m.fetchKB())
|
||||
}
|
||||
if m.menu == cMCP {
|
||||
cmds = append(cmds, m.fetchMCP())
|
||||
}
|
||||
if m.menu == cMirror {
|
||||
cmds = append(cmds, m.fetchMirror())
|
||||
}
|
||||
@@ -404,6 +409,8 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.quotas, m.quotasErr = msg.rows, msg.err
|
||||
case consoleKBMsg:
|
||||
m.kb = kbState{view: msg.view, err: msg.err, loaded: true}
|
||||
case consoleMCPMsg:
|
||||
m.mcp = mcpState(msg)
|
||||
case consoleSignInMsg:
|
||||
m.signIn = signInState{view: msg.view, err: msg.err, loaded: true, ldap: msg.ldap, oidc: msg.oidc}
|
||||
case consoleGuardMsg:
|
||||
@@ -568,6 +575,8 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.menuD = m.signInMenu(m.signInRowKey())
|
||||
case cKB:
|
||||
m.menuD = m.kbMenu(m.kb.view.Collections[m.row])
|
||||
case cMCP:
|
||||
m.menuD = m.mcpMenu(m.mcp.servers[m.row])
|
||||
case cMirror:
|
||||
m.menuD = m.mirrorMenu(m.mirrorGroups()[m.row])
|
||||
case cJobs:
|
||||
@@ -633,6 +642,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.kb.view.Available {
|
||||
m.kbCreateForm()
|
||||
}
|
||||
case cMCP:
|
||||
if m.mcp.loaded && m.mcp.err == nil {
|
||||
m.mcpAddForm()
|
||||
}
|
||||
case cModels:
|
||||
m.addModelForm()
|
||||
case cMirror:
|
||||
@@ -705,6 +718,8 @@ func (m consoleModel) onEnter() tea.Cmd {
|
||||
return m.fetchSignIn()
|
||||
case cKB:
|
||||
return m.fetchKB()
|
||||
case cMCP:
|
||||
return m.fetchMCP()
|
||||
case cMirror:
|
||||
return m.fetchMirror()
|
||||
case cMonitor:
|
||||
@@ -1085,6 +1100,8 @@ func (m consoleModel) rowCount() int {
|
||||
return len(signInRows)
|
||||
case cKB:
|
||||
return len(m.kb.view.Collections)
|
||||
case cMCP:
|
||||
return len(m.mcp.servers)
|
||||
case cMirror:
|
||||
return len(m.mirrorGroups())
|
||||
case cJobs:
|
||||
@@ -1326,6 +1343,8 @@ func (m consoleModel) panel() (title, body, keys string) {
|
||||
return m.signInPanel(pw)
|
||||
case cKB:
|
||||
return m.kbPanel(pw)
|
||||
case cMCP:
|
||||
return m.mcpPanel(pw)
|
||||
case cAccounts, cKeys, cOrgs:
|
||||
return m.peoplePanel(pw)
|
||||
case cManual:
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// The company's own MCP servers, as the console shows them: each with its
|
||||
// address, the names of the credentials it is sent (never their values),
|
||||
// its rate, and who has been granted its tools; Enter for what can be done
|
||||
// to one, A to add one. Clients reach them all through the gateway's /mcp
|
||||
// with an llmgw key, as server.tool.
|
||||
|
||||
type mcpState struct {
|
||||
servers []adminapi.MCPServerView
|
||||
err error
|
||||
loaded bool
|
||||
// grants are the tool grants in the tenancy tree, nil when the store
|
||||
// could not be read from here; where says what each scope's ID is
|
||||
// called, and ids the other way, for the grant form.
|
||||
grants []mcpGrant
|
||||
where map[string]string
|
||||
ids map[string]store.LimitOwner
|
||||
}
|
||||
|
||||
// mcpGrant is one level of the tree that grants tools, and which.
|
||||
type mcpGrant struct {
|
||||
owner store.LimitOwner
|
||||
label string
|
||||
tools []string
|
||||
}
|
||||
|
||||
type consoleMCPMsg mcpState
|
||||
|
||||
func (m consoleModel) fetchMCP() tea.Cmd {
|
||||
c, h := m.client, m.st
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var msg consoleMCPMsg
|
||||
msg.err = c.get(ctx, "/api/v1/mcp/servers", &msg.servers)
|
||||
msg.loaded = true
|
||||
if h == nil {
|
||||
return msg
|
||||
}
|
||||
s, err := h.get(ctx)
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
limits, err := s.AllLimits(ctx)
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
msg.where, msg.ids = map[string]string{}, map[string]store.LimitOwner{}
|
||||
if tree, err := s.Tree(ctx); err == nil {
|
||||
for _, o := range tree {
|
||||
msg.name(store.ScopeOrg, o.ID, o.Name)
|
||||
for _, t := range o.Teams {
|
||||
msg.name(store.ScopeTeam, t.ID, o.Name+"/"+t.Name)
|
||||
for _, p := range t.Projects {
|
||||
msg.name(store.ScopeProject, p.ID, o.Name+"/"+t.Name+"/"+p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if users, err := s.Users(ctx, ""); err == nil {
|
||||
for _, u := range users {
|
||||
msg.name(store.ScopeUser, u.ID, u.Email)
|
||||
}
|
||||
}
|
||||
msg.grants = []mcpGrant{}
|
||||
for _, l := range limits {
|
||||
if len(l.AllowedTools) == 0 {
|
||||
continue
|
||||
}
|
||||
o := store.LimitOwner{Scope: l.Scope, ID: l.ScopeID}
|
||||
label := msg.where[o.CounterKey()]
|
||||
if label == "" {
|
||||
label = string(l.Scope) + " " + l.ScopeID
|
||||
}
|
||||
msg.grants = append(msg.grants, mcpGrant{owner: o, label: string(l.Scope) + " " + label, tools: l.AllowedTools})
|
||||
}
|
||||
slices.SortFunc(msg.grants, func(a, b mcpGrant) int { return strings.Compare(a.label, b.label) })
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func (s *consoleMCPMsg) name(scope store.Scope, id, label string) {
|
||||
o := store.LimitOwner{Scope: scope, ID: id}
|
||||
s.where[o.CounterKey()] = label
|
||||
s.ids[string(scope)+":"+label] = o
|
||||
}
|
||||
|
||||
// mcpGrantsOf is who may use a server's tools, and which of them.
|
||||
func (k mcpState) grantsOf(server string) []mcpGrant {
|
||||
var out []mcpGrant
|
||||
for _, g := range k.grants {
|
||||
var mine []string
|
||||
for _, t := range g.tools {
|
||||
if strings.HasPrefix(t, server+".") || t == "*" {
|
||||
mine = append(mine, t)
|
||||
}
|
||||
}
|
||||
if len(mine) > 0 {
|
||||
out = append(out, mcpGrant{owner: g.owner, label: g.label, tools: mine})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m consoleModel) mcpPanel(pw int) (string, string, string) {
|
||||
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
||||
if m.inPanel {
|
||||
keys = "↑↓ server · Enter actions · A add a server · ← back · Q leave"
|
||||
}
|
||||
k := m.mcp
|
||||
var b strings.Builder
|
||||
b.WriteString("\n " + sDim.Render("The company's MCP servers, offered to agents through the gateway's /mcp as server.tool.") + "\n\n")
|
||||
switch {
|
||||
case k.err != nil:
|
||||
b.WriteString(" " + sBad.Render(k.err.Error()))
|
||||
return "MCP servers", b.String(), keys
|
||||
case !k.loaded:
|
||||
b.WriteString(" " + sDim.Render("asking the control plane…"))
|
||||
return "MCP servers", b.String(), keys
|
||||
case len(k.servers) == 0:
|
||||
b.WriteString(" " + sDim.Render("None yet. A to add one: its address, and the credential it wants."))
|
||||
b.WriteString("\n\n" + mcpClientHint())
|
||||
return "MCP servers", b.String(), keys
|
||||
}
|
||||
var rows [][]string
|
||||
for _, s := range k.servers {
|
||||
state := sOK.Render("on")
|
||||
if !s.Enabled {
|
||||
state = sDim.Render("off")
|
||||
}
|
||||
granted := "—"
|
||||
if k.grants != nil {
|
||||
var who []string
|
||||
for _, g := range k.grantsOf(s.Name) {
|
||||
who = append(who, g.label)
|
||||
}
|
||||
granted = sBad.Render("nobody yet")
|
||||
if len(who) > 0 {
|
||||
granted = strings.Join(who, ", ")
|
||||
}
|
||||
}
|
||||
rate := "—"
|
||||
if s.RatePerMinute > 0 {
|
||||
rate = fmt.Sprintf("%d/min", s.RatePerMinute)
|
||||
}
|
||||
rows = append(rows, []string{s.Name, state, granted, rate, dash(strings.Join(mcpCredentials(s), ", ")), s.URL})
|
||||
}
|
||||
b.WriteString(table([]string{"SERVER", "STATE", "GRANTED TO", "RATE", "SENT", "ADDRESS"}, rows, m.row, pw, m.inPanel))
|
||||
b.WriteString("\n\n " + sDim.Render("A server's tools are seen only where they are granted; nothing is granted by default.") + "\n\n")
|
||||
b.WriteString(mcpClientHint())
|
||||
return "MCP servers", b.String(), keys
|
||||
}
|
||||
|
||||
// mcpClientHint is how a person's agent is pointed at the gateway.
|
||||
func mcpClientHint() string {
|
||||
return " " + sDim.Render("In a person's agent: the gateway's https://…/mcp, with their key from /profile/keys") + "\n" +
|
||||
" " + sDim.Render(" claude mcp add --transport http company https://HOST/mcp --header \"Authorization: Bearer sk-llmgw-…\"")
|
||||
}
|
||||
|
||||
// mcpCredentials are the names of the headers a server is sent: which are
|
||||
// kept as secrets and which as written. Never their values.
|
||||
func mcpCredentials(s adminapi.MCPServerView) []string {
|
||||
var out []string
|
||||
for h := range s.HeaderRefs {
|
||||
out = append(out, h+" (secret)")
|
||||
}
|
||||
for h := range s.Headers {
|
||||
out = append(out, h)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// mcpPath is a server's admin API address.
|
||||
func mcpPath(name string) string { return "/api/v1/mcp/servers/" + url.PathEscape(name) }
|
||||
|
||||
// mcpAuth turns the form's credential fields into what the server is sent:
|
||||
// a bearer token, another header, or nothing.
|
||||
func mcpAuth(kind, header, secret string) (map[string]string, error) {
|
||||
switch kind {
|
||||
case "none":
|
||||
return nil, nil
|
||||
case "bearer":
|
||||
if secret == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return map[string]string{"Authorization": "Bearer " + strings.TrimPrefix(secret, "Bearer ")}, nil
|
||||
default:
|
||||
if header == "" {
|
||||
return nil, fmt.Errorf("which header carries the credential?")
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return map[string]string{header: secret}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func atoiOr(s string, def int) (int, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// mcpAddForm registers a server.
|
||||
func (m *consoleModel) mcpAddForm() {
|
||||
m.openForm(&formDialog{title: "Add an MCP server",
|
||||
note: "Reached by the control plane over HTTP (streamable HTTP or SSE). Its credential is kept in\n" +
|
||||
"the store, sealed, and never reaches a client. Its tools are seen nowhere until granted.",
|
||||
fields: []formField{
|
||||
textF("name", "", "lowercase, the tools' prefix: jira → jira.search"),
|
||||
textF("address", "", "https://jira-mcp.internal/mcp"),
|
||||
textF("about", "", "what it is for, in a line"),
|
||||
choiceF("credential", []string{"bearer", "header", "none"}, "bearer"),
|
||||
textF("header", "", "for credential: header, its name (X-Api-Key)"),
|
||||
pwF("secret", "the token or key; kept sealed in the store"),
|
||||
textF("timeout", "", "seconds a call may take; empty is the default"),
|
||||
textF("rate", "", "calls a minute per caller; empty is no bound"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
name, addr := strings.TrimSpace(v[0]), strings.TrimSpace(v[1])
|
||||
if name == "" || addr == "" {
|
||||
return nil, fmt.Errorf("a name and an address")
|
||||
}
|
||||
if u, err := url.Parse(addr); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, fmt.Errorf("the address is http(s)://host/path")
|
||||
}
|
||||
secrets, err := mcpAuth(v[3], strings.TrimSpace(v[4]), v[5])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout, err := atoiOr(v[6], 0)
|
||||
if err != nil || timeout < 0 {
|
||||
return nil, fmt.Errorf("timeout is whole seconds")
|
||||
}
|
||||
rate, err := atoiOr(v[7], 0)
|
||||
if err != nil || rate < 0 {
|
||||
return nil, fmt.Errorf("rate is calls a minute")
|
||||
}
|
||||
on := true
|
||||
req := adminapi.MCPServerRequest{URL: addr, Description: v[2], Secrets: secrets, Enabled: &on,
|
||||
TimeoutSeconds: timeout, RatePerMinute: rate}
|
||||
return m.call("Add "+name, http.MethodPut, mcpPath(name), req, func(json.RawMessage) string {
|
||||
return "Added " + name + ". Test it from its menu, then grant its tools: nobody sees them yet."
|
||||
}), nil
|
||||
}})
|
||||
}
|
||||
|
||||
// mcpMenu is what can be done to one server.
|
||||
func (m consoleModel) mcpMenu(s adminapi.MCPServerView) *menuDialog {
|
||||
items := []menuItem{
|
||||
{"Test, and list its tools", func(m *consoleModel) tea.Cmd { return m.mcpTest(s) }},
|
||||
{"Grant its tools to…", func(m *consoleModel) tea.Cmd { m.mcpGrantForm(s); return nil }},
|
||||
}
|
||||
for _, g := range m.mcp.grantsOf(s.Name) {
|
||||
if !slices.ContainsFunc(g.tools, func(t string) bool { return t != "*" }) {
|
||||
continue // every tool, granted with the Limits form; revoked there too
|
||||
}
|
||||
items = append(items, menuItem{"Revoke from " + g.label + " (" + strings.Join(g.tools, ", ") + ")…",
|
||||
func(m *consoleModel) tea.Cmd {
|
||||
m.confirmD = &confirmDialog{title: "Revoke " + s.Name + " from " + g.label,
|
||||
text: "It and everything below it then no longer see " + strings.Join(g.tools, ", ") +
|
||||
",\nunless another level grants them.",
|
||||
yes: func(m *consoleModel) tea.Cmd { return m.mcpSetTools(s.Name, g.owner, nil) }}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
items = append(items,
|
||||
menuItem{"Change…", func(m *consoleModel) tea.Cmd { m.mcpEditForm(s); return nil }},
|
||||
menuItem{"Replace its credential…", func(m *consoleModel) tea.Cmd { m.mcpCredentialForm(s); return nil }},
|
||||
)
|
||||
if s.Enabled {
|
||||
items = append(items, menuItem{"Turn off", func(m *consoleModel) tea.Cmd {
|
||||
off := false
|
||||
return m.call("Turn off "+s.Name, http.MethodPut, mcpPath(s.Name), adminapi.MCPServerRequest{URL: s.URL, Enabled: &off},
|
||||
func(json.RawMessage) string {
|
||||
return s.Name + " is off: its tools are listed nowhere and every call is refused."
|
||||
})
|
||||
}})
|
||||
} else {
|
||||
items = append(items, menuItem{"Turn on", func(m *consoleModel) tea.Cmd {
|
||||
on := true
|
||||
return m.call("Turn on "+s.Name, http.MethodPut, mcpPath(s.Name), adminapi.MCPServerRequest{URL: s.URL, Enabled: &on}, nil)
|
||||
}})
|
||||
}
|
||||
items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd {
|
||||
m.confirmD = &confirmDialog{title: "Remove " + s.Name,
|
||||
text: "Its tools go from every agent at once, and its sealed credential with it.\nThe grants stay in the tree until revoked.",
|
||||
yes: func(m *consoleModel) tea.Cmd {
|
||||
return m.call("Remove "+s.Name, http.MethodDelete, mcpPath(s.Name), nil, nil)
|
||||
}}
|
||||
return nil
|
||||
}})
|
||||
return &menuDialog{title: s.Name + " · " + s.URL, items: items}
|
||||
}
|
||||
|
||||
// mcpTest asks the server for its tools, with its credentials, as a call
|
||||
// through the gateway would.
|
||||
func (m *consoleModel) mcpTest(s adminapi.MCPServerView) tea.Cmd {
|
||||
return m.call("Test "+s.Name, http.MethodPost, mcpPath(s.Name)+"/test", nil, func(raw json.RawMessage) string {
|
||||
var t adminapi.MCPTestView
|
||||
_ = json.Unmarshal(raw, &t)
|
||||
if !t.OK {
|
||||
return "It did not answer: " + t.Error
|
||||
}
|
||||
if len(t.Tools) == 0 {
|
||||
return "It answers, and offers no tools."
|
||||
}
|
||||
return fmt.Sprintf("It answers, with %d tools:\n\n %s", len(t.Tools), strings.Join(t.Tools, "\n "))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *consoleModel) mcpEditForm(s adminapi.MCPServerView) {
|
||||
rate := ""
|
||||
if s.RatePerMinute > 0 {
|
||||
rate = strconv.Itoa(s.RatePerMinute)
|
||||
}
|
||||
timeout := ""
|
||||
if s.TimeoutSeconds > 0 {
|
||||
timeout = strconv.Itoa(s.TimeoutSeconds)
|
||||
}
|
||||
m.openForm(&formDialog{title: "Change " + s.Name,
|
||||
note: "Its credential stays as it is; Replace its credential for that.",
|
||||
fields: []formField{
|
||||
textF("address", s.URL, ""),
|
||||
textF("about", s.Description, ""),
|
||||
textF("timeout", timeout, "seconds a call may take"),
|
||||
textF("rate", rate, "calls a minute per caller; 0 is no bound"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if u, err := url.Parse(v[0]); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, fmt.Errorf("the address is http(s)://host/path")
|
||||
}
|
||||
timeout, err := atoiOr(v[2], 0)
|
||||
if err != nil || timeout < 0 {
|
||||
return nil, fmt.Errorf("timeout is whole seconds")
|
||||
}
|
||||
rate, err := atoiOr(v[3], 0)
|
||||
if err != nil || rate < 0 {
|
||||
return nil, fmt.Errorf("rate is calls a minute")
|
||||
}
|
||||
if rate == 0 && s.RatePerMinute > 0 {
|
||||
rate = -1 // the API's way of taking the bound away
|
||||
}
|
||||
req := adminapi.MCPServerRequest{URL: v[0], Description: v[1], TimeoutSeconds: timeout, RatePerMinute: rate}
|
||||
return m.call("Change "+s.Name, http.MethodPut, mcpPath(s.Name), req, nil), nil
|
||||
}})
|
||||
}
|
||||
|
||||
func (m *consoleModel) mcpCredentialForm(s adminapi.MCPServerView) {
|
||||
m.openForm(&formDialog{title: "Replace the credential " + s.Name + " is sent",
|
||||
note: "Kept in the store, sealed. The old value is overwritten, not shown.",
|
||||
fields: []formField{
|
||||
choiceF("credential", []string{"bearer", "header"}, "bearer"),
|
||||
textF("header", "", "for credential: header, its name (X-Api-Key)"),
|
||||
pwF("secret", "the token or key"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if v[2] == "" {
|
||||
return nil, fmt.Errorf("the new secret?")
|
||||
}
|
||||
secrets, err := mcpAuth(v[0], strings.TrimSpace(v[1]), v[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.call("Credential of "+s.Name, http.MethodPut, mcpPath(s.Name),
|
||||
adminapi.MCPServerRequest{URL: s.URL, Secrets: secrets}, nil), nil
|
||||
}})
|
||||
}
|
||||
|
||||
// mcpGrantForm grants a server's tools to part of the tree, a person or a
|
||||
// key, beside whatever else that level is granted.
|
||||
func (m *consoleModel) mcpGrantForm(s adminapi.MCPServerView) {
|
||||
if m.mcp.ids == nil {
|
||||
m.dialog, m.dialogTitle = "error", "Grant "+s.Name
|
||||
m.dialogText = "The store cannot be read from this machine, so its grants cannot be changed here.\n" +
|
||||
"On the control plane: llmgw keys limits --scope team --id ORG/TEAM --tools '" + s.Name + ".*'"
|
||||
return
|
||||
}
|
||||
m.openForm(&formDialog{title: "Grant " + s.Name + "'s tools",
|
||||
note: "It and everything below it then see them, unless a level below grants\n" +
|
||||
"tools and not these. Other grants at that level stay.",
|
||||
fields: []formField{
|
||||
choiceF("to", []string{"team", "project", "org", "user", "key"}, "team"),
|
||||
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, an e-mail, or a key's ID"),
|
||||
textF("tools", s.Name+".*", "patterns: "+s.Name+".* for all, or "+s.Name+".search,"+s.Name+".get"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
which := strings.TrimSpace(v[1])
|
||||
if which == "" {
|
||||
return nil, fmt.Errorf("which %s?", v[0])
|
||||
}
|
||||
owner, ok := m.mcp.ids[v[0]+":"+which]
|
||||
if v[0] == "key" {
|
||||
owner, ok = store.LimitOwner{Scope: store.ScopeKey, ID: which}, true
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no %s %q", v[0], which)
|
||||
}
|
||||
var tools []string
|
||||
for _, t := range strings.Split(v[2], ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
if !strings.HasPrefix(t, s.Name+".") {
|
||||
return nil, fmt.Errorf("%q is not one of %s's tools: they start %s.", t, s.Name, s.Name)
|
||||
}
|
||||
tools = append(tools, t)
|
||||
}
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
return nil, fmt.Errorf("which tools? %s.* for all", s.Name)
|
||||
}
|
||||
return m.mcpSetTools(s.Name, owner, tools), nil
|
||||
}})
|
||||
}
|
||||
|
||||
// mcpSetTools makes a level's grants of a server's tools these, leaving its
|
||||
// grants of other servers' as they are. None revokes them.
|
||||
func (m *consoleModel) mcpSetTools(server string, owner store.LimitOwner, tools []string) tea.Cmd {
|
||||
var keep []string
|
||||
for _, g := range m.mcp.grants {
|
||||
if g.owner != owner {
|
||||
continue
|
||||
}
|
||||
for _, t := range g.tools {
|
||||
if !strings.HasPrefix(t, server+".") {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
all := append(keep, tools...)
|
||||
title := "Grant " + server
|
||||
if len(tools) == 0 {
|
||||
title = "Revoke " + server
|
||||
}
|
||||
return m.runCLI(title, "keys", "limits", "--scope="+string(owner.Scope), "--id="+owner.ID,
|
||||
"--tools="+strings.Join(all, ","))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestAnMCPServerIsAddedAndTestedFromTheConsole(t *testing.T) {
|
||||
m, s := consoleWith(t, cMCP)
|
||||
m.mcp = mcpState{loaded: true, grants: []mcpGrant{}, servers: []adminapi.MCPServerView{{
|
||||
Name: "jira", URL: "https://jira-mcp.internal/mcp", Enabled: true, RatePerMinute: 30,
|
||||
HeaderRefs: map[string]string{"Authorization": "store:mcp/jira/Authorization"},
|
||||
}}}
|
||||
var tm tea.Model = m
|
||||
view := tm.View()
|
||||
for _, want := range []string{"MCP servers", "jira", "Authorization (secret)",
|
||||
"30/min", "nobody yet", "claude mcp add"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
if strings.Contains(view, "store:mcp/jira") {
|
||||
t.Error("where the secret is kept is shown")
|
||||
}
|
||||
|
||||
// A: a server with a bearer token, which is sent once and kept sealed.
|
||||
tm = press(tm, "a")
|
||||
f := tm.(consoleModel).formD
|
||||
if f == nil {
|
||||
t.Fatal("no form")
|
||||
}
|
||||
f.fields[0].text.SetValue("hr")
|
||||
f.fields[1].text.SetValue("https://hr-mcp.internal/mcp")
|
||||
f.fields[5].text.SetValue("t0ken")
|
||||
f.focus = len(f.fields)
|
||||
_ = pressRun(tm, "enter")
|
||||
got := s.last()
|
||||
var req adminapi.MCPServerRequest
|
||||
if !strings.HasPrefix(got, "PUT /api/v1/mcp/servers/hr ") ||
|
||||
json.Unmarshal([]byte(strings.SplitN(got, " ", 3)[2]), &req) != nil ||
|
||||
req.URL != "https://hr-mcp.internal/mcp" || req.Secrets["Authorization"] != "Bearer t0ken" ||
|
||||
req.Enabled == nil || !*req.Enabled {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
|
||||
// Enter on jira: test it, then turn it off.
|
||||
tm = press(m, "enter")
|
||||
md := tm.(consoleModel).menuD
|
||||
if md == nil || md.items[0].label != "Test, and list its tools" || !strings.Contains(md.title, "https://jira-mcp.internal/mcp") {
|
||||
t.Fatalf("menu %+v", md)
|
||||
}
|
||||
_ = pressRun(tm, "enter")
|
||||
if got := s.last(); got != "POST /api/v1/mcp/servers/jira/test " {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnMCPServersToolsAreGrantedBesideOtherGrants(t *testing.T) {
|
||||
// The servers, from a control plane; the grants, from the store.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]adminapi.MCPServerView{
|
||||
{Name: "hr", URL: "https://hr/mcp", Enabled: true}, {Name: "jira", URL: "https://jira/mcp", Enabled: true}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
cfgPath := dir + "/server.yaml"
|
||||
if err := os.WriteFile(cfgPath, []byte("auth:\n store: "+dir+"/llmgw.db\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := config.Load(cfgPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newConsoleModel(srv.URL, cfgPath)
|
||||
defer m.st.close()
|
||||
m.loaded, m.inPanel, m.menu = true, true, cMCP
|
||||
ctx := context.Background()
|
||||
st, err := m.st.get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, _ := st.CreateOrg(ctx, "Acme")
|
||||
team, _ := st.CreateTeam(ctx, org, "ml")
|
||||
if err := st.PutLimits(ctx, store.Limits{Scope: store.ScopeTeam, ScopeID: team, AllowedTools: []string{"jira.*"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolsOf := func() []string {
|
||||
all, _ := st.AllLimits(ctx)
|
||||
return all[store.LimitOwner{Scope: store.ScopeTeam, ID: team}.CounterKey()].AllowedTools
|
||||
}
|
||||
refresh := func(tm tea.Model) tea.Model {
|
||||
tm, _ = tm.Update(tm.(consoleModel).fetchMCP()())
|
||||
return tm
|
||||
}
|
||||
|
||||
var tm tea.Model = refresh(m)
|
||||
if v := tm.View(); !strings.Contains(v, "team Acme/ml") || !strings.Contains(v, "nobody yet") {
|
||||
t.Fatalf("jira's grant, and hr's none, should show:\n%s", v)
|
||||
}
|
||||
// hr is the first row: grant its tools to the team.
|
||||
tm = press(tm, "enter", "down", "enter")
|
||||
f := tm.(consoleModel).formD
|
||||
if f == nil || f.fields[2].value() != "hr.*" {
|
||||
t.Fatalf("no grant form, or not for hr: %+v", f)
|
||||
}
|
||||
f.fields[1].text.SetValue("Acme/ml")
|
||||
f.focus = len(f.fields)
|
||||
tm = pressRun(tm, "enter")
|
||||
if got := toolsOf(); !slices.Equal(got, []string{"jira.*", "hr.*"}) {
|
||||
t.Fatalf("the team's grants are %v; jira's should have stayed beside hr's", got)
|
||||
}
|
||||
|
||||
// Revoke hr from the team: jira's stays.
|
||||
tm = refresh(press(tm, "x"))
|
||||
tm = press(tm, "enter", "down", "down", "enter")
|
||||
if c := tm.(consoleModel).confirmD; c == nil || !strings.Contains(c.title, "Revoke hr from team Acme/ml") {
|
||||
t.Fatalf("no revoke: %+v", c)
|
||||
}
|
||||
_ = pressRun(tm, "y")
|
||||
if got := toolsOf(); !slices.Equal(got, []string{"jira.*"}) {
|
||||
t.Fatalf("after the revoke the team's grants are %v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user