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