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