489 lines
13 KiB
Go
489 lines
13 KiB
Go
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 }
|