返回 DeepSeek-Reasonix
transport_http_test.go
根目录 / internal / plugin / transport_http_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strings"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "reasonix/internal/tool"
16 )
17
18 // mcpHTTPServer is a minimal Streamable HTTP MCP server for tests. When sse is
19 // true it replies as text/event-stream (prefixing a server notification event
20 // to prove the client skips non-matching messages); otherwise application/json.
21 // It assigns a session id on initialize and fails any later request that
22 // doesn't echo it, and requires the Authorization header — so the test proves
23 // session + header plumbing, not just the happy path.
24 func mcpHTTPServer(t *testing.T, sse bool) *httptest.Server {
25 t.Helper()
26 const sessionID = "sess-xyz"
27 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 if got := r.Header.Get("Authorization"); got != "Bearer secret" {
29 http.Error(w, "missing auth", http.StatusUnauthorized)
30 return
31 }
32 var req struct {
33 ID *int `json:"id"`
34 Method string `json:"method"`
35 Params json.RawMessage `json:"params"`
36 }
37 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
38 http.Error(w, "bad body", http.StatusBadRequest)
39 return
40 }
41
42 if req.Method == "initialize" {
43 w.Header().Set("Mcp-Session-Id", sessionID)
44 } else if got := r.Header.Get("Mcp-Session-Id"); got != sessionID {
45 http.Error(w, "missing session id", http.StatusBadRequest)
46 return
47 }
48
49 if req.ID == nil { // notification
50 w.WriteHeader(http.StatusAccepted)
51 return
52 }
53
54 var result any
55 progressToken := ""
56 switch req.Method {
57 case "initialize":
58 result = map[string]any{"protocolVersion": testLegacyProtocolVersion, "serverInfo": map[string]any{"name": "h", "version": "0"}}
59 case "tools/list":
60 result = map[string]any{"tools": []map[string]any{{
61 "name": "greet",
62 "description": "Greet someone.",
63 "inputSchema": map[string]any{"type": "object"},
64 "annotations": map[string]any{"readOnlyHint": true, "destructiveHint": true},
65 }}}
66 case "tools/call":
67 var p struct {
68 Meta map[string]any `json:"_meta"`
69 Arguments struct {
70 Name string `json:"name"`
71 } `json:"arguments"`
72 }
73 _ = json.Unmarshal(req.Params, &p)
74 progressToken, _ = p.Meta["progressToken"].(string)
75 result = map[string]any{"content": []map[string]any{{"type": "text", "text": "hello " + p.Arguments.Name}}}
76 }
77 resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
78 b, _ := json.Marshal(resp)
79
80 if sse {
81 w.Header().Set("Content-Type", "text/event-stream")
82 // A server notification first: the client must skip it and keep
83 // reading for the id-matching response.
84 fmt.Fprint(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{}}\n\n")
85 if progressToken != "" {
86 progress, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
87 "progressToken": progressToken, "progress": 3, "total": 4, "message": "Streaming",
88 }})
89 fmt.Fprintf(w, "event: message\ndata: %s\n\n", progress)
90 }
91 fmt.Fprintf(w, "event: message\ndata: %s\n\n", b)
92 return
93 }
94 w.Header().Set("Content-Type", "application/json")
95 _, _ = w.Write(b)
96 }))
97 }
98
99 func runHTTPTransportTest(t *testing.T, sse bool) {
100 srv := mcpHTTPServer(t, sse)
101 defer srv.Close()
102
103 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
104 defer cancel()
105
106 host, tools, err := StartAll(ctx, []Spec{{
107 Name: "h",
108 Type: "http",
109 URL: srv.URL,
110 Headers: map[string]string{"Authorization": "Bearer secret"},
111 }})
112 if err != nil {
113 t.Fatalf("StartAll: %v", err)
114 }
115 defer host.Close()
116
117 if len(tools) != 1 || tools[0].Name() != "mcp__h__greet" {
118 t.Fatalf("tools = %v, want [mcp__h__greet]", names(tools))
119 }
120 if !tools[0].ReadOnly() {
121 t.Error("readOnlyHint not honoured over HTTP")
122 }
123 annotations, ok := tools[0].(tool.MCPAnnotations)
124 if !ok || !annotations.MCPDestructiveHint() {
125 t.Error("destructiveHint not honoured over HTTP")
126 }
127 progress := make(chan string, 1)
128 executeCtx := tool.WithProgress(ctx, func(chunk string) { progress <- chunk })
129 got, err := tools[0].Execute(executeCtx, json.RawMessage(`{"name":"sam"}`))
130 if err != nil {
131 t.Fatalf("Execute: %v", err)
132 }
133 if got != "hello sam" {
134 t.Errorf("Execute = %q, want %q", got, "hello sam")
135 }
136 if sse {
137 select {
138 case chunk := <-progress:
139 if chunk != "Streaming (3/4)\n" {
140 t.Fatalf("progress = %q", chunk)
141 }
142 case <-time.After(time.Second):
143 t.Fatal("Streamable HTTP progress notification was not routed")
144 }
145 }
146 }
147
148 func TestHTTPTransportJSON(t *testing.T) { runHTTPTransportTest(t, false) }
149 func TestHTTPTransportSSE(t *testing.T) { runHTTPTransportTest(t, true) }
150
151 func TestHTTPTransportDoesNotRedirectCredentialsAcrossOrigins(t *testing.T) {
152 var targetCalls atomic.Int32
153 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154 targetCalls.Add(1)
155 if got := r.Header.Get("X-API-Key"); got != "" {
156 t.Errorf("redirect target received credential header %q", got)
157 }
158 w.WriteHeader(http.StatusOK)
159 }))
160 defer target.Close()
161 source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
162 http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
163 }))
164 defer source.Close()
165
166 transport, err := newHTTPTransport(Spec{
167 Name: "redirect", Type: "http", URL: source.URL,
168 Headers: map[string]string{"X-API-Key": "secret"},
169 })
170 if err != nil {
171 t.Fatal(err)
172 }
173 resp, err := transport.do(context.Background(), []byte(`{}`))
174 if err != nil {
175 t.Fatal(err)
176 }
177 defer resp.Body.Close()
178 if resp.StatusCode != http.StatusTemporaryRedirect {
179 t.Fatalf("cross-origin redirect status = %d, want %d", resp.StatusCode, http.StatusTemporaryRedirect)
180 }
181 if targetCalls.Load() != 0 {
182 t.Fatalf("cross-origin redirect target received %d requests", targetCalls.Load())
183 }
184 }
185
186 func TestHTTPTransportDeleteHasBoundedCleanupContext(t *testing.T) {
187 origin, err := url.Parse("https://mcp.example.test/stream")
188 if err != nil {
189 t.Fatal(err)
190 }
191 roundTripper := &sameOriginMCPRoundTripper{
192 origin: origin,
193 headers: map[string]string{
194 "IJ_MCP_SERVER_PROJECT_PATH": "/redacted/project",
195 },
196 base: roundTripFunc(func(request *http.Request) (*http.Response, error) {
197 if request.Header.Get("IJ_MCP_SERVER_PROJECT_PATH") == "" || request.Header.Get("Mcp-Session-Id") == "" {
198 t.Error("DELETE did not carry the configured and session headers")
199 }
200 <-request.Context().Done()
201 return nil, request.Context().Err()
202 }),
203 }
204 request, err := http.NewRequest(http.MethodDelete, origin.String(), nil)
205 if err != nil {
206 t.Fatal(err)
207 }
208 request.Header.Set("Mcp-Session-Id", "must-not-be-logged")
209 started := time.Now()
210 if _, err := roundTripper.RoundTrip(request); err == nil {
211 t.Fatal("hanging DELETE unexpectedly succeeded")
212 }
213 if elapsed := time.Since(started); elapsed > 2500*time.Millisecond {
214 t.Fatalf("hanging DELETE returned after %s, want <=2.5s", elapsed)
215 }
216 }
217
218 func TestHTTPTransportDoesNotLoadOAuthStateWithStaticAPIKey(t *testing.T) {
219 stateDir := t.TempDir()
220 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
221 if got := r.Header.Get("X-API-Key"); got != "configured" {
222 t.Errorf("API key = %q, want configured", got)
223 }
224 if got := r.Header.Get("Authorization"); got != "" {
225 t.Errorf("stale OAuth authorization leaked alongside static credentials: %q", got)
226 }
227 w.Header().Set("Content-Type", "application/json")
228 _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`))
229 }))
230 defer server.Close()
231 if err := saveMCPOAuthState(stateDir, mcpOAuthState{
232 Version: 1, Resource: server.URL, AccessToken: "stale-oauth", TokenType: "Bearer",
233 }); err != nil {
234 t.Fatal(err)
235 }
236 transport, err := newHTTPTransport(Spec{
237 Name: "remote", Type: "http", URL: server.URL, StateDir: stateDir,
238 Headers: map[string]string{"X-API-Key": "configured"},
239 })
240 if err != nil {
241 t.Fatal(err)
242 }
243 defer transport.close()
244 if transport.oauth != nil {
245 t.Fatal("static authentication must disable Reasonix OAuth state")
246 }
247 resp, err := transport.do(context.Background(), []byte(`{}`))
248 if err != nil {
249 t.Fatal(err)
250 }
251 defer resp.Body.Close()
252 if resp.StatusCode != http.StatusOK {
253 t.Fatalf("transport status = %d, want 200", resp.StatusCode)
254 }
255 }
256
257 func TestHTTPTransportReinitializesExpiredSession(t *testing.T) {
258 var initializeCount atomic.Int32
259 var toolCallCount atomic.Int32
260
261 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
262 var req struct {
263 ID *int `json:"id"`
264 Method string `json:"method"`
265 Params json.RawMessage `json:"params"`
266 }
267 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
268 http.Error(w, "bad body", http.StatusBadRequest)
269 return
270 }
271
272 if req.Method == "initialize" {
273 n := initializeCount.Add(1)
274 w.Header().Set("Mcp-Session-Id", fmt.Sprintf("sess-%d", n))
275 writeHTTPRPCResult(w, req.ID, map[string]any{
276 "protocolVersion": testLegacyProtocolVersion,
277 "serverInfo": map[string]any{"name": "h", "version": "0"},
278 })
279 return
280 }
281
282 expectedSession := fmt.Sprintf("sess-%d", initializeCount.Load())
283 if got := r.Header.Get("Mcp-Session-Id"); got != expectedSession {
284 http.Error(w, "missing session id", http.StatusBadRequest)
285 return
286 }
287
288 if req.ID == nil { // notifications/initialized
289 w.WriteHeader(http.StatusAccepted)
290 return
291 }
292
293 switch req.Method {
294 case "tools/list":
295 writeHTTPRPCResult(w, req.ID, map[string]any{"tools": []map[string]any{{
296 "name": "greet",
297 "description": "Greet someone.",
298 "inputSchema": map[string]any{"type": "object"},
299 }}})
300 case "tools/call":
301 n := toolCallCount.Add(1)
302 if n == 1 {
303 w.Header().Set("Content-Type", "application/json")
304 w.WriteHeader(http.StatusNotFound)
305 fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32001,"message":"Session not found"}}`, *req.ID)
306 return
307 }
308 if got := r.Header.Get("Mcp-Session-Id"); got != "sess-2" {
309 http.Error(w, "retry did not use the new session", http.StatusBadRequest)
310 return
311 }
312 writeHTTPRPCResult(w, req.ID, map[string]any{
313 "content": []map[string]any{{"type": "text", "text": "hello retry"}},
314 })
315 default:
316 http.Error(w, "unknown method", http.StatusBadRequest)
317 }
318 }))
319 defer srv.Close()
320
321 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
322 defer cancel()
323
324 host, tools, err := StartAll(ctx, []Spec{{Name: "h", Type: "http", URL: srv.URL}})
325 if err != nil {
326 t.Fatalf("StartAll: %v", err)
327 }
328 defer host.Close()
329 host.mu.RLock()
330 client := host.clients[0]
331 host.mu.RUnlock()
332
333 done := make(chan struct{})
334 readerDone := make(chan struct{})
335 go func() {
336 defer close(readerDone)
337 for {
338 select {
339 case <-done:
340 return
341 default:
342 _, _ = client.capabilities.prompts, client.capabilities.resources
343 }
344 }
345 }()
346 defer func() {
347 close(done)
348 <-readerDone
349 }()
350
351 got, err := tools[0].Execute(ctx, json.RawMessage(`{"name":"sam"}`))
352 if err != nil {
353 t.Fatalf("Execute after expired session: %v", err)
354 }
355 if got != "hello retry" {
356 t.Errorf("Execute = %q, want %q", got, "hello retry")
357 }
358 if got := initializeCount.Load(); got != 2 {
359 t.Errorf("initialize count = %d, want 2", got)
360 }
361 if got := toolCallCount.Load(); got != 2 {
362 t.Errorf("tools/call count = %d, want 2", got)
363 }
364 }
365
366 func TestHTTPTransportReinitializesPlainAndEmptyExpiredSession(t *testing.T) {
367 for _, test := range []struct {
368 name string
369 contentType string
370 body string
371 }{
372 {name: "plain-text", contentType: "text/plain", body: "Streamable HTTP session not found"},
373 {name: "empty-body"},
374 } {
375 t.Run(test.name, func(t *testing.T) {
376 var initializeCount atomic.Int32
377 var toolCallCount atomic.Int32
378 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
379 if r.Method == http.MethodGet {
380 w.WriteHeader(http.StatusMethodNotAllowed)
381 return
382 }
383 var req struct {
384 ID *int `json:"id"`
385 Method string `json:"method"`
386 }
387 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
388 http.Error(w, "bad body", http.StatusBadRequest)
389 return
390 }
391 if req.Method == "initialize" {
392 n := initializeCount.Add(1)
393 w.Header().Set("Mcp-Session-Id", fmt.Sprintf("session-%d", n))
394 writeHTTPRPCResult(w, req.ID, map[string]any{
395 "protocolVersion": testLegacyProtocolVersion,
396 "serverInfo": map[string]any{"name": "session-body", "version": "1"},
397 })
398 return
399 }
400 if req.ID == nil {
401 w.WriteHeader(http.StatusAccepted)
402 return
403 }
404 switch req.Method {
405 case "tools/list":
406 writeHTTPRPCResult(w, req.ID, map[string]any{"tools": []any{}})
407 case "tools/call":
408 if toolCallCount.Add(1) == 1 {
409 if test.contentType != "" {
410 w.Header().Set("Content-Type", test.contentType)
411 }
412 w.WriteHeader(http.StatusNotFound)
413 _, _ = w.Write([]byte(test.body))
414 return
415 }
416 writeHTTPRPCResult(w, req.ID, map[string]any{"content": []map[string]any{{"type": "text", "text": "recovered"}}})
417 default:
418 http.Error(w, "unknown method", http.StatusBadRequest)
419 }
420 }))
421 defer srv.Close()
422
423 transport, err := newHTTPTransport(Spec{Name: "session-body", Type: "http", URL: srv.URL})
424 if err != nil {
425 t.Fatal(err)
426 }
427 defer transport.close()
428 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
429 t.Fatalf("tools/list: %v", err)
430 }
431 result, err := transport.call(t.Context(), "tools/call", map[string]any{"name": "work", "arguments": map[string]any{}})
432 if err != nil {
433 t.Fatalf("tools/call: %v", err)
434 }
435 if !containsJSONText(result, "recovered") {
436 t.Fatalf("tools/call result = %s", result)
437 }
438 if got := initializeCount.Load(); got != 2 {
439 t.Fatalf("initialize count = %d, want 2", got)
440 }
441 if got := toolCallCount.Load(); got != 2 {
442 t.Fatalf("tools/call count = %d, want 2", got)
443 }
444 })
445 }
446 }
447
448 func TestHTTPTransportSessionMissingConcurrentCallsShareOneRebuild(t *testing.T) {
449 var initializeCount atomic.Int32
450 var expiredCalls atomic.Int32
451 var totalCalls atomic.Int32
452 releaseExpired := make(chan struct{})
453
454 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
455 if r.Method == http.MethodGet {
456 w.WriteHeader(http.StatusMethodNotAllowed)
457 return
458 }
459 var req struct {
460 ID *int `json:"id"`
461 Method string `json:"method"`
462 }
463 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
464 http.Error(w, "bad body", http.StatusBadRequest)
465 return
466 }
467 if req.Method == "initialize" {
468 n := initializeCount.Add(1)
469 w.Header().Set("Mcp-Session-Id", fmt.Sprintf("concurrent-%d", n))
470 writeHTTPRPCResult(w, req.ID, map[string]any{
471 "protocolVersion": testLegacyProtocolVersion,
472 "serverInfo": map[string]any{"name": "concurrent", "version": "1"},
473 })
474 return
475 }
476 if req.ID == nil {
477 w.WriteHeader(http.StatusAccepted)
478 return
479 }
480 switch req.Method {
481 case "tools/list":
482 writeHTTPRPCResult(w, req.ID, map[string]any{"tools": []any{}})
483 case "tools/call":
484 totalCalls.Add(1)
485 if r.Header.Get("Mcp-Session-Id") == "concurrent-1" {
486 if expiredCalls.Add(1) == 2 {
487 close(releaseExpired)
488 }
489 select {
490 case <-releaseExpired:
491 case <-r.Context().Done():
492 return
493 }
494 http.Error(w, "Streamable HTTP session not found", http.StatusNotFound)
495 return
496 }
497 writeHTTPRPCResult(w, req.ID, map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}})
498 default:
499 http.Error(w, "unknown method", http.StatusBadRequest)
500 }
501 }))
502 defer srv.Close()
503
504 transport, err := newHTTPTransport(Spec{Name: "concurrent", Type: "http", URL: srv.URL})
505 if err != nil {
506 t.Fatal(err)
507 }
508 defer transport.close()
509 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
510 t.Fatalf("tools/list: %v", err)
511 }
512
513 errs := make(chan error, 2)
514 for range 2 {
515 go func() {
516 _, err := transport.call(t.Context(), "tools/call", map[string]any{"name": "work", "arguments": map[string]any{}})
517 errs <- err
518 }()
519 }
520 for range 2 {
521 if err := <-errs; err != nil {
522 t.Fatalf("concurrent tools/call: %v", err)
523 }
524 }
525 if got := initializeCount.Load(); got != 2 {
526 t.Fatalf("initialize count = %d, want initial + one shared rebuild", got)
527 }
528 if got := expiredCalls.Load(); got != 2 {
529 t.Fatalf("expired first-generation calls = %d, want 2", got)
530 }
531 if got := totalCalls.Load(); got != 4 {
532 t.Fatalf("total tools/call requests = %d, want 2 failed + 2 replayed", got)
533 }
534 }
535
536 func TestHTTPTransportSessionMissingWithoutSessionDoesNotLoop(t *testing.T) {
537 var initializeCount atomic.Int32
538 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
539 if r.Method == http.MethodPost {
540 initializeCount.Add(1)
541 }
542 http.Error(w, "not an MCP endpoint", http.StatusNotFound)
543 }))
544 defer srv.Close()
545
546 transport, err := newHTTPTransport(Spec{Name: "missing-endpoint", Type: "http", URL: srv.URL})
547 if err != nil {
548 t.Fatal(err)
549 }
550 defer transport.close()
551 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err == nil {
552 t.Fatal("tools/list unexpectedly succeeded")
553 }
554 // The SDK performs its bounded modern-to-legacy protocol discovery fallback,
555 // but Reasonix must not treat a sessionless 404 as a lost established session
556 // and start another supervisor generation.
557 if got := initializeCount.Load(); got != 2 {
558 t.Fatalf("initialize requests = %d, want the SDK's two bounded protocol probes", got)
559 }
560 transport.mu.Lock()
561 generations := transport.nextGeneration
562 transport.mu.Unlock()
563 if generations != 1 {
564 t.Fatalf("supervisor generations = %d, want no session rebuild", generations)
565 }
566 }
567
568 func TestHTTPTransportSessionMissingReplaysAtMostOnce(t *testing.T) {
569 var initializeCount atomic.Int32
570 var toolCallCount atomic.Int32
571 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
572 if r.Method == http.MethodGet {
573 w.WriteHeader(http.StatusMethodNotAllowed)
574 return
575 }
576 var req struct {
577 ID *int `json:"id"`
578 Method string `json:"method"`
579 }
580 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
581 http.Error(w, "bad body", http.StatusBadRequest)
582 return
583 }
584 if req.Method == "initialize" {
585 n := initializeCount.Add(1)
586 w.Header().Set("Mcp-Session-Id", fmt.Sprintf("missing-%d", n))
587 writeHTTPRPCResult(w, req.ID, map[string]any{
588 "protocolVersion": testLegacyProtocolVersion,
589 "serverInfo": map[string]any{"name": "missing", "version": "1"},
590 })
591 return
592 }
593 if req.ID == nil {
594 w.WriteHeader(http.StatusAccepted)
595 return
596 }
597 switch req.Method {
598 case "tools/list":
599 writeHTTPRPCResult(w, req.ID, map[string]any{"tools": []any{}})
600 case "tools/call":
601 toolCallCount.Add(1)
602 http.Error(w, "session gone", http.StatusNotFound)
603 default:
604 http.Error(w, "unknown method", http.StatusBadRequest)
605 }
606 }))
607 defer srv.Close()
608
609 transport, err := newHTTPTransport(Spec{Name: "missing", Type: "http", URL: srv.URL})
610 if err != nil {
611 t.Fatal(err)
612 }
613 defer transport.close()
614 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
615 t.Fatalf("tools/list: %v", err)
616 }
617 if _, err := transport.call(t.Context(), "tools/call", map[string]any{"name": "work", "arguments": map[string]any{}}); err == nil {
618 t.Fatal("tools/call unexpectedly succeeded")
619 }
620 if got := initializeCount.Load(); got != 2 {
621 t.Fatalf("initialize count = %d, want 2", got)
622 }
623 if got := toolCallCount.Load(); got != 2 {
624 t.Fatalf("tools/call count = %d, want exactly one replay", got)
625 }
626 }
627
628 func TestHTTPTransportDoesNotReplayWriterAfterUnknownDisconnect(t *testing.T) {
629 var toolCallCount atomic.Int32
630 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
631 if r.Method == http.MethodGet {
632 w.WriteHeader(http.StatusMethodNotAllowed)
633 return
634 }
635 var req struct {
636 ID *int `json:"id"`
637 Method string `json:"method"`
638 }
639 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
640 http.Error(w, "bad body", http.StatusBadRequest)
641 return
642 }
643 if req.Method == "initialize" {
644 w.Header().Set("Mcp-Session-Id", "unknown-result")
645 writeHTTPRPCResult(w, req.ID, map[string]any{
646 "protocolVersion": testLegacyProtocolVersion,
647 "serverInfo": map[string]any{"name": "unknown", "version": "1"},
648 })
649 return
650 }
651 if req.ID == nil {
652 w.WriteHeader(http.StatusAccepted)
653 return
654 }
655 switch req.Method {
656 case "tools/list":
657 writeHTTPRPCResult(w, req.ID, map[string]any{"tools": []any{}})
658 case "tools/call":
659 toolCallCount.Add(1)
660 hijacker, ok := w.(http.Hijacker)
661 if !ok {
662 t.Error("response writer does not support hijacking")
663 return
664 }
665 conn, _, err := hijacker.Hijack()
666 if err != nil {
667 t.Errorf("hijack: %v", err)
668 return
669 }
670 _ = conn.Close()
671 default:
672 http.Error(w, "unknown method", http.StatusBadRequest)
673 }
674 }))
675 defer srv.Close()
676
677 transport, err := newHTTPTransport(Spec{Name: "unknown", Type: "http", URL: srv.URL})
678 if err != nil {
679 t.Fatal(err)
680 }
681 transport.reconnectDelays = nil
682 defer transport.close()
683 if _, err := transport.call(t.Context(), "tools/list", map[string]any{}); err != nil {
684 t.Fatalf("tools/list: %v", err)
685 }
686 _, err = transport.call(t.Context(), "tools/call", map[string]any{"name": "work", "arguments": map[string]any{}})
687 if err == nil || !strings.Contains(err.Error(), "was not retried") {
688 t.Fatalf("tools/call error = %v, want unknown-result non-replay error", err)
689 }
690 if got := toolCallCount.Load(); got != 1 {
691 t.Fatalf("tools/call count = %d, want no replay", got)
692 }
693 }
694
695 // TestHTTPTransportRPCError checks a JSON-RPC error response surfaces as an
696 // error rather than an empty result.
697 func TestHTTPTransportRPCError(t *testing.T) {
698 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
699 var req struct {
700 ID *int `json:"id"`
701 }
702 _ = json.NewDecoder(r.Body).Decode(&req)
703 if req.ID == nil {
704 w.WriteHeader(http.StatusAccepted)
705 return
706 }
707 w.Header().Set("Content-Type", "application/json")
708 fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32000,"message":"boom"}}`, *req.ID)
709 }))
710 defer srv.Close()
711
712 ctx := context.Background()
713 _, _, err := StartAll(ctx, []Spec{{Name: "e", Type: "http", URL: srv.URL}})
714 if err == nil || !strings.Contains(err.Error(), "boom") {
715 t.Fatalf("want initialize to fail with rpc error, got %v", err)
716 }
717 }
718
719 // TestSSETransportUnsupported documents that the legacy sse transport is
720 // recognised but deferred with a clear, actionable error.
721 func TestSSETransportUnsupported(t *testing.T) {
722 _, _, err := StartAll(context.Background(), []Spec{{Name: "legacy", Type: "sse", URL: "http://x"}})
723 if err == nil || !strings.Contains(err.Error(), "http") {
724 t.Fatalf("sse should error pointing to http, got %v", err)
725 }
726 }
727
728 func writeHTTPRPCResult(w http.ResponseWriter, id *int, result any) {
729 if id == nil {
730 w.WriteHeader(http.StatusAccepted)
731 return
732 }
733 resp := map[string]any{"jsonrpc": "2.0", "id": *id, "result": result}
734 w.Header().Set("Content-Type", "application/json")
735 _ = json.NewEncoder(w).Encode(resp)
736 }
737
738 func writeRawHTTPRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
739 w.Header().Set("Content-Type", "application/json")
740 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
741 }
742
743 func writeRawHTTPRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) {
744 w.Header().Set("Content-Type", "application/json")
745 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": message}})
746 }
747
748 func names(ts []tool.Tool) []string {
749 out := make([]string, len(ts))
750 for i, t := range ts {
751 out[i] = t.Name()
752 }
753 return out
754 }
755
755 lines GO