The company's MCP servers in the console
CI / test (push) Successful in 9m6s

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:
Ilker ManapandClaude Opus 5.5 committed 2026-10-28 13:23:25 +01:00
1 parent 89435735de
commit 98c45ed28b
5 files changed
+631 -1

No files matched your search

+6
View File
@@ -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
+15
View File
@@ -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
+20 -1
View File
@@ -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:
+456
View File
@@ -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, ","))
}
+134
View File
@@ -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)
}
}