返回 DeepSeek-Reasonix
mcp_manager.go
根目录 / internal / cli / mcp_manager.go
1 package cli
2
3 import (
4 "sort"
5 "strings"
6 "unicode/utf8"
7
8 tea "charm.land/bubbletea/v2"
9
10 "reasonix/internal/config"
11 "reasonix/internal/mcpdiag"
12 "reasonix/internal/plugin"
13 )
14
15 const (
16 mcpListMaxRows = 10
17 mcpToolMaxRows = 14
18 )
19
20 type mcpStage int
21
22 const (
23 mcpStageList mcpStage = iota
24 mcpStageDetail
25 mcpStageTools
26 mcpStageLogs
27 mcpStageMode
28 mcpStageConfirmRemove
29 mcpStageConfirmClearAuth
30 )
31
32 type mcpManager struct {
33 stage mcpStage
34 snapshot mcpSnapshot
35 sel int
36 name string
37 action int
38 mode int
39 confirm int
40 }
41
42 type mcpSnapshot struct {
43 servers []mcpServerView
44 configPath string
45 err string
46 }
47
48 type mcpServerView struct {
49 Name string
50 Transport string
51 Status string
52 BuiltIn bool
53 Configured bool
54 AutoStart bool
55 Tier string
56 Command string
57 Args []string
58 URL string
59 EnvKeys []string
60 Tools int
61 Prompts int
62 Resources int
63 HasTools bool
64 Error string
65 ToolList []plugin.ToolInfo
66 AuthStatus string
67 AuthURL string
68 Source config.MCPConfigSource
69 ConfigPath string
70
71 authConfigured bool
72 }
73
74 type mcpAction string
75
76 const (
77 mcpActionViewTools mcpAction = "view-tools"
78 mcpActionMode mcpAction = "mode"
79 mcpActionEdit mcpAction = "edit"
80 mcpActionConnect mcpAction = "connect"
81 mcpActionAuth mcpAction = "auth"
82 mcpActionClearAuth mcpAction = "clear-auth"
83 mcpActionLogs mcpAction = "logs"
84 mcpActionDisable mcpAction = "disable"
85 mcpActionRemove mcpAction = "remove"
86 )
87
88 type mcpActionItem struct {
89 kind mcpAction
90 label string
91 }
92
93 // mcpConnectCause is which request started a background connect; it decides
94 // the notice the result posts.
95 type mcpConnectCause int
96
97 const (
98 mcpConnectRetry mcpConnectCause = iota
99 mcpConnectMode
100 mcpConnectAuth
101 )
102
103 type mcpConnectDoneMsg struct {
104 server string
105 cause mcpConnectCause
106 entry *config.PluginEntry // the saved entry when cause is mcpConnectMode
107 tools int
108 err error
109 }
110
111 type mcpExternalDoneMsg struct {
112 label string
113 target string
114 server string
115 err error
116 }
117
118 var mcpTierChoices = []string{"background", "eager"}
119
120 func (m *chatTUI) openMCPManager(name string) {
121 m.mcp = &mcpManager{stage: mcpStageList, snapshot: m.buildMCPSnapshot()}
122 if name != "" {
123 m.mcp.selectName(name)
124 m.mcp.stage = mcpStageDetail
125 }
126 m.mcp.clamp()
127 }
128
129 func (m *chatTUI) refreshMCPManager() {
130 if m.mcp == nil {
131 return
132 }
133 m.mcp.snapshot = m.buildMCPSnapshot()
134 m.mcp.clamp()
135 }
136
137 func (m chatTUI) handleMCPManagerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
138 p := m.mcp
139 if p == nil {
140 return m, nil
141 }
142 switch msg.String() {
143 case "ctrl+c", "q":
144 m.mcp = nil
145 return m, nil
146 case "esc", "left", "h":
147 switch p.stage {
148 case mcpStageList:
149 m.mcp = nil
150 return m, nil
151 case mcpStageDetail:
152 p.stage = mcpStageList
153 p.action = 0
154 return m, nil
155 default:
156 p.stage = mcpStageDetail
157 p.action = 0
158 if p.name == "" {
159 p.stage = mcpStageList
160 }
161 return m, nil
162 }
163 }
164
165 switch p.stage {
166 case mcpStageList:
167 switch msg.String() {
168 case "up", "k":
169 if p.sel > 0 {
170 p.sel--
171 }
172 case "down", "j":
173 if p.sel < len(p.snapshot.servers)-1 {
174 p.sel++
175 }
176 case "r":
177 p.snapshot = m.buildMCPSnapshot()
178 case "enter", "right", "l":
179 if len(p.snapshot.servers) > 0 {
180 p.name = p.snapshot.servers[p.sel].Name
181 p.stage = mcpStageDetail
182 p.action = 0
183 }
184 }
185 case mcpStageDetail:
186 v, ok := p.selectedServer()
187 if !ok {
188 p.stage = mcpStageList
189 return m, nil
190 }
191 actions := mcpActionsFor(v, p.snapshot.configPath)
192 switch msg.String() {
193 case "up", "k":
194 if p.action > 0 {
195 p.action--
196 }
197 case "down", "j":
198 if p.action < len(actions)-1 {
199 p.action++
200 }
201 case "enter":
202 if len(actions) > 0 {
203 return m.applyMCPAction(v, actions[p.action].kind)
204 }
205 default:
206 if idx, ok := numberKeyIndex(msg.String(), len(actions)); ok {
207 p.action = idx
208 return m.applyMCPAction(v, actions[p.action].kind)
209 }
210 }
211 case mcpStageMode:
212 switch msg.String() {
213 case "up", "k":
214 if p.mode > 0 {
215 p.mode--
216 }
217 case "down", "j":
218 if p.mode < len(mcpTierChoices)-1 {
219 p.mode++
220 }
221 case "enter":
222 return m.applyMCPMode(mcpTierChoices[p.mode])
223 default:
224 if idx, ok := numberKeyIndex(msg.String(), len(mcpTierChoices)); ok {
225 p.mode = idx
226 return m.applyMCPMode(mcpTierChoices[p.mode])
227 }
228 }
229 case mcpStageConfirmRemove:
230 switch msg.String() {
231 case "up", "k", "down", "j":
232 if p.confirm == 0 {
233 p.confirm = 1
234 } else {
235 p.confirm = 0
236 }
237 case "y":
238 p.confirm = 0
239 return m.removeSelectedMCP()
240 case "n":
241 p.stage = mcpStageDetail
242 case "enter":
243 if p.confirm == 0 {
244 return m.removeSelectedMCP()
245 }
246 p.stage = mcpStageDetail
247 }
248 case mcpStageConfirmClearAuth:
249 switch msg.String() {
250 case "up", "k", "down", "j":
251 if p.confirm == 0 {
252 p.confirm = 1
253 } else {
254 p.confirm = 0
255 }
256 case "y":
257 p.confirm = 0
258 return m.clearSelectedMCPAuthentication()
259 case "n":
260 p.stage = mcpStageDetail
261 case "enter":
262 if p.confirm == 0 {
263 return m.clearSelectedMCPAuthentication()
264 }
265 p.stage = mcpStageDetail
266 }
267 }
268 return m, nil
269 }
270
271 func (p *mcpManager) clamp() {
272 if p.sel < 0 {
273 p.sel = 0
274 }
275 if n := len(p.snapshot.servers); n > 0 && p.sel >= n {
276 p.sel = n - 1
277 }
278 if p.name != "" {
279 p.selectName(p.name)
280 }
281 if p.action < 0 {
282 p.action = 0
283 }
284 if p.mode < 0 {
285 p.mode = 0
286 }
287 if p.mode >= len(mcpTierChoices) {
288 p.mode = len(mcpTierChoices) - 1
289 }
290 if p.confirm < 0 || p.confirm > 1 {
291 p.confirm = 0
292 }
293 }
294
295 func (p *mcpManager) selectName(name string) bool {
296 for i, s := range p.snapshot.servers {
297 if s.Name == name {
298 p.sel = i
299 p.name = name
300 return true
301 }
302 }
303 return false
304 }
305
306 func (p *mcpManager) selectedServer() (mcpServerView, bool) {
307 if p.name != "" {
308 for _, s := range p.snapshot.servers {
309 if s.Name == p.name {
310 return s, true
311 }
312 }
313 }
314 if p.sel >= 0 && p.sel < len(p.snapshot.servers) {
315 return p.snapshot.servers[p.sel], true
316 }
317 return mcpServerView{}, false
318 }
319
320 func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
321 workspace := m.mcpWorkspaceRoot()
322 snap := mcpSnapshot{configPath: config.UserConfigPath()}
323 cfg, err := config.LoadForRoot(workspace)
324 if err != nil {
325 snap.err = err.Error()
326 }
327 configured := map[string]config.PluginEntry{}
328 var configuredEntries []config.PluginEntry
329 if cfg != nil {
330 configuredEntries = append(configuredEntries, cfg.Plugins...)
331 for _, p := range configuredEntries {
332 configured[p.Name] = p
333 }
334 }
335 seen := map[string]bool{}
336 if m.host != nil {
337 for _, s := range m.host.Servers() {
338 v := mcpServerView{
339 Name: s.Name, Transport: fallbackText(s.Transport, "stdio"), Status: "connected",
340 Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
341 HasTools: s.HasTools,
342 ToolList: append([]plugin.ToolInfo(nil), s.ToolList...),
343 }
344 if p, ok := configured[s.Name]; ok {
345 v = withMCPPluginConfig(v, p, workspace)
346 }
347 snap.servers = append(snap.servers, v)
348 seen[s.Name] = true
349 }
350 for _, f := range m.host.Failures() {
351 v := mcpServerView{
352 Name: f.Name, Transport: fallbackText(f.Transport, "stdio"), Status: "failed",
353 Error: f.Error,
354 }
355 if p, ok := configured[f.Name]; ok {
356 v = withMCPPluginConfig(v, p, workspace)
357 }
358 snap.servers = append(snap.servers, v)
359 seen[f.Name] = true
360 }
361 for _, name := range m.host.ConnectingServers() {
362 if seen[name] {
363 continue
364 }
365 v := mcpServerView{Name: name, Status: "initializing"}
366 if p, ok := configured[name]; ok {
367 v = withMCPPluginConfig(v, p, workspace)
368 }
369 snap.servers = append(snap.servers, v)
370 seen[name] = true
371 }
372 }
373 for _, p := range configuredEntries {
374 if seen[p.Name] {
375 continue
376 }
377 v := mcpServerView{Name: p.Name}
378 switch {
379 case m.mcpDisabled[p.Name] || !config.MCPServerEnabled(p, workspace):
380 v.Status = "disabled"
381 default:
382 v.Status = "deferred"
383 }
384 v = withMCPPluginConfig(v, p, workspace)
385 snap.servers = append(snap.servers, v)
386 seen[p.Name] = true
387 }
388 sort.SliceStable(snap.servers, func(i, j int) bool {
389 return mcpServerGroupRank(snap.servers[i]) < mcpServerGroupRank(snap.servers[j])
390 })
391 return snap
392 }
393
394 func (m chatTUI) mcpWorkspaceRoot() string {
395 if m.ctrl != nil && strings.TrimSpace(m.ctrl.WorkspaceRoot()) != "" {
396 return m.ctrl.WorkspaceRoot()
397 }
398 return mcpCLIWorkspaceRoot()
399 }
400
401 func withMCPPluginConfig(v mcpServerView, p config.PluginEntry, workspace string) mcpServerView {
402 transport := strings.ToLower(strings.TrimSpace(p.Type))
403 if transport == "" {
404 transport = "stdio"
405 }
406 v.Transport = transport
407 v.Configured = true
408 v.AutoStart = p.ShouldAutoStart()
409 v.Tier = p.ResolvedTier()
410 v.Command = p.Command
411 v.Args = append([]string(nil), p.Args...)
412 v.URL = p.URL
413 v.Source = p.Source
414 v.ConfigPath = config.MCPConfigPathForEntry(workspace, p)
415 v.authConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
416 if len(p.Env) > 0 {
417 v.EnvKeys = make([]string, 0, len(p.Env))
418 for k := range p.Env {
419 v.EnvKeys = append(v.EnvKeys, k)
420 }
421 sort.Strings(v.EnvKeys)
422 }
423 auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.authConfigured)
424 v.AuthStatus = auth.Status
425 v.AuthURL = auth.URL
426 return v
427 }
428
429 func mcpServerGroupRank(v mcpServerView) int {
430 switch {
431 case v.BuiltIn || v.Source == config.MCPSourcePluginPackage:
432 return 0
433 case v.Source.ProjectScoped():
434 return 1
435 default:
436 return 2
437 }
438 }
439
440 func visibleRange(total, sel, limit int) (int, int) {
441 if limit <= 0 || total <= limit {
442 return 0, total
443 }
444 if sel < 0 {
445 sel = 0
446 }
447 if sel >= total {
448 sel = total - 1
449 }
450 start := max(sel-limit/2, 0)
451 if start+limit > total {
452 start = total - limit
453 }
454 return start, start + limit
455 }
456
457 func numberKeyIndex(s string, limit int) (int, bool) {
458 if len(s) != 1 || s[0] < '1' || s[0] > '9' {
459 return 0, false
460 }
461 idx := int(s[0] - '1')
462 return idx, idx < limit
463 }
464
465 func fallbackText(s, fallback string) string {
466 if strings.TrimSpace(s) == "" {
467 return fallback
468 }
469 return s
470 }
471
472 func titleText(s string) string {
473 if s == "" {
474 return "MCP"
475 }
476 r, size := utf8.DecodeRuneInString(s)
477 if r == utf8.RuneError && size == 0 {
478 return s
479 }
480 return strings.ToUpper(string(r)) + s[size:]
481 }
482
482 lines GO