返回 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 type mcpExternalDoneMsg struct {
94 label string
95 target string
96 server string
97 err error
98 }
99
100 var mcpTierChoices = []string{"background", "eager"}
101
102 func (m *chatTUI) openMCPManager(name string) {
103 m.mcp = &mcpManager{stage: mcpStageList, snapshot: m.buildMCPSnapshot()}
104 if name != "" {
105 m.mcp.selectName(name)
106 m.mcp.stage = mcpStageDetail
107 }
108 m.mcp.clamp()
109 }
110
111 func (m *chatTUI) refreshMCPManager() {
112 if m.mcp == nil {
113 return
114 }
115 m.mcp.snapshot = m.buildMCPSnapshot()
116 m.mcp.clamp()
117 }
118
119 func (m chatTUI) handleMCPManagerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
120 p := m.mcp
121 if p == nil {
122 return m, nil
123 }
124 switch msg.String() {
125 case "ctrl+c", "q":
126 m.mcp = nil
127 return m, nil
128 case "esc", "left", "h":
129 switch p.stage {
130 case mcpStageList:
131 m.mcp = nil
132 return m, nil
133 case mcpStageDetail:
134 p.stage = mcpStageList
135 p.action = 0
136 return m, nil
137 default:
138 p.stage = mcpStageDetail
139 p.action = 0
140 if p.name == "" {
141 p.stage = mcpStageList
142 }
143 return m, nil
144 }
145 }
146
147 switch p.stage {
148 case mcpStageList:
149 switch msg.String() {
150 case "up", "k":
151 if p.sel > 0 {
152 p.sel--
153 }
154 case "down", "j":
155 if p.sel < len(p.snapshot.servers)-1 {
156 p.sel++
157 }
158 case "r":
159 p.snapshot = m.buildMCPSnapshot()
160 case "enter", "right", "l":
161 if len(p.snapshot.servers) > 0 {
162 p.name = p.snapshot.servers[p.sel].Name
163 p.stage = mcpStageDetail
164 p.action = 0
165 }
166 }
167 case mcpStageDetail:
168 v, ok := p.selectedServer()
169 if !ok {
170 p.stage = mcpStageList
171 return m, nil
172 }
173 actions := mcpActionsFor(v, p.snapshot.configPath)
174 switch msg.String() {
175 case "up", "k":
176 if p.action > 0 {
177 p.action--
178 }
179 case "down", "j":
180 if p.action < len(actions)-1 {
181 p.action++
182 }
183 case "enter":
184 if len(actions) > 0 {
185 return m.applyMCPAction(v, actions[p.action].kind)
186 }
187 default:
188 if idx, ok := numberKeyIndex(msg.String(), len(actions)); ok {
189 p.action = idx
190 return m.applyMCPAction(v, actions[p.action].kind)
191 }
192 }
193 case mcpStageMode:
194 switch msg.String() {
195 case "up", "k":
196 if p.mode > 0 {
197 p.mode--
198 }
199 case "down", "j":
200 if p.mode < len(mcpTierChoices)-1 {
201 p.mode++
202 }
203 case "enter":
204 return m.applyMCPMode(mcpTierChoices[p.mode])
205 default:
206 if idx, ok := numberKeyIndex(msg.String(), len(mcpTierChoices)); ok {
207 p.mode = idx
208 return m.applyMCPMode(mcpTierChoices[p.mode])
209 }
210 }
211 case mcpStageConfirmRemove:
212 switch msg.String() {
213 case "up", "k", "down", "j":
214 if p.confirm == 0 {
215 p.confirm = 1
216 } else {
217 p.confirm = 0
218 }
219 case "y":
220 p.confirm = 0
221 return m.removeSelectedMCP()
222 case "n":
223 p.stage = mcpStageDetail
224 case "enter":
225 if p.confirm == 0 {
226 return m.removeSelectedMCP()
227 }
228 p.stage = mcpStageDetail
229 }
230 case mcpStageConfirmClearAuth:
231 switch msg.String() {
232 case "up", "k", "down", "j":
233 if p.confirm == 0 {
234 p.confirm = 1
235 } else {
236 p.confirm = 0
237 }
238 case "y":
239 p.confirm = 0
240 return m.clearSelectedMCPAuthentication()
241 case "n":
242 p.stage = mcpStageDetail
243 case "enter":
244 if p.confirm == 0 {
245 return m.clearSelectedMCPAuthentication()
246 }
247 p.stage = mcpStageDetail
248 }
249 }
250 return m, nil
251 }
252
253 func (p *mcpManager) clamp() {
254 if p.sel < 0 {
255 p.sel = 0
256 }
257 if n := len(p.snapshot.servers); n > 0 && p.sel >= n {
258 p.sel = n - 1
259 }
260 if p.name != "" {
261 p.selectName(p.name)
262 }
263 if p.action < 0 {
264 p.action = 0
265 }
266 if p.mode < 0 {
267 p.mode = 0
268 }
269 if p.mode >= len(mcpTierChoices) {
270 p.mode = len(mcpTierChoices) - 1
271 }
272 if p.confirm < 0 || p.confirm > 1 {
273 p.confirm = 0
274 }
275 }
276
277 func (p *mcpManager) selectName(name string) bool {
278 for i, s := range p.snapshot.servers {
279 if s.Name == name {
280 p.sel = i
281 p.name = name
282 return true
283 }
284 }
285 return false
286 }
287
288 func (p *mcpManager) selectedServer() (mcpServerView, bool) {
289 if p.name != "" {
290 for _, s := range p.snapshot.servers {
291 if s.Name == p.name {
292 return s, true
293 }
294 }
295 }
296 if p.sel >= 0 && p.sel < len(p.snapshot.servers) {
297 return p.snapshot.servers[p.sel], true
298 }
299 return mcpServerView{}, false
300 }
301
302 func (m chatTUI) buildMCPSnapshot() mcpSnapshot {
303 workspace := m.mcpWorkspaceRoot()
304 snap := mcpSnapshot{configPath: config.UserConfigPath()}
305 cfg, err := config.LoadForRoot(workspace)
306 if err != nil {
307 snap.err = err.Error()
308 }
309 configured := map[string]config.PluginEntry{}
310 var configuredEntries []config.PluginEntry
311 if cfg != nil {
312 configuredEntries = append(configuredEntries, cfg.Plugins...)
313 for _, p := range configuredEntries {
314 configured[p.Name] = p
315 }
316 }
317 seen := map[string]bool{}
318 if m.host != nil {
319 for _, s := range m.host.Servers() {
320 v := mcpServerView{
321 Name: s.Name, Transport: fallbackText(s.Transport, "stdio"), Status: "connected",
322 Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
323 HasTools: s.HasTools,
324 ToolList: append([]plugin.ToolInfo(nil), s.ToolList...),
325 }
326 if p, ok := configured[s.Name]; ok {
327 v = withMCPPluginConfig(v, p, workspace)
328 }
329 snap.servers = append(snap.servers, v)
330 seen[s.Name] = true
331 }
332 for _, f := range m.host.Failures() {
333 v := mcpServerView{
334 Name: f.Name, Transport: fallbackText(f.Transport, "stdio"), Status: "failed",
335 Error: f.Error,
336 }
337 if p, ok := configured[f.Name]; ok {
338 v = withMCPPluginConfig(v, p, workspace)
339 }
340 snap.servers = append(snap.servers, v)
341 seen[f.Name] = true
342 }
343 for _, name := range m.host.ConnectingServers() {
344 if seen[name] {
345 continue
346 }
347 v := mcpServerView{Name: name, Status: "initializing"}
348 if p, ok := configured[name]; ok {
349 v = withMCPPluginConfig(v, p, workspace)
350 }
351 snap.servers = append(snap.servers, v)
352 seen[name] = true
353 }
354 }
355 for _, p := range configuredEntries {
356 if seen[p.Name] {
357 continue
358 }
359 v := mcpServerView{Name: p.Name}
360 switch {
361 case m.mcpDisabled[p.Name] || !p.ShouldAutoStart():
362 v.Status = "disabled"
363 default:
364 v.Status = "deferred"
365 }
366 v = withMCPPluginConfig(v, p, workspace)
367 snap.servers = append(snap.servers, v)
368 seen[p.Name] = true
369 }
370 sort.SliceStable(snap.servers, func(i, j int) bool {
371 return mcpServerGroupRank(snap.servers[i]) < mcpServerGroupRank(snap.servers[j])
372 })
373 return snap
374 }
375
376 func (m chatTUI) mcpWorkspaceRoot() string {
377 if m.ctrl != nil && strings.TrimSpace(m.ctrl.WorkspaceRoot()) != "" {
378 return m.ctrl.WorkspaceRoot()
379 }
380 return mcpCLIWorkspaceRoot()
381 }
382
383 func withMCPPluginConfig(v mcpServerView, p config.PluginEntry, workspace string) mcpServerView {
384 transport := strings.ToLower(strings.TrimSpace(p.Type))
385 if transport == "" {
386 transport = "stdio"
387 }
388 v.Transport = transport
389 v.Configured = true
390 v.AutoStart = p.ShouldAutoStart()
391 v.Tier = p.ResolvedTier()
392 v.Command = p.Command
393 v.Args = append([]string(nil), p.Args...)
394 v.URL = p.URL
395 v.Source = p.Source
396 v.ConfigPath = config.MCPConfigPathForEntry(workspace, p)
397 v.authConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
398 if len(p.Env) > 0 {
399 v.EnvKeys = make([]string, 0, len(p.Env))
400 for k := range p.Env {
401 v.EnvKeys = append(v.EnvKeys, k)
402 }
403 sort.Strings(v.EnvKeys)
404 }
405 auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.authConfigured)
406 v.AuthStatus = auth.Status
407 v.AuthURL = auth.URL
408 return v
409 }
410
411 func mcpServerGroupRank(v mcpServerView) int {
412 switch {
413 case v.BuiltIn || v.Source == config.MCPSourcePluginPackage:
414 return 0
415 case v.Source.ProjectScoped():
416 return 1
417 default:
418 return 2
419 }
420 }
421
422 func visibleRange(total, sel, limit int) (int, int) {
423 if limit <= 0 || total <= limit {
424 return 0, total
425 }
426 if sel < 0 {
427 sel = 0
428 }
429 if sel >= total {
430 sel = total - 1
431 }
432 start := max(sel-limit/2, 0)
433 if start+limit > total {
434 start = total - limit
435 }
436 return start, start + limit
437 }
438
439 func numberKeyIndex(s string, limit int) (int, bool) {
440 if len(s) != 1 || s[0] < '1' || s[0] > '9' {
441 return 0, false
442 }
443 idx := int(s[0] - '1')
444 return idx, idx < limit
445 }
446
447 func fallbackText(s, fallback string) string {
448 if strings.TrimSpace(s) == "" {
449 return fallback
450 }
451 return s
452 }
453
454 func titleText(s string) string {
455 if s == "" {
456 return "MCP"
457 }
458 r, size := utf8.DecodeRuneInString(s)
459 if r == utf8.RuneError && size == 0 {
460 return s
461 }
462 return strings.ToUpper(string(r)) + s[size:]
463 }
464
464 lines GO