package cli import ( "sort" "strings" "unicode/utf8" tea "charm.land/bubbletea/v2" "reasonix/internal/config" "reasonix/internal/mcpdiag" "reasonix/internal/plugin" ) const ( mcpListMaxRows = 10 mcpToolMaxRows = 14 ) type mcpStage int const ( mcpStageList mcpStage = iota mcpStageDetail mcpStageTools mcpStageLogs mcpStageMode mcpStageConfirmRemove mcpStageConfirmClearAuth ) type mcpManager struct { stage mcpStage snapshot mcpSnapshot sel int name string action int mode int confirm int } type mcpSnapshot struct { servers []mcpServerView configPath string err string } type mcpServerView struct { Name string Transport string Status string BuiltIn bool Configured bool AutoStart bool Tier string Command string Args []string URL string EnvKeys []string Tools int Prompts int Resources int HasTools bool Error string ToolList []plugin.ToolInfo AuthStatus string AuthURL string authConfigured bool } type mcpAction string const ( mcpActionViewTools mcpAction = "view-tools" mcpActionMode mcpAction = "mode" mcpActionEdit mcpAction = "edit" mcpActionConnect mcpAction = "connect" mcpActionAuth mcpAction = "auth" mcpActionClearAuth mcpAction = "clear-auth" mcpActionLogs mcpAction = "logs" mcpActionDisable mcpAction = "disable" mcpActionRemove mcpAction = "remove" ) type mcpActionItem struct { kind mcpAction label string } type mcpExternalDoneMsg struct { label string target string err error } var mcpTierChoices = []string{"background", "eager"} func (m *chatTUI) openMCPManager(name string) { m.mcp = &mcpManager{stage: mcpStageList, snapshot: m.buildMCPSnapshot()} if name != "" { m.mcp.selectName(name) m.mcp.stage = mcpStageDetail } m.mcp.clamp() } func (m *chatTUI) refreshMCPManager() { if m.mcp == nil { return } m.mcp.snapshot = m.buildMCPSnapshot() m.mcp.clamp() } func (m chatTUI) handleMCPManagerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { p := m.mcp if p == nil { return m, nil } switch msg.String() { case "ctrl+c", "q": m.mcp = nil return m, nil case "esc", "left", "h": switch p.stage { case mcpStageList: m.mcp = nil return m, nil case mcpStageDetail: p.stage = mcpStageList p.action = 0 return m, nil default: p.stage = mcpStageDetail p.action = 0 if p.name == "" { p.stage = mcpStageList } return m, nil } } switch p.stage { case mcpStageList: switch msg.String() { case "up", "k": if p.sel > 0 { p.sel-- } case "down", "j": if p.sel < len(p.snapshot.servers)-1 { p.sel++ } case "r": p.snapshot = m.buildMCPSnapshot() case "enter", "right", "l": if len(p.snapshot.servers) > 0 { p.name = p.snapshot.servers[p.sel].Name p.stage = mcpStageDetail p.action = 0 } } case mcpStageDetail: v, ok := p.selectedServer() if !ok { p.stage = mcpStageList return m, nil } actions := mcpActionsFor(v, p.snapshot.configPath) switch msg.String() { case "up", "k": if p.action > 0 { p.action-- } case "down", "j": if p.action < len(actions)-1 { p.action++ } case "enter": if len(actions) > 0 { return m.applyMCPAction(v, actions[p.action].kind) } default: if idx, ok := numberKeyIndex(msg.String(), len(actions)); ok { p.action = idx return m.applyMCPAction(v, actions[p.action].kind) } } case mcpStageMode: switch msg.String() { case "up", "k": if p.mode > 0 { p.mode-- } case "down", "j": if p.mode < len(mcpTierChoices)-1 { p.mode++ } case "enter": return m.applyMCPMode(mcpTierChoices[p.mode]) default: if idx, ok := numberKeyIndex(msg.String(), len(mcpTierChoices)); ok { p.mode = idx return m.applyMCPMode(mcpTierChoices[p.mode]) } } case mcpStageConfirmRemove: switch msg.String() { case "up", "k", "down", "j": if p.confirm == 0 { p.confirm = 1 } else { p.confirm = 0 } case "y": p.confirm = 0 return m.removeSelectedMCP() case "n": p.stage = mcpStageDetail case "enter": if p.confirm == 0 { return m.removeSelectedMCP() } p.stage = mcpStageDetail } case mcpStageConfirmClearAuth: switch msg.String() { case "up", "k", "down", "j": if p.confirm == 0 { p.confirm = 1 } else { p.confirm = 0 } case "y": p.confirm = 0 return m.clearSelectedMCPAuthentication() case "n": p.stage = mcpStageDetail case "enter": if p.confirm == 0 { return m.clearSelectedMCPAuthentication() } p.stage = mcpStageDetail } } return m, nil } func (p *mcpManager) clamp() { if p.sel < 0 { p.sel = 0 } if n := len(p.snapshot.servers); n > 0 && p.sel >= n { p.sel = n - 1 } if p.name != "" { p.selectName(p.name) } if p.action < 0 { p.action = 0 } if p.mode < 0 { p.mode = 0 } if p.mode >= len(mcpTierChoices) { p.mode = len(mcpTierChoices) - 1 } if p.confirm < 0 || p.confirm > 1 { p.confirm = 0 } } func (p *mcpManager) selectName(name string) bool { for i, s := range p.snapshot.servers { if s.Name == name { p.sel = i p.name = name return true } } return false } func (p *mcpManager) selectedServer() (mcpServerView, bool) { if p.name != "" { for _, s := range p.snapshot.servers { if s.Name == p.name { return s, true } } } if p.sel >= 0 && p.sel < len(p.snapshot.servers) { return p.snapshot.servers[p.sel], true } return mcpServerView{}, false } func (m chatTUI) buildMCPSnapshot() mcpSnapshot { snap := mcpSnapshot{configPath: mcpConfigLocation()} cfg, err := config.Load() if err != nil { snap.err = err.Error() } configured := map[string]config.PluginEntry{} var configuredEntries []config.PluginEntry if cfg != nil { configuredEntries = append(configuredEntries, cfg.Plugins...) for _, p := range configuredEntries { configured[p.Name] = p } } seen := map[string]bool{} if m.host != nil { for _, s := range m.host.Servers() { v := mcpServerView{ Name: s.Name, Transport: fallbackText(s.Transport, "stdio"), Status: "connected", Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources, HasTools: s.HasTools, ToolList: append([]plugin.ToolInfo(nil), s.ToolList...), } if p, ok := configured[s.Name]; ok { v = withMCPPluginConfig(v, p) } snap.servers = append(snap.servers, v) seen[s.Name] = true } for _, f := range m.host.Failures() { v := mcpServerView{ Name: f.Name, Transport: fallbackText(f.Transport, "stdio"), Status: "failed", Error: f.Error, } if p, ok := configured[f.Name]; ok { v = withMCPPluginConfig(v, p) } snap.servers = append(snap.servers, v) seen[f.Name] = true } for _, name := range m.host.ConnectingServers() { if seen[name] { continue } v := mcpServerView{Name: name, Status: "initializing"} if p, ok := configured[name]; ok { v = withMCPPluginConfig(v, p) } snap.servers = append(snap.servers, v) seen[name] = true } } for _, p := range configuredEntries { if seen[p.Name] { continue } v := mcpServerView{Name: p.Name} switch { case m.mcpDisabled[p.Name] || !p.ShouldAutoStart(): v.Status = "disabled" default: v.Status = "deferred" } v = withMCPPluginConfig(v, p) snap.servers = append(snap.servers, v) seen[p.Name] = true } return snap } func withMCPPluginConfig(v mcpServerView, p config.PluginEntry) mcpServerView { transport := strings.ToLower(strings.TrimSpace(p.Type)) if transport == "" { transport = "stdio" } v.Transport = transport v.Configured = true v.AutoStart = p.ShouldAutoStart() v.Tier = p.ResolvedTier() v.Command = p.Command v.Args = append([]string(nil), p.Args...) v.URL = p.URL v.authConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL) if len(p.Env) > 0 { v.EnvKeys = make([]string, 0, len(p.Env)) for k := range p.Env { v.EnvKeys = append(v.EnvKeys, k) } sort.Strings(v.EnvKeys) } auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.authConfigured) v.AuthStatus = auth.Status v.AuthURL = auth.URL return v } func visibleRange(total, sel, limit int) (int, int) { if limit <= 0 || total <= limit { return 0, total } if sel < 0 { sel = 0 } if sel >= total { sel = total - 1 } start := sel - limit/2 if start < 0 { start = 0 } if start+limit > total { start = total - limit } return start, start + limit } func numberKeyIndex(s string, limit int) (int, bool) { if len(s) != 1 || s[0] < '1' || s[0] > '9' { return 0, false } idx := int(s[0] - '1') return idx, idx < limit } func fallbackText(s, fallback string) string { if strings.TrimSpace(s) == "" { return fallback } return s } func titleText(s string) string { if s == "" { return "MCP" } r, size := utf8.DecodeRuneInString(s) if r == utf8.RuneError && size == 0 { return s } return strings.ToUpper(string(r)) + s[size:] }