CI / test (push) Successful in 9m15s
- 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>
535 lines
19 KiB
Go
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."}
|
|
}
|
|
}
|