返回 DeepSeek-Reasonix
transport_sse_test.go
根目录 / internal / plugin / transport_sse_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "reasonix/internal/tool"
16 )
17
18 func TestLegacySSETransportSupportsRootsToolsAndProgress(t *testing.T) {
19 workspaceRoot := t.TempDir()
20 events := make(chan string, 16)
21 serverErr := make(chan error, 4)
22 toolListRefreshed := make(chan struct{}, 1)
23 var toolListCalls atomic.Int32
24 var state struct {
25 sync.Mutex
26 initializeID int
27 }
28
29 mux := http.NewServeMux()
30 mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
31 if r.Header.Get("Authorization") != "Bearer secret" {
32 http.Error(w, "missing auth", http.StatusUnauthorized)
33 return
34 }
35 flusher, ok := w.(http.Flusher)
36 if !ok {
37 http.Error(w, "streaming unsupported", http.StatusInternalServerError)
38 return
39 }
40 w.Header().Set("Content-Type", "text/event-stream")
41 w.WriteHeader(http.StatusOK)
42 _, _ = fmt.Fprint(w, "event: endpoint\ndata: /messages?session=test\n\n")
43 flusher.Flush()
44 for {
45 select {
46 case <-r.Context().Done():
47 return
48 case event := <-events:
49 _, _ = fmt.Fprint(w, event)
50 flusher.Flush()
51 }
52 }
53 })
54 mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
55 if r.Header.Get("Authorization") != "Bearer secret" || r.URL.Query().Get("session") != "test" {
56 http.Error(w, "missing auth or session", http.StatusUnauthorized)
57 return
58 }
59 var message struct {
60 ID json.RawMessage `json:"id"`
61 Method string `json:"method"`
62 Params json.RawMessage `json:"params"`
63 Result json.RawMessage `json:"result"`
64 }
65 if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
66 http.Error(w, err.Error(), http.StatusBadRequest)
67 return
68 }
69 emit := func(payload any) {
70 body, _ := json.Marshal(payload)
71 events <- "event: message\ndata: " + string(body) + "\n\n"
72 }
73 switch message.Method {
74 case "server/discover":
75 emit(map[string]any{"jsonrpc": "2.0", "id": message.ID, "error": map[string]any{
76 "code": -32601, "message": "Method not found",
77 }})
78 case "initialize":
79 var params struct {
80 Capabilities map[string]json.RawMessage `json:"capabilities"`
81 }
82 _ = json.Unmarshal(message.Params, &params)
83 if _, ok := params.Capabilities["roots"]; !ok {
84 serverErr <- fmt.Errorf("initialize capabilities = %v, want roots", params.Capabilities)
85 }
86 var initializeID int
87 _ = json.Unmarshal(message.ID, &initializeID)
88 state.Lock()
89 state.initializeID = initializeID
90 state.Unlock()
91 emit(map[string]any{"jsonrpc": "2.0", "id": "server-roots", "method": "roots/list"})
92 case "notifications/initialized":
93 case "tools/list":
94 if toolListCalls.Add(1) > 1 {
95 toolListRefreshed <- struct{}{}
96 }
97 var id int
98 _ = json.Unmarshal(message.ID, &id)
99 emit(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
100 "tools": []any{map[string]any{
101 "name": "work", "description": "Do work", "inputSchema": map[string]any{"type": "object"},
102 }},
103 }})
104 case "tools/call":
105 var id int
106 _ = json.Unmarshal(message.ID, &id)
107 var params struct {
108 Meta map[string]any `json:"_meta"`
109 }
110 _ = json.Unmarshal(message.Params, &params)
111 token, _ := params.Meta["progressToken"].(string)
112 if token == "" {
113 serverErr <- fmt.Errorf("tools/call missing progressToken: %s", message.Params)
114 }
115 emit(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
116 "progressToken": token, "progress": 1, "total": 2, "message": "Working",
117 }})
118 emit(map[string]any{"jsonrpc": "2.0", "method": "notifications/tools/list_changed"})
119 emit(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
120 "content": []any{map[string]any{"type": "text", "text": "done"}},
121 }})
122 case "":
123 if strings.TrimSpace(string(message.ID)) != `"server-roots"` {
124 serverErr <- fmt.Errorf("unexpected server response id %s", message.ID)
125 break
126 }
127 var result struct {
128 Roots []mcpRoot `json:"roots"`
129 }
130 _ = json.Unmarshal(message.Result, &result)
131 want := mcpRoots(workspaceRoot)
132 if len(result.Roots) != 1 || result.Roots[0] != want[0] {
133 serverErr <- fmt.Errorf("roots/list result = %+v, want %+v", result.Roots, want)
134 }
135 state.Lock()
136 initializeID := state.initializeID
137 state.Unlock()
138 emit(map[string]any{"jsonrpc": "2.0", "id": initializeID, "result": map[string]any{
139 "protocolVersion": testLegacyProtocolVersion,
140 "serverInfo": map[string]any{"name": "legacy", "version": "1"},
141 "capabilities": map[string]any{"tools": map[string]any{"listChanged": true}},
142 }})
143 default:
144 serverErr <- fmt.Errorf("unexpected method %q", message.Method)
145 }
146 w.WriteHeader(http.StatusAccepted)
147 })
148
149 server := httptest.NewServer(mux)
150 defer server.Close()
151 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
152 defer cancel()
153 host, tools, err := StartAll(ctx, []Spec{{
154 Name: "legacy",
155 Type: "sse",
156 URL: server.URL + "/sse",
157 Headers: map[string]string{"Authorization": "Bearer secret"},
158 WorkspaceRoot: workspaceRoot,
159 }})
160 if err != nil {
161 t.Fatalf("StartAll legacy SSE: %v", err)
162 }
163 defer host.Close()
164 if len(tools) != 1 || tools[0].Name() != "mcp__legacy__work" {
165 t.Fatalf("tools = %v", names(tools))
166 }
167 toolsChanged := make(chan struct{}, 1)
168 unsubscribe := host.SubscribeToolListChanges(ctx, func(spec Spec, tools []tool.Tool) {
169 if spec.Name == "legacy" && len(tools) == 1 {
170 toolsChanged <- struct{}{}
171 }
172 })
173 defer unsubscribe()
174
175 progress := make(chan string, 1)
176 toolCtx := tool.WithProgress(ctx, func(chunk string) { progress <- chunk })
177 result, err := tools[0].Execute(toolCtx, json.RawMessage(`{}`))
178 if err != nil || result != "done" {
179 t.Fatalf("Execute = %q, %v", result, err)
180 }
181 select {
182 case got := <-progress:
183 if got != "Working (1/2)\n" {
184 t.Fatalf("progress = %q", got)
185 }
186 case <-time.After(time.Second):
187 t.Fatal("legacy SSE progress was not routed")
188 }
189 select {
190 case <-toolListRefreshed:
191 case <-time.After(time.Second):
192 t.Fatal("legacy SSE tools/list_changed notification was not routed")
193 }
194 select {
195 case <-toolsChanged:
196 t.Fatal("unchanged tool catalog should not publish a registry update")
197 default:
198 }
199 select {
200 case err := <-serverErr:
201 t.Fatal(err)
202 default:
203 }
204 }
205
206 func TestLegacySSERejectsCrossOriginEndpoint(t *testing.T) {
207 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
208 w.Header().Set("Content-Type", "text/event-stream")
209 _, _ = fmt.Fprint(w, "event: endpoint\ndata: https://other.example/messages\n\n")
210 }))
211 defer server.Close()
212 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
213 defer cancel()
214 transport, err := newSSETransport(ctx, Spec{Name: "unsafe", URL: server.URL})
215 if err != nil {
216 t.Fatal(err)
217 }
218 defer transport.close()
219 _, err = transport.call(ctx, "initialize", map[string]any{})
220 if err == nil {
221 t.Fatal("cross-origin endpoint unexpectedly connected")
222 }
223 }
224
225 type acknowledgedSSEEvent struct {
226 payload string
227 done chan struct{}
228 }
229
230 func TestLegacySSEDisconnectRebuildsBeforeNextCall(t *testing.T) {
231 var connections atomic.Int32
232 var streamsMu sync.Mutex
233 streams := make(map[int]chan acknowledgedSSEEvent)
234 firstClosed := make(chan struct{})
235 var closeFirst sync.Once
236
237 mux := http.NewServeMux()
238 mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
239 generation := int(connections.Add(1))
240 events := make(chan acknowledgedSSEEvent, 8)
241 streamsMu.Lock()
242 streams[generation] = events
243 streamsMu.Unlock()
244 defer func() {
245 streamsMu.Lock()
246 delete(streams, generation)
247 streamsMu.Unlock()
248 }()
249
250 flusher, ok := w.(http.Flusher)
251 if !ok {
252 http.Error(w, "streaming unsupported", http.StatusInternalServerError)
253 return
254 }
255 w.Header().Set("Content-Type", "text/event-stream")
256 w.WriteHeader(http.StatusOK)
257 _, _ = fmt.Fprintf(w, "event: endpoint\ndata: /messages?generation=%d\n\n", generation)
258 flusher.Flush()
259 closeSignal := (<-chan struct{})(firstClosed)
260 if generation != 1 {
261 closeSignal = nil
262 }
263 for {
264 select {
265 case <-r.Context().Done():
266 return
267 case <-closeSignal:
268 return
269 case event := <-events:
270 _, _ = fmt.Fprint(w, event.payload)
271 flusher.Flush()
272 close(event.done)
273 }
274 }
275 })
276 mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
277 generation := 0
278 _, _ = fmt.Sscanf(r.URL.Query().Get("generation"), "%d", &generation)
279 streamsMu.Lock()
280 events := streams[generation]
281 streamsMu.Unlock()
282 if events == nil {
283 http.Error(w, "missing stream", http.StatusGone)
284 return
285 }
286 var request struct {
287 ID json.RawMessage `json:"id"`
288 Method string `json:"method"`
289 }
290 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
291 http.Error(w, "bad request", http.StatusBadRequest)
292 return
293 }
294 if len(request.ID) == 0 {
295 w.WriteHeader(http.StatusAccepted)
296 return
297 }
298 response := map[string]any{"jsonrpc": "2.0", "id": request.ID}
299 switch request.Method {
300 case "server/discover":
301 response["error"] = map[string]any{"code": -32601, "message": "Method not found"}
302 case "initialize":
303 response["result"] = map[string]any{
304 "protocolVersion": testLegacyProtocolVersion,
305 "serverInfo": map[string]any{"name": "disconnect", "version": "1"},
306 "capabilities": map[string]any{"tools": map[string]any{}},
307 }
308 case "tools/list":
309 response["result"] = map[string]any{"tools": []any{}}
310 default:
311 response["error"] = map[string]any{"code": -32601, "message": "Method not found"}
312 }
313 body, _ := json.Marshal(response)
314 event := acknowledgedSSEEvent{payload: "event: message\ndata: " + string(body) + "\n\n", done: make(chan struct{})}
315 select {
316 case events <- event:
317 case <-r.Context().Done():
318 return
319 }
320 select {
321 case <-event.done:
322 case <-r.Context().Done():
323 return
324 }
325 if generation == 1 && request.Method == "tools/list" {
326 closeFirst.Do(func() { close(firstClosed) })
327 }
328 w.WriteHeader(http.StatusAccepted)
329 })
330
331 server := httptest.NewServer(mux)
332 defer server.Close()
333 transport, err := newSSETransport(t.Context(), Spec{Name: "disconnect", Type: "sse", URL: server.URL + "/sse"})
334 if err != nil {
335 t.Fatal(err)
336 }
337 transport.reconnectDelays = []time.Duration{time.Millisecond}
338 defer transport.close()
339 initial, err := transport.acquire(t.Context())
340 if err != nil {
341 t.Fatal(err)
342 }
343
344 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
345 t.Fatalf("first tools/list: %v", err)
346 }
347 ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
348 defer cancel()
349 // The first POST returning does not mean the client has read SSE EOF.
350 // Wait for that actual connection boundary before testing a call after EOF;
351 // otherwise it can legitimately race the retired endpoint and receive 410.
352 ended := make(chan struct{})
353 go func() {
354 _ = initial.session.Wait()
355 close(ended)
356 }()
357 select {
358 case <-ended:
359 case <-ctx.Done():
360 t.Fatal("client did not observe the initial SSE stream ending")
361 }
362 if _, err := transport.call(ctx, "tools/list", map[string]any{}); err != nil {
363 t.Fatalf("tools/list after SSE EOF: %v", err)
364 }
365 if got := connections.Load(); got != 2 {
366 t.Fatalf("SSE connections = %d, want initial + one replacement", got)
367 }
368 }
369
369 lines GO