Files
llmgw/internal/cli/tui_mcp.go
T
Ilker ManapandClaude Opus 5.5 1948a787ae
CI / test (push) Successful in 9m15s
Fixes from the second review: grants, flags, updates, setup
- Revoking a server's tools takes them from every level beneath too, and a
  level left naming no tool stays closed ([]) while any level above or
  beneath names tools: an organization's revoke left its teams the copies
  their first grants took, and going back to no list opened the level to
  what was above it. A closed level is shown on the page and in the
  console with an action to open it (store.OpenTools). A key's owner's
  list is among the levels above a key.
- The flag check matches short flags as written again (-m, -hf, -mu were
  let through), takes a long flag's beginning for it only for the engines
  that parse with argparse (vLLM, SGLang, ComfyUI: --li is --listen), so
  llama.cpp's own --chat-template is not refused, and refuses llama.cpp's
  short spellings (-hfr, -hff, -mm, -jf, …).
- A stored model with an adapter by Hugging Face id can be changed again:
  what it already had is not asked about anew.
- The catalog reads a Hugging Face id as the agent does (engines.IsHubID).
- The page's form cannot move llmgw:admin to another address.
- server init skips the memory check only where the gateway already runs,
  not wherever a half-finished first run left server.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:31:07 +01:00

535 lines
19 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
// keys are the IDs of the keys there are, for a grant to one.
keys map[string]bool
}
// 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.keys = map[string]bool{}
if keys, err := s.AllKeys(ctx); err == nil {
for _, k := range keys {
msg.keys[k.ID] = true
msg.where[store.LimitOwner{Scope: store.ScopeKey, ID: k.ID}.CounterKey()] = k.Name
}
}
msg.grants = []mcpGrant{}
for _, l := range limits {
if l.AllowedTools == nil {
continue
}
o := store.LimitOwner{Scope: l.Scope, ID: l.ScopeID}
label := msg.where[o.CounterKey()]
if label == "" {
label = 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)+":"+strings.ToLower(label)] = o
if m := store.MemberScope(scope); m != "" {
s.where[store.LimitOwner{Scope: m, ID: id}.CounterKey()] = label
}
}
// closed are the levels whose list names no tool at all.
func (k mcpState) closed() []mcpGrant {
var out []mcpGrant
for _, g := range k.grants {
if len(g.tools) == 0 {
out = append(out, g)
}
}
return out
}
// 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")
if closed := k.closed(); len(closed) > 0 {
var names []string
for _, g := range closed {
names = append(names, g.label)
}
b.WriteString(" " + sBad.Render("Closed to every tool: ") + strings.Join(names, ", ") +
sDim.Render(" (a server's menu opens them)") + "\n")
}
b.WriteString("\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 err := adminapi.CheckMCPAddress(addr); err != nil {
return nil, err
}
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: "Taken from it and from everything beneath it. Its other grants stay; left naming\n" +
"no tool, it is closed rather than opened to what the levels above allow.",
yes: func(m *consoleModel) tea.Cmd { return m.mcpSetTools(s.Name, g.owner, nil) }}
return nil
}})
}
for _, g := range m.mcp.closed() {
items = append(items, menuItem{"Open " + g.label + ", closed to every tool…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Open " + g.label,
text: "It then names no tools of its own and sees what the levels above it allow.",
yes: func(m *consoleModel) tea.Cmd { return m.mcpOpen(g.owner) }}
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 err := adminapi.CheckMCPAddress(strings.TrimSpace(v[0])); err != nil {
return nil, err
}
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")
}
req := adminapi.MCPServerRequest{URL: strings.TrimSpace(v[0]), Description: v[1], TimeoutSeconds: timeout,
RatePerMinute: rate}
// An emptied field empties it, rather than leaving it as it was.
if strings.TrimSpace(v[1]) == "" {
req.Clear = append(req.Clear, "description")
}
if timeout == 0 {
req.Clear = append(req.Clear, "timeout")
}
if rate == 0 {
req.Clear = append(req.Clear, "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, HeaderRefs: map[string]string{}}, 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: "Seen there, and below, where every level above that names tools names these too.\n" +
"A level given its first grant keeps what it saw from above; other grants 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]+":"+strings.ToLower(which)]
if v[0] == "key" {
owner, ok = store.LimitOwner{Scope: store.ScopeKey, ID: which}, m.mcp.keys[which]
}
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 grants a level these of a server's tools, or with none takes
// them away, in the store (store.GrantTools, store.RevokeTools): read and
// written at once, so another administrator's change since this screen
// was loaded is kept.
func (m *consoleModel) mcpSetTools(server string, owner store.LimitOwner, tools []string) tea.Cmd {
h, where := m.st, m.mcp.where
title := "Grant " + server
if len(tools) == 0 {
title = "Revoke " + server
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s, err := h.get(ctx)
if err != nil {
return actionDoneMsg{title: title, err: err}
}
if len(tools) == 0 {
if err := s.RevokeTools(ctx, owner.Scope, owner.ID, server); err != nil {
return actionDoneMsg{title: title, err: err}
}
return actionDoneMsg{title: title, text: server + "'s tools taken back there; its other grants stay."}
}
g, err := s.GrantTools(ctx, owner.Scope, owner.ID, server, tools)
if err != nil {
return actionDoneMsg{title: title, err: err}
}
text := "Granted " + strings.Join(tools, ", ") + "."
for _, b := range g.BlockedBy {
text += "\n\nBut " + string(b.Scope) + " " + or(where[b.CounterKey()], b.ID) + " names tools and not these:\n" +
server + "'s stay out of reach there until it grants them too."
}
return actionDoneMsg{title: title, text: text}
}
}
// mcpOpen has a closed level name no tools of its own (store.OpenTools).
func (m *consoleModel) mcpOpen(owner store.LimitOwner) tea.Cmd {
h := m.st
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s, err := h.get(ctx)
if err == nil {
err = s.OpenTools(ctx, owner.Scope, owner.ID)
}
if err != nil {
return actionDoneMsg{title: "Open", err: err}
}
return actionDoneMsg{title: "Open", text: "Opened: it names no tools of its own now."}
}
}