返回 DeepSeek-Reasonix
mcp_manager_actions.go
根目录 / internal / cli / mcp_manager_actions.go
1 package cli
2
3 // mcp_manager_actions.go applies /mcp manager actions: connect, disable, remove,
4 // mode, auth, and config editing.
5
6 import (
7 "fmt"
8 "os"
9 "os/exec"
10 "runtime"
11 "strings"
12
13 tea "charm.land/bubbletea/v2"
14
15 "reasonix/internal/config"
16 "reasonix/internal/control"
17 "reasonix/internal/mcpdiag"
18 "reasonix/internal/plugin"
19 "reasonix/internal/shellparse"
20 )
21
22 func (m chatTUI) applyMCPAction(v mcpServerView, action mcpAction) (tea.Model, tea.Cmd) {
23 switch action {
24 case mcpActionViewTools:
25 m.mcp.stage = mcpStageTools
26 case mcpActionMode:
27 m.mcp.stage = mcpStageMode
28 m.mcp.mode = mcpModeIndex(v.Tier)
29 case mcpActionEdit:
30 return m.openMCPConfig(v)
31 case mcpActionAuth:
32 return m.authenticateMCP(v)
33 case mcpActionClearAuth:
34 m.mcp.stage = mcpStageConfirmClearAuth
35 m.mcp.confirm = 1
36 case mcpActionConnect:
37 return m.connectSelectedMCP(v)
38 case mcpActionLogs:
39 m.mcp.stage = mcpStageLogs
40 case mcpActionDisable:
41 return m.disableSelectedMCP(v)
42 case mcpActionRemove:
43 m.mcp.stage = mcpStageConfirmRemove
44 m.mcp.confirm = 1
45 }
46 return m, nil
47 }
48
49 func (m chatTUI) connectSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) {
50 if m.ctrl == nil {
51 m.notice("mcp: no active session")
52 return m, nil
53 }
54 if v.Status == "connected" {
55 m.ctrl.DisconnectMCPServer(v.Name)
56 }
57 n, err := m.ctrl.ConnectConfiguredMCPServer(v.Name)
58 if err != nil {
59 m.notice("mcp connect: " + err.Error())
60 return m, nil
61 }
62 if m.mcpDisabled != nil {
63 delete(m.mcpDisabled, v.Name)
64 }
65 m.refreshHostAndInvalidateSlashCatalog()
66 m.refreshMCPManager()
67 if m.mcp != nil {
68 m.mcp.stage = mcpStageDetail
69 m.mcp.selectName(v.Name)
70 }
71 m.notice(fmt.Sprintf("connected %s — %d tools (available next message)", v.Name, n))
72 return m, nil
73 }
74
75 func (m chatTUI) disableSelectedMCP(v mcpServerView) (tea.Model, tea.Cmd) {
76 if m.ctrl == nil {
77 m.notice("mcp: no active session")
78 return m, nil
79 }
80 persisted := false
81 if m.mcpDisabled == nil {
82 m.mcpDisabled = map[string]bool{}
83 }
84 m.mcpDisabled[v.Name] = true
85 m.ctrl.DisconnectMCPServer(v.Name)
86 m.refreshHostAndInvalidateSlashCatalog()
87 m.refreshMCPManager()
88 if m.mcp != nil {
89 m.mcp.stage = mcpStageDetail
90 m.mcp.selectName(v.Name)
91 }
92 if persisted {
93 m.notice("disabled " + v.Name)
94 } else {
95 m.notice("disabled " + v.Name + " for this session")
96 }
97 return m, nil
98 }
99
100 func (m chatTUI) removeSelectedMCP() (tea.Model, tea.Cmd) {
101 v, ok := m.mcp.selectedServer()
102 if !ok {
103 m.mcp.stage = mcpStageList
104 return m, nil
105 }
106 if m.ctrl == nil {
107 m.notice("mcp: no active session")
108 return m, nil
109 }
110 disconnected, err := m.ctrl.RemoveMCPServer(v.Name)
111 if err != nil {
112 m.notice("mcp remove: " + err.Error())
113 m.mcp.stage = mcpStageDetail
114 return m, nil
115 }
116 if m.mcpDisabled != nil {
117 delete(m.mcpDisabled, v.Name)
118 }
119 m.refreshHostAndInvalidateSlashCatalog()
120 m.refreshMCPManager()
121 if m.mcp != nil {
122 m.mcp.stage = mcpStageList
123 m.mcp.name = ""
124 }
125 if disconnected {
126 m.notice("disconnected " + v.Name + " and removed it from config")
127 } else {
128 m.notice("removed " + v.Name + " from config")
129 }
130 return m, nil
131 }
132
133 func (m chatTUI) applyMCPMode(tier string) (tea.Model, tea.Cmd) {
134 v, ok := m.mcp.selectedServer()
135 if !ok {
136 return m, nil
137 }
138 workspace := m.mcpWorkspaceRoot()
139 cfg, err := config.LoadForRoot(workspace)
140 if err != nil {
141 m.notice("mcp mode: " + err.Error())
142 return m, nil
143 }
144 found := false
145 var selected config.PluginEntry
146 for _, entry := range cfg.Plugins {
147 if entry.Name == v.Name {
148 entry.Tier = normalizeMCPTierForCLI(tier)
149 if !entry.ShouldAutoStart() {
150 entry.AutoStart = mcpBoolPtr(true)
151 }
152 selected = entry
153 found = true
154 break
155 }
156 }
157 if !found {
158 m.notice(fmt.Sprintf("mcp mode: no configured MCP server named %q", v.Name))
159 return m, nil
160 }
161 if _, err := config.UpsertPluginInSourceForRoot(workspace, selected); err != nil {
162 m.notice("mcp mode: " + err.Error())
163 return m, nil
164 }
165 if m.mcpDisabled != nil {
166 delete(m.mcpDisabled, v.Name)
167 }
168 if m.ctrl != nil && !mcpConnected(m.ctrl, v.Name) {
169 if _, err := m.ctrl.ConnectConfiguredMCPServer(v.Name); err != nil {
170 recordMCPModePluginFailure(m.ctrl, selected, err)
171 m.notice("saved connection mode, but connect failed: " + err.Error())
172 }
173 m.refreshHostAndInvalidateSlashCatalog()
174 }
175 m.refreshMCPManager()
176 if m.mcp != nil {
177 m.mcp.stage = mcpStageDetail
178 m.mcp.selectName(v.Name)
179 }
180 m.notice("updated connection mode for " + v.Name)
181 return m, nil
182 }
183
184 func recordMCPModePluginFailure(ctrl control.Capabilities, e config.PluginEntry, err error) {
185 if ctrl == nil || ctrl.Host() == nil || err == nil {
186 return
187 }
188 exp := e.ExpandedPlugin()
189 ctrl.Host().RecordFailure(plugin.Spec{
190 Name: exp.Name,
191 Type: exp.Type,
192 Command: exp.Command,
193 Args: exp.Args,
194 Env: exp.Env,
195 URL: exp.URL,
196 Headers: exp.Headers,
197 }, err)
198 }
199
200 func (m chatTUI) openMCPConfig(v mcpServerView) (tea.Model, tea.Cmd) {
201 fallback := config.UserConfigPath()
202 if m.mcp != nil && strings.TrimSpace(m.mcp.snapshot.configPath) != "" {
203 fallback = m.mcp.snapshot.configPath
204 }
205 path := mcpConfigPathForView(v, fallback)
206 launch, err := mcpEditConfigLaunchCommand(path, exec.LookPath)
207 if err != nil {
208 m.notice("edit config: " + err.Error())
209 return m, nil
210 }
211 if launch.systemDefault {
212 m.notice("no terminal editor found; opened config with the system default app. Set EDITOR=vim to edit in terminal.")
213 } else if launch.editor != "" {
214 m.notice("opening config with " + launch.editor)
215 }
216 return m, tea.ExecProcess(launch.cmd, func(err error) tea.Msg {
217 return mcpExternalDoneMsg{label: "edit config", target: path, err: err}
218 })
219 }
220
221 func (m chatTUI) authenticateMCP(v mcpServerView) (tea.Model, tea.Cmd) {
222 u := mcpAuthURL(v)
223 if u == "" {
224 m.notice("mcp auth: no authorization URL was returned; view logs for details")
225 return m, nil
226 }
227 cmd, err := mcpOpenCommand(u)
228 if err != nil {
229 m.notice("mcp auth: " + err.Error())
230 return m, nil
231 }
232 return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
233 return mcpExternalDoneMsg{label: "authorization page", target: u, err: err}
234 })
235 }
236
237 func (m chatTUI) clearSelectedMCPAuthentication() (tea.Model, tea.Cmd) {
238 if m.mcp == nil {
239 return m, nil
240 }
241 v, ok := m.mcp.selectedServer()
242 if !ok {
243 m.mcp.stage = mcpStageList
244 return m, nil
245 }
246 return m.clearMCPAuthentication(v)
247 }
248
249 func (m chatTUI) clearMCPAuthentication(v mcpServerView) (tea.Model, tea.Cmd) {
250 if v.BuiltIn {
251 m.notice("managed MCP servers do not store authentication")
252 return m, nil
253 }
254 _, changed, _, err := config.ClearPluginAuthenticationInSourceForRoot(m.mcpWorkspaceRoot(), v.Name)
255 if err != nil {
256 m.notice("clear authentication: " + err.Error())
257 return m, nil
258 }
259 if m.ctrl != nil {
260 m.ctrl.DisconnectMCPServer(v.Name)
261 if h := m.ctrl.Host(); h != nil {
262 h.ClearFailure(v.Name)
263 }
264 m.refreshHostAndInvalidateSlashCatalog()
265 }
266 m.refreshMCPManager()
267 if m.mcp != nil {
268 m.mcp.stage = mcpStageDetail
269 m.mcp.selectName(v.Name)
270 }
271 if changed {
272 m.notice("cleared authentication for " + v.Name + "; reconnect to authorize again")
273 } else {
274 m.notice("cleared local authentication state for " + v.Name)
275 }
276 return m, nil
277 }
278
279 func mcpModeIndex(tier string) int {
280 tier = normalizeMCPTierForCLI(tier)
281 for i, choice := range mcpTierChoices {
282 if choice == tier {
283 return i
284 }
285 }
286 return 0
287 }
288
289 func normalizeMCPTierForCLI(tier string) string {
290 switch strings.ToLower(strings.TrimSpace(tier)) {
291 case "eager":
292 return "eager"
293 case "background", "lazy":
294 return "background"
295 case "":
296 return "background"
297 default:
298 return "background"
299 }
300 }
301
302 type mcpEditConfigLaunch struct {
303 cmd *exec.Cmd
304 editor string
305 systemDefault bool
306 }
307
308 func mcpEditConfigLaunchCommand(path string, lookPath func(string) (string, error)) (mcpEditConfigLaunch, error) {
309 path = strings.TrimSpace(path)
310 if path == "" {
311 return mcpEditConfigLaunch{}, fmt.Errorf("no config path available")
312 }
313 if editor := strings.TrimSpace(os.Getenv("VISUAL")); editor != "" {
314 cmd, err := editorLaunchCmd(editor, path)
315 if err != nil {
316 return mcpEditConfigLaunch{}, err
317 }
318 return mcpEditConfigLaunch{
319 cmd: cmd,
320 editor: mcpEditorDisplayName(editor),
321 }, nil
322 }
323 if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" {
324 cmd, err := editorLaunchCmd(editor, path)
325 if err != nil {
326 return mcpEditConfigLaunch{}, err
327 }
328 return mcpEditConfigLaunch{
329 cmd: cmd,
330 editor: mcpEditorDisplayName(editor),
331 }, nil
332 }
333 if lookPath == nil {
334 lookPath = exec.LookPath
335 }
336 for _, editor := range []string{"vim", "vi", "nano"} {
337 if bin, err := lookPath(editor); err == nil && strings.TrimSpace(bin) != "" {
338 return mcpEditConfigLaunch{
339 cmd: exec.Command(bin, path),
340 editor: editor,
341 }, nil
342 }
343 }
344 cmd, err := mcpOpenCommand(path)
345 if err != nil {
346 return mcpEditConfigLaunch{}, err
347 }
348 return mcpEditConfigLaunch{cmd: cmd, systemDefault: true}, nil
349 }
350
351 func mcpEditorDisplayName(editor string) string {
352 fields, err := splitEditorCommand(os.ExpandEnv(editor))
353 if err != nil || len(fields) == 0 {
354 return ""
355 }
356 return fields[0]
357 }
358
359 func mcpOpenCommand(target string) (*exec.Cmd, error) {
360 target = strings.TrimSpace(target)
361 if target == "" {
362 return nil, fmt.Errorf("empty target")
363 }
364 switch runtime.GOOS {
365 case "darwin":
366 return exec.Command("open", target), nil
367 case "windows":
368 return exec.Command("rundll32", "url.dll,FileProtocolHandler", target), nil
369 default:
370 return exec.Command("xdg-open", target), nil
371 }
372 }
373
374 func mcpAuthURL(v mcpServerView) string {
375 auth := mcpAuthDiagnosis(v)
376 if auth.Status != mcpdiag.AuthRequired {
377 return ""
378 }
379 return strings.TrimSpace(auth.URL)
380 }
381
382 func mcpAuthStatus(v mcpServerView) string {
383 return mcpAuthDiagnosis(v).Status
384 }
385
386 func mcpAuthDiagnosis(v mcpServerView) mcpdiag.AuthDiagnosis {
387 if v.AuthStatus != "" {
388 return mcpdiag.AuthDiagnosis{Status: v.AuthStatus, URL: v.AuthURL}
389 }
390 return mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.authConfigured)
391 }
392
393 func mcpCanClearAuth(v mcpServerView) bool {
394 if !v.Configured || v.BuiltIn {
395 return false
396 }
397 if v.authConfigured || mcpAuthStatus(v) != mcpdiag.AuthNone {
398 return true
399 }
400 return mcpdiag.IsRemoteTransport(v.Transport)
401 }
402
403 func mcpConnected(ctrl control.Capabilities, name string) bool {
404 if ctrl == nil || ctrl.Host() == nil {
405 return false
406 }
407 for _, s := range ctrl.Host().Servers() {
408 if s.Name == name {
409 return true
410 }
411 }
412 return false
413 }
414
415 // editorLaunchCmd builds an exec.Cmd for an editor invocation read from the
416 // VISUAL/EDITOR environment variable. The editor string may carry arguments
417 // (e.g. "code --wait", "nvim -p") and shell variable / tilde references
418 // (e.g. "$HOME/bin/myeditor", "~/bin/myeditor"); these are expanded without
419 // invoking a shell, and the editor binary is resolved by the OS directly.
420 // Shell metacharacters in the value cannot be executed: the expanded value must
421 // parse as one static shell command. Control operators, redirection,
422 // substitution, globbing, assignments, and other shell-shaping syntax are
423 // rejected before launch.
424 //
425 // This matches the safe pattern already used by the terminal-editor
426 // fallback (exec.Command(bin, path)) in the same function and avoids the
427 // previous sh -lc construction that concatenated the raw editor value into
428 // a shell command string.
429 //
430 // Quoting and backslash escaping are honored for word splitting only; shell
431 // operators, globbing, command substitution, and redirection are rejected.
432 // Tilde expansion only covers the leading-token forms "~" and "~/..."; "~user"
433 // is not supported (and was not reliably supported by the prior sh -lc path
434 // either, since $HOME for another user is not available without getpwuid).
435 func editorLaunchCmd(editor, path string) (*exec.Cmd, error) {
436 expanded := os.ExpandEnv(editor)
437 args, err := splitEditorCommand(expanded)
438 if err != nil {
439 return nil, fmt.Errorf("invalid EDITOR/VISUAL value: %w", err)
440 }
441 if len(args) == 0 {
442 return nil, fmt.Errorf("invalid EDITOR/VISUAL value: %q", editor)
443 }
444 args[0] = expandLeadingTilde(args[0])
445 return exec.Command(args[0], append(args[1:], path)...), nil
446 }
447
448 func splitEditorCommand(s string) ([]string, error) {
449 args, malformed := shellparse.StaticFields(s)
450 if malformed != "" {
451 return nil, fmt.Errorf("%s", malformed)
452 }
453 return args, nil
454 }
455
456 // expandLeadingTilde replaces a leading "~" or "~/" prefix with the current
457 // user's home directory. Other forms (e.g. "~user") are returned unchanged.
458 // If the home directory cannot be determined the value is returned as-is so
459 // the caller surfaces the exec failure rather than panicking.
460 func expandLeadingTilde(p string) string {
461 if p != "~" && !strings.HasPrefix(p, "~/") {
462 return p
463 }
464 home, err := os.UserHomeDir()
465 if err != nil {
466 return p
467 }
468 if p == "~" {
469 return home
470 }
471 return home + p[1:]
472 }
473
474 func mcpBoolPtr(v bool) *bool { return &v }
475
475 lines GO