diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb6a58..b7812e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/console.md b/docs/console.md index e26e578..4c2d1de 100644 --- a/docs/console.md +++ b/docs/console.md @@ -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 diff --git a/internal/cli/tui_console.go b/internal/cli/tui_console.go index 1f2cdcb..03bddbc 100644 --- a/internal/cli/tui_console.go +++ b/internal/cli/tui_console.go @@ -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: diff --git a/internal/cli/tui_mcp.go b/internal/cli/tui_mcp.go new file mode 100644 index 0000000..a02439f --- /dev/null +++ b/internal/cli/tui_mcp.go @@ -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, ",")) +} diff --git a/internal/cli/tui_mcp_test.go b/internal/cli/tui_mcp_test.go new file mode 100644 index 0000000..0692e44 --- /dev/null +++ b/internal/cli/tui_mcp_test.go @@ -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) + } +}