返回 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 if mcpAuthStatus(v) != mcpdiag.AuthRequired {
223 m.notice("mcp auth: this server is not requesting OAuth authorization")
224 return m, nil
225 }
226 executable, err := os.Executable()
227 if err != nil {
228 m.notice("mcp auth: " + err.Error())
229 return m, nil
230 }
231 cmd := exec.Command(executable, "mcp", "auth", v.Name)
232 cmd.Dir = m.mcpWorkspaceRoot()
233 return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
234 return mcpExternalDoneMsg{label: "MCP authorization", target: v.Name, server: v.Name, err: err}
235 })
236 }
237
238 func (m *chatTUI) handleMCPExternalDone(msg mcpExternalDoneMsg) {
239 if msg.err != nil {
240 m.notice(msg.label + ": " + msg.err.Error())
241 return
242 }
243 if msg.server == "" || m.ctrl == nil {
244 if msg.target != "" {
245 m.notice(msg.label + ": " + msg.target)
246 }
247 return
248 }
249 n, err := m.ctrl.ConnectConfiguredMCPServer(msg.server)
250 if err != nil {
251 m.notice("MCP authorization saved, but reconnect failed: " + err.Error())
252 return
253 }
254 if m.host != nil {
255 m.host.ClearFailure(msg.server)
256 }
257 m.refreshHostAndInvalidateSlashCatalog()
258 m.refreshMCPManager()
259 m.notice(fmt.Sprintf("authorized and connected %s — %d tools (available next message)", msg.server, n))
260 }
261
262 func (m chatTUI) clearSelectedMCPAuthentication() (tea.Model, tea.Cmd) {
263 if m.mcp == nil {
264 return m, nil
265 }
266 v, ok := m.mcp.selectedServer()
267 if !ok {
268 m.mcp.stage = mcpStageList
269 return m, nil
270 }
271 return m.clearMCPAuthentication(v)
272 }
273
274 func (m chatTUI) clearMCPAuthentication(v mcpServerView) (tea.Model, tea.Cmd) {
275 if v.BuiltIn {
276 m.notice("managed MCP servers do not store authentication")
277 return m, nil
278 }
279 workspace := m.mcpWorkspaceRoot()
280 if _, err := plugin.ClearHTTPMCPOAuth(plugin.Spec{
281 Name: v.Name,
282 StateDir: plugin.MCPStateDir(config.ReasonixHomeDir(), workspace, v.Name),
283 }); err != nil {
284 m.notice("clear authentication: " + err.Error())
285 return m, nil
286 }
287 _, changed, _, err := config.ClearPluginAuthenticationInSourceForRoot(workspace, v.Name)
288 if err != nil {
289 m.notice("clear authentication: " + err.Error())
290 return m, nil
291 }
292 if m.ctrl != nil {
293 m.ctrl.DisconnectMCPServer(v.Name)
294 if h := m.ctrl.Host(); h != nil {
295 h.ClearFailure(v.Name)
296 }
297 m.refreshHostAndInvalidateSlashCatalog()
298 }
299 m.refreshMCPManager()
300 if m.mcp != nil {
301 m.mcp.stage = mcpStageDetail
302 m.mcp.selectName(v.Name)
303 }
304 if changed {
305 m.notice("cleared authentication for " + v.Name + "; reconnect to authorize again")
306 } else {
307 m.notice("cleared local authentication state for " + v.Name)
308 }
309 return m, nil
310 }
311
312 func mcpModeIndex(tier string) int {
313 tier = normalizeMCPTierForCLI(tier)
314 for i, choice := range mcpTierChoices {
315 if choice == tier {
316 return i
317 }
318 }
319 return 0
320 }
321
322 func normalizeMCPTierForCLI(tier string) string {
323 switch strings.ToLower(strings.TrimSpace(tier)) {
324 case "eager":
325 return "eager"
326 case "background", "lazy":
327 return "background"
328 case "":
329 return "background"
330 default:
331 return "background"
332 }
333 }
334
335 type mcpEditConfigLaunch struct {
336 cmd *exec.Cmd
337 editor string
338 systemDefault bool
339 }
340
341 func mcpEditConfigLaunchCommand(path string, lookPath func(string) (string, error)) (mcpEditConfigLaunch, error) {
342 path = strings.TrimSpace(path)
343 if path == "" {
344 return mcpEditConfigLaunch{}, fmt.Errorf("no config path available")
345 }
346 if editor := strings.TrimSpace(os.Getenv("VISUAL")); editor != "" {
347 cmd, err := editorLaunchCmd(editor, path)
348 if err != nil {
349 return mcpEditConfigLaunch{}, err
350 }
351 return mcpEditConfigLaunch{
352 cmd: cmd,
353 editor: mcpEditorDisplayName(editor),
354 }, nil
355 }
356 if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" {
357 cmd, err := editorLaunchCmd(editor, path)
358 if err != nil {
359 return mcpEditConfigLaunch{}, err
360 }
361 return mcpEditConfigLaunch{
362 cmd: cmd,
363 editor: mcpEditorDisplayName(editor),
364 }, nil
365 }
366 if lookPath == nil {
367 lookPath = exec.LookPath
368 }
369 for _, editor := range []string{"vim", "vi", "nano"} {
370 if bin, err := lookPath(editor); err == nil && strings.TrimSpace(bin) != "" {
371 return mcpEditConfigLaunch{
372 cmd: exec.Command(bin, path),
373 editor: editor,
374 }, nil
375 }
376 }
377 cmd, err := mcpOpenCommand(path)
378 if err != nil {
379 return mcpEditConfigLaunch{}, err
380 }
381 return mcpEditConfigLaunch{cmd: cmd, systemDefault: true}, nil
382 }
383
384 func mcpEditorDisplayName(editor string) string {
385 fields, err := splitEditorCommand(os.ExpandEnv(editor))
386 if err != nil || len(fields) == 0 {
387 return ""
388 }
389 return fields[0]
390 }
391
392 func mcpOpenCommand(target string) (*exec.Cmd, error) {
393 target = strings.TrimSpace(target)
394 if target == "" {
395 return nil, fmt.Errorf("empty target")
396 }
397 switch runtime.GOOS {
398 case "darwin":
399 return exec.Command("open", target), nil
400 case "windows":
401 return exec.Command("rundll32", "url.dll,FileProtocolHandler", target), nil
402 default:
403 return exec.Command("xdg-open", target), nil
404 }
405 }
406
407 func mcpAuthStatus(v mcpServerView) string {
408 return mcpAuthDiagnosis(v).Status
409 }
410
411 func mcpAuthDiagnosis(v mcpServerView) mcpdiag.AuthDiagnosis {
412 var diagnosis mcpdiag.AuthDiagnosis
413 if v.AuthStatus != "" {
414 diagnosis = mcpdiag.AuthDiagnosis{Status: v.AuthStatus, URL: v.AuthURL}
415 } else {
416 diagnosis = mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.authConfigured)
417 }
418 if diagnosis.Status != mcpdiag.AuthNone && !mcpdiag.CanUseHTTPMCPOAuth(v.Transport, v.URL, v.authConfigured) {
419 return mcpdiag.AuthDiagnosis{Status: mcpdiag.AuthNone}
420 }
421 return diagnosis
422 }
423
424 func mcpCanClearAuth(v mcpServerView) bool {
425 if !v.Configured || v.BuiltIn {
426 return false
427 }
428 if v.authConfigured || mcpAuthStatus(v) != mcpdiag.AuthNone {
429 return true
430 }
431 return mcpdiag.IsRemoteTransport(v.Transport)
432 }
433
434 func mcpConnected(ctrl control.Capabilities, name string) bool {
435 if ctrl == nil || ctrl.Host() == nil {
436 return false
437 }
438 for _, s := range ctrl.Host().Servers() {
439 if s.Name == name {
440 return true
441 }
442 }
443 return false
444 }
445
446 // editorLaunchCmd builds an exec.Cmd for an editor invocation read from the
447 // VISUAL/EDITOR environment variable. The editor string may carry arguments
448 // (e.g. "code --wait", "nvim -p") and shell variable / tilde references
449 // (e.g. "$HOME/bin/myeditor", "~/bin/myeditor"); these are expanded without
450 // invoking a shell, and the editor binary is resolved by the OS directly.
451 // Shell metacharacters in the value cannot be executed: the expanded value must
452 // parse as one static shell command. Control operators, redirection,
453 // substitution, globbing, assignments, and other shell-shaping syntax are
454 // rejected before launch.
455 //
456 // This matches the safe pattern already used by the terminal-editor
457 // fallback (exec.Command(bin, path)) in the same function and avoids the
458 // previous sh -lc construction that concatenated the raw editor value into
459 // a shell command string.
460 //
461 // Quoting and backslash escaping are honored for word splitting only; shell
462 // operators, globbing, command substitution, and redirection are rejected.
463 // Tilde expansion only covers the leading-token forms "~" and "~/..."; "~user"
464 // is not supported (and was not reliably supported by the prior sh -lc path
465 // either, since $HOME for another user is not available without getpwuid).
466 func editorLaunchCmd(editor, path string) (*exec.Cmd, error) {
467 expanded := os.ExpandEnv(editor)
468 args, err := splitEditorCommand(expanded)
469 if err != nil {
470 return nil, fmt.Errorf("invalid EDITOR/VISUAL value: %w", err)
471 }
472 if len(args) == 0 {
473 return nil, fmt.Errorf("invalid EDITOR/VISUAL value: %q", editor)
474 }
475 args[0] = expandLeadingTilde(args[0])
476 return exec.Command(args[0], append(args[1:], path)...), nil
477 }
478
479 func splitEditorCommand(s string) ([]string, error) {
480 args, malformed := shellparse.StaticFields(s)
481 if malformed != "" {
482 return nil, fmt.Errorf("%s", malformed)
483 }
484 return args, nil
485 }
486
487 // expandLeadingTilde replaces a leading "~" or "~/" prefix with the current
488 // user's home directory. Other forms (e.g. "~user") are returned unchanged.
489 // If the home directory cannot be determined the value is returned as-is so
490 // the caller surfaces the exec failure rather than panicking.
491 func expandLeadingTilde(p string) string {
492 if p != "~" && !strings.HasPrefix(p, "~/") {
493 return p
494 }
495 home, err := os.UserHomeDir()
496 if err != nil {
497 return p
498 }
499 if p == "~" {
500 return home
501 }
502 return home + p[1:]
503 }
504
505 func mcpBoolPtr(v bool) *bool { return &v }
506
506 lines GO