Files
llmgw/internal/cli/tui_mcp.go
T
Ilker ManapandClaude Opus 5.5 98c45ed28b
CI / test (push) Successful in 9m6s
The company's MCP servers in the console
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>
2026-10-28 13:23:25 +01:00

457 lines
16 KiB
Go

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, ","))
}