返回 DeepSeek-Reasonix
mcp_manager_connect_test.go
根目录 / internal / cli / mcp_manager_connect_test.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/config"
15 "reasonix/internal/control"
16 "reasonix/internal/event"
17 "reasonix/internal/plugin"
18 )
19
20 // gatedMCPServer answers MCP over HTTP but holds initialize until release is
21 // closed, and reports on started when the handshake reaches it.
22 func gatedMCPServer(t *testing.T) (url string, started <-chan struct{}, release func()) {
23 t.Helper()
24 gate := make(chan struct{})
25 reached := make(chan struct{})
26 var reachedOnce, releaseOnce sync.Once
27 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 var req struct {
29 ID json.RawMessage `json:"id"`
30 Method string `json:"method"`
31 }
32 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
33 http.Error(w, "bad request", http.StatusBadRequest)
34 return
35 }
36 if len(req.ID) == 0 || string(req.ID) == "null" {
37 w.WriteHeader(http.StatusAccepted)
38 return
39 }
40 result := any(map[string]any{})
41 switch req.Method {
42 case "initialize":
43 reachedOnce.Do(func() { close(reached) })
44 select {
45 case <-gate:
46 case <-r.Context().Done():
47 return
48 }
49 result = map[string]any{"protocolVersion": "2025-03-26", "serverInfo": map[string]any{"name": "docs", "version": "1"}}
50 case "tools/list":
51 result = map[string]any{"tools": []map[string]any{{"name": "search", "description": "Search.", "inputSchema": map[string]any{"type": "object"}}}}
52 }
53 w.Header().Set("Content-Type", "application/json")
54 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
55 }))
56 release = func() { releaseOnce.Do(func() { close(gate) }) }
57 t.Cleanup(srv.Close)
58 t.Cleanup(release)
59 return srv.URL, reached, release
60 }
61
62 func writeMCPWorkspace(t *testing.T, root, url string) {
63 t.Helper()
64 raw := minimalTestModelTOML
65 if url != "" {
66 raw += `
67 [[plugins]]
68 name = "docs"
69 type = "http"
70 url = "` + url + `"
71 auto_start = false
72 startup_timeout_seconds = 10
73 `
74 }
75 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(raw), 0o644); err != nil {
76 t.Fatal(err)
77 }
78 }
79
80 func newMCPConnectModel(t *testing.T, url string) (chatTUI, control.SessionAPI, string) {
81 t.Helper()
82 isolateCLIConfigHome(t)
83 root := t.TempDir()
84 writeMCPWorkspace(t, root, url)
85 ctrl, err := setupProfile(context.Background(), "", 0, false, event.Discard, root)
86 if err != nil {
87 t.Fatalf("setupProfile: %v", err)
88 }
89 t.Cleanup(func() { ctrl.Close() })
90 model := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
91 model.mcp = &mcpManager{stage: mcpStageDetail, name: "docs"}
92 return model, ctrl, root
93 }
94
95 func runConnect(t *testing.T, cmd func() any) <-chan mcpConnectDoneMsg {
96 t.Helper()
97 out := make(chan mcpConnectDoneMsg, 1)
98 go func() {
99 done, _ := cmd().(mcpConnectDoneMsg)
100 out <- done
101 }()
102 return out
103 }
104
105 func TestMCPManagerRetryDoesNotBlockTheUILoop(t *testing.T) {
106 url, started, release := gatedMCPServer(t)
107 model, ctrl, _ := newMCPConnectModel(t, url)
108 view := mcpServerView{Name: "docs", Status: "failed"}
109
110 begin := time.Now()
111 next, cmd := model.applyMCPAction(view, mcpActionConnect)
112 if elapsed := time.Since(begin); elapsed > time.Second {
113 t.Fatalf("Retry held the UI loop for %v while the server was unresponsive", elapsed)
114 }
115 if cmd == nil {
116 t.Fatal("Retry returned no command to run the connect off the UI loop")
117 }
118 model = next.(chatTUI)
119 result := runConnect(t, func() any { return cmd() })
120 <-started
121
122 model.mcp = nil
123 model.openMCPManager("docs")
124 if _, again := model.applyMCPAction(view, mcpActionConnect); again != nil {
125 t.Fatal("reopening /mcp let a second Retry start another connect while one was in flight")
126 }
127 next, _ = model.applyMCPAction(view, mcpActionDisable)
128 model = next.(chatTUI)
129 if model.mcpDisabled["docs"] {
130 t.Fatal("Disable ran while the connect was in flight")
131 }
132 next, _ = model.applyMCPAction(view, mcpActionRemove)
133 model = next.(chatTUI)
134 if model.mcp.stage == mcpStageConfirmRemove {
135 t.Fatal("Remove opened its confirmation while the connect was in flight")
136 }
137 model.mcp.stage = mcpStageList
138
139 release()
140 done := <-result
141 if done.server != "docs" || done.err != nil {
142 t.Fatalf("connect result = %+v, want success for docs", done)
143 }
144 next, _ = model.Update(done)
145 model = next.(chatTUI)
146 if model.mcpConnecting["docs"] {
147 t.Fatal("connect result did not clear the in-flight marker")
148 }
149 if model.mcp.stage != mcpStageList {
150 t.Fatalf("connect result moved the manager to stage %v", model.mcp.stage)
151 }
152 if !mcpConnected(ctrl, "docs") {
153 t.Fatal("server is not connected after a successful Retry")
154 }
155 next, _ = model.applyMCPAction(mcpServerView{Name: "docs", Status: "connected"}, mcpActionDisable)
156 if model = next.(chatTUI); !model.mcpDisabled["docs"] || mcpConnected(ctrl, "docs") {
157 t.Fatal("Disable after the connect finished did not take effect")
158 }
159 }
160
161 func TestMCPConnectFinishingAfterRemovalIsDropped(t *testing.T) {
162 url, started, release := gatedMCPServer(t)
163 model, ctrl, root := newMCPConnectModel(t, url)
164
165 next, cmd := model.applyMCPAction(mcpServerView{Name: "docs", Status: "failed"}, mcpActionConnect)
166 model = next.(chatTUI)
167 result := runConnect(t, func() any { return cmd() })
168 <-started
169 writeMCPWorkspace(t, root, "")
170 release()
171
172 done := <-result
173 if done.err != nil {
174 t.Fatalf("connect: %v", done.err)
175 }
176 next, _ = model.Update(done)
177 model = next.(chatTUI)
178 if mcpConnected(ctrl, "docs") {
179 t.Fatal("a server removed from config while connecting stayed live")
180 }
181 if model.mcpConnecting["docs"] {
182 t.Fatal("in-flight marker survived the dropped connect")
183 }
184 }
185
186 func TestApplyMCPModeRecordsPluginConnectFailure(t *testing.T) {
187 isolateUserConfig(t)
188 t.Setenv("PATH", "")
189 cfg := config.Default()
190 cfg.Plugins = []config.PluginEntry{{Name: "broken", Command: "definitely-missing-reasonix-mcp", Tier: "background"}}
191 if err := cfg.SaveTo("reasonix.toml"); err != nil {
192 t.Fatalf("save config: %v", err)
193 }
194
195 m := newTestChatTUI()
196 m.ctrl = newOwnedTestController(t, control.Options{Host: plugin.NewHost()})
197 defer m.ctrl.Close()
198 m.host = m.ctrl.Host()
199 m.mcp = &mcpManager{
200 stage: mcpStageMode,
201 name: "broken",
202 snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{
203 Name: "broken", Transport: "stdio", Status: "deferred", Configured: true, Tier: "background",
204 }}},
205 }
206
207 next, cmd := m.applyMCPMode("background")
208 if cmd == nil {
209 t.Fatal("mode change started no connect")
210 }
211 m = next.(chatTUI)
212 m.handleMCPConnectDone(cmd().(mcpConnectDoneMsg))
213
214 failures := m.ctrl.Host().Failures()
215 if len(failures) != 1 || failures[0].Name != "broken" {
216 t.Fatalf("Host.Failures() = %+v, want broken failure", failures)
217 }
218 v, ok := m.mcp.selectedServer()
219 if !ok {
220 t.Fatal("selected server missing after refresh")
221 }
222 if v.Status != "failed" {
223 t.Fatalf("server status = %q, want failed; server = %+v", v.Status, v)
224 }
225 }
226
226 lines GO