// Copyright 2025 The Go MCP SDK Authors. All rights reserved. // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. //lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). package mcp import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "maps" "net" "net/http" "net/http/cookiejar" "net/http/httptest" "net/http/httputil" "net/url" "os" "runtime" "slices" "sort" "strings" "sync" "sync/atomic" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) func TestStreamableTransports(t *testing.T) { // This test checks that the streamable server and client transports can // communicate. ctx := context.Background() tests := []struct { useJSON bool replay bool }{ {false, false}, {false, true}, {true, false}, {true, true}, } for _, test := range tests { t.Run(fmt.Sprintf("JSONResponse=%v;replay=%v", test.useJSON, test.replay), func(t *testing.T) { // Create a server with some simple tools. server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) // The "hang" tool checks that context cancellation is propagated. // It hangs until the context is cancelled. var ( start = make(chan struct{}) cancelled = make(chan struct{}, 1) // don't block the request ) hang := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { start <- struct{}{} select { case <-ctx.Done(): cancelled <- struct{}{} case <-time.After(5 * time.Second): return nil, nil, nil } return nil, nil, nil } AddTool(server, &Tool{Name: "hang"}, hang) // We use sampling to test server->client requests, both before and after // the related client->server request completes. sampleDone := make(chan struct{}) var sampleWG sync.WaitGroup AddTool(server, &Tool{Name: "sample"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { type testCase struct { label string ctx context.Context wantSuccess bool } testSample := func(tc testCase) { res, err := req.Session.CreateMessage(tc.ctx, &CreateMessageParams{}) if gotSuccess := err == nil; gotSuccess != tc.wantSuccess { t.Errorf("%s: CreateMessage success=%v, want %v", tc.label, gotSuccess, tc.wantSuccess) } if err != nil { return } if g, w := res.Model, "aModel"; g != w { t.Errorf("%s: got model %q, want %q", tc.label, g, w) } } // Test that we can make sampling requests during tool handling. // // Try this on both the request context and a background context, so // that messages may be delivered on either the POST or GET connection. for _, test := range []testCase{ {"request context", ctx, true}, {"background context", context.Background(), true}, } { testSample(test) } // Now, spin off a goroutine that runs after the sampling request, to // check behavior when the client request has completed. sampleWG.Go(func() { <-sampleDone // Test that sampling requests in the tool context fail outside of // tool handling, but succeed on the background context. for _, test := range []testCase{ {"request context", ctx, false}, {"background context", context.Background(), true}, } { testSample(test) } }) return &CallToolResult{}, nil, nil }) // Start an httptest.Server with the StreamableHTTPHandler, wrapped in a // cookie-checking middleware. opts := &StreamableHTTPOptions{ JSONResponse: test.useJSON, } if test.replay { opts.EventStore = NewMemoryEventStore(nil) } handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) var ( headerMu sync.Mutex lastHeader http.Header ) httpServer := httptest.NewServer(mustNotPanic(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { headerMu.Lock() lastHeader = r.Header headerMu.Unlock() cookie, err := r.Cookie("test-cookie") if err != nil { t.Errorf("missing cookie: %v", err) } else if cookie.Value != "test-value" { t.Errorf("got cookie %q, want %q", cookie.Value, "test-value") } handler.ServeHTTP(w, r) }))) defer httpServer.Close() // Create a client and connect it to the server using our StreamableClientTransport. // Check that all requests honor a custom client. jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } u, err := url.Parse(httpServer.URL) if err != nil { t.Fatal(err) } jar.SetCookies(u, []*http.Cookie{{Name: "test-cookie", Value: "test-value"}}) httpClient := &http.Client{Jar: jar} transport := &StreamableClientTransport{ Endpoint: httpServer.URL, HTTPClient: httpClient, } client := NewClient(testImpl, &ClientOptions{ CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil }, }) session, err := client.Connect(ctx, transport, nil) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } defer session.Close() sid := session.ID() if sid == "" { t.Fatalf("empty session ID") } // Stateful HTTP servers reject 2026-07-28 (which requires // StreamableHTTPOptions.Stateless = true), so the client falls // back to the legacy initialize handshake which explicitly // requests 2025-11-25. if g, w := session.mcpConn.(*streamableClientConn).initializedResult.ProtocolVersion, protocolVersion20251125; g != w { t.Fatalf("got protocol version %q, want %q", g, w) } // Verify the behavior of various tools. // The "greet" tool should just work. params := &CallToolParams{ Name: "greet", Arguments: map[string]any{"Name": "foo"}, } got, err := session.CallTool(ctx, params) if err != nil { t.Fatalf("CallTool() failed: %v", err) } if g := session.ID(); g != sid { t.Errorf("session ID: got %q, want %q", g, sid) } // Stateful HTTP requires the legacy protocol (see the earlier // initializeResult assertion); the client sends the matching // MCP-Protocol-Version header. if g, w := lastHeader.Get(protocolVersionHeader), protocolVersion20251125; g != w { t.Errorf("got protocol version header %q, want %q", g, w) } want := &CallToolResult{ Content: []Content{&TextContent{Text: "hi foo"}}, } if diff := cmp.Diff(want, got, ctrCmpOpts...); diff != "" { t.Errorf("CallTool() returned unexpected content (-want +got):\n%s", diff) } // The "hang" tool should be cancellable. ctx2, cancel := context.WithCancel(context.Background()) go session.CallTool(ctx2, &CallToolParams{Name: "hang"}) <-start cancel() select { case <-cancelled: case <-time.After(5 * time.Second): t.Fatal("timeout waiting for cancellation") } // The "sampling" tool checks the validity of server->client requests // both within and without the tool context. result, err := session.CallTool(ctx, &CallToolParams{ Name: "sample", Arguments: map[string]any{}, }) if err != nil { t.Fatal(err) } // Run the out-of-band checks. close(sampleDone) sampleWG.Wait() if result.IsError { t.Fatalf("tool failed: %s", result.Content[0].(*TextContent).Text) } }) } } func TestStreamableConcurrentHandling(t *testing.T) { // This test checks that the streamable server and client transports can // communicate. type count struct { Count int } var mu sync.Mutex counts := make(map[string]int) server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "inc"}, func(ctx context.Context, req *CallToolRequest, _ any) (*CallToolResult, count, error) { id := req.Session.ID() mu.Lock() defer mu.Unlock() c := counts[id] counts[id] = c + 1 return nil, count{c}, nil }) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() ctx := context.Background() client := NewClient(testImpl, nil) var wg sync.WaitGroup for range 100 { wg.Go(func() { clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) if err != nil { t.Errorf("Connect failed: %v", err) return } defer clientSession.Close() for i := range 10 { res, err := clientSession.CallTool(ctx, &CallToolParams{Name: "inc"}) if err != nil { t.Errorf("CallTool failed: %v", err) return } number, ok := res.StructuredContent.(map[string]any)["Count"].(json.Number) if !ok { t.Errorf("count type = %T, want json.Number", res.StructuredContent.(map[string]any)["Count"]) continue } got, err := number.Int64() if err != nil || got != int64(i) { t.Errorf("got count %d, %v; want %d", got, err, i) } } }) } wg.Wait() } func TestStreamableServerShutdown(t *testing.T) { ctx := context.Background() // This test checks that closing the streamable HTTP server actually results // in client session termination, provided one of following holds: // 1. The server is stateful, and therefore the hanging GET fails the connection. // 2. The server is stateless, and the client uses a KeepAlive. tests := []struct { name string stateless, keepalive bool }{ {"stateful", false, false}, {"stateless with keepalive", true, true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := NewServer(testImpl, nil) // Add a tool, just so we can check things are working. AddTool(server, &Tool{Name: "greet"}, sayHi) handler := NewStreamableHTTPHandler( func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: test.stateless}) // When we shut down the server, we need to explicitly close ongoing // connections. Otherwise, the hanging GET may never terminate. httpServer := httptest.NewUnstartedServer(handler) httpServer.Config.RegisterOnShutdown(func() { for session := range server.Sessions() { session.Close() } }) httpServer.Start() defer httpServer.Close() // Connect and run a tool. var opts ClientOptions if test.keepalive { opts.KeepAlive = 50 * time.Millisecond } client := NewClient(testImpl, &opts) // Pin to 2025-11-25: this test relies on the legacy hanging GET // (stateful case) or Ping-based keepalive (stateless case) for // the client to notice the server shutdown. Both mechanisms are // removed in 2026-07-28 (SEP-2575), so the new protocol would // leave the client waiting forever. clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, MaxRetries: -1, // avoid slow tests during exponential retries }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } defer clientSession.Close() params := &CallToolParams{ Name: "greet", Arguments: map[string]any{"Name": "foo"}, } // Verify that we can call a tool. if _, err := clientSession.CallTool(ctx, params); err != nil { t.Fatalf("CallTool() failed: %v", err) } // Shut down the server. Sessions should terminate. go func() { if err := httpServer.Config.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { t.Errorf("closing http server: %v", err) } }() // Wait may return an error (after all, the connection failed), but it // should not hang. t.Log("Client waiting") _ = clientSession.Wait() }) } } // TestStreamableStatelessKeepaliveRace verifies that there is no data race between // ServerSession.startKeepalive and ServerSession.Close in stateless servers. func TestStreamableStatelessKeepaliveRace(t *testing.T) { ctx := context.Background() server := NewServer(testImpl, &ServerOptions{KeepAlive: time.Hour}) AddTool(server, &Tool{Name: "greet"}, sayHi) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: true}, ) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() for range 50 { cs, err := NewClient(testImpl, nil).Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, }, nil) if err != nil { t.Fatalf("NewClient() failed: %v", err) } _, _ = cs.CallTool(ctx, &CallToolParams{ Name: "greet", Arguments: map[string]any{"Name": "world"}, }) _ = cs.Close() } } // TestClientReplay verifies that the client can recover from a mid-stream // network failure and receive replayed messages (if replay is configured). It // uses a proxy that is killed and restarted to simulate a recoverable network // outage. // // TODO: Until we have a way to clean up abandoned sessions, this test will leak goroutines (see #499) func TestClientReplay(t *testing.T) { for _, test := range []clientReplayTest{ {"default", 0, true}, {"no retries", -1, false}, } { t.Run(test.name, func(t *testing.T) { testClientReplay(t, test) }) } } type clientReplayTest struct { name string maxRetries int wantRecovered bool } func testClientReplay(t *testing.T, test clientReplayTest) { notifications := make(chan string) // Configure the real MCP server. server := NewServer(testImpl, nil) // Use a channel to synchronize the server's message sending with the test's // proxy-killing action. serverReadyToKillProxy := make(chan struct{}) serverClosed := make(chan struct{}) AddTool(server, &Tool{Name: "multiMessageTool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) { // Send one message to the request context, and another to a background // context (which will end up on the hanging GET). bgCtx := context.Background() req.Session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg1"}) req.Session.NotifyProgress(bgCtx, &ProgressNotificationParams{Message: "msg2"}) // Signal the test that it can now kill the proxy. close(serverReadyToKillProxy) <-serverClosed // These messages should be queued for replay by the server after // the client's connection drops. req.Session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg3"}) req.Session.NotifyProgress(bgCtx, &ProgressNotificationParams{Message: "msg4"}) return new(CallToolResult), nil, nil }) realServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ EventStore: NewMemoryEventStore(nil), // necessary for replay }))) t.Cleanup(func() { t.Log("Closing real HTTP server") realServer.Close() }) realServerURL, err := url.Parse(realServer.URL) if err != nil { t.Fatalf("Failed to parse real server URL: %v", err) } // Configure a proxy that sits between the client and the real server. proxyHandler := httputil.NewSingleHostReverseProxy(realServerURL) // note: don't use mustNotPanic here as the proxy WILL panic when killed. proxy := httptest.NewServer(proxyHandler) proxyAddr := proxy.Listener.Addr().String() // Get the address to restart it later. // Configure the client to connect to the proxy with default options. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client := NewClient(testImpl, &ClientOptions{ ProgressNotificationHandler: func(ctx context.Context, req *ProgressNotificationClientRequest) { notifications <- req.Params.Message }, }) clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: proxy.URL, MaxRetries: test.maxRetries, }, nil) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } t.Cleanup(func() { t.Log("Closing clientSession") clientSession.Close() }) toolCallResult := make(chan error, 1) go func() { _, callErr := clientSession.CallTool(ctx, &CallToolParams{Name: "multiMessageTool"}) toolCallResult <- callErr }() select { case <-serverReadyToKillProxy: t.Log("Server has sent the first two messages and is paused.") case <-ctx.Done(): t.Fatalf("Context timed out before server was ready to kill proxy") } // We should always get the first two notifications. msgs := readNotifications(t, ctx, notifications, 2) sort.Strings(msgs) // notifications may arrive in either order want := []string{"msg1", "msg2"} if diff := cmp.Diff(want, msgs); diff != "" { t.Errorf("Recovered notifications mismatch (-want +got):\n%s", diff) } // Simulate a total network failure by closing the proxy. t.Log("--- Killing proxy to simulate network failure ---") proxy.CloseClientConnections() proxy.Close() close(serverClosed) // Simulate network recovery by restarting the proxy on the same address. t.Logf("--- Restarting proxy on %s ---", proxyAddr) listener, err := net.Listen("tcp", proxyAddr) if err != nil { t.Fatalf("Failed to listen on proxy address: %v", err) } restartedProxy := &http.Server{Handler: proxyHandler} go restartedProxy.Serve(listener) t.Cleanup(func() { restartedProxy.Close() }) callErr := <-toolCallResult if test.wantRecovered { // If we've recovered, we should get all 4 notifications and the tool call // should have succeeded. msgs := readNotifications(t, ctx, notifications, 2) sort.Strings(msgs) want := []string{"msg3", "msg4"} if diff := cmp.Diff(want, msgs); diff != "" { t.Errorf("Recovered notifications mismatch (-want +got):\n%s", diff) } if callErr != nil { t.Errorf("CallTool failed unexpectedly: %v", err) } } else { // Otherwise, the call should fail. if callErr == nil { t.Errorf("CallTool succeeded unexpectedly") } } } func TestStreamableServerDisconnect(t *testing.T) { server := NewServer(testImpl, nil) // Test that client replayability allows the server to terminate incoming // requests immediately, and have the client replay them. // testStream exercises stream resumption by interleaving stream termination // with progress notifications. testStream := func(ctx context.Context, session *ServerSession, extra *RequestExtra) { // Close the stream before the first message. We should have sent an // initial priming message already, so the client will be able to replay extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: 10 * time.Millisecond}) session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg1"}) time.Sleep(20 * time.Millisecond) extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: 10 * time.Millisecond}) // Closing twice should still be supported. session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg2"}) } AddTool(server, &Tool{Name: "disconnect"}, func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, map[string]any, error) { testStream(ctx, req.Session, req.Extra) return new(CallToolResult), nil, nil }) server.AddPrompt(&Prompt{Name: "disconnect"}, func(ctx context.Context, req *GetPromptRequest) (*GetPromptResult, error) { testStream(ctx, req.Session, req.Extra) return nil, nil }) tests := []struct { name string doCall func(context.Context, *ClientSession) error }{ { "tool", func(ctx context.Context, cs *ClientSession) error { _, err := cs.CallTool(ctx, &CallToolParams{Name: "disconnect"}) return err }, }, { "prompt", func(ctx context.Context, cs *ClientSession) error { _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "disconnect"}) return err }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { notifications := make(chan string, 2) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ EventStore: NewMemoryEventStore(nil), // support replayability }) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client := NewClient(testImpl, &ClientOptions{ ProgressNotificationHandler: func(ctx context.Context, req *ProgressNotificationClientRequest) { notifications <- req.Params.Message }, }) clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } defer clientSession.Close() if err = test.doCall(ctx, clientSession); err != nil { t.Fatalf("CallTool failed: %v", err) } got := readNotifications(t, ctx, notifications, 2) want := []string{"msg1", "msg2"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("got unexpected notifications (-want +got):\n%s", diff) } }) } } func TestServerTransportCleanup(t *testing.T) { nClient := 3 var mu sync.Mutex var id int = -1 // session id starting from "0", "1", "2"... chans := make(map[string]chan struct{}, nClient) server := NewServer(testImpl, &ServerOptions{ KeepAlive: 10 * time.Millisecond, GetSessionID: func() string { mu.Lock() defer mu.Unlock() id++ if id == nClient { t.Errorf("creating more than %v session", nClient) } chans[fmt.Sprint(id)] = make(chan struct{}, 1) return fmt.Sprint(id) }, }) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) handler.onTransportDeletion = func(sessionID string) { mu.Lock() ch := chans[sessionID] mu.Unlock() ch <- struct{}{} } httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Spin up clients connect to the same server but refuse to ping request. for range nClient { client := NewClient(testImpl, nil) pingMiddleware := func(next MethodHandler) MethodHandler { return func( ctx context.Context, method string, req Request, ) (Result, error) { if method == "ping" { return &emptyResult{}, errors.New("ping error") } return next(ctx, method, req) } } client.AddReceivingMiddleware(pingMiddleware) // Pin to 2025-11-25: this test counts server-side sessions, but // the 2026-07-28 client probes server/discover first against a // stateful HTTP server (which rejects the new protocol), opening // an extra HTTP connection and therefore an extra session. Pinning // to 2025-11-25 skips the discover probe. clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } t.Cleanup(func() { _ = clientSession.Close() }) } mu.Lock() channels := slices.Collect(maps.Values(chans)) mu.Unlock() for _, ch := range channels { select { case <-ctx.Done(): t.Errorf("did not capture transport deletion event from all session in 10 seconds") case <-ch: t.Log("Received session transport deletion signal") } } handler.mu.Lock() if len(handler.sessions) != 0 { t.Errorf("want empty transports map, find %v entries from handler's transports map", len(handler.sessions)) } handler.mu.Unlock() } // TestServerInitiatedSSE verifies that the persistent SSE connection remains // open and can receive server-initiated events. func TestServerInitiatedSSE(t *testing.T) { notifications := make(chan string) server := NewServer(testImpl, nil) httpServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client := NewClient(testImpl, &ClientOptions{ ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) { notifications <- "toolListChanged" }, }) clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } defer clientSession.Close() AddTool(server, &Tool{Name: "testTool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { return &CallToolResult{}, nil, nil }) receivedNotifications := readNotifications(t, ctx, notifications, 1) wantReceived := []string{"toolListChanged"} if diff := cmp.Diff(wantReceived, receivedNotifications); diff != "" { t.Errorf("Received notifications mismatch (-want +got):\n%s", diff) } } // Helper to read a specific number of notifications. func readNotifications(t *testing.T, ctx context.Context, notifications chan string, count int) []string { t.Helper() var collectedNotifications []string for { select { case n := <-notifications: collectedNotifications = append(collectedNotifications, n) if len(collectedNotifications) == count { return collectedNotifications } case <-ctx.Done(): if len(collectedNotifications) != count { t.Fatalf("readProgressNotifications(): did not receive expected notifications, got %d, want %d", len(collectedNotifications), count) } return collectedNotifications } } } // JSON-RPC message constructors. func req(id int64, method string, params any) *jsonrpc.Request { r := &jsonrpc.Request{ Method: method, Params: mustMarshal(params), } if id > 0 { r.ID = jsonrpc2.Int64ID(id) } return r } func completeCallToolResult() *CallToolResult { r := &CallToolResult{Content: []Content{}} r.resultType = resultTypeComplete return r } // completeCallToolResultWithServerInfo returns a completeCallToolResult // annotated with the auto-populated [MetaKeyServerInfo] entry that servers // add to every new-protocol response. func completeCallToolResultWithServerInfo(impl *Implementation) *CallToolResult { r := completeCallToolResult() r.Meta = Meta{MetaKeyServerInfo: impl} return r } func resp(id int64, result any, err error) *jsonrpc.Response { return &jsonrpc.Response{ ID: jsonrpc2.Int64ID(id), Result: mustMarshal(result), Error: err, } } func TestStreamableServerTransport(t *testing.T) { // This test checks detailed behavior of the streamable server transport, by // faking the behavior of a streamable client using a sequence of HTTP // requests. // Predefined steps, to avoid repetition below. initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20250618}) initResp := resp(1, &InitializeResult{ Capabilities: &ServerCapabilities{ Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, ProtocolVersion: protocolVersion20250618, ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, }, nil) initializedMsg := req(0, notificationInitialized, &InitializedParams{}) initialize := streamableRequest{ method: "POST", messages: []jsonrpc.Message{initReq}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{initResp}, wantSessionID: true, } initialized := streamableRequest{ method: "POST", messages: []jsonrpc.Message{initializedMsg}, wantStatusCode: http.StatusAccepted, } // Protocol version 2025-11-25 variants, for testing prime/close events (SEP-1699). initReq20251125 := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20251125}) initResp20251125 := resp(1, &InitializeResult{ Capabilities: &ServerCapabilities{ Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, ProtocolVersion: protocolVersion20251125, ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, }, nil) initialize20251125 := streamableRequest{ method: "POST", messages: []jsonrpc.Message{initReq20251125}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{initResp20251125}, wantSessionID: true, } initialized20251125 := streamableRequest{ method: "POST", headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, messages: []jsonrpc.Message{initializedMsg}, wantStatusCode: http.StatusAccepted, } tests := []struct { name string replay bool // if set, use a MemoryEventStore to enable replay tool func(*testing.T, context.Context, *CallToolRequest) // if set, called during execution requests []streamableRequest wantSessions int // number of sessions expected after the test }{ { name: "basic", requests: []streamableRequest{ initialize, initialized, { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, }, }, wantSessions: 1, }, { name: "uninitialized", requests: []streamableRequest{ { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantBodyContaining: "invalid during session initialization", }, }, wantSessions: 0, }, { name: "content type headers", requests: []streamableRequest{ initialize, initialized, { // Request with incorrect Content-Type should be rejected. method: "POST", headers: http.Header{"Content-Type": {"text/plain"}}, messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusUnsupportedMediaType, }, { // Request with empty Content-Type should be rejected. method: "POST", headers: http.Header{"Content-Type": {""}}, messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusUnsupportedMediaType, }, { // Correct Content-Type should pass. method: "POST", headers: http.Header{"Content-Type": {"application/json"}}, messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, }, { // Correct Content-Type with parameters should pass. method: "POST", headers: http.Header{"Content-Type": {"application/json; charset=utf-8"}}, messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, }, }, wantSessions: 1, }, { name: "accept headers", requests: []streamableRequest{ initialize, initialized, // Test various accept headers. { method: "POST", headers: http.Header{"Accept": {"text/plain", "application/*"}}, messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusBadRequest, // missing text/event-stream }, { method: "POST", headers: http.Header{"Accept": {"text/event-stream"}}, messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusBadRequest, // missing application/json }, { method: "POST", headers: http.Header{"Accept": {"text/plain", "*/*"}}, messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{Content: []Content{}}, nil)}, }, { method: "POST", headers: http.Header{"Accept": {"application/json;charset=utf-8, text/event-stream"}}, messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, }, { method: "POST", headers: http.Header{"Accept": {"application/json;charset=utf-8", "text/event-stream"}}, messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)}, }, { method: "POST", headers: http.Header{"Accept": {"text/*, application/*"}}, messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(7, &CallToolResult{Content: []Content{}}, nil)}, }, }, wantSessions: 1, }, { name: "protocol version headers", requests: []streamableRequest{ initialize, initialized, { method: "POST", headers: http.Header{"mcp-protocol-version": {"2025-01-01"}}, // an invalid protocol version messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "2025-03-26", // a supported version wantSessionID: false, // could be true, but shouldn't matter }, }, wantSessions: 1, }, { name: "batch rejected on 2025-06-18", requests: []streamableRequest{ initialize, initialized, { method: "POST", // Explicitly set the protocol version header headers: http.Header{"MCP-Protocol-Version": {"2025-06-18"}}, // Two messages => batch. Expect reject. messages: []jsonrpc.Message{ req(101, "tools/call", &CallToolParams{Name: "tool"}), req(102, "tools/call", &CallToolParams{Name: "tool"}), }, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "batch", }, }, wantSessions: 1, }, { name: "batch accepted on 2025-03-26", requests: []streamableRequest{ initialize, initialized, { method: "POST", headers: http.Header{"MCP-Protocol-Version": {"2025-03-26"}}, // Two messages => batch. Expect OK with two responses in order. messages: []jsonrpc.Message{ // Note: only include one request here, because responses are not // necessarily sorted. req(201, "tools/call", &CallToolParams{Name: "tool"}), req(0, "notifications/roots/list_changed", &RootsListChangedParams{}), }, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ resp(201, &CallToolResult{Content: []Content{}}, nil), }, }, }, wantSessions: 1, }, { name: "tool notification", tool: func(t *testing.T, ctx context.Context, req *CallToolRequest) { // Send an arbitrary notification. if err := req.Session.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil { t.Errorf("Notify failed: %v", err) } }, requests: []streamableRequest{ initialize, initialized, { method: "POST", messages: []jsonrpc.Message{ req(2, "tools/call", &CallToolParams{Name: "tool"}), }, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ req(0, "notifications/progress", &ProgressNotificationParams{}), resp(2, &CallToolResult{Content: []Content{}}, nil), }, }, }, wantSessions: 1, }, { name: "tool upcall", tool: func(t *testing.T, ctx context.Context, req *CallToolRequest) { // Make an arbitrary call. if _, err := req.Session.ListRoots(ctx, &ListRootsParams{}); err != nil { t.Errorf("Call failed: %v", err) } }, requests: []streamableRequest{ initialize, initialized, { method: "POST", onRequest: 1, messages: []jsonrpc.Message{ resp(1, &ListRootsResult{}, nil), }, wantStatusCode: http.StatusAccepted, }, { method: "POST", messages: []jsonrpc.Message{ req(2, "tools/call", &CallToolParams{Name: "tool"}), }, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ req(1, "roots/list", &ListRootsParams{}), resp(2, &CallToolResult{Content: []Content{}}, nil), }, }, }, wantSessions: 1, }, { name: "background", // Enabling replay is necessary here because the standalone "GET" request // is fully asynchronous. Replay is needed to guarantee message delivery. // // TODO(rfindley): this should no longer be necessary. replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { // Perform operations on a background context, and ensure the client // receives it. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() if err := req.Session.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil { t.Errorf("Notify failed: %v", err) } // TODO(rfindley): finish implementing logging. // if err := ss.LoggingMessage(ctx, &LoggingMessageParams{}); err != nil { // t.Errorf("Logging failed: %v", err) // } if _, err := req.Session.ListRoots(ctx, &ListRootsParams{}); err != nil { t.Errorf("ListRoots failed: %v", err) } }, requests: []streamableRequest{ initialize, initialized, { method: "POST", onRequest: 1, messages: []jsonrpc.Message{ resp(1, &ListRootsResult{}, nil), }, wantStatusCode: http.StatusAccepted, }, { method: "GET", async: true, wantStatusCode: http.StatusOK, closeAfter: 2, wantMessages: []jsonrpc.Message{ req(0, "notifications/progress", &ProgressNotificationParams{}), req(1, "roots/list", &ListRootsParams{}), }, }, { method: "POST", messages: []jsonrpc.Message{ req(2, "tools/call", &CallToolParams{Name: "tool"}), }, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ resp(2, &CallToolResult{Content: []Content{}}, nil), }, }, { method: "DELETE", wantStatusCode: http.StatusNoContent, // Delete request expects 204 No Content with empty body. So override // the default "accept: application/json, text/event-stream" header. headers: map[string][]string{"Accept": nil}, }, }, wantSessions: 0, // session deleted }, { name: "no priming message on old protocol", replay: true, requests: []streamableRequest{ initialize, initialized, { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, wantBodyNotContaining: "prime", }, }, wantSessions: 1, }, { name: "no close message on old protocol", replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { req.Extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: time.Millisecond}) }, requests: []streamableRequest{ initialize, initialized, { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, wantBodyNotContaining: "close", }, }, wantSessions: 1, }, { name: "priming message on 2025-11-25", replay: true, requests: []streamableRequest{ initialize20251125, initialized20251125, { method: "POST", headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, wantBodyContaining: "prime", }, }, wantSessions: 1, }, { name: "close message on 2025-11-25", replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { req.Extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: time.Millisecond}) }, requests: []streamableRequest{ initialize20251125, initialized20251125, { method: "POST", headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, wantBodyContaining: "close", wantBodyNotContaining: "result", }, }, wantSessions: 1, }, { name: "no close message", replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { req.Extra.CloseSSEStream(CloseSSEStreamArgs{}) }, requests: []streamableRequest{ initialize, initialized, { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, wantBodyNotContaining: "close", }, }, wantSessions: 1, }, { name: "errors", requests: []streamableRequest{ { method: "PUT", wantStatusCode: http.StatusMethodNotAllowed, }, { method: "DELETE", wantStatusCode: http.StatusBadRequest, }, { method: "POST", messages: []jsonrpc.Message{req(1, "notamethod", nil)}, wantStatusCode: http.StatusBadRequest, // notamethod is an invalid method }, { method: "POST", messages: []jsonrpc.Message{req(0, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusBadRequest, // tools/call must have an ID }, { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc.Error{ Message: `method "tools/call" is invalid during session initialization`, })}, }, }, wantSessions: 0, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { // Create a server containing a single tool, which runs the test tool // behavior, if any. server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( &Tool{Name: "tool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { if test.tool != nil { test.tool(t, ctx, req) } return &CallToolResult{}, nil }) opts := &StreamableHTTPOptions{} if test.replay { opts.EventStore = NewMemoryEventStore(nil) } // Start the streamable handler. handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) defer handler.closeAll() testStreamableHandler(t, handler, test.requests) if got := len(slices.Collect(server.Sessions())); got != test.wantSessions { t.Errorf("after test, got %d sessions, want %d", got, test.wantSessions) } }) } } func testStreamableHandler(t *testing.T, handler http.Handler, requests []streamableRequest) { httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() // blocks records request blocks by jsonrpc. ID. // // When an OnRequest step is encountered, it waits on the corresponding // block. When a request with that ID is received, the block is closed. var mu sync.Mutex blocks := make(map[int64]chan struct{}) for _, req := range requests { if req.onRequest > 0 { blocks[req.onRequest] = make(chan struct{}) } } // signal when all synchronous requests have executed, so we can fail // async requests that are blocked. syncRequestsDone := make(chan struct{}) // To avoid complicated accounting for session ID, just set the first // non-empty session ID from a response. var sessionID atomic.Value sessionID.Store("") // doStep executes a single step. doStep := func(t *testing.T, i int, request streamableRequest) { if request.onRequest > 0 { // Block the step until we've received the server->client request. mu.Lock() block := blocks[request.onRequest] mu.Unlock() select { case <-block: case <-syncRequestsDone: t.Errorf("after all sync requests are complete, request still blocked on %d", request.onRequest) return } } // Collect messages received during this request, unblock other steps // when requests are received. var got []jsonrpc.Message out := make(chan jsonrpc.Message) // Cancel the step if we encounter a request that isn't going to be // handled. // // Also, add a timeout (hopefully generous). ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) var wg sync.WaitGroup wg.Go(func() { for m := range out { if req, ok := m.(*jsonrpc.Request); ok && req.IsCall() { // Encountered a server->client request. We should have a // response queued. Otherwise, we may deadlock. mu.Lock() if block, ok := blocks[req.ID.Raw().(int64)]; ok { close(block) } else { t.Errorf("no queued response for %v", req.ID) cancel() } mu.Unlock() } got = append(got, m) if request.closeAfter > 0 && len(got) == request.closeAfter { cancel() } } }) gotSessionID, gotStatusCode, gotBody, err := request.do(ctx, httpServer.URL, sessionID.Load().(string), out) // Don't fail on cancelled requests: error (if any) is handled // elsewhere. if err != nil && ctx.Err() == nil { t.Fatal(err) } if gotStatusCode != request.wantStatusCode { t.Errorf("request #%d: got status %d, want %d", i, gotStatusCode, request.wantStatusCode) } if got := gotSessionID != ""; got != request.wantSessionID { t.Errorf("request #%d: got session id: %t, want %t", i, got, request.wantSessionID) } wg.Wait() if request.wantBodyContaining != "" || request.wantBodyNotContaining != "" { body := string(gotBody) if request.wantBodyContaining != "" && !strings.Contains(body, request.wantBodyContaining) { t.Errorf("body does not contain %q:\n%s", request.wantBodyContaining, body) } if request.wantBodyNotContaining != "" && strings.Contains(body, request.wantBodyNotContaining) { t.Errorf("body contains %q:\n%s", request.wantBodyNotContaining, body) } } else { transform := cmpopts.AcyclicTransformer("jsonrpcid", func(id jsonrpc.ID) any { return id.Raw() }) if diff := cmp.Diff(request.wantMessages, got, transform); diff != "" { t.Errorf("request #%d: received unexpected messages (-want +got):\n%s", i, diff) } } sessionID.CompareAndSwap("", gotSessionID) } var wg sync.WaitGroup for i, request := range requests { if request.async || request.onRequest > 0 { wg.Go(func() { doStep(t, i, request) }) } else { doStep(t, i, request) } } // Fail any blocked responses if they weren't needed by a synchronous // request. close(syncRequestsDone) wg.Wait() } // A streamableRequest describes a single streamable HTTP request, consisting // of a request payload and expected response. type streamableRequest struct { // If onRequest is > 0, this step only executes after a request with the // given ID is received. // // All onRequest steps must occur before the step that creates the request. // // To avoid tests hanging when there's a bug, it's expected that this // request is received in the course of a *synchronous* request to the // server (otherwise, we wouldn't be able to terminate the test without // analyzing a dependency graph). onRequest int64 // If set, async causes the step to run asynchronously to other steps. // Redundant with OnRequest: all OnRequest steps are asynchronous. async bool // Request attributes method string // HTTP request method (required) headers http.Header // additional headers to set, overlaid on top of the default headers messages []jsonrpc.Message // messages to send closeAfter int // if nonzero, close after receiving this many messages wantStatusCode int // expected status code wantBodyContaining string // if set, expect the response body to contain this text; overrides wantMessages wantBodyNotContaining string // if set, a negative assertion on the body; overrides wantMessages wantMessages []jsonrpc.Message // expected messages to receive; ignored if wantBodyContaining is set wantSessionID bool // whether or not a session ID is expected in the response } // streamingRequest makes a request to the given streamable server with the // given url, sessionID, and method. // // If provided, the in messages are encoded in the request body. A single // message is encoded as a JSON object. Multiple messages are batched as a JSON // array. // // Any received messages are sent to the out channel, which is closed when the // request completes. // // Returns the sessionID and http status code from the response. If an error is // returned, sessionID and status code may still be set if the error occurs // after the response headers have been received. func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, out chan<- jsonrpc.Message) (string, int, []byte, error) { defer close(out) var body []byte if len(s.messages) == 1 { data, err := jsonrpc2.EncodeMessage(s.messages[0]) if err != nil { return "", 0, nil, fmt.Errorf("encoding message: %w", err) } body = data } else { var rawMsgs []json.RawMessage for _, msg := range s.messages { data, err := jsonrpc2.EncodeMessage(msg) if err != nil { return "", 0, nil, fmt.Errorf("encoding message: %w", err) } rawMsgs = append(rawMsgs, data) } data, err := json.Marshal(rawMsgs) if err != nil { return "", 0, nil, fmt.Errorf("marshaling batch: %w", err) } body = data } req, err := http.NewRequestWithContext(ctx, s.method, serverURL, bytes.NewReader(body)) if err != nil { return "", 0, nil, fmt.Errorf("creating request: %w", err) } if sessionID != "" { req.Header.Set(sessionIDHeader, sessionID) } if s.method == http.MethodPost { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json, text/event-stream") maps.Copy(req.Header, s.headers) if req.Header.Get("Content-Type") == "" { req.Header.Del("Content-Type") } resp, err := http.DefaultClient.Do(req) if err != nil { return "", 0, nil, fmt.Errorf("request failed: %v", err) } defer resp.Body.Close() newSessionID := resp.Header.Get(sessionIDHeader) contentType := baseMediaType(resp.Header.Get("Content-Type")) var respBody []byte if contentType == "text/event-stream" { r := readerInto{resp.Body, new(bytes.Buffer)} for evt, err := range scanEventsLimited(r, DefaultMaxEventSize) { if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading events: %v", err) } if evt.Name == "" || evt.Name == "message" { // ordinary message // TODO(rfindley): do we need to check evt.name? // Does the MCP spec say anything about this? msg, err := jsonrpc2.DecodeMessage(evt.Data) if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("decoding message: %w", err) } out <- msg } } respBody = r.w.Bytes() } else if contentType == "application/json" { data, err := io.ReadAll(resp.Body) if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading json body: %w", err) } respBody = data msg, err := jsonrpc2.DecodeMessage(data) if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("decoding message: %w", err) } out <- msg } else { respBody, err = io.ReadAll(resp.Body) if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading response: %v", err) } } return newSessionID, resp.StatusCode, respBody, nil } // readerInto is an io.Reader that writes any bytes read from r into w. type readerInto struct { r io.Reader w *bytes.Buffer } // Read implements io.Reader. func (r readerInto) Read(p []byte) (n int, err error) { n, err = r.r.Read(p) if err == nil || err == io.EOF { n2, err2 := r.w.Write(p[:n]) if err2 != nil { return n, fmt.Errorf("failed to write: %v", err) } if n2 != n { return n, fmt.Errorf("short write: %d != %d", n2, n) } } return n, err } func mustMarshal(v any) json.RawMessage { if v == nil { return nil } data, err := json.Marshal(v) if err != nil { panic(err) } return data } func TestEventID(t *testing.T) { tests := []struct { sid string idx int }{ {"0", 0}, {"0", 1}, {"1", 0}, {"1", 1}, {"", 1}, {"1234", 5678}, } for _, test := range tests { t.Run(fmt.Sprintf("%s_%d", test.sid, test.idx), func(t *testing.T) { eventID := formatEventID(test.sid, test.idx) gotSID, gotIdx, ok := parseEventID(eventID) if !ok { t.Fatalf("parseEventID(%q) failed, want ok", eventID) } if gotSID != test.sid || gotIdx != test.idx { t.Errorf("parseEventID(%q) = %s, %d, want %s, %d", eventID, gotSID, gotIdx, test.sid, test.idx) } }) } invalid := []string{ "", "_", "1_", "1_a", "1_-1", } for _, eventID := range invalid { t.Run(fmt.Sprintf("invalid_%q", eventID), func(t *testing.T) { if _, _, ok := parseEventID(eventID); ok { t.Errorf("parseEventID(%q) succeeded, want failure", eventID) } }) } } func TestStreamableStateless(t *testing.T) { initReq := req(1, methodInitialize, &InitializeParams{}) initResp := resp(1, &InitializeResult{ Capabilities: &ServerCapabilities{ Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, ProtocolVersion: protocolVersion20251125, ServerInfo: &Implementation{Name: "test", Version: "v1.0.0"}, }, nil) // This version of sayHi expects // that request from our client). sayHi := func(ctx context.Context, req *CallToolRequest, args hiParams) (*CallToolResult, any, error) { if err := req.Session.Ping(ctx, nil); err == nil { // ping should fail, but not break the connection t.Errorf("ping succeeded unexpectedly") } return &CallToolResult{Content: []Content{&TextContent{Text: "hi " + args.Name}}}, nil, nil } requests := []streamableRequest{ { method: "POST", messages: []jsonrpc.Message{initReq}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{initResp}, wantSessionID: false, // sessionless }, { method: "POST", wantStatusCode: http.StatusOK, messages: []jsonrpc.Message{req(1, "tools/list", struct{}{})}, wantBodyContaining: "greet", }, { method: "GET", wantStatusCode: http.StatusMethodNotAllowed, }, { method: "POST", wantStatusCode: http.StatusOK, messages: []jsonrpc.Message{ req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "World"}}), }, wantMessages: []jsonrpc.Message{ resp(2, &CallToolResult{ Content: []Content{&TextContent{Text: "hi World"}}, }, nil), }, }, { method: "POST", wantStatusCode: http.StatusOK, messages: []jsonrpc.Message{ req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "foo"}}), }, wantMessages: []jsonrpc.Message{ resp(2, &CallToolResult{ Content: []Content{&TextContent{Text: "hi foo"}}, }, nil), }, }, } testClientCompatibility := func(t *testing.T, handler http.Handler) { ctx := context.Background() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() cs, err := NewClient(testImpl, nil).Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) if err != nil { t.Fatal(err) } t.Cleanup(func() { cs.Close() }) res, err := cs.CallTool(ctx, &CallToolParams{Name: "greet", Arguments: hiParams{Name: "bar"}}) if err != nil { t.Fatal(err) } if got, want := textContent(t, res), "hi bar"; got != want { t.Errorf("Result = %q, want %q", got, want) } } statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) return server }, &StreamableHTTPOptions{ Stateless: true, }) testStreamableHandler(t, statelessHandler, requests) testClientCompatibility(t, statelessHandler) } func textContent(t *testing.T, res *CallToolResult) string { t.Helper() if len(res.Content) != 1 { t.Fatalf("len(Content) = %d, want 1", len(res.Content)) } text, ok := res.Content[0].(*TextContent) if !ok { t.Fatalf("Content[0] is %T, want *TextContent", res.Content[0]) } return text.Text } func TestSessionHijackingPrevention(t *testing.T) { // This test verifies that sessions bound to a user ID cannot be accessed // by a different user (session hijacking prevention). ctx := context.Background() server := NewServer(testImpl, nil) streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) // Use the bearer token directly as the user ID. This simulates how a real // verifier might extract a user ID from a JWT "sub" claim or introspection. verifier := func(_ context.Context, token string, _ *http.Request) (*auth.TokenInfo, error) { return &auth.TokenInfo{ Scopes: []string{"scope"}, UserID: token, Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC), }, nil } handler := auth.RequireBearerToken(verifier, nil)(streamHandler) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() // Helper to send a JSON-RPC request as a given user. doRequest := func(msg jsonrpc.Message, sessionID, userID string) *http.Response { t.Helper() data, _ := jsonrpc2.EncodeMessage(msg) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Authorization", "Bearer "+userID) if sessionID != "" { req.Header.Set(sessionIDHeader, sessionID) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request failed: %v", err) } return resp } // Create a session as user1. initReq := &jsonrpc.Request{Method: "initialize", ID: jsonrpc2.Int64ID(1)} initReq.Params, _ = json.Marshal(&InitializeParams{ ProtocolVersion: protocolVersion20250618, ClientInfo: &Implementation{Name: "test", Version: "1.0"}, }) resp := doRequest(initReq, "", "user1") defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("initialize failed with status %d: %s", resp.StatusCode, body) } sessionID := resp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatal("no session ID in response") } pingReq := &jsonrpc.Request{Method: "ping", ID: jsonrpc2.Int64ID(2)} pingReq.Params, _ = json.Marshal(&PingParams{}) // Try to access the session as user2 - should fail. resp2 := doRequest(pingReq, sessionID, "user2") defer resp2.Body.Close() if resp2.StatusCode != http.StatusForbidden { body, _ := io.ReadAll(resp2.Body) t.Errorf("expected status %d for user mismatch, got %d: %s", http.StatusForbidden, resp2.StatusCode, body) } // Access as original user1 should succeed. resp3 := doRequest(pingReq, sessionID, "user1") defer resp3.Body.Close() if resp3.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp3.Body) t.Errorf("expected status %d for matching user, got %d: %s", http.StatusOK, resp3.StatusCode, body) } } func TestStreamableGET(t *testing.T) { // This test checks the fix for problematic behavior described in #410: // Hanging GET headers should be written immediately, even if there are no // messages. server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() newReq := func(method string, msg jsonrpc.Message) *http.Request { var body io.Reader if msg != nil { data, err := jsonrpc2.EncodeMessage(msg) if err != nil { t.Fatal(err) } body = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, httpServer.URL, body) if err != nil { t.Fatal(err) } req.Header.Set("Accept", "application/json, text/event-stream") if msg != nil { req.Header.Set("Content-Type", "application/json") } return req } get1 := newReq(http.MethodGet, nil) resp, err := http.DefaultClient.Do(get1) if err != nil { t.Fatal(err) } // GET without session should return 400 Bad Request (not 405) because // GET is a valid method - it just requires a session ID. if got, want := resp.StatusCode, http.StatusBadRequest; got != want { t.Errorf("initial GET: got status %d, want %d", got, want) } defer resp.Body.Close() post1 := newReq(http.MethodPost, req(1, methodInitialize, &InitializeParams{})) resp, err = http.DefaultClient.Do(post1) if err != nil { t.Fatal(err) } defer resp.Body.Close() if got, want := resp.StatusCode, http.StatusOK; got != want { body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } t.Errorf("initialize POST: got status %d, want %d; body:\n%s", got, want, string(body)) } sessionID := resp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatalf("initialized missing session ID") } get2 := newReq("GET", nil) get2.Header.Set(sessionIDHeader, sessionID) resp, err = http.DefaultClient.Do(get2) if err != nil { t.Fatal(err) } defer resp.Body.Close() if got, want := resp.StatusCode, http.StatusOK; got != want { t.Errorf("GET with session ID: got status %d, want %d", got, want) } t.Log("Sending final DELETE request to close session and release resources") del := newReq("DELETE", nil) del.Header.Set(sessionIDHeader, sessionID) resp, err = http.DefaultClient.Do(del) if err != nil { t.Fatal(err) } defer resp.Body.Close() if got, want := resp.StatusCode, http.StatusNoContent; got != want { t.Errorf("DELETE with session ID: got status %d, want %d", got, want) } } // TestStreamableMcpHeaderValidation tests the server-side Mcp-Method, // Mcp-Name, and Mcp-Param header validation through the full HTTP handler, // as specified in SEP-2243. func TestStreamableMcpHeaderValidation(t *testing.T) { // Temporarily register the future version so the handler accepts it. server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil }) server.AddTool( &Tool{ Name: "execute_sql", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "region": map[string]any{ "type": "string", "x-mcp-header": "Region", }, "query": map[string]any{ "type": "string", }, }, }, }, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil }) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, }) defer handler.closeAll() testMeta := Meta{ MetaKeyProtocolVersion: minVersionForStandardHeaders, MetaKeyClientInfo: map[string]any{"name": "testClient", "version": "v1.0.0"}, MetaKeyClientCapabilities: map[string]any{}, } serverImpl := &Implementation{Name: "testServer", Version: "v1.0.0"} testStreamableHandler(t, handler, []streamableRequest{ { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"tools/call"}, nameHeader: {"my-tool"}, }, messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, completeCallToolResultWithServerInfo(serverImpl), nil)}, }, { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"prompts/get"}, nameHeader: {"my-tool"}, }, messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Method header value", }, { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"tools/call"}, nameHeader: {"wrong-tool"}, }, messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Name header value", }, { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"TOOLS/CALL"}, nameHeader: {"my-tool"}, }, messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Method header value", }, { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"tools/call"}, nameHeader: {"my-tool"}, }, messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(6, completeCallToolResultWithServerInfo(serverImpl), nil)}, }, { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"tools/call"}, nameHeader: {"execute_sql"}, paramHeaderPrefix + "Region": {"us-west1"}, }, messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{ Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, })}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(7, completeCallToolResultWithServerInfo(serverImpl), nil)}, }, { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"tools/call"}, nameHeader: {"execute_sql"}, paramHeaderPrefix + "Region": {"eu-central1"}, }, messages: []jsonrpc.Message{req(8, "tools/call", &CallToolParams{ Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1"}, })}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "header mismatch", }, { method: "POST", headers: http.Header{ protocolVersionHeader: {minVersionForStandardHeaders}, methodHeader: {"tools/call"}, nameHeader: {"execute_sql"}, }, messages: []jsonrpc.Message{req(9, "tools/call", &CallToolParams{ Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1"}, })}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "missing", }, }) } // TestStreamableMcpHeaderValidationErrorFormat verifies that header // validation errors return a JSON-RPC error with code -32020 and // Content-Type application/json, per SEP-2243. func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil }) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() // Use the MCP client with a custom RoundTripper to inject a bad header. var toolCallResp *http.Response var toolCallRespBody []byte customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { var originalMethodHeader string if req.Header.Get(methodHeader) == "tools/call" { originalMethodHeader = req.Header.Get(methodHeader) req.Header.Set(methodHeader, "wrong-method") } resp, err := http.DefaultTransport.RoundTrip(req) if err == nil && originalMethodHeader == "tools/call" { toolCallResp = resp toolCallRespBody, _ = io.ReadAll(resp.Body) } return resp, err }), } clientTransport := &StreamableClientTransport{ Endpoint: httpServer.URL, HTTPClient: customClient, } client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } defer session.Close() _, err = session.CallTool(ctx, &CallToolParams{Name: "my-tool"}) // We expect an error because the server should reject it. if err == nil { t.Error("CallTool succeeded unexpectedly") } if toolCallResp == nil { t.Fatal("no response captured") } // Verify HTTP status code. if toolCallResp.StatusCode != http.StatusBadRequest { t.Errorf("status code = %d, want %d", toolCallResp.StatusCode, http.StatusBadRequest) } // Verify Content-Type. if baseMediaType(toolCallResp.Header.Get("Content-Type")) != "application/json" { t.Errorf("Content-Type = %q, want %q", baseMediaType(toolCallResp.Header.Get("Content-Type")), "application/json") } // Verify JSON-RPC error body contains error code -32020. msg, err := jsonrpc2.DecodeMessage(toolCallRespBody) if err != nil { t.Fatalf("failed to decode message: %v", err) } resp, ok := msg.(*jsonrpc2.Response) if !ok { t.Fatalf("expected *jsonrpc2.Response, got %T", msg) } var wireErr *jsonrpc2.WireError if !errors.As(resp.Error, &wireErr) { t.Fatalf("expected *jsonrpc2.WireError, got %T", resp.Error) } if wireErr.Code != CodeHeaderMismatch { t.Errorf("wireErr.Code = %d, want %d", wireErr.Code, CodeHeaderMismatch) } if !strings.Contains(wireErr.Message, "Mcp-Method header value") { t.Errorf("wireErr.Message = %q, want it to contain %q", wireErr.Message, "Mcp-Method header value") } } // TestStreamableMcpHeaderVersionGating verifies that header validation // is skipped for protocol versions older than minVersionForStandardHeaders. func TestStreamableMcpHeaderVersionGating(t *testing.T) { server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil }) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) defer handler.closeAll() initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20251125}) initResp := resp(1, &InitializeResult{ Capabilities: &ServerCapabilities{ Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, ProtocolVersion: protocolVersion20251125, ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, }, nil) testStreamableHandler(t, handler, []streamableRequest{ { method: "POST", messages: []jsonrpc.Message{initReq}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{initResp}, wantSessionID: true, }, { method: "POST", headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, messages: []jsonrpc.Message{req(0, notificationInitialized, &InitializedParams{})}, wantStatusCode: http.StatusAccepted, }, // Requests with deliberately wrong MCP headers should still succeed // because the protocol version is too old for validation. { method: "POST", headers: http.Header{ protocolVersionHeader: {protocolVersion20251125}, methodHeader: {"wrong-method"}, nameHeader: {"wrong-name"}, }, messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "my-tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, }, }) } // TestStreamableParamHeadersClientSetsHeaders verifies that the client sets // Mcp-Param-* headers on tool calls when the tool has x-mcp-header annotations. func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( &Tool{ Name: "execute_sql", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "region": map[string]any{ "type": "string", "x-mcp-header": "Region", }, "query": map[string]any{ "type": "string", }, }, }, }, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil }) // Install a receiving middleware on the server that opts the tools/list // response caching by setting a positive ttlMs. server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { return func(ctx context.Context, method string, req Request) (Result, error) { res, err := next(ctx, method, req) if err == nil && method == methodListTools { if r, ok := res.(*ListToolsResult); ok { r.TTLMs = 60_000 } } return res, err } }) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() var capturedHeaders http.Header customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { if req.Header.Get(methodHeader) == "tools/call" { capturedHeaders = req.Header.Clone() } return http.DefaultTransport.RoundTrip(req) }), } clientTransport := &StreamableClientTransport{ Endpoint: httpServer.URL, HTTPClient: customClient, } client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } defer session.Close() // ListTools to populate the tool cache (needed for param headers). // Pass a non-nil params so the SEP-2575 per-request _meta triple is // injected; injectMeta is a no-op when params is nil. if _, err := session.ListTools(ctx, &ListToolsParams{}); err != nil { t.Fatal(err) } _, err = session.CallTool(ctx, &CallToolParams{ Name: "execute_sql", Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, }) if err != nil { t.Fatal(err) } if capturedHeaders == nil { t.Fatal("no tool call headers captured") } if got := capturedHeaders.Get(methodHeader); got != "tools/call" { t.Errorf("Mcp-Method = %q, want %q", got, "tools/call") } if got := capturedHeaders.Get(nameHeader); got != "execute_sql" { t.Errorf("Mcp-Name = %q, want %q", got, "execute_sql") } if got := capturedHeaders.Get(paramHeaderPrefix + "Region"); got != "us-west1" { t.Errorf("Mcp-Param-Region = %q, want %q", got, "us-west1") } } // TestStreamableFilterValidToolsIntegration verifies that AddTool rejects // tools with invalid x-mcp-header annotations at registration time, and // that valid tools are returned by ListTools. func TestStreamableFilterValidToolsIntegration(t *testing.T) { server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) noop := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil } // Valid tool with correct x-mcp-header annotation. server.AddTool(&Tool{ Name: "valid-tool", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "region": map[string]any{ "type": "string", "x-mcp-header": "Region", }, }, }, }, noop) // Invalid tool: x-mcp-header on an array type should panic. func() { defer func() { r := recover() if r == nil { t.Fatal("AddTool with invalid x-mcp-header annotation did not panic") } }() server.AddTool(&Tool{ Name: "invalid-tool", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "items": map[string]any{ "type": "array", "x-mcp-header": "Items", }, }, }, }, noop) }() // Tool with no x-mcp-header annotations (always valid). server.AddTool(&Tool{ Name: "plain-tool", InputSchema: &jsonschema.Schema{Type: "object"}, }, noop) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() session, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, }, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } defer session.Close() // Pass non-nil params so the SEP-2575 per-request _meta triple is // injected; injectMeta is a no-op when params is nil. result, err := session.ListTools(ctx, &ListToolsParams{}) if err != nil { t.Fatal(err) } toolNames := make([]string, len(result.Tools)) for i, tool := range result.Tools { toolNames[i] = tool.Name } sort.Strings(toolNames) wantNames := []string{"plain-tool", "valid-tool"} if !slices.Equal(toolNames, wantNames) { t.Errorf("ListTools returned %v, want %v", toolNames, wantNames) } } // TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance: // 405 Method Not Allowed responses MUST include an Allow header. func TestStreamable405AllowHeader(t *testing.T) { server := NewServer(testImpl, nil) tests := []struct { name string stateless bool method string wantStatus int wantAllow string }{ { name: "unsupported method stateful", stateless: false, method: "PUT", wantStatus: http.StatusMethodNotAllowed, wantAllow: "GET, POST, DELETE", }, { name: "GET in stateless mode", stateless: true, method: "GET", wantStatus: http.StatusMethodNotAllowed, wantAllow: "POST", }, { // In stateless mode, only POST is supported. // DELETE returns 405, consistent with Allow: POST. name: "DELETE without session stateless", stateless: true, method: "DELETE", wantStatus: http.StatusMethodNotAllowed, wantAllow: "POST", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opts := &StreamableHTTPOptions{Stateless: tt.stateless} handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() req, err := http.NewRequest(tt.method, httpServer.URL, nil) if err != nil { t.Fatal(err) } req.Header.Set("Accept", "application/json, text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if got := resp.StatusCode; got != tt.wantStatus { t.Errorf("status code: got %d, want %d", got, tt.wantStatus) } allow := resp.Header.Get("Allow") if allow != tt.wantAllow { t.Errorf("Allow header: got %q, want %q", allow, tt.wantAllow) } }) } } // TestStreamableGETWithoutSession verifies that GET without session ID in stateful mode // returns 400 Bad Request (not 405), since GET is a supported method that requires a session. func TestStreamableGETWithoutSession(t *testing.T) { server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() req, err := http.NewRequest("GET", httpServer.URL, nil) if err != nil { t.Fatal(err) } req.Header.Set("Accept", "text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } // GET without session should return 400 Bad Request, not 405 Method Not Allowed, // because GET is a valid method - it just requires a session ID. if got, want := resp.StatusCode, http.StatusBadRequest; got != want { t.Errorf("status code: got %d, want %d", got, want) } if got, want := strings.TrimSpace(string(body)), "Bad Request: GET requires an Mcp-Session-Id header"; got != want { t.Errorf("body: got %q, want %q", got, want) } } func TestStreamableGETWithoutEventStreamAccept(t *testing.T) { server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() req, err := http.NewRequest("GET", httpServer.URL, nil) if err != nil { t.Fatal(err) } req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } if got, want := resp.StatusCode, http.StatusBadRequest; got != want { t.Errorf("status code: got %d, want %d", got, want) } if got, want := strings.TrimSpace(string(body)), "Accept must contain 'text/event-stream' for GET requests"; got != want { t.Errorf("body: got %q, want %q", got, want) } } func TestBaseMediaType(t *testing.T) { tests := []struct { name string value string want string }{ {name: "empty", want: ""}, {name: "json", value: "application/json", want: "application/json"}, {name: "json with params", value: "Application/JSON; charset=utf-8", want: "application/json"}, {name: "event stream with params", value: "Text/Event-Stream; charset=utf-8", want: "text/event-stream"}, {name: "invalid", value: "application/json; charset", want: ""}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := baseMediaType(test.value); got != test.want { t.Errorf("baseMediaType(%q) = %q, want %q", test.value, got, test.want) } }) } } func TestStreamableClientContextPropagation(t *testing.T) { type contextKey string const testKey = contextKey("test-key") const testValue = "test-value" ctx, cancel := context.WithCancel(context.Background()) defer cancel() ctx2 := context.WithValue(ctx, testKey, testValue) server := httptest.NewServer(mustNotPanic(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { switch req.Method { case "POST": w.Header().Set("Content-Type", "application/json") w.Header().Set(sessionIDHeader, "test-session") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"test","version":"1.0"}}}`)) case "GET": w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) case "DELETE": w.WriteHeader(http.StatusNoContent) } }))) defer server.Close() transport := &StreamableClientTransport{Endpoint: server.URL} conn, err := transport.Connect(ctx2) if err != nil { t.Fatalf("Connect failed: %v", err) } defer conn.Close() streamableConn, ok := conn.(*streamableClientConn) if !ok { t.Fatalf("Expected *streamableClientConn, got %T", conn) } if got := streamableConn.ctx.Value(testKey); got != testValue { t.Errorf("Context value not propagated: got %v, want %v", got, testValue) } if streamableConn.ctx.Done() == nil { t.Error("Connection context is not cancellable") } cancel() select { case <-streamableConn.ctx.Done(): t.Errorf("cancelling the connection context after successful connection broke the connection") default: } } func TestStreamableSessionTimeout(t *testing.T) { // TODO: this test relies on timing and may be flaky. // Fixing with testing/synctest is challenging because it uses real I/O (via // httptest.NewServer). ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() server := NewServer(testImpl, nil) deleted := make(chan string, 1) handler := NewStreamableHTTPHandler( func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ SessionTimeout: 50 * time.Millisecond, }, ) handler.onTransportDeletion = func(sessionID string) { deleted <- sessionID } httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() // Connect a client to create a session. // Pin to 2025-11-25: under 2026-07-28 the client probes server/discover // first against a stateful HTTP server (which rejects the new // protocol), opening an extra HTTP connection and therefore an extra // session. Pinning to 2025-11-25 skips the discover probe. client := NewClient(testImpl, nil) session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } defer session.Close() sessionID := session.ID() if sessionID == "" { t.Fatal("client session has empty ID") } // Verify the session exists on the server. serverSessions := slices.Collect(server.Sessions()) if len(serverSessions) != 1 { t.Fatalf("got %d sessions, want 1", len(serverSessions)) } if got := serverSessions[0].ID(); got != sessionID { t.Fatalf("server session is %q, want %q", got, sessionID) } // Test that (possibly concurrent) requests keep the session alive. // // Spin up two goroutines, each making a request every 10ms. These requests // should keep the server from timing out. var wg sync.WaitGroup for range 2 { wg.Go(func() { for range 20 { if _, err := session.ListTools(ctx, nil); err != nil { t.Errorf("ListTools failed: %v", err) } time.Sleep(10 * time.Millisecond) } }) } wg.Wait() // Wait for the session to be cleaned up. select { case deletedID := <-deleted: if deletedID != sessionID { t.Errorf("deleted session ID = %q, want %q", deletedID, sessionID) } case <-ctx.Done(): t.Fatal("timed out waiting for session cleanup") } // Verify the session is gone from both handler and server. handler.mu.Lock() if len(handler.sessions) != 0 { t.Errorf("handler.sessions is not empty; length %d", len(handler.sessions)) } if ss := slices.Collect(server.Sessions()); len(ss) != 0 { t.Errorf("server.Sessions() is not empty; length %d", len(ss)) } handler.mu.Unlock() } // mustNotPanic is a helper to enforce that test handlers do not panic (see // issue #556). func mustNotPanic(t *testing.T, h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { defer func() { if r := recover(); r != nil { buf := make([]byte, 1<<20) n := runtime.Stack(buf, false) fmt.Fprintf(os.Stderr, "handler panic: %v\n\n%s", r, buf[:n]) t.Errorf("handler panicked: %v", r) } }() h.ServeHTTP(w, req) }) } // TestPingEventFiltering verifies that the streamable client correctly filters // out SSE "ping" events, which are used for keep-alive but should not be // treated as JSON-RPC messages. // // This test addresses issue #636: the client should skip non-"message" events // according to the SSE specification. It tests the fix in processStream where // events with evt.Name != "" && evt.Name != "message" are skipped. func TestPingEventFiltering(t *testing.T) { // This test verifies the low-level processStream filtering. // We create a mock response with ping and message events. sseData := `event: ping data: ping event: message id: 1 data: {"jsonrpc":"2.0","id":1,"result":{}} event: ping data: keepalive ` resp := &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(bytes.NewReader([]byte(sseData))), } // Create a minimal streamableClientConn for testing ctx := t.Context() incoming := make(chan jsonrpc.Message, 10) done := make(chan struct{}) conn := &streamableClientConn{ ctx: ctx, done: done, incoming: incoming, } // Create a test request testReq := &jsonrpc.Request{ ID: jsonrpc2.Int64ID(1), Method: "test", } // Process the stream go conn.processStream(ctx, "test", resp, testReq) // Collect messages with timeout var messages []jsonrpc.Message timeout := time.After(1 * time.Second) collectLoop: for { select { case msg := <-incoming: messages = append(messages, msg) // We expect only 1 message (the response), not the ping events if len(messages) >= 1 { break collectLoop } case <-timeout: break collectLoop } } // Verify we only received the actual message, not the ping events if len(messages) != 1 { t.Errorf("got %d messages, want 1 (ping events should be filtered)", len(messages)) for i, msg := range messages { t.Logf("message %d: %T", i, msg) } } // Verify the message is the response if len(messages) > 0 { resp, ok := messages[0].(*jsonrpc.Response) if !ok { t.Errorf("first message is %T, want *jsonrpc.Response", messages[0]) } else if resp.ID.Raw() != int64(1) { t.Errorf("response ID is %v, want 1", resp.ID.Raw()) } } } // TestProcessStreamPrimingEvent verifies that the streamable client correctly ignores // SSE events with empty data buffers, which are used as priming events (e.g. SEP-1699). func TestProcessStreamPrimingEvent(t *testing.T) { // We create a mock response with a priming event (empty data, with an ID), // followed by a normal event. sseData := `id: 123 id: 124 data: {"jsonrpc":"2.0","id":1,"result":{}} ` ctx := t.Context() resp := &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(sseData)), } incoming := make(chan jsonrpc.Message, 10) done := make(chan struct{}) conn := &streamableClientConn{ ctx: ctx, done: done, incoming: incoming, failed: make(chan struct{}), logger: ensureLogger(nil), } lastID, _, clientClosed := conn.processStream(ctx, "test", resp, nil) if clientClosed { t.Fatalf("processStream was unexpectedly closed by client") } if lastID != "124" { t.Errorf("lastEventID = %q, want %q", lastID, "124") } select { case msg := <-incoming: if res, ok := msg.(*jsonrpc.Response); !(ok && res.ID == jsonrpc2.Int64ID(1)) { t.Errorf("got unexpected message: %v", msg) } default: t.Errorf("expected a JSON-RPC message to be produced") } } // TestProcessStreamMaxEventSize verifies that a streamableClientConn honors its // configured maxEventSize. func TestProcessStreamMaxEventSize(t *testing.T) { jsonrpcEventOfSize := func(padSize int) string { msg := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":{"pad":%q}}`, strings.Repeat("A", padSize)) return "data: " + msg + "\n\n" } tests := []struct { name string maxEventSize int event string wantFail bool }{ { name: "event over cap is rejected", event: jsonrpcEventOfSize(4096), maxEventSize: 1024, wantFail: true, }, { name: "event under cap is accepted", event: jsonrpcEventOfSize(1024), maxEventSize: 4096, wantFail: false, }, { name: "negative cap disables the limit", event: jsonrpcEventOfSize(4096), maxEventSize: -1, wantFail: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := t.Context() resp := &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(tt.event)), } conn := &streamableClientConn{ ctx: ctx, done: make(chan struct{}), incoming: make(chan jsonrpc.Message, 10), failed: make(chan struct{}), logger: ensureLogger(nil), maxEventSize: tt.maxEventSize, } conn.processStream(ctx, "test", resp, nil) err := conn.failure() if tt.wantFail && err == nil { t.Fatal("failure() = nil, want a non-nil error for an oversized event") } if !tt.wantFail && err != nil { t.Fatalf("failure() = %v, want nil", err) } }) } } // TestScanEventsPingFiltering is a unit test for the low-level event scanning // with ping events to verify scanEvents properly parses all event types. func TestScanEventsPingFiltering(t *testing.T) { // Create SSE stream with mixed events sseData := `event: ping data: ping event: message data: {"jsonrpc":"2.0","method":"test","params":{}} event: ping data: keepalive event: message data: {"jsonrpc":"2.0","method":"test2","params":{}} ` reader := strings.NewReader(sseData) var events []Event // Scan all events for evt, err := range scanEventsLimited(reader, DefaultMaxEventSize) { if err != nil { if err != io.EOF { t.Fatalf("scanEvents error: %v", err) } break } events = append(events, evt) } // Verify we got all 4 events if len(events) != 4 { t.Fatalf("got %d events, want 4", len(events)) } // Verify event types expectedNames := []string{"ping", "message", "ping", "message"} for i, evt := range events { if evt.Name != expectedNames[i] { t.Errorf("event %d: got name %q, want %q", i, evt.Name, expectedNames[i]) } } // Verify that we can decode the message events but would fail on ping events for i, evt := range events { switch evt.Name { case "message": _, err := jsonrpc.DecodeMessage(evt.Data) if err != nil { t.Errorf("event %d: failed to decode message event: %v", i, err) } case "ping": // Ping events have non-JSON data and should fail decoding _, err := jsonrpc.DecodeMessage(evt.Data) if err == nil { t.Errorf("event %d: ping event unexpectedly decoded as valid JSON-RPC", i) } } } } func Test_ExportErrSessionMissing(t *testing.T) { ctx := context.Background() // 1. Setup server impl := &Implementation{Name: "test", Version: "1.0.0"} server := NewServer(impl, nil) handler := NewStreamableHTTPHandler(func(r *http.Request) *Server { return server }, nil) ts := httptest.NewServer(handler) defer ts.Close() // 2. Setup client clientTransport := &StreamableClientTransport{ Endpoint: ts.URL, } client := NewClient(impl, nil) // Pin to 2025-11-25: under 2026-07-28 the client probes server/discover // first against a stateful HTTP server (which rejects the new // protocol), opening an extra HTTP connection and therefore an extra // session. Pinning to 2025-11-25 skips the discover probe. session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } defer session.Close() // 3. Manually invalidate session on server handler.mu.Lock() if len(handler.sessions) != 1 { handler.mu.Unlock() t.Fatalf("expected 1 session, got %d", len(handler.sessions)) } for id := range handler.sessions { delete(handler.sessions, id) } handler.mu.Unlock() // 4. Try to call a tool (or any request) _, err = session.ListTools(ctx, nil) if err == nil { t.Fatal("expected error, got nil") } // 5. Verify it's ErrSessionMissing if !errors.Is(err, ErrSessionMissing) { t.Errorf("expected error to wrap ErrSessionMissing, got: %v", err) } } // TestStreamableLocalhostProtection verifies that DNS rebinding protection // is automatically enabled for localhost servers. func TestStreamableLocalhostProtection(t *testing.T) { server := NewServer(testImpl, nil) tests := []struct { name string listenAddr string // Address to listen on hostHeader string // Host header in request disableProtection bool // DisableLocalhostProtection setting wantStatus int }{ // Auto-enabled for localhost listeners (127.0.0.1). { name: "127.0.0.1 accepts 127.0.0.1", listenAddr: "127.0.0.1:0", hostHeader: "127.0.0.1:1234", disableProtection: false, wantStatus: http.StatusOK, }, { name: "127.0.0.1 accepts localhost", listenAddr: "127.0.0.1:0", hostHeader: "localhost:1234", disableProtection: false, wantStatus: http.StatusOK, }, { name: "127.0.0.1 rejects evil.com", listenAddr: "127.0.0.1:0", hostHeader: "evil.com", disableProtection: false, wantStatus: http.StatusForbidden, }, { name: "127.0.0.1 rejects evil.com:80", listenAddr: "127.0.0.1:0", hostHeader: "evil.com:80", disableProtection: false, wantStatus: http.StatusForbidden, }, { name: "127.0.0.1 rejects localhost.evil.com", listenAddr: "127.0.0.1:0", hostHeader: "localhost.evil.com", disableProtection: false, wantStatus: http.StatusForbidden, }, // When listening on 0.0.0.0, requests arriving via localhost are still protected // because LocalAddrContextKey returns the actual connection's local address. // This is actually more secure - DNS rebinding attacks target localhost regardless // of the listener configuration. { name: "0.0.0.0 via localhost rejects evil.com", listenAddr: "0.0.0.0:0", hostHeader: "evil.com", disableProtection: false, wantStatus: http.StatusForbidden, }, // Explicit disable { name: "disabled accepts evil.com", listenAddr: "127.0.0.1:0", hostHeader: "evil.com", disableProtection: true, wantStatus: http.StatusOK, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opts := &StreamableHTTPOptions{ Stateless: true, // Simpler for testing DisableLocalhostProtection: tt.disableProtection, } handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) listener, err := net.Listen("tcp", tt.listenAddr) if err != nil { t.Fatalf("Failed to listen on %s: %v", tt.listenAddr, err) } defer listener.Close() srv := &http.Server{Handler: handler} go srv.Serve(listener) defer srv.Close() reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) req, err := http.NewRequest("POST", fmt.Sprintf("http://%s", listener.Addr().String()), reqReader) if err != nil { t.Fatal(err) } req.Host = tt.hostHeader req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if got := resp.StatusCode; got != tt.wantStatus { t.Errorf("Status code: got %d, want %d", got, tt.wantStatus) } }) } } func TestStreamableOriginProtection(t *testing.T) { server := NewServer(testImpl, nil) tests := []struct { name string protection *http.CrossOriginProtection requestOrigin string wantStatusCode int }{ { name: "no protection with Origin header", protection: nil, requestOrigin: "https://example.com", wantStatusCode: http.StatusOK, }, { name: "custom protection with trusted origin and same Origin", protection: func() *http.CrossOriginProtection { p := http.NewCrossOriginProtection() if err := p.AddTrustedOrigin("https://example.com"); err != nil { t.Fatal(err) } return p }(), requestOrigin: "https://example.com", wantStatusCode: http.StatusOK, }, { name: "custom protection with trusted origin and different Origin", protection: func() *http.CrossOriginProtection { p := http.NewCrossOriginProtection() if err := p.AddTrustedOrigin("https://example.com"); err != nil { t.Fatal(err) } return p }(), requestOrigin: "https://malicious.com", wantStatusCode: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opts := &StreamableHTTPOptions{ Stateless: true, // avoid session ID requirement CrossOriginProtection: tt.protection, } handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) httpServer := httptest.NewServer(handler) defer httpServer.Close() reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) req, err := http.NewRequest(http.MethodPost, httpServer.URL, reqReader) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", tt.requestOrigin) req.Header.Set("Accept", "application/json, text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if got := resp.StatusCode; got != tt.wantStatusCode { t.Errorf("Status code: got %d, want %d", got, tt.wantStatusCode) } }) } } // TestStandaloneSSEEmitsCommentForHTTP2Flush is a regression test for the // HTTP/2 reverse-proxy header-buffering bug. The standalone SSE GET stream // must emit at least one body byte (an SSE comment) immediately after the // response headers. Without a DATA frame, HTTP/2 reverse proxies (Envoy, // Caddy, net/http/httputil, etc.) buffer the HEADERS frame indefinitely // because they have nothing to coalesce it with — calling Flush() alone is // not sufficient. // // SSE explicitly defines lines starting with ":" as comments that clients // ignore, so this is behavior-preserving for the SSE protocol while // producing the DATA frame that HTTP/2 proxies need. func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) { server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Initialize a session so we have a valid Mcp-Session-Id to use on the // standalone SSE GET. initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) initReq, err := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, initBody) if err != nil { t.Fatal(err) } initReq.Header.Set("Content-Type", "application/json") initReq.Header.Set("Accept", "application/json, text/event-stream") initResp, err := http.DefaultClient.Do(initReq) if err != nil { t.Fatal(err) } io.Copy(io.Discard, initResp.Body) initResp.Body.Close() sessionID := initResp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatalf("initialize response missing %s header", sessionIDHeader) } // Open the standalone SSE stream. getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpServer.URL, nil) if err != nil { t.Fatal(err) } getReq.Header.Set("Accept", "text/event-stream") getReq.Header.Set(sessionIDHeader, sessionID) resp, err := http.DefaultClient.Do(getReq) if err != nil { t.Fatalf("GET standalone SSE: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d; want 200", resp.StatusCode) } if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") { t.Fatalf("Content-Type = %q; want text/event-stream", got) } // Read the first chunk. With the fix, an SSE comment (": ok\n\n") is // written immediately. Without the fix, the body has no bytes to read // until the server pushes an event, which won't happen in this test. type readResult struct { n int err error buf []byte } ch := make(chan readResult, 1) go func() { buf := make([]byte, 64) n, err := resp.Body.Read(buf) ch <- readResult{n: n, err: err, buf: buf} }() select { case r := <-ch: if r.err != nil && r.err != io.EOF { t.Fatalf("reading first SSE chunk: %v", r.err) } if r.n == 0 { t.Fatal("first SSE chunk was empty; expected an SSE comment to flush HTTP/2 proxy HEADERS frame") } got := string(r.buf[:r.n]) // SSE spec: lines starting with ":" are comments, ignored by clients. // We don't pin the exact comment text; just require the first byte to // be the SSE comment marker so HTTP/2 proxies have a DATA frame. if !strings.HasPrefix(got, ":") { t.Errorf("first SSE chunk = %q; want it to start with %q (SSE comment marker, so HTTP/2 proxies receive a DATA frame and forward HEADERS)", got, ":") } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for first SSE bytes; the standalone SSE stream must emit a DATA frame immediately so HTTP/2 reverse proxies don't buffer the HEADERS frame") } } // newProtocolBody builds a raw JSON body for a tools/call request that // carries the >= 2026-07-28 per-request _meta fields. func newProtocolBody(t *testing.T, toolName string, args any) []byte { t.Helper() rawArgs, err := json.Marshal(args) if err != nil { t.Fatal(err) } body, err := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{ "_meta": map[string]any{ MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, }, "name": toolName, "arguments": json.RawMessage(rawArgs), }, }) if err != nil { t.Fatal(err) } return body } func TestEphemeralConnectOpts(t *testing.T) { mkReq := func(body []byte) *http.Request { r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") return r } h := &StreamableHTTPHandler{opts: StreamableHTTPOptions{}} oldProtocolBody, err := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "x", "arguments": map[string]any{}}, }) if err != nil { t.Fatal(err) } initializeBody, err := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": methodInitialize, "params": map[string]any{"protocolVersion": protocolVersion20250618}, }) if err != nil { t.Fatal(err) } tests := []struct { name string body []byte wantUsesNew bool wantInitializeParams bool wantInitializedParams bool }{ { name: "new-protocol request: no synthetic state", body: newProtocolBody(t, "x", struct{}{}), wantUsesNew: true, wantInitializeParams: false, wantInitializedParams: false, }, { name: "old-protocol request: synthetic state populated", body: oldProtocolBody, wantUsesNew: false, wantInitializeParams: true, wantInitializedParams: true, }, { name: "initialize request: no synthetic InitializeParams", body: initializeBody, wantUsesNew: false, wantInitializeParams: false, wantInitializedParams: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := mkReq(tt.body) var pver string if tt.wantUsesNew { pver = protocolVersion20260728 } else { pver = protocolVersion20250326 } req.Header.Set(protocolVersionHeader, pver) req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, pver)) info, err := h.ephemeralConnectOpts(req) if err != nil { t.Fatal(err) } if info.usesNewProtocol != tt.wantUsesNew { t.Errorf("usesNewProtocol = %v, want %v", info.usesNewProtocol, tt.wantUsesNew) } if got := info.opts.State.InitializeParams != nil; got != tt.wantInitializeParams { t.Errorf("InitializeParams non-nil = %v, want %v (value = %+v)", got, tt.wantInitializeParams, info.opts.State.InitializeParams) } if got := info.opts.State.InitializedParams != nil; got != tt.wantInitializedParams { t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)", got, tt.wantInitializedParams, info.opts.State.InitializedParams) } }) } } // statelessHandlerCapture builds a stateless server with a single tool whose // handler captures everything we want to assert about the per-request view of // the session and the new-protocol accessors. type statelessHandlerCapture struct { mu sync.Mutex sessionInitParams *InitializeParams reqProtocolVersion string reqClientInfo *Implementation reqClientCapabilities *ClientCapabilities } func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { // SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol // requests and must be a supported version. The 2026-07-28 version is // not yet in the global list, so register it for the duration of the test. capture := &statelessHandlerCapture{} mcpServer := NewServer(testImpl, nil) AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"}, func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { capture.mu.Lock() defer capture.mu.Unlock() capture.sessionInitParams = req.Session.InitializeParams() capture.reqProtocolVersion = req.ProtocolVersion() capture.reqClientInfo = req.ClientInfo() capture.reqClientCapabilities = req.ClientCapabilities() return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil }) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return mcpServer }, &StreamableHTTPOptions{Stateless: true}, ) httpServer := httptest.NewServer(handler) defer httpServer.Close() body := newProtocolBody(t, "capture", struct{}{}) httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) if err != nil { t.Fatal(err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json, text/event-stream") httpReq.Header.Set(protocolVersionHeader, protocolVersion20260728) // >= 2026-07-28 also requires the Mcp-Method and Mcp-Name standard // headers (see streamable_headers.go). httpReq.Header.Set(methodHeader, "tools/call") httpReq.Header.Set(nameHeader, "capture") resp, err := http.DefaultClient.Do(httpReq) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) } capture.mu.Lock() defer capture.mu.Unlock() if capture.sessionInitParams == nil { t.Errorf("Session.InitializeParams() is nil, want populated initializeParams for new-protocol session") } else { if got, want := capture.sessionInitParams.ProtocolVersion, protocolVersion20260728; got != want { t.Errorf("Session.InitializeParams().ProtocolVersion = %q, want %q", got, want) } if got, want := capture.sessionInitParams.ClientInfo.Name, "new-proto-client"; got != want { t.Errorf("Session.InitializeParams().ClientInfo.Name = %q, want %q", got, want) } } if got, want := capture.reqProtocolVersion, protocolVersion20260728; got != want { t.Errorf("req.ProtocolVersion() = %q, want %q", got, want) } if capture.reqClientInfo == nil || capture.reqClientInfo.Name != "new-proto-client" { t.Errorf("req.ClientInfo() = %+v, want Name=new-proto-client", capture.reqClientInfo) } if capture.reqClientCapabilities == nil || capture.reqClientCapabilities.Sampling == nil { t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities) } } // TestStreamableStateful_RejectsNewProtocol verifies that a stateful HTTP // server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-07-28 // requests) with HTTP 400. The new protocol is // supported on HTTP only when StreamableHTTPOptions.Stateless=true. func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { // Make 2026-07-28 a "known" version so that the request reaches servePOST // (otherwise the early header validation at ServeHTTP rejects it). server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil }) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(handler) defer httpServer.Close() // Initialize a legacy session first. initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody) if err != nil { t.Fatal(err) } initReq.Header.Set("Content-Type", "application/json") initReq.Header.Set("Accept", "application/json, text/event-stream") initResp, err := http.DefaultClient.Do(initReq) if err != nil { t.Fatal(err) } io.Copy(io.Discard, initResp.Body) initResp.Body.Close() sessionID := initResp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatalf("initialize response missing %s header", sessionIDHeader) } // Drive the existing session with a new-protocol request whose header and // body agree. The cross-check passes; the stateful-rejection check fires. body := newProtocolBody(t, "noop", struct{}{}) req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set(sessionIDHeader, sessionID) req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, "tools/call") req.Header.Set(nameHeader, "noop") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) } msg, err := jsonrpc.DecodeMessage(respBody) if err != nil { t.Fatalf("decoding response body as JSON-RPC: %v; body = %s", err, respBody) } jresp, ok := msg.(*jsonrpc.Response) if !ok || jresp.Error == nil { t.Fatalf("response is not a JSON-RPC error: %s", respBody) } var jerr *jsonrpc.Error if !errors.As(jresp.Error, &jerr) { t.Fatalf("response error is not *jsonrpc.Error: %v", jresp.Error) } if jerr.Code != CodeUnsupportedProtocolVersion { t.Errorf("error code = %d, want %d (CodeUnsupportedProtocolVersion)", jerr.Code, CodeUnsupportedProtocolVersion) } var data UnsupportedProtocolVersionData if err := json.Unmarshal(jerr.Data, &data); err != nil { t.Fatalf("decoding error data: %v; data = %s", err, jerr.Data) } if data.Requested != protocolVersion20260728 { t.Errorf("data.Requested = %q, want %q", data.Requested, protocolVersion20260728) } if slices.Contains(data.Supported, protocolVersion20260728) { t.Errorf("data.Supported = %v, must not contain the rejected version %q", data.Supported, protocolVersion20260728) } if len(data.Supported) == 0 { t.Errorf("data.Supported is empty; expected at least one legacy version") } } // TestStreamableStateful_RejectsNewProtocol_LegacyPlainText verifies that // MCPGODEBUG=plaintextstatefulrejection=1 restores the pre-v1.8.0 plain-text // 400 body for the stateful/new-protocol rejection. func TestStreamableStateful_RejectsNewProtocol_LegacyPlainText(t *testing.T) { prev := plaintextstatefulrejection plaintextstatefulrejection = "1" t.Cleanup(func() { plaintextstatefulrejection = prev }) server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil }) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(handler) defer httpServer.Close() initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody) if err != nil { t.Fatal(err) } initReq.Header.Set("Content-Type", "application/json") initReq.Header.Set("Accept", "application/json, text/event-stream") initResp, err := http.DefaultClient.Do(initReq) if err != nil { t.Fatal(err) } io.Copy(io.Discard, initResp.Body) initResp.Body.Close() sessionID := initResp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatalf("initialize response missing %s header", sessionIDHeader) } body := newProtocolBody(t, "noop", struct{}{}) req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set(sessionIDHeader, sessionID) req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, "tools/call") req.Header.Set(nameHeader, "noop") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { t.Errorf("Content-Type = %q, want text/plain; body = %s", ct, respBody) } if !strings.Contains(string(respBody), "stateless") { t.Errorf("body = %q, want a message mentioning 'stateless'", respBody) } } // TestCallCancellation_FastReturn verifies that a cancelled tool call // returns as soon as its context is cancelled, even when the peer is slow // to accept the follow-up notifications/cancelled POST. func TestCallCancellation_FastReturn(t *testing.T) { // stallDuration is the time the middleware holds a // notifications/cancelled POST. It must be larger than // notifyCancellationTimeout so the blocking branch clearly overshoots // even after cushioning for scheduler jitter. const stallDuration = notifyCancellationTimeout + 2*time.Second // callerDeadline is the deadline the client passes into CallTool. It // must be short enough that any waiting on the cancel notification is // obvious in the measured elapsed time. const callerDeadline = 100 * time.Millisecond // slack accounts for scheduling jitter around the deadline. const slack = 500 * time.Millisecond stallCancelNotify := func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && r.Body != nil { body, _ := io.ReadAll(r.Body) r.Body = io.NopCloser(bytes.NewReader(body)) if bytes.Contains(body, []byte(notificationCancelled)) { time.Sleep(stallDuration) } } h.ServeHTTP(w, r) }) } server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "slow"}, func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { // Bound the handler above the caller deadline so the test // always exercises the cancellation path, but not so long // that a stuck cancellation makes the test slow. select { case <-ctx.Done(): case <-time.After(2 * stallDuration): } return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil }) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(stallCancelNotify(handler)) t.Cleanup(httpServer.Close) client := NewClient(testImpl, nil) cs, err := client.Connect(context.Background(), &StreamableClientTransport{Endpoint: httpServer.URL}, nil) if err != nil { t.Fatalf("client connect: %v", err) } t.Cleanup(func() { cs.Close() }) callCtx, cancel := context.WithTimeout(context.Background(), callerDeadline) defer cancel() start := time.Now() _, err = cs.CallTool(callCtx, &CallToolParams{Name: "slow"}) elapsed := time.Since(start) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("CallTool error = %v, want context.DeadlineExceeded", err) } // The caller should return within slack of its own deadline. if elapsed > callerDeadline+slack { t.Errorf("CallTool returned after %v; want <= %v (caller deadline %v + slack %v)", elapsed, callerDeadline+slack, callerDeadline, slack) } } // TestStreamableStateless_AcceptsNewProtocol is the positive control: // confirms that a stateless server still accepts new-protocol requests // (the rejection in TestStreamableStateful_RejectsNewProtocol must not // fire on Stateless: true). func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil }) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: true}, ) httpServer := httptest.NewServer(handler) defer httpServer.Close() body := newProtocolBody(t, "noop", struct{}{}) req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, "tools/call") req.Header.Set(nameHeader, "noop") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) } } // TestStreamableStateless_NotificationMetaValidation checks that the SEP-2575 // per-request `_meta` triple is required of calls only. NotificationParams // declares `_meta` optional with no protocolVersion, so a notification that // omits it is well-formed and must be accepted with 202. func TestStreamableStateless_NotificationMetaValidation(t *testing.T) { newProtocolMeta := map[string]any{ MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, MetaKeyClientCapabilities: map[string]any{}, } tests := []struct { name string message map[string]any wantStatus int }{ { name: "cancelled without meta", message: map[string]any{ "jsonrpc": "2.0", "method": notificationCancelled, "params": map[string]any{"requestID": 1, "reason": "context canceled"}, }, wantStatus: http.StatusAccepted, }, { name: "progress without meta", message: map[string]any{ "jsonrpc": "2.0", "method": notificationProgress, "params": map[string]any{"progressToken": "t", "progress": 1}, }, wantStatus: http.StatusAccepted, }, { name: "notification with matching meta", message: map[string]any{ "jsonrpc": "2.0", "method": notificationCancelled, "params": map[string]any{ "_meta": newProtocolMeta, "requestID": 1, }, }, wantStatus: http.StatusAccepted, }, { // A notification that volunteers a version is still held to the // header-match rule, so relaxing the requirement does not open a // hole for inconsistent messages. name: "notification with mismatched meta", message: map[string]any{ "jsonrpc": "2.0", "method": notificationCancelled, "params": map[string]any{ "_meta": map[string]any{ MetaKeyProtocolVersion: "2025-06-18", MetaKeyClientCapabilities: map[string]any{}, }, "requestID": 1, }, }, wantStatus: http.StatusBadRequest, }, { // Calls remain subject to the requirement. name: "call without meta", message: map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": map[string]any{}, }, wantStatus: http.StatusBadRequest, }, } server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil }) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: true}, ) httpServer := httptest.NewServer(handler) defer httpServer.Close() for _, test := range tests { t.Run(test.name, func(t *testing.T) { body, err := json.Marshal(test.message) if err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, test.message["method"].(string)) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != test.wantStatus { t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, respBody) } if test.wantStatus == http.StatusAccepted && len(respBody) > 0 { t.Errorf("accepted notification returned body %q, want empty", respBody) } }) } } // TestStreamableClientUnsupportedVersionFallback exercises the full // SEP-2575 fallback. The client requests protocolVersion20260728, which the // server is configured to NOT advertise in supportedProtocolVersions for the // duration of this test. The server therefore rejects the server/discover // POST at the transport-level header validation with a plain HTTP 400 ("Bad // Request: Unsupported protocol version ..."). The streamable client must // recognize this body, keep the connection alive, and successfully complete // the legacy initialize handshake. func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { // Remove protocolVersion20260728 from the server-side supported list so // the fallback path is exercised. orig := supportedProtocolVersions supportedProtocolVersions = slices.DeleteFunc(slices.Clone(orig), func(v string) bool { return v == protocolVersion20260728 }) t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return server }, nil, ) httpServer := httptest.NewServer(handler) defer httpServer.Close() client := NewClient(testImpl, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } defer session.Close() ir := session.InitializeResult() if ir == nil { t.Fatal("InitializeResult is nil; expected legacy initialize to populate it") } // The fallback initialize explicitly requests protocolVersion20251125 // (see client.go), so the server negotiates that version. if ir.ProtocolVersion != protocolVersion20251125 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)", ir.ProtocolVersion, protocolVersion20251125) } // Verify the session is fully usable after the fallback by issuing a // real call against the server. if err := session.Ping(ctx, nil); err != nil { t.Errorf("Ping after fallback initialize: %v", err) } } // TestStreamableStateful_AcceptsDiscover verifies that a stateful HTTP server // accepts a server/discover probe carrying MCP-Protocol-Version: 2026-07-28 // (and the matching _meta.protocolVersion), instead of rejecting it with the // "stateless required" 400. The SEP-2575 client flow has the client probing // the server with the new protocol version to learn which versions are // supported. func TestStreamableStateful_AcceptsDiscover(t *testing.T) { server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() body, err := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": methodDiscover, "params": map[string]any{ "_meta": map[string]any{ MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, }, }, }) if err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, methodDiscover) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) } // Parse the JSON-RPC response. The body may arrive as a bare JSON object // or as a single SSE event depending on the Accept negotiation; both // shapes are valid here. jsonPayload := respBody if i := bytes.Index(respBody, []byte("data: ")); i >= 0 { jsonPayload = respBody[i+len("data: "):] if j := bytes.IndexByte(jsonPayload, '\n'); j >= 0 { jsonPayload = jsonPayload[:j] } } var rpcResp struct { Result *DiscoverResult `json:"result"` Error *jsonrpc.Error `json:"error"` } if err := json.Unmarshal(jsonPayload, &rpcResp); err != nil { t.Fatalf("unmarshal response %q: %v", respBody, err) } if rpcResp.Error != nil { t.Fatalf("discover returned error: %+v (body = %s)", rpcResp.Error, respBody) } if rpcResp.Result == nil { t.Fatalf("discover returned no result; body = %s", respBody) } if slices.Contains(rpcResp.Result.SupportedVersions, protocolVersion20260728) { t.Errorf("DiscoverResult.SupportedVersions = %v, must not include %q on a stateful transport", rpcResp.Result.SupportedVersions, protocolVersion20260728) } if len(rpcResp.Result.SupportedVersions) == 0 { t.Errorf("DiscoverResult.SupportedVersions is empty; want at least one legacy version") } } // On a stateful StreamableHTTPHandler, a v1.7.0-style client always // probes server/discover before falling back to initialize. That probe used // to create a ServerSession that was registered in the handler's session // map but never surfaced via Mcp-Session-Id, so the client could not DELETE // it and it was leaked for the lifetime of the process. // // The fix in Server.discover skips populating InitializeParams when the // transport cannot serve the new protocol, which lets the safety-net // cleanup in serveStatefulPOST close the otherwise-unaddressable session. func TestStreamableStateful_DiscoverDoesNotLeakSession(t *testing.T) { ctx := context.Background() server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "noop", Description: "noop"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { return &CallToolResult{}, nil, nil }) // Stateful (default). SessionTimeout unset so nothing masks the leak // via idle eviction. handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) httpServer := httptest.NewServer(handler) defer httpServer.Close() countSessions := func() int { n := 0 for range server.Sessions() { n++ } return n } const clients = 3 for i := 1; i <= clients; i++ { c := NewClient(&Implementation{Name: "c", Version: "1.0.0"}, nil) sess, err := c.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, DisableStandaloneSSE: true, MaxRetries: -1, }, nil) if err != nil { t.Fatalf("client %d Connect: %v", i, err) } if _, err := sess.ListTools(ctx, nil); err != nil { t.Fatalf("client %d ListTools: %v", i, err) } // Exactly one addressable session should exist while this client // is connected. Anything more means a discover-only session // slipped through. if got := countSessions(); got != 1 { t.Errorf("client %d connected: sessions = %d, want 1", i, got) } if err := sess.Close(); err != nil { t.Fatalf("client %d Close: %v", i, err) } if got := countSessions(); got != 0 { t.Errorf("client %d closed: sessions = %d, want 0", i, got) } } if got := countSessions(); got != 0 { t.Errorf("final leaked sessions = %d, want 0", got) } } // TestStreamableHTTP_E2E_DiscoverSuccess is a full end-to-end smoke test for // SEP-2575 over the streamable HTTP transport. func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { ctx := context.Background() server := NewServer(&Implementation{Name: "e2e-server", Version: "v1"}, nil) // Register a simple tool so we can prove the session is usable end-to-end. AddTool(server, &Tool{Name: "echo", Description: "echoes its input"}, func(_ context.Context, _ *CallToolRequest, args struct { Msg string `json:"msg"` }) (*CallToolResult, struct{}, error) { return &CallToolResult{ Content: []Content{&TextContent{Text: args.Msg}}, }, struct{}{}, nil }, ) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: true}, ) httpServer := httptest.NewServer(handler) defer httpServer.Close() client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } defer cs.Close() ir := cs.InitializeResult() if ir == nil { t.Fatal("InitializeResult is nil after Connect; discover should have populated it") } if ir.ProtocolVersion != protocolVersion20260728 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover)", ir.ProtocolVersion, protocolVersion20260728) } if ir.ServerInfo == nil || ir.ServerInfo.Name != "e2e-server" { t.Errorf("InitializeResult.ServerInfo = %+v, want name=e2e-server", ir.ServerInfo) } // Prove the session is fully usable: list tools and call one. tools, err := cs.ListTools(ctx, nil) if err != nil { t.Fatalf("ListTools: %v", err) } if len(tools.Tools) != 1 || tools.Tools[0].Name != "echo" { t.Errorf("ListTools = %+v, want one tool named 'echo'", tools.Tools) } res, err := cs.CallTool(ctx, &CallToolParams{ Name: "echo", Arguments: map[string]any{"msg": "hello"}, }) if err != nil { t.Fatalf("CallTool: %v", err) } if len(res.Content) != 1 { t.Fatalf("CallTool result content = %+v, want 1 entry", res.Content) } tc, ok := res.Content[0].(*TextContent) if !ok || tc.Text != "hello" { t.Errorf("CallTool result[0] = %+v, want TextContent{Text:\"hello\"}", res.Content[0]) } } // A POST whose call ID is already in flight on the same session must be rejected // with a JSON-RPC error, must not overwrite the existing request-to-stream // mapping, and must not publish the duplicate message to the session's incoming // channel. func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) { id := jsonrpc2.Int64ID(1) conn := &streamableServerConn{ logger: ensureLogger(nil), incoming: make(chan jsonrpc.Message, 1), done: make(chan struct{}), streams: map[string]*stream{ "existing": { id: "existing", logger: ensureLogger(nil), requests: map[jsonrpc.ID]struct{}{id: {}}, }, }, requestStreams: map[jsonrpc.ID]string{id: "existing"}, } data, err := jsonrpc2.EncodeMessage(req(1, methodPing, &PingParams{})) if err != nil { t.Fatalf("EncodeMessage() error = %v", err) } ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) defer cancel() httpReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(data)) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json, text/event-stream") rec := httptest.NewRecorder() conn.servePOST(rec, httpReq) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } msg, err := jsonrpc2.DecodeMessage(rec.Body.Bytes()) if err != nil { t.Fatalf("DecodeMessage() error = %v; body = %s", err, rec.Body.String()) } resp, ok := msg.(*jsonrpc.Response) if !ok { t.Fatalf("response type = %T, want *jsonrpc.Response", msg) } if got := resp.ID.Raw(); got != int64(1) { t.Fatalf("response ID = %v, want 1", got) } var jerr *jsonrpc.Error if !errors.As(resp.Error, &jerr) { t.Fatalf("response error = %v, want *jsonrpc.Error", resp.Error) } if jerr.Code != jsonrpc.CodeInvalidRequest { t.Fatalf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidRequest) } if got := conn.requestStreams[id]; got != "existing" { t.Fatalf("requestStreams[%v] = %q, want %q", id, got, "existing") } select { case msg := <-conn.incoming: t.Fatalf("duplicate request was published to incoming: %v", msg) default: } } // TestStreamableServerPreservesSingleRequestBatchResponse verifies that a // single-request JSON-RPC batch is returned as a one-element JSON array. func TestStreamableServerPreservesSingleRequestBatchResponse(t *testing.T) { server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return server }, &StreamableHTTPOptions{JSONResponse: true}, ) defer handler.closeAll() httpServer := httptest.NewServer(handler) defer httpServer.Close() body := []byte(`[{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0"}}}]`) req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusOK, data) } var messages []json.RawMessage if err := json.Unmarshal(data, &messages); err != nil { t.Fatalf("response is not a JSON array: %v; body = %s", err, data) } if len(messages) != 1 { t.Fatalf("response contains %d messages, want 1; body = %s", len(messages), data) } if _, err := jsonrpc2.DecodeMessage(messages[0]); err != nil { t.Fatalf("response item is not a JSON-RPC message: %v", err) } } // TestStreamableMaxRequestBodyBytes verifies that the streamable HTTP handler // enforces StreamableHTTPOptions.MaxRequestBodyBytes on incoming request // bodies. The limit must apply uniformly regardless of transfer encoding: // Content-Length, Transfer-Encoding: chunked, or HTTP/2 (which has no // Content-Length). func TestStreamableMaxRequestBodyBytes(t *testing.T) { // A minimally-valid JSON-RPC initialize request. The body-size check runs // before parsing, so exact contents don't matter for the reject cases, // but we use a real one for the "under limit" success case. validInit := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"v1"}}}`) // Build a payload of the requested size by padding a JSON object. The // server doesn't need to parse it; the MaxBytesReader trips first. oversizedBody := func(n int) []byte { b := make([]byte, n) b[0] = '{' for i := 1; i < n-1; i++ { b[i] = ' ' } b[n-1] = '}' return b } tests := []struct { name string limit int64 // value assigned to MaxRequestBodyBytes body []byte wantStatus int wantStatusDesc string }{ { name: "default limit rejects oversized", limit: 0, // triggers DefaultMaxRequestBodyBytes (4 MiB) body: oversizedBody(DefaultMaxRequestBodyBytes + 1), wantStatus: http.StatusRequestEntityTooLarge, wantStatusDesc: "413 for body > 4MiB default", }, { name: "custom limit rejects oversized", limit: 1024, body: oversizedBody(2048), wantStatus: http.StatusRequestEntityTooLarge, wantStatusDesc: "413 for body > custom 1KiB limit", }, { name: "chunked encoding does not bypass limit", limit: 1024, body: oversizedBody(4096), wantStatus: http.StatusRequestEntityTooLarge, wantStatusDesc: "413 for chunked body > limit (regression: Content-Length-only checks are bypassable)", }, { name: "negative limit disables the check", limit: -1, body: oversizedBody(2 * DefaultMaxRequestBodyBytes), wantStatus: http.StatusBadRequest, // body is not valid JSON-RPC; but the size check must not fire wantStatusDesc: "not 413 when limit is disabled", }, { name: "under limit succeeds", limit: 64 * 1024, body: validInit, wantStatus: http.StatusOK, wantStatusDesc: "200 for a valid initialize under the limit", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler( func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, MaxRequestBodyBytes: tc.limit, }, ) ts := httptest.NewServer(handler) defer ts.Close() req, err := http.NewRequest(http.MethodPost, ts.URL, bytes.NewReader(tc.body)) if err != nil { t.Fatalf("NewRequest: %v", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") resp, err := ts.Client().Do(req) if err != nil { t.Fatalf("Do: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != tc.wantStatus { t.Fatalf("%s: status = %d, want %d (body=%q)", tc.wantStatusDesc, resp.StatusCode, tc.wantStatus, string(body)) } }) } } // TestStreamableSupportedProtocolVersions_Header verifies that the // MCP-Protocol-Version header is validated against // [ServerOptions.SupportedProtocolVersions], and not merely against the // versions the SDK knows about. func TestStreamableSupportedProtocolVersions_Header(t *testing.T) { const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` newServer := func(t *testing.T, stateless bool) *httptest.Server { server := NewServer(testImpl, &ServerOptions{ SupportedProtocolVersions: []string{protocolVersion20251125}, }) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: stateless}) httpServer := httptest.NewServer(handler) t.Cleanup(httpServer.Close) return httpServer } post := func(t *testing.T, url, version, sessionID string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(initBody)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set(protocolVersionHeader, version) if sessionID != "" { req.Header.Set(sessionIDHeader, sessionID) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return resp } for _, stateless := range []bool{false, true} { for _, test := range []struct { version string wantStatus int }{ {protocolVersion20251125, http.StatusOK}, // Supported by the SDK, but excluded by the server. {protocolVersion20250618, http.StatusBadRequest}, // Unknown to the SDK: rejected before any server is consulted. {"1999-01-01", http.StatusBadRequest}, } { t.Run(fmt.Sprintf("stateless=%v/%s", stateless, test.version), func(t *testing.T) { resp := post(t, newServer(t, stateless).URL, test.version, "") defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != test.wantStatus { t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, body) } }) } } t.Run("existing session", func(t *testing.T) { httpServer := newServer(t, false) resp := post(t, httpServer.URL, protocolVersion20251125, "") io.Copy(io.Discard, resp.Body) resp.Body.Close() sessionID := resp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatalf("initialize response missing %s header", sessionIDHeader) } resp = post(t, httpServer.URL, protocolVersion20250618, sessionID) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusBadRequest { t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, body) } }) }