返回 DeepSeek-Reasonix
transport_http_compat_test.go
根目录 / internal / plugin / transport_http_compat_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/atomic"
11 "testing"
12 "time"
13 )
14
15 func TestHTTPTransportBufferedSubscriptionDoesNotBlockStartup(t *testing.T) {
16 listenStarted := make(chan struct{})
17 listenStopped := make(chan struct{})
18 var started atomic.Bool
19 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20 var req struct {
21 ID *int `json:"id"`
22 Method string `json:"method"`
23 }
24 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
25 http.Error(w, "bad body", http.StatusBadRequest)
26 return
27 }
28
29 switch req.Method {
30 case "server/discover":
31 writeHTTPRPCResult(w, req.ID, map[string]any{
32 "supportedVersions": []string{"2026-07-28"},
33 "capabilities": map[string]any{
34 "tools": map[string]any{"listChanged": true},
35 "resources": map[string]any{"listChanged": true},
36 },
37 "_meta": map[string]any{
38 "io.modelcontextprotocol/serverInfo": map[string]any{"name": "qmd-like", "version": "1"},
39 },
40 })
41 case "subscriptions/listen":
42 if started.CompareAndSwap(false, true) {
43 close(listenStarted)
44 }
45 // qmd 2.8.3 converts the infinite Web Response to an arrayBuffer
46 // before writing Node's response headers. Model that observable wire
47 // behavior: the request arrived, but no headers are ever flushed.
48 <-r.Context().Done()
49 close(listenStopped)
50 case "tools/list":
51 writeHTTPRPCResult(w, req.ID, map[string]any{"tools": []map[string]any{{
52 "name": "query",
53 "description": "Search local markdown.",
54 "inputSchema": map[string]any{"type": "object"},
55 }}})
56 default:
57 http.Error(w, "unknown method", http.StatusBadRequest)
58 }
59 }))
60 defer srv.Close()
61
62 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
63 defer cancel()
64 host, tools, err := StartAll(ctx, []Spec{{
65 Name: "qmd-like", Type: "http", URL: srv.URL, StartupTimeout: 750 * time.Millisecond,
66 }})
67 if err != nil {
68 t.Fatalf("StartAll: %v", err)
69 }
70 if len(tools) != 1 || tools[0].Name() != "mcp__qmd-like__query" {
71 host.Close()
72 t.Fatalf("tools = %v, want [mcp__qmd-like__query]", names(tools))
73 }
74 select {
75 case <-listenStarted:
76 case <-time.After(time.Second):
77 host.Close()
78 t.Fatal("subscriptions/listen was not attempted")
79 }
80
81 host.Close()
82 select {
83 case <-listenStopped:
84 case <-time.After(time.Second):
85 t.Fatal("buffered subscriptions/listen request survived host close")
86 }
87 }
88
89 func TestHTTPTransportAsyncSubscriptionStillRoutesNotifications(t *testing.T) {
90 notificationSent := make(chan struct{})
91 var sent atomic.Bool
92 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93 var req struct {
94 ID json.RawMessage `json:"id"`
95 Method string `json:"method"`
96 }
97 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
98 http.Error(w, "bad body", http.StatusBadRequest)
99 return
100 }
101
102 switch req.Method {
103 case "server/discover":
104 writeRawHTTPRPCResult(w, req.ID, map[string]any{
105 "supportedVersions": []string{"2026-07-28"},
106 "capabilities": map[string]any{
107 "tools": map[string]any{"listChanged": true},
108 },
109 "_meta": map[string]any{
110 "io.modelcontextprotocol/serverInfo": map[string]any{"name": "streaming", "version": "1"},
111 },
112 })
113 case "subscriptions/listen":
114 w.Header().Set("Content-Type", "text/event-stream")
115 w.WriteHeader(http.StatusOK)
116 ack, _ := json.Marshal(map[string]any{
117 "jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged",
118 "params": map[string]any{
119 "notifications": map[string]any{"toolsListChanged": true},
120 "_meta": map[string]any{"io.modelcontextprotocol/subscriptionId": json.RawMessage(req.ID)},
121 },
122 })
123 changed, _ := json.Marshal(map[string]any{
124 "jsonrpc": "2.0", "method": "notifications/tools/list_changed",
125 "params": map[string]any{
126 "_meta": map[string]any{"io.modelcontextprotocol/subscriptionId": json.RawMessage(req.ID)},
127 },
128 })
129 fmt.Fprintf(w, "event: message\ndata: %s\n\nevent: message\ndata: %s\n\n", ack, changed)
130 if flusher, ok := w.(http.Flusher); ok {
131 flusher.Flush()
132 }
133 if sent.CompareAndSwap(false, true) {
134 close(notificationSent)
135 }
136 <-r.Context().Done()
137 case "tools/list":
138 writeRawHTTPRPCResult(w, req.ID, map[string]any{"tools": []map[string]any{{
139 "name": "query", "inputSchema": map[string]any{"type": "object"},
140 }}})
141 default:
142 http.Error(w, "unknown method", http.StatusBadRequest)
143 }
144 }))
145 defer srv.Close()
146
147 transport, err := newHTTPTransport(Spec{Name: "streaming", Type: "http", URL: srv.URL})
148 if err != nil {
149 t.Fatal(err)
150 }
151 defer transport.close()
152 notificationReceived := make(chan json.RawMessage, 1)
153 unregister := transport.registerNotification("notifications/tools/list_changed", func(params json.RawMessage) {
154 notificationReceived <- params
155 })
156 defer unregister()
157
158 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
159 t.Fatalf("tools/list: %v", err)
160 }
161 select {
162 case <-notificationSent:
163 case <-time.After(time.Second):
164 t.Fatal("server did not flush the subscription notification")
165 }
166 select {
167 case params := <-notificationReceived:
168 if !strings.Contains(string(params), "subscriptionId") {
169 t.Fatalf("notification params = %s, want subscription metadata", params)
170 }
171 case <-time.After(time.Second):
172 t.Fatal("streamed tools/list_changed notification was not routed")
173 }
174 }
175
176 func TestHTTPTransportStatelessSubscription404KeepsSessionUsable(t *testing.T) {
177 listenServed := make(chan struct{})
178 var discoverCount atomic.Int32
179 var listCount atomic.Int32
180 var listenReported atomic.Bool
181 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182 if r.Method == http.MethodGet {
183 w.WriteHeader(http.StatusMethodNotAllowed)
184 return
185 }
186 var req struct {
187 ID json.RawMessage `json:"id"`
188 Method string `json:"method"`
189 }
190 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
191 http.Error(w, "bad body", http.StatusBadRequest)
192 return
193 }
194
195 switch req.Method {
196 case "server/discover":
197 discoverCount.Add(1)
198 writeRawHTTPRPCResult(w, req.ID, map[string]any{
199 "supportedVersions": []string{"2026-07-28"},
200 "capabilities": map[string]any{
201 "tools": map[string]any{"listChanged": true},
202 },
203 "_meta": map[string]any{
204 "io.modelcontextprotocol/serverInfo": map[string]any{"name": "stateless-404", "version": "1"},
205 },
206 })
207 case "subscriptions/listen":
208 w.Header().Set("Content-Type", "text/plain")
209 w.WriteHeader(http.StatusNotFound)
210 _, _ = w.Write([]byte("404 Not Found"))
211 if listenReported.CompareAndSwap(false, true) {
212 close(listenServed)
213 }
214 case "tools/list":
215 listCount.Add(1)
216 writeRawHTTPRPCResult(w, req.ID, map[string]any{"tools": []any{}})
217 default:
218 http.Error(w, "unknown method", http.StatusBadRequest)
219 }
220 }))
221 defer srv.Close()
222
223 transport, err := newHTTPTransport(Spec{Name: "stateless-404", Type: "http", URL: srv.URL})
224 if err != nil {
225 t.Fatal(err)
226 }
227 defer transport.close()
228 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
229 t.Fatalf("first tools/list: %v", err)
230 }
231 select {
232 case <-listenServed:
233 case <-time.After(time.Second):
234 t.Fatal("subscriptions/listen was not rejected")
235 }
236 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
237 t.Fatalf("tools/list after subscriptions/listen 404: %v", err)
238 }
239 if got := discoverCount.Load(); got != 1 {
240 t.Fatalf("server/discover count = %d, want one surviving stateless session", got)
241 }
242 if got := listCount.Load(); got != 2 {
243 t.Fatalf("tools/list count = %d, want 2", got)
244 }
245 transport.mu.Lock()
246 generations := transport.nextGeneration
247 transport.mu.Unlock()
248 if generations != 1 {
249 t.Fatalf("supervisor generations = %d, want no rebuild after optional subscription 404", generations)
250 }
251 }
252
253 func TestHTTPTransportPerCallJSONRPC4xxKeepsSession(t *testing.T) {
254 var discoverCount atomic.Int32
255 var toolCallCount atomic.Int32
256 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
257 if r.Method == http.MethodGet {
258 w.WriteHeader(http.StatusMethodNotAllowed)
259 return
260 }
261 var req struct {
262 ID json.RawMessage `json:"id"`
263 Method string `json:"method"`
264 }
265 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
266 http.Error(w, "bad body", http.StatusBadRequest)
267 return
268 }
269
270 switch req.Method {
271 case "server/discover":
272 discoverCount.Add(1)
273 writeRawHTTPRPCResult(w, req.ID, map[string]any{
274 "supportedVersions": []string{"2026-07-28"},
275 "capabilities": map[string]any{"tools": map[string]any{}},
276 "_meta": map[string]any{
277 "io.modelcontextprotocol/serverInfo": map[string]any{"name": "request-error", "version": "1"},
278 },
279 })
280 case "tools/list":
281 writeRawHTTPRPCResult(w, req.ID, map[string]any{"tools": []any{}})
282 case "subscriptions/listen":
283 // Keep the optional SEP-2575 listener out of this test's failure
284 // path so the HTTP 400 below is attributable to tools/call.
285 writeRawHTTPRPCResult(w, req.ID, map[string]any{})
286 case "tools/call":
287 toolCallCount.Add(1)
288 w.Header().Set("Content-Type", "application/json")
289 w.WriteHeader(http.StatusBadRequest)
290 _ = json.NewEncoder(w).Encode(map[string]any{
291 "jsonrpc": "2.0",
292 "id": req.ID,
293 "error": map[string]any{
294 "code": -32021, "message": "missing required client capability",
295 },
296 })
297 default:
298 http.Error(w, "unknown method", http.StatusBadRequest)
299 }
300 }))
301 defer srv.Close()
302
303 transport, err := newHTTPTransport(Spec{Name: "request-error", Type: "http", URL: srv.URL})
304 if err != nil {
305 t.Fatal(err)
306 }
307 defer transport.close()
308 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
309 t.Fatalf("initial tools/list: %v", err)
310 }
311 if _, err := transport.call(t.Context(), "tools/call", map[string]any{
312 "name": "requires-capability", "arguments": map[string]any{},
313 }); err == nil || !strings.Contains(err.Error(), "missing required client capability") {
314 t.Fatalf("tools/call error = %v, want the server's per-call JSON-RPC error", err)
315 }
316 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
317 t.Fatalf("tools/list after per-call HTTP 400: %v", err)
318 }
319 if got := discoverCount.Load(); got != 1 {
320 t.Fatalf("server/discover count = %d, want the original session to survive", got)
321 }
322 if got := toolCallCount.Load(); got != 1 {
323 t.Fatalf("tools/call count = %d, want no replay of a rejected writer", got)
324 }
325 transport.mu.Lock()
326 generations := transport.nextGeneration
327 transport.mu.Unlock()
328 if generations != 1 {
329 t.Fatalf("supervisor generations = %d, want no rebuild for a per-call rejection", generations)
330 }
331 }
332
333 func TestHTTPTransportUnsupportedProtocolClosesNegotiatedSession(t *testing.T) {
334 deleteReceived := make(chan struct{})
335 var deleteReported atomic.Bool
336 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
337 if r.Method == http.MethodDelete {
338 if got := r.Header.Get("Mcp-Session-Id"); got != "unsupported-session" {
339 t.Errorf("DELETE session ID = %q, want unsupported-session", got)
340 }
341 w.WriteHeader(http.StatusOK)
342 if deleteReported.CompareAndSwap(false, true) {
343 close(deleteReceived)
344 }
345 return
346 }
347 if r.Method == http.MethodGet {
348 w.WriteHeader(http.StatusMethodNotAllowed)
349 return
350 }
351 var req struct {
352 ID json.RawMessage `json:"id"`
353 Method string `json:"method"`
354 }
355 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
356 http.Error(w, "bad body", http.StatusBadRequest)
357 return
358 }
359
360 switch req.Method {
361 case "server/discover":
362 writeRawHTTPRPCError(w, req.ID, -32601, "method not found")
363 case "initialize":
364 w.Header().Set("Mcp-Session-Id", "unsupported-session")
365 writeRawHTTPRPCResult(w, req.ID, map[string]any{
366 "protocolVersion": "2099-01-01",
367 "serverInfo": map[string]any{"name": "unsupported", "version": "1"},
368 "capabilities": map[string]any{},
369 })
370 default:
371 http.Error(w, "unexpected method", http.StatusBadRequest)
372 }
373 }))
374 defer srv.Close()
375
376 transport, err := newHTTPTransport(Spec{Name: "unsupported", Type: "http", URL: srv.URL})
377 if err != nil {
378 t.Fatal(err)
379 }
380 defer transport.close()
381 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err == nil || !strings.Contains(err.Error(), "unsupported protocol version") {
382 t.Fatalf("tools/list error = %v, want unsupported protocol version", err)
383 }
384 select {
385 case <-deleteReceived:
386 case <-time.After(time.Second):
387 t.Fatal("failed protocol negotiation did not terminate the allocated server session")
388 }
389 }
390
391 func TestHTTPTransportLegacySessionlessJSONPostOnly(t *testing.T) {
392 var discoverCount atomic.Int32
393 var initializeCount atomic.Int32
394 var getCount atomic.Int32
395 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
396 if r.Method == http.MethodGet {
397 getCount.Add(1)
398 w.Header().Set("Allow", http.MethodPost)
399 w.WriteHeader(http.StatusMethodNotAllowed)
400 return
401 }
402 if r.Method != http.MethodPost {
403 http.Error(w, "POST only", http.StatusMethodNotAllowed)
404 return
405 }
406 var req struct {
407 ID json.RawMessage `json:"id"`
408 Method string `json:"method"`
409 }
410 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
411 http.Error(w, "bad body", http.StatusBadRequest)
412 return
413 }
414
415 switch req.Method {
416 case "server/discover":
417 discoverCount.Add(1)
418 writeRawHTTPRPCError(w, req.ID, -32601, "method not found")
419 case "initialize":
420 initializeCount.Add(1)
421 writeRawHTTPRPCResult(w, req.ID, map[string]any{
422 "protocolVersion": "2025-11-25",
423 "serverInfo": map[string]any{"name": "legacy-post", "version": "1"},
424 "capabilities": map[string]any{"tools": map[string]any{}},
425 })
426 case "notifications/initialized":
427 w.WriteHeader(http.StatusAccepted)
428 case "tools/list":
429 writeRawHTTPRPCResult(w, req.ID, map[string]any{"tools": []map[string]any{{
430 "name": "legacy_tool", "inputSchema": map[string]any{"type": "object"},
431 }}})
432 default:
433 http.Error(w, "unknown method", http.StatusBadRequest)
434 }
435 }))
436 defer srv.Close()
437
438 host, tools, err := StartAll(t.Context(), []Spec{{Name: "legacy-post", Type: "http", URL: srv.URL}})
439 if err != nil {
440 t.Fatalf("StartAll: %v", err)
441 }
442 defer host.Close()
443 if got := names(tools); len(got) != 1 || got[0] != "mcp__legacy-post__legacy_tool" {
444 t.Fatalf("tools = %v, want [mcp__legacy-post__legacy_tool]", got)
445 }
446 if got := discoverCount.Load(); got != 1 {
447 t.Fatalf("server/discover count = %d, want one bounded modern probe", got)
448 }
449 if got := initializeCount.Load(); got != 1 {
450 t.Fatalf("initialize count = %d, want one legacy fallback", got)
451 }
452 if got := getCount.Load(); got != 1 {
453 t.Fatalf("standalone GET count = %d, want one optional probe accepted as HTTP 405", got)
454 }
455 }
456
456 lines GO