package cli // mcp_manager_actions.go applies /mcp manager actions: connect, disable, remove, // mode, auth, and config editing. import ( "fmt" "os" "os/exec" "runtime" "strings" tea "charm.land/bubbletea/v2" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/mcpdiag" "reasonix/internal/plugin" "reasonix/internal/shellparse" ) func (m chatTUI) applyMCPAction(v mcpServerView, action mcpAction) (tea.Model, tea.Cmd) { switch action { case mcpActionViewTools: m.mcp.stage = mcpStageTools case mcpActionMode: m.mcp.stage = mcpStageMode m.mcp.mode = mcpModeIndex(v.Tier) case mcpActionEdit: return m.openMCPConfig() case mcpActionAuth: return m.authenticateMCP(v) case mcpActionClearAuth: m.mcp.stage = mcpStageConfirmClearAuth m.mcp.confirm = 1 case mcpActionConnect: return m.connectSelectedMCP(v) case mcpActionLogs: m.mcp.stage = mcpStageLogs case mcpActionDisable: return m.disableSelectedMCP(v) case mcpActionRemove: m.mcp.stage = mcpStageConfirmRemove m.mcp.confirm = 1 } return m, nil } func (m chatTUI) connectSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) { if m.ctrl == nil { m.notice("mcp: no active session") return m, nil } if v.Status == "connected" { m.ctrl.DisconnectMCPServer(v.Name) } n, err := m.ctrl.ConnectConfiguredMCPServer(v.Name) if err != nil { m.notice("mcp connect: " + err.Error()) return m, nil } if m.mcpDisabled != nil { delete(m.mcpDisabled, v.Name) } m.host = m.ctrl.Host() m.refreshMCPManager() if m.mcp != nil { m.mcp.stage = mcpStageDetail m.mcp.selectName(v.Name) } m.notice(fmt.Sprintf("connected %s — %d tools (available next message)", v.Name, n)) return m, nil } func (m chatTUI) disableSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) { if m.ctrl == nil { m.notice("mcp: no active session") return m, nil } persisted := false if m.mcpDisabled == nil { m.mcpDisabled = map[string]bool{} } m.mcpDisabled[v.Name] = true m.ctrl.DisconnectMCPServer(v.Name) m.host = m.ctrl.Host() m.refreshMCPManager() if m.mcp != nil { m.mcp.stage = mcpStageDetail m.mcp.selectName(v.Name) } if persisted { m.notice("disabled " + v.Name) } else { m.notice("disabled " + v.Name + " for this session") } return m, nil } func (m chatTUI) removeSelectedMCP() (tea.Model, tea.Cmd) { v, ok := m.mcp.selectedServer() if !ok { m.mcp.stage = mcpStageList return m, nil } if m.ctrl == nil { m.notice("mcp: no active session") return m, nil } disconnected, err := m.ctrl.RemoveMCPServer(v.Name) if err != nil { m.notice("mcp remove: " + err.Error()) m.mcp.stage = mcpStageDetail return m, nil } if m.mcpDisabled != nil { delete(m.mcpDisabled, v.Name) } m.host = m.ctrl.Host() m.refreshMCPManager() if m.mcp != nil { m.mcp.stage = mcpStageList m.mcp.name = "" } if disconnected { m.notice("disconnected " + v.Name + " and removed it from config") } else { m.notice("removed " + v.Name + " from config") } return m, nil } func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) { v, ok := m.mcp.selectedServer() if !ok { return m, nil } cfg, err := config.Load() if err != nil { m.notice("mcp mode: " + err.Error()) return m, nil } found := false var selected config.PluginEntry for i := range cfg.Plugins { if cfg.Plugins[i].Name == v.Name { cfg.Plugins[i].Tier = normalizeMCPTierForCLI(tier) if !cfg.Plugins[i].ShouldAutoStart() { cfg.Plugins[i].AutoStart = mcpBoolPtr(true) } selected = cfg.Plugins[i] found = true break } } if !found { m.notice(fmt.Sprintf("mcp mode: no configured MCP server named %q", v.Name)) return m, nil } if err := cfg.Save(); err != nil { m.notice("mcp mode: " + err.Error()) return m, nil } if m.mcpDisabled != nil { delete(m.mcpDisabled, v.Name) } if m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) { if _, err := m.ctrl.ConnectConfiguredMCPServer(v.Name); err != nil { recordMCPModePluginFailure(m.ctrl, selected, err) m.notice("saved connection mode, but connect failed: " + err.Error()) } m.host = m.ctrl.Host() } m.refreshMCPManager() if m.mcp != nil { m.mcp.stage = mcpStageDetail m.mcp.selectName(v.Name) } m.notice("updated connection mode for " + v.Name) return m, nil } func recordMCPModePluginFailure(ctrl control.Capabilities, e config.PluginEntry, err error) { if ctrl == nil || ctrl.Host() == nil || err == nil { return } exp := e.ExpandedPlugin() ctrl.Host().RecordFailure(plugin.Spec{ Name: exp.Name, Type: exp.Type, Command: exp.Command, Args: exp.Args, Env: exp.Env, URL: exp.URL, Headers: exp.Headers, }, err) } func (m chatTUI) openMCPConfig() (tea.Model, tea.Cmd) { path := "" if m.mcp != nil { path = m.mcp.snapshot.configPath } if strings.TrimSpace(path) == "" { path = mcpConfigLocation() } launch, err := mcpEditConfigLaunchCommand(path, exec.LookPath) if err != nil { m.notice("edit config: " + err.Error()) return m, nil } if launch.systemDefault { m.notice("no terminal editor found; opened config with the system default app. Set EDITOR=vim to edit in terminal.") } else if launch.editor != "" { m.notice("opening config with " + launch.editor) } return m, tea.ExecProcess(launch.cmd, func(err error) tea.Msg { return mcpExternalDoneMsg{label: "edit config", target: path, err: err} }) } func (m chatTUI) authenticateMCP(v mcpServerView) (tea.Model, tea.Cmd) { u := mcpAuthURL(v) if u == "" { m.notice("mcp auth: no authorization URL was returned; view logs for details") return m, nil } cmd, err := mcpOpenCommand(u) if err != nil { m.notice("mcp auth: " + err.Error()) return m, nil } return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return mcpExternalDoneMsg{label: "authorization page", target: u, err: err} }) } func (m chatTUI) clearSelectedMCPAuthentication() (tea.Model, tea.Cmd) { if m.mcp == nil { return m, nil } v, ok := m.mcp.selectedServer() if !ok { m.mcp.stage = mcpStageList return m, nil } return m.clearMCPAuthentication(v) } func (m chatTUI) clearMCPAuthentication(v mcpServerView) (tea.Model, tea.Cmd) { if v.BuiltIn { m.notice("managed MCP servers do not store authentication") return m, nil } _, changed, _, err := config.ClearPluginAuthenticationInSource(v.Name) if err != nil { m.notice("clear authentication: " + err.Error()) return m, nil } if m.ctrl != nil { m.ctrl.DisconnectMCPServer(v.Name) if h := m.ctrl.Host(); h != nil { h.ClearFailure(v.Name) } m.host = m.ctrl.Host() } m.refreshMCPManager() if m.mcp != nil { m.mcp.stage = mcpStageDetail m.mcp.selectName(v.Name) } if changed { m.notice("cleared authentication for " + v.Name + "; reconnect to authorize again") } else { m.notice("cleared local authentication state for " + v.Name) } return m, nil } func mcpModeIndex(tier string) int { tier = normalizeMCPTierForCLI(tier) for i, choice := range mcpTierChoices { if choice == tier { return i } } return 0 } func normalizeMCPTierForCLI(tier string) string { switch strings.ToLower(strings.TrimSpace(tier)) { case "eager": return "eager" case "background", "lazy": return "background" case "": return "background" default: return "background" } } func mcpConfigLocation() string { if path := config.SourcePath(); path != "" { return path } if _, err := os.Stat(".mcp.json"); err == nil { return ".mcp.json" } if path := config.UserConfigPath(); path != "" { return path } return "reasonix.toml" } type mcpEditConfigLaunch struct { cmd *exec.Cmd editor string systemDefault bool } func mcpEditConfigLaunchCommand(path string, lookPath func(string) (string, error)) (mcpEditConfigLaunch, error) { path = strings.TrimSpace(path) if path == "" { return mcpEditConfigLaunch{}, fmt.Errorf("no config path available") } if editor := strings.TrimSpace(os.Getenv("VISUAL")); editor != "" { cmd, err := editorLaunchCmd(editor, path) if err != nil { return mcpEditConfigLaunch{}, err } return mcpEditConfigLaunch{ cmd: cmd, editor: mcpEditorDisplayName(editor), }, nil } if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" { cmd, err := editorLaunchCmd(editor, path) if err != nil { return mcpEditConfigLaunch{}, err } return mcpEditConfigLaunch{ cmd: cmd, editor: mcpEditorDisplayName(editor), }, nil } if lookPath == nil { lookPath = exec.LookPath } for _, editor := range []string{"vim", "vi", "nano"} { if bin, err := lookPath(editor); err == nil && strings.TrimSpace(bin) != "" { return mcpEditConfigLaunch{ cmd: exec.Command(bin, path), editor: editor, }, nil } } cmd, err := mcpOpenCommand(path) if err != nil { return mcpEditConfigLaunch{}, err } return mcpEditConfigLaunch{cmd: cmd, systemDefault: true}, nil } func mcpEditorDisplayName(editor string) string { fields, err := splitEditorCommand(os.ExpandEnv(editor)) if err != nil || len(fields) == 0 { return "" } return fields[0] } func mcpOpenCommand(target string) (*exec.Cmd, error) { target = strings.TrimSpace(target) if target == "" { return nil, fmt.Errorf("empty target") } switch runtime.GOOS { case "darwin": return exec.Command("open", target), nil case "windows": return exec.Command("rundll32", "url.dll,FileProtocolHandler", target), nil default: return exec.Command("xdg-open", target), nil } } func mcpAuthURL(v mcpServerView) string { auth := mcpAuthDiagnosis(v) if auth.Status != mcpdiag.AuthRequired { return "" } return strings.TrimSpace(auth.URL) } func mcpAuthStatus(v mcpServerView) string { return mcpAuthDiagnosis(v).Status } func mcpAuthDiagnosis(v mcpServerView) mcpdiag.AuthDiagnosis { if v.AuthStatus != "" { return mcpdiag.AuthDiagnosis{Status: v.AuthStatus, URL: v.AuthURL} } return mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.authConfigured) } func mcpCanClearAuth(v mcpServerView) bool { if !v.Configured || v.BuiltIn { return false } if v.authConfigured || mcpAuthStatus(v) != mcpdiag.AuthNone { return true } return mcpdiag.IsRemoteTransport(v.Transport) } func mcpConnected(ctrl control.Capabilities, name string) bool { if ctrl == nil || ctrl.Host() == nil { return false } for _, s := range ctrl.Host().Servers() { if s.Name == name { return true } } return false } // editorLaunchCmd builds an exec.Cmd for an editor invocation read from the // VISUAL/EDITOR environment variable. The editor string may carry arguments // (e.g. "code --wait", "nvim -p") and shell variable / tilde references // (e.g. "$HOME/bin/myeditor", "~/bin/myeditor"); these are expanded without // invoking a shell, and the editor binary is resolved by the OS directly. // Shell metacharacters in the value cannot be executed: the expanded value must // parse as one static shell command. Control operators, redirection, // substitution, globbing, assignments, and other shell-shaping syntax are // rejected before launch. // // This matches the safe pattern already used by the terminal-editor // fallback (exec.Command(bin, path)) in the same function and avoids the // previous sh -lc construction that concatenated the raw editor value into // a shell command string. // // Quoting and backslash escaping are honored for word splitting only; shell // operators, globbing, command substitution, and redirection are rejected. // Tilde expansion only covers the leading-token forms "~" and "~/..."; "~user" // is not supported (and was not reliably supported by the prior sh -lc path // either, since $HOME for another user is not available without getpwuid). func editorLaunchCmd(editor, path string) (*exec.Cmd, error) { expanded := os.ExpandEnv(editor) args, err := splitEditorCommand(expanded) if err != nil { return nil, fmt.Errorf("invalid EDITOR/VISUAL value: %w", err) } if len(args) == 0 { return nil, fmt.Errorf("invalid EDITOR/VISUAL value: %q", editor) } args[0] = expandLeadingTilde(args[0]) return exec.Command(args[0], append(args[1:], path)...), nil } func splitEditorCommand(s string) ([]string, error) { args, malformed := shellparse.StaticFields(s) if malformed != "" { return nil, fmt.Errorf("%s", malformed) } return args, nil } // expandLeadingTilde replaces a leading "~" or "~/" prefix with the current // user's home directory. Other forms (e.g. "~user") are returned unchanged. // If the home directory cannot be determined the value is returned as-is so // the caller surfaces the exec failure rather than panicking. func expandLeadingTilde(p string) string { if p != "~" && !strings.HasPrefix(p, "~/") { return p } home, err := os.UserHomeDir() if err != nil { return p } if p == "~" { return home } return home + p[1:] } func mcpBoolPtr(v bool) *bool { return &v }