// 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.

// NOTE: see streamable_server.go and streamable_client.go for detailed
// documentation of the streamable server design.
// TODO: move the client and server logic into those files.

package mcp

import (
	"bytes"
	"context"
	crand "crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"maps"
	"math"
	"math/rand/v2"
	"mime"
	"net"
	"net/http"
	"slices"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/standardbeagle/go-sdk/auth"
	internaljson "github.com/standardbeagle/go-sdk/internal/json"
	"github.com/standardbeagle/go-sdk/internal/jsonrpc2"
	"github.com/standardbeagle/go-sdk/internal/mcpgodebug"
	"github.com/standardbeagle/go-sdk/internal/util"
	"github.com/standardbeagle/go-sdk/internal/xcontext"
	"github.com/standardbeagle/go-sdk/jsonrpc"
)

const (
	protocolVersionHeader = "Mcp-Protocol-Version"
	sessionIDHeader       = "Mcp-Session-Id"
	lastEventIDHeader     = "Last-Event-ID"
)

// A StreamableHTTPHandler is an http.Handler that serves streamable MCP
// sessions, as defined by the [MCP spec].
//
// [MCP spec]: https://modelcontextprotocol.io/2025/03/26/streamable-http-transport.html
type StreamableHTTPHandler struct {
	getServer func(*http.Request) *Server
	opts      StreamableHTTPOptions

	onTransportDeletion func(sessionID string) // for testing

	mu       sync.Mutex
	sessions map[string]*sessionInfo // keyed by session ID
}

type sessionInfo struct {
	session   *ServerSession
	transport *StreamableServerTransport
	// userID is the user ID from the TokenInfo when the session was created.
	// If non-empty, subsequent requests must have the same user ID to prevent
	// session hijacking.
	userID string

	// If timeout is set, automatically close the session after an idle period.
	timeout time.Duration
	timerMu sync.Mutex
	refs    int // reference count
	timer   *time.Timer
}

// startPOST signals that a POST request for this session is starting (which
// carries a client->server message), pausing the session timeout if it was
// running.
//
// TODO: we may want to also pause the timer when resuming non-standalone SSE
// streams, but that is tricy to implement. Clients should generally make
// keepalive pings if they want to keep the session live.
func (i *sessionInfo) startPOST() {
	if i.timeout <= 0 {
		return
	}

	i.timerMu.Lock()
	defer i.timerMu.Unlock()

	if i.timer == nil {
		return // timer stopped permanently
	}
	if i.refs == 0 {
		i.timer.Stop()
	}
	i.refs++
}

// endPOST signals that a request for this session is ending, starting the
// timeout if there are no other requests running.
func (i *sessionInfo) endPOST() {
	if i.timeout <= 0 {
		return
	}

	i.timerMu.Lock()
	defer i.timerMu.Unlock()

	if i.timer == nil {
		return // timer stopped permanently
	}

	i.refs--
	assert(i.refs >= 0, "negative ref count")
	if i.refs == 0 {
		i.timer.Reset(i.timeout)
	}
}

// stopTimer stops the inactivity timer permanently.
func (i *sessionInfo) stopTimer() {
	i.timerMu.Lock()
	defer i.timerMu.Unlock()
	if i.timer != nil {
		i.timer.Stop()
		i.timer = nil
	}
}

// StreamableHTTPOptions configures the StreamableHTTPHandler.
type StreamableHTTPOptions struct {
	// Stateless controls whether the session is 'stateless'.
	//
	// A stateless server does not validate the Mcp-Session-Id header, and uses a
	// temporary session with default initialization parameters. Any
	// server->client request is rejected immediately as there's no way for the
	// client to respond. Server->Client notifications may reach the client if
	// they are made in the context of an incoming request, as described in the
	// documentation for [StreamableServerTransport].
	Stateless bool

	// TODO(#148): support session retention (?)

	// JSONResponse causes streamable responses to return application/json rather
	// than text/event-stream ([§2.1.5] of the spec).
	//
	// [§2.1.5]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
	JSONResponse bool

	// Logger specifies the logger to use.
	// If nil, do not log.
	Logger *slog.Logger

	// EventStore enables stream resumption.
	//
	// If set, EventStore will be used to persist stream events and replay them
	// upon stream resumption.
	EventStore EventStore

	// SessionTimeout configures a timeout for idle sessions.
	//
	// When sessions receive no new HTTP requests from the client for this
	// duration, they are automatically closed.
	//
	// If SessionTimeout is the zero value, idle sessions are never closed.
	SessionTimeout time.Duration

	// DisableLocalhostProtection disables automatic DNS rebinding protection.
	// By default, requests arriving via a localhost address (127.0.0.1, [::1])
	// that have a non-localhost Host header are rejected with 403 Forbidden.
	// This protects against DNS rebinding attacks regardless of whether the
	// server is listening on localhost specifically or on 0.0.0.0.
	//
	// Only disable this if you understand the security implications.
	// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
	DisableLocalhostProtection bool

	// CrossOriginProtection allows to customize cross-origin protection.
	// The deny handler set in the CrossOriginProtection through SetDenyHandler
	// is ignored.
	// If nil, default (zero-value) cross-origin protection will be used.
	// Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter
	// to disable the default protection until v1.7.0.
	CrossOriginProtection *http.CrossOriginProtection
}

// NewStreamableHTTPHandler returns a new [StreamableHTTPHandler].
//
// The getServer function is used to create or look up servers for new
// sessions. It is OK for getServer to return the same server multiple times.
// If getServer returns nil, a 400 Bad Request will be served.
func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *StreamableHTTPOptions) *StreamableHTTPHandler {
	h := &StreamableHTTPHandler{
		getServer: getServer,
		sessions:  make(map[string]*sessionInfo),
	}
	if opts != nil {
		h.opts = *opts
	}

	h.opts.Logger = ensureLogger(h.opts.Logger)

	if h.opts.CrossOriginProtection == nil {
		h.opts.CrossOriginProtection = &http.CrossOriginProtection{}
	}

	return h
}

// closeAll closes all ongoing sessions, for tests.
//
// TODO(rfindley): investigate the best API for callers to configure their
// session lifecycle. (?)
//
// Should we allow passing in a session store? That would allow the handler to
// be stateless.
func (h *StreamableHTTPHandler) closeAll() {
	// TODO: if we ever expose this outside of tests, we'll need to do better
	// than simply collecting sessions while holding the lock: we need to prevent
	// new sessions from being added.
	//
	// Currently, sessions remove themselves from h.sessions when closed, so we
	// can't call Close while holding the lock.
	h.mu.Lock()
	sessionInfos := slices.Collect(maps.Values(h.sessions))
	h.sessions = nil
	h.mu.Unlock()
	for _, s := range sessionInfos {
		s.session.Close()
	}
}

// disablelocalhostprotection is a compatibility parameter that allows to disable
// DNS rebinding protection, which was added in the 1.4.0 version of the SDK.
// See the documentation for the mcpgodebug package for instructions how to enable it.
// The option will be removed in the 1.7.0 version of the SDK.
var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection")

// disablecrossoriginprotection is a compatibility parameter that allows to disable
// the verification of the 'Origin' and 'Content-Type' headers, which was added in
// the 1.4.1 version of the SDK. See the documentation for the mcpgodebug package
// for instructions how to enable it.
// The option will be removed in the 1.7.0 version of the SDK.
var disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection")

func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	// DNS rebinding protection: auto-enabled for localhost servers.
	// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
	if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" {
		if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil {
			if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) {
				http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden)
				return
			}
		}
	}

	if disablecrossoriginprotection != "1" {
		// Verify the 'Origin' header to protect against CSRF attacks.
		if err := h.opts.CrossOriginProtection.Check(req); err != nil {
			http.Error(w, err.Error(), http.StatusForbidden)
			return
		}
		// Validate 'Content-Type' header.
		if req.Method == http.MethodPost {
			mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
			if err != nil || mediaType != "application/json" {
				http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
				return
			}
		}
	}

	// Allow multiple 'Accept' headers.
	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax
	jsonOK, streamOK := streamableAccepts(req.Header.Values("Accept"))

	if req.Method == http.MethodGet {
		if !streamOK {
			http.Error(w, "Accept must contain 'text/event-stream' for GET requests", http.StatusBadRequest)
			return
		}
	} else if (!jsonOK || !streamOK) && req.Method != http.MethodDelete { // TODO: consolidate with handling of http method below.
		http.Error(w, "Accept must contain both 'application/json' and 'text/event-stream'", http.StatusBadRequest)
		return
	}

	sessionID := req.Header.Get(sessionIDHeader)
	var sessInfo *sessionInfo
	if sessionID != "" {
		h.mu.Lock()
		sessInfo = h.sessions[sessionID]
		h.mu.Unlock()
		if sessInfo == nil && !h.opts.Stateless {
			// Unless we're in 'stateless' mode, which doesn't perform any Session-ID
			// validation, we require that the session ID matches a known session.
			//
			// In stateless mode, a temporary transport is be created below.
			http.Error(w, "session not found", http.StatusNotFound)
			return
		}
		// Prevent session hijacking: if the session was created with a user ID,
		// verify that subsequent requests come from the same user.
		if sessInfo != nil && sessInfo.userID != "" {
			tokenInfo := auth.TokenInfoFromContext(req.Context())
			if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID {
				http.Error(w, "session user mismatch", http.StatusForbidden)
				return
			}
		}
	}

	if req.Method == http.MethodDelete {
		if sessionID == "" {
			http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest)
			return
		}
		if sessInfo != nil { // sessInfo may be nil in stateless mode
			// Closing the session also removes it from h.sessions, due to the
			// onClose callback.
			sessInfo.session.Close()
		}
		w.WriteHeader(http.StatusNoContent)
		return
	}

	switch req.Method {
	case http.MethodPost, http.MethodGet:
		if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") {
			if h.opts.Stateless {
				// Per MCP spec: server MUST return 405 if it doesn't offer SSE stream.
				// In stateless mode, GET (SSE streaming) is not supported.
				// RFC 9110 §15.5.6: 405 responses MUST include Allow header.
				w.Header().Set("Allow", "POST")
				http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
			} else {
				// In stateful mode, GET is supported but requires a session ID.
				// This is a precondition error, similar to DELETE without session.
				http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest)
			}
			return
		}
	default:
		// RFC 9110 §15.5.6: 405 responses MUST include Allow header.
		if h.opts.Stateless {
			w.Header().Set("Allow", "POST")
		} else {
			w.Header().Set("Allow", "GET, POST, DELETE")
		}
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
		return
	}

	// [§2.7] of the spec (2025-06-18) states:
	//
	// "If using HTTP, the client MUST include the MCP-Protocol-Version:
	// <protocol-version> HTTP header on all subsequent requests to the MCP
	// server, allowing the MCP server to respond based on the MCP protocol
	// version.
	//
	// For example: MCP-Protocol-Version: 2025-06-18
	// The protocol version sent by the client SHOULD be the one negotiated during
	// initialization.
	//
	// For backwards compatibility, if the server does not receive an
	// MCP-Protocol-Version header, and has no other way to identify the version -
	// for example, by relying on the protocol version negotiated during
	// initialization - the server SHOULD assume protocol version 2025-03-26.
	//
	// If the server receives a request with an invalid or unsupported
	// MCP-Protocol-Version, it MUST respond with 400 Bad Request."
	//
	// Since this wasn't present in the 2025-03-26 version of the spec, this
	// effectively means:
	//  1. IF the client provides a version header, it must be a supported
	//     version.
	//  2. In stateless mode, where we've lost the state of the initialize
	//     request, we assume that whatever the client tells us is the truth (or
	//     assume 2025-03-26 if the client doesn't say anything).
	//
	// This logic matches the typescript SDK.
	//
	// [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header
	protocolVersion := req.Header.Get(protocolVersionHeader)
	if protocolVersion == "" {
		protocolVersion = protocolVersion20250326
	}
	if !slices.Contains(supportedProtocolVersions, protocolVersion) {
		http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest)
		return
	}

	if sessInfo == nil {
		server := h.getServer(req)
		if server == nil {
			// The getServer argument to NewStreamableHTTPHandler returned nil.
			http.Error(w, "no server available", http.StatusBadRequest)
			return
		}
		if sessionID == "" {
			// In stateless mode, sessionID may be nonempty even if there's no
			// existing transport.
			sessionID = server.opts.GetSessionID()
		}
		transport := &StreamableServerTransport{
			SessionID:    sessionID,
			Stateless:    h.opts.Stateless,
			EventStore:   h.opts.EventStore,
			jsonResponse: h.opts.JSONResponse,
			logger:       h.opts.Logger,
		}

		// Sessions without a session ID are also stateless: there's no way to
		// address them.
		stateless := h.opts.Stateless || sessionID == ""
		// To support stateless mode, we initialize the session with a default
		// state, so that it doesn't reject subsequent requests.
		var connectOpts *ServerSessionOptions
		if stateless {
			// Peek at the body to see if it is initialize or initialized.
			// We want those to be handled as usual.
			var hasInitialize, hasInitialized bool
			{
				// TODO: verify that this allows protocol version negotiation for
				// stateless servers.
				body, err := io.ReadAll(req.Body)
				if err != nil {
					http.Error(w, "failed to read body", http.StatusBadRequest)
					return
				}
				req.Body.Close()

				// Reset the body so that it can be read later.
				req.Body = io.NopCloser(bytes.NewBuffer(body))

				msgs, _, err := readBatch(body)
				if err == nil {
					for _, msg := range msgs {
						if req, ok := msg.(*jsonrpc.Request); ok {
							switch req.Method {
							case methodInitialize:
								hasInitialize = true
							case notificationInitialized:
								hasInitialized = true
							}
						}
					}
				}
			}

			// If we don't have InitializeParams or InitializedParams in the request,
			// set the initial state to a default value.
			state := new(ServerSessionState)
			if !hasInitialize {
				state.InitializeParams = &InitializeParams{
					ProtocolVersion: protocolVersion,
				}
			}
			if !hasInitialized {
				state.InitializedParams = new(InitializedParams)
			}
			state.LogLevel = "info"
			connectOpts = &ServerSessionOptions{
				State: state,
			}
		} else {
			// Cleanup is only required in stateful mode, as transportation is
			// not stored in the map otherwise.
			connectOpts = &ServerSessionOptions{
				onClose: func() {
					h.mu.Lock()
					defer h.mu.Unlock()
					if info, ok := h.sessions[transport.SessionID]; ok {
						info.stopTimer()
						delete(h.sessions, transport.SessionID)
						if h.onTransportDeletion != nil {
							h.onTransportDeletion(transport.SessionID)
						}
					}
				},
			}
		}

		// Pass req.Context() here, to allow middleware to add context values.
		// The context is detached in the jsonrpc2 library when handling the
		// long-running stream.
		session, err := server.Connect(req.Context(), transport, connectOpts)
		if err != nil {
			http.Error(w, "failed connection", http.StatusInternalServerError)
			return
		}
		// Capture the user ID from the token info to enable session hijacking
		// prevention on subsequent requests.
		var userID string
		if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil {
			userID = tokenInfo.UserID
		}
		sessInfo = &sessionInfo{
			session:   session,
			transport: transport,
			userID:    userID,
		}

		if stateless {
			// Stateless mode: close the session when the request exits.
			defer session.Close() // close the fake session after handling the request
		} else {
			// Otherwise, save the transport so that it can be reused

			// Clean up the session when it times out.
			//
			// Note that the timer here may fire multiple times, but
			// sessInfo.session.Close is idempotent.
			if h.opts.SessionTimeout > 0 {
				sessInfo.timeout = h.opts.SessionTimeout
				sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() {
					sessInfo.session.Close()
				})
			}
			h.mu.Lock()
			h.sessions[transport.SessionID] = sessInfo
			h.mu.Unlock()
			defer func() {
				// If initialization failed, clean up the session (#578).
				if session.InitializeParams() == nil {
					// Initialization failed.
					session.Close()
				}
			}()
		}
	}

	if req.Method == http.MethodPost {
		sessInfo.startPOST()
		defer sessInfo.endPOST()
	}

	sessInfo.transport.ServeHTTP(w, req)
}

func streamableAccepts(values []string) (jsonOK, streamOK bool) {
	for _, value := range values {
		for _, raw := range strings.Split(value, ",") {
			token := strings.TrimSpace(raw)
			// Ignore Accept parameters like ";charset=utf-8"; match the base media type.
			base, _, _ := strings.Cut(token, ";")
			switch strings.ToLower(strings.TrimSpace(base)) {
			case "application/json", "application/*":
				jsonOK = true
			case "text/event-stream", "text/*":
				streamOK = true
			case "*/*":
				jsonOK = true
				streamOK = true
			}
		}
	}
	return jsonOK, streamOK
}

// A StreamableServerTransport implements the server side of the MCP streamable
// transport.
//
// Each StreamableServerTransport must be connected (via [Server.Connect]) at
// most once, since [StreamableServerTransport.ServeHTTP] serves messages to
// the connected session.
//
// Reads from the streamable server connection receive messages from http POST
// requests from the client. Writes to the streamable server connection are
// sent either to the related stream, or to the standalone SSE stream,
// according to the following rules:
//   - JSON-RPC responses to incoming requests are always routed to the
//     appropriate HTTP response.
//   - Requests or notifications made with a context.Context value derived from
//     an incoming request handler, are routed to the HTTP response
//     corresponding to that request, unless it has already terminated, in
//     which case they are routed to the standalone SSE stream.
//   - Requests or notifications made with a detached context.Context value are
//     routed to the standalone SSE stream.
type StreamableServerTransport struct {
	// SessionID is the ID of this session.
	//
	// If SessionID is the empty string, this is a 'stateless' session, which has
	// limited ability to communicate with the client. Otherwise, the session ID
	// must be globally unique, that is, different from any other session ID
	// anywhere, past and future. (We recommend using a crypto random number
	// generator to produce one, as with [crypto/rand.Text].)
	SessionID string

	// Stateless controls whether the eventstore is 'Stateless'. Server sessions
	// connected to a stateless transport are disallowed from making outgoing
	// requests.
	//
	// See also [StreamableHTTPOptions.Stateless].
	Stateless bool

	// EventStore enables stream resumption.
	//
	// If set, EventStore will be used to persist stream events and replay them
	// upon stream resumption.
	EventStore EventStore

	// jsonResponse, if set, tells the server to prefer to respond to requests
	// using application/json responses rather than text/event-stream.
	//
	// Specifically, responses will be application/json whenever incoming POST
	// request contain only a single message. In this case, notifications or
	// requests made within the context of a server request will be sent to the
	// standalone SSE stream, if any.
	//
	// TODO(rfindley): jsonResponse should be exported, since
	// StreamableHTTPOptions.JSONResponse is exported, and we want to allow users
	// to write their own streamable HTTP handler.
	jsonResponse bool

	// optional logger provided through the [StreamableHTTPOptions.Logger].
	//
	// TODO(rfindley): logger should be exported, since we want to allow users
	// to write their own streamable HTTP handler.
	logger *slog.Logger

	// connection is non-nil if and only if the transport has been connected.
	connection *streamableServerConn
}

// Connect implements the [Transport] interface.
func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, error) {
	if t.connection != nil {
		return nil, fmt.Errorf("transport already connected")
	}
	t.connection = &streamableServerConn{
		sessionID:      t.SessionID,
		stateless:      t.Stateless,
		eventStore:     t.EventStore,
		jsonResponse:   t.jsonResponse,
		logger:         ensureLogger(t.logger), // see #556: must be non-nil
		incoming:       make(chan jsonrpc.Message, 10),
		done:           make(chan struct{}),
		streams:        make(map[string]*stream),
		requestStreams: make(map[jsonrpc.ID]string),
	}
	// Stream 0 corresponds to the standalone SSE stream.
	//
	// It is always text/event-stream, since it must carry arbitrarily many
	// messages.
	var err error
	t.connection.streams[""], err = t.connection.newStream(ctx, nil, "")
	if err != nil {
		return nil, err
	}
	return t.connection, nil
}

type streamableServerConn struct {
	sessionID    string
	stateless    bool
	jsonResponse bool
	eventStore   EventStore

	logger *slog.Logger

	incoming chan jsonrpc.Message // messages from the client to the server

	mu sync.Mutex // guards all fields below

	// Sessions are closed exactly once.
	isDone bool
	done   chan struct{}

	// Sessions can have multiple logical connections (which we call streams),
	// corresponding to HTTP requests. Additionally, streams may be resumed by
	// subsequent HTTP requests, when the HTTP connection is terminated
	// unexpectedly.
	//
	// Therefore, we use a logical stream ID to key the stream state, and
	// perform the accounting described below when incoming HTTP requests are
	// handled.

	// streams holds the logical streams for this session, keyed by their ID.
	//
	// Lifecycle: streams persist until all of their responses are received from
	// the server.
	streams map[string]*stream

	// requestStreams maps incoming requests to their logical stream ID.
	//
	// Lifecycle: requestStreams persist until their response is received.
	requestStreams map[jsonrpc.ID]string
}

func (c *streamableServerConn) SessionID() string {
	return c.sessionID
}

// A stream is a single logical stream of SSE events within a server session.
// A stream begins with a client request, or with a client GET that has
// no Last-Event-ID header.
//
// A stream ends only when its session ends; we cannot determine its end otherwise,
// since a client may send a GET with a Last-Event-ID that references the stream
// at any time.
type stream struct {
	// id is the logical ID for the stream, unique within a session.
	//
	// The standalone SSE stream has id "".
	id string

	// logger is used for logging errors during stream operations.
	logger *slog.Logger

	// mu guards the fields below, as well as storage of new messages in the
	// connection's event store (if any).
	mu sync.Mutex

	// If pendingJSONMessages is non-nil, this is a JSON stream and messages are
	// collected here until the stream is complete, at which point they are
	// flushed as a single JSON response. Note that the non-nilness of this field
	// is significant, as it signals the expected content type.
	//
	// Note: if we remove support for batching, this could just be a bool.
	pendingJSONMessages []json.RawMessage

	// w is the HTTP response writer for this stream. A non-nil w indicates
	// that the stream is claimed by an HTTP request (the hanging POST or GET);
	// it is set to nil when the request completes.
	w http.ResponseWriter

	// done is closed to release the hanging HTTP request.
	//
	// Invariant: a non-nil done implies w is also non-nil, though the converse
	// is not necessarily true: done is set to nil when it is closed, to avoid
	// duplicate closure.
	done chan struct{}

	// lastIdx is the index of the last written SSE event, for event ID generation.
	// It starts at -1 since indices start at 0.
	lastIdx int

	// protocolVersion is the protocol version for this stream.
	protocolVersion string

	// requests is the set of unanswered incoming requests for the stream.
	//
	// Requests are removed when their response has been received.
	// In practice, there is only one request, but in the 2025-03-26 version of
	// the spec and earlier there was a concept of batching, in which POST
	// payloads could hold multiple requests or responses.
	requests map[jsonrpc.ID]struct{}
}

// close sends a 'close' event to the client (if protocolVersion >= 2025-11-25
// and reconnectAfter > 0) and closes the done channel.
//
// The done channel is set to nil after closing, so that done != nil implies
// the stream is active and done is open. This simplifies checks elsewhere.
func (s *stream) close(reconnectAfter time.Duration) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.done == nil {
		return // stream not connected or already closed
	}
	if s.protocolVersion >= protocolVersion20251125 && reconnectAfter > 0 {
		reconnectStr := strconv.FormatInt(reconnectAfter.Milliseconds(), 10)
		if _, err := writeEvent(s.w, Event{
			Name:  "close",
			Retry: reconnectStr,
		}); err != nil {
			s.logger.Warn(fmt.Sprintf("Writing close event: %v", err))
		}
	}
	close(s.done)
	s.done = nil
}

// release releases the stream from its HTTP request, allowing it to be
// claimed by another request (e.g., for resumption).
func (s *stream) release() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.w = nil
	s.done = nil // may already be nil, if the stream is done or closed
}

// deliverLocked writes data to the stream (for SSE) or stores it in
// pendingJSONMessages (for JSON mode). The eventID is used for SSE event ID;
// pass "" to omit.
//
// If responseTo is valid, it is removed from the requests map. When all
// requests have been responded to, the done channel is closed and set to nil.
//
// Returns true if the stream is now done (all requests have been responded to).
// The done value is always accurate, even if an error is returned.
//
// s.mu must be held when calling this method.
func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) {
	// First, record the response. We must do this *before* returning an error
	// below, as even if the stream is disconnected we want to update our
	// accounting.
	if responseTo.IsValid() {
		delete(s.requests, responseTo)
	}
	// Now, try to deliver the message to the client.
	done = len(s.requests) == 0 && s.id != ""
	if s.done == nil {
		return done, fmt.Errorf("stream not connected or already closed")
	}
	if done {
		defer func() { close(s.done); s.done = nil }()
	}
	// Try to write to the response.
	//
	// If we get here, the request is still hanging (because s.done != nil
	// implies s.w != nil), but may have been cancelled by the client/http layer:
	// there's a brief race between request cancellation and releasing the
	// stream.
	if s.pendingJSONMessages != nil {
		s.pendingJSONMessages = append(s.pendingJSONMessages, data)
		if done {
			// Flush all pending messages as JSON response.
			var toWrite []byte
			if len(s.pendingJSONMessages) == 1 {
				toWrite = s.pendingJSONMessages[0]
			} else {
				toWrite, err = json.Marshal(s.pendingJSONMessages)
				if err != nil {
					return done, err
				}
			}
			if _, err := s.w.Write(toWrite); err != nil {
				return done, err
			}
		}
	} else {
		// SSE mode: write event to response writer.
		s.lastIdx++
		if _, err := writeEvent(s.w, Event{Name: "message", Data: data, ID: eventID}); err != nil {
			return done, err
		}
	}
	return done, nil
}

// doneLocked reports whether the stream is logically complete.
//
// s.requests was populated when reading the POST body, requests are deleted as
// they are responded to. Once all requests have been responded to, the stream
// is done.
//
// s.mu must be held while calling this function.
func (s *stream) doneLocked() bool {
	return len(s.requests) == 0 && s.id != ""
}

func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonrpc.ID]struct{}, id string) (*stream, error) {
	if c.eventStore != nil {
		if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil {
			return nil, err
		}
	}
	return &stream{
		id:       id,
		requests: requests,
		lastIdx:  -1, // indices start at 0, incremented before each write
		logger:   c.logger,
	}, nil
}

// We track the incoming request ID inside the handler context using
// idContextValue, so that notifications and server->client calls that occur in
// the course of handling incoming requests are correlated with the incoming
// request that caused them, and can be dispatched as server-sent events to the
// correct HTTP request.
//
// Currently, this is implemented in [ServerSession.handle]. This is not ideal,
// because it means that a user of the MCP package couldn't implement the
// streamable transport, as they'd lack this privileged access.
//
// If we ever wanted to expose this mechanism, we have a few options:
//  1. Make ServerSession an interface, and provide an implementation of
//     ServerSession to handlers that closes over the incoming request ID.
//  2. Expose a 'HandlerTransport' interface that allows transports to provide
//     a handler middleware, so that we don't hard-code this behavior in
//     ServerSession.handle.
//  3. Add a `func ForRequest(context.Context) jsonrpc.ID` accessor that lets
//     any transport access the incoming request ID.
//
// For now, by giving only the StreamableServerTransport access to the request
// ID, we avoid having to make this API decision.
type idContextKey struct{}

// ServeHTTP handles a single HTTP request for the session.
func (t *StreamableServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	if t.connection == nil {
		http.Error(w, "transport not connected", http.StatusInternalServerError)
		return
	}
	switch req.Method {
	case http.MethodGet:
		t.connection.serveGET(w, req)
	case http.MethodPost:
		t.connection.servePOST(w, req)
	default:
		// Should not be reached, as this is checked in StreamableHTTPHandler.ServeHTTP.
		w.Header().Set("Allow", "GET, POST")
		http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
		return
	}
}

// serveGET streams messages to a hanging http GET, with stream ID and last
// message parsed from the Last-Event-ID header.
//
// It returns an HTTP status code and error message.
func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request) {
	// streamID "" corresponds to the default GET request.
	streamID := ""
	// By default, we haven't seen a last index. Since indices start at 0, we represent
	// that by -1. This is incremented just before each event is written.
	lastIdx := -1
	if len(req.Header.Values(lastEventIDHeader)) > 0 {
		eid := req.Header.Get(lastEventIDHeader)
		var ok bool
		streamID, lastIdx, ok = parseEventID(eid)
		if !ok {
			http.Error(w, fmt.Sprintf("malformed Last-Event-ID %q", eid), http.StatusBadRequest)
			return
		}
		if c.eventStore == nil {
			http.Error(w, "stream replay unsupported", http.StatusBadRequest)
			return
		}
	}

	ctx := req.Context()

	// Read the protocol version from the header. For GET requests, this should
	// always be present since GET only happens after initialization.
	protocolVersion := req.Header.Get(protocolVersionHeader)
	if protocolVersion == "" {
		protocolVersion = protocolVersion20250326
	}

	stream, done := c.acquireStream(ctx, w, streamID, lastIdx, protocolVersion)
	if stream == nil {
		return
	}
	defer stream.release()
	c.hangResponse(ctx, done)
}

// hangResponse blocks the HTTP response until one of three conditions is met:
//   - ctx is cancelled (the client disconnected or the request timed out)
//   - done is closed (all responses have been sent, or the stream was explicitly closed)
//   - the session is closed
//
// This keeps the HTTP connection open so that server-sent events can be
// written to the response.
func (c *streamableServerConn) hangResponse(ctx context.Context, done <-chan struct{}) {
	select {
	case <-ctx.Done():
	case <-done:
	case <-c.done:
	}
}

// acquireStream replays all events since lastIdx, and acquires the ongoing
// stream, if any. If non-nil, the resulting stream will be registered for
// receiving new messages, and the stream's done channel will be closed when
// all related messages have been delivered.
//
// If any errors occur, they will be written to w and the resulting stream will
// be nil. The resulting stream may also be nil if the stream is complete.
//
// Importantly, this function must hold the stream mutex until done replaying
// all messages, so that no delivery or storage of new messages occurs while
// the stream is still replaying.
//
// protocolVersion is the protocol version for this stream, used to determine
// feature support (e.g. prime and close events were added in 2025-11-25).
func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx int, protocolVersion string) (*stream, chan struct{}) {
	// if tempStream is set, the stream is done and we're just replaying messages.
	//
	// We record a temporary stream to claim exclusive replay rights. The spec
	// (https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery)
	// does not explicitly require exclusive replay, but we enforce it defensively.
	tempStream := false
	c.mu.Lock()
	s, ok := c.streams[streamID]
	if !ok {
		// The stream is logically done, but claim exclusive rights to replay it by
		// adding a temporary entry in the streams map.
		//
		// We create this entry with a non-nil w, to ensure it isn't claimed by
		// another request before we lock it below.
		tempStream = true
		s = &stream{
			id: streamID,
			w:  w,
		}
		c.streams[streamID] = s

		// Since this stream is transient, we must clean up after replaying.
		defer func() {
			c.mu.Lock()
			delete(c.streams, streamID)
			c.mu.Unlock()
		}()
	}
	c.mu.Unlock()

	s.mu.Lock()
	defer s.mu.Unlock()

	// Check that this stream wasn't claimed by another request.
	if !tempStream && s.w != nil {
		http.Error(w, "stream ID conflicts with ongoing stream", http.StatusConflict)
		return nil, nil
	}

	// Collect events to replay. Collect them all before writing, so that we
	// have an opportunity to set the HTTP status code on an error.
	//
	// As indicated above, we must do that while holding stream.mu, so that no
	// new messages are added to the eventstore until we've replayed all previous
	// messages, and registered our delivery function.
	var toReplay [][]byte
	if c.eventStore != nil {
		for data, err := range c.eventStore.After(ctx, c.SessionID(), s.id, lastIdx) {
			if err != nil {
				// We can't replay events, perhaps because the underlying event store
				// has garbage collected its storage.
				//
				// We must be careful here: any 404 will signal to the client that the
				// *session* is not found, rather than the stream.
				//
				// 400 is not really accurate, but should at least have no side effects.
				// Other SDKs (typescript) do not have a mechanism for events to be purged.
				http.Error(w, "failed to replay events", http.StatusBadRequest)
				return nil, nil
			}
			if len(data) > 0 {
				toReplay = append(toReplay, data)
			}
		}
	}

	w.Header().Set("Cache-Control", "no-cache, no-transform")
	w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler]
	w.Header().Set("Connection", "keep-alive")

	if s.id == "" {
		// Issue #410: the standalone SSE stream is likely not to receive messages
		// for a long time. Ensure that headers are flushed.
		w.WriteHeader(http.StatusOK)
		rc := http.NewResponseController(w)
		// Ignore returned error as flushing is best-effort.
		_ = rc.Flush()
	}

	for _, data := range toReplay {
		lastIdx++
		e := Event{Name: "message", Data: data}
		if c.eventStore != nil {
			e.ID = formatEventID(s.id, lastIdx)
		}
		if _, err := writeEvent(w, e); err != nil {
			return nil, nil
		}
	}

	if tempStream || s.doneLocked() {
		// Nothing more to do.
		return nil, nil
	}

	// The stream is not done: set up delivery state before the stream is
	// unlocked, allowing the connection to write new events.
	s.w = w
	s.done = make(chan struct{})
	s.lastIdx = lastIdx
	s.protocolVersion = protocolVersion
	return s, s.done
}

// servePOST handles an incoming message, and replies with either an outgoing
// message stream or single response object, depending on whether the
// jsonResponse option is set.
//
// It returns an HTTP status code and error message.
func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Request) {
	if len(req.Header.Values(lastEventIDHeader)) > 0 {
		http.Error(w, "can't send Last-Event-ID for POST request", http.StatusBadRequest)
		return
	}

	// Read incoming messages.
	body, err := io.ReadAll(req.Body)
	if err != nil {
		http.Error(w, "failed to read body", http.StatusBadRequest)
		return
	}
	if len(body) == 0 {
		http.Error(w, "POST requires a non-empty body", http.StatusBadRequest)
		return
	}
	// TODO(#674): once we've documented the support matrix for 2025-03-26 and
	// earlier, drop support for matching entirely; that will simplify this
	// logic.
	incoming, isBatch, err := readBatch(body)
	if err != nil {
		http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest)
		return
	}

	protocolVersion := req.Header.Get(protocolVersionHeader)
	if protocolVersion == "" {
		protocolVersion = protocolVersion20250326
	}

	if isBatch && protocolVersion >= protocolVersion20250618 {
		http.Error(w, fmt.Sprintf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, protocolVersion), http.StatusBadRequest)
		return
	}

	// TODO(rfindley): no tests fail if we reject batch JSON requests entirely.
	// We need to test this with older protocol versions.
	// if isBatch && c.jsonResponse {
	// 	http.Error(w, "server does not support batch requests", http.StatusBadRequest)
	// 	return
	// }

	calls := make(map[jsonrpc.ID]struct{})
	tokenInfo := auth.TokenInfoFromContext(req.Context())
	isInitialize := false
	var initializeProtocolVersion string
	for _, msg := range incoming {
		if jreq, ok := msg.(*jsonrpc.Request); ok {
			// Preemptively check that this is a valid request, so that we can fail
			// the HTTP request. If we didn't do this, a request with a bad method or
			// missing ID could be silently swallowed.
			if _, err := checkRequest(jreq, serverMethodInfos); err != nil {
				http.Error(w, err.Error(), http.StatusBadRequest)
				return
			}
			if jreq.Method == methodInitialize {
				isInitialize = true
				// Extract the protocol version from InitializeParams.
				var params InitializeParams
				if err := internaljson.Unmarshal(jreq.Params, &params); err == nil {
					initializeProtocolVersion = params.ProtocolVersion
				}
			}
			// Include metadata for all requests (including notifications).
			jreq.Extra = &RequestExtra{
				TokenInfo: tokenInfo,
				Header:    req.Header,
			}
			if jreq.IsCall() {
				calls[jreq.ID] = struct{}{}
				// See the doc for CloseSSEStream: allow the request handler to
				// explicitly close the ongoing stream.
				jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) {
					c.mu.Lock()
					streamID, ok := c.requestStreams[jreq.ID]
					var stream *stream
					if ok {
						stream = c.streams[streamID]
					}
					c.mu.Unlock()

					if stream != nil {
						stream.close(args.RetryAfter)
					}
				}
			}
		}
	}

	// The prime and close events were added in protocol version 2025-11-25 (SEP-1699).
	// Use the version from InitializeParams if this is an initialize request,
	// otherwise use the protocol version header.
	effectiveVersion := protocolVersion
	if isInitialize && initializeProtocolVersion != "" {
		effectiveVersion = initializeProtocolVersion
	}

	// If we don't have any calls, we can just publish the incoming messages and return.
	// No need to track a logical stream.
	//
	// See section [§2.1.4] of the spec: "If the server accepts the input, the
	// server MUST return HTTP status code 202 Accepted with no body."
	//
	// [§2.1.4]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
	if len(calls) == 0 {
		for _, msg := range incoming {
			select {
			case c.incoming <- msg:
			case <-c.done:
				// The session is closing. Since we haven't yet written any data to the
				// response, we can signal to the client that the session is gone.
				http.Error(w, "session is closing", http.StatusNotFound)
				return
			}
		}
		w.WriteHeader(http.StatusAccepted)
		return
	}

	// Invariant: we have at least one call.
	//
	// Create a logical stream to track its responses.
	// Important: don't publish the incoming messages until the stream is
	// registered, as the server may attempt to respond to incoming messages as
	// soon as they're published.
	stream, err := c.newStream(req.Context(), calls, crand.Text())
	if err != nil {
		http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError)
		return
	}

	// Set response headers. Accept was checked in [StreamableHTTPHandler].
	w.Header().Set("Cache-Control", "no-cache, no-transform")
	if c.jsonResponse {
		w.Header().Set("Content-Type", "application/json")
	} else {
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Connection", "keep-alive")
	}
	if c.sessionID != "" && isInitialize {
		w.Header().Set(sessionIDHeader, c.sessionID)
	}

	// Set up stream delivery state.
	stream.w = w
	done := make(chan struct{})
	stream.done = done
	stream.protocolVersion = effectiveVersion
	if c.jsonResponse {
		// JSON mode: collect messages in pendingJSONMessages until done.
		// Set pendingJSONMessages to a non-nil value to signal that this is an
		// application/json stream.
		stream.pendingJSONMessages = []json.RawMessage{}
	} else {
		// SSE mode: write a priming event if supported.
		if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 {
			// Write a priming event, as defined by [§2.1.6] of the spec.
			//
			// [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
			//
			// We must also write it to the event store in order for indexes to
			// align.
			if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil {
				c.logger.Warn(fmt.Sprintf("Storing priming event: %v", err))
			}
			stream.lastIdx++
			e := Event{Name: "prime", ID: formatEventID(stream.id, stream.lastIdx)}
			if _, err := writeEvent(w, e); err != nil {
				c.logger.Warn(fmt.Sprintf("Writing priming event: %v", err))
			}
		}
	}

	// TODO(rfindley): if we have no event store, we should really cancel all
	// remaining requests here, since the client will never get the results.
	defer stream.release()

	// The stream is now set up to deliver messages.
	//
	// Register it before publishing incoming messages.
	c.mu.Lock()
	c.streams[stream.id] = stream
	for reqID := range calls {
		c.requestStreams[reqID] = stream.id
	}
	c.mu.Unlock()

	// Publish incoming messages.
	for _, msg := range incoming {
		select {
		case c.incoming <- msg:
		// Note: don't select on req.Context().Done() here, since we've already
		// received the requests and may have already published a response message
		// or notification. The client could resume the stream.
		//
		// In fact, this send could be in a separate goroutine.
		case <-c.done:
			// Session closed: we don't know if any data has been written, so it's
			// too late to write a status code here.
			return
		}
	}

	c.hangResponse(req.Context(), done)
}

// Event IDs: encode both the logical connection ID and the index, as
// <streamID>_<idx>, to be consistent with the typescript implementation.

// formatEventID returns the event ID to use for the logical connection ID
// streamID and message index idx.
//
// See also [parseEventID].
func formatEventID(sid string, idx int) string {
	return fmt.Sprintf("%s_%d", sid, idx)
}

// parseEventID parses a Last-Event-ID value into a logical stream id and
// index.
//
// See also [formatEventID].
func parseEventID(eventID string) (streamID string, idx int, ok bool) {
	parts := strings.Split(eventID, "_")
	if len(parts) != 2 {
		return "", 0, false
	}
	streamID = parts[0]
	idx, err := strconv.Atoi(parts[1])
	if err != nil || idx < 0 {
		return "", 0, false
	}
	return streamID, idx, true
}

// Read implements the [Connection] interface.
func (c *streamableServerConn) Read(ctx context.Context) (jsonrpc.Message, error) {
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case msg, ok := <-c.incoming:
		if !ok {
			return nil, io.EOF
		}
		return msg, nil
	case <-c.done:
		return nil, io.EOF
	}
}

// Write implements the [Connection] interface.
func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) error {
	// Throughout this function, note that any error that wraps ErrRejected
	// indicates a does not cause the connection to break.
	//
	// Most errors don't break the connection: unlike a true bidirectional
	// stream, a failure to deliver to a stream is not an indication that the
	// logical session is broken.
	data, err := jsonrpc2.EncodeMessage(msg)
	if err != nil {
		return err
	}

	if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() && (c.stateless || c.sessionID == "") {
		// Requests aren't possible with stateless servers, or when there's no session ID.
		return fmt.Errorf("%w: stateless servers cannot make requests", jsonrpc2.ErrRejected)
	}

	// Find the incoming request that this write relates to, if any.
	var (
		relatedRequest jsonrpc.ID
		responseTo     jsonrpc.ID // if valid, the message is a response to this request
	)
	if resp, ok := msg.(*jsonrpc.Response); ok {
		// If the message is a response, it relates to its request (of course).
		relatedRequest = resp.ID
		responseTo = resp.ID
	} else {
		// Otherwise, we check to see if it request was made in the context of an
		// ongoing request. This may not be the case if the request was made with
		// an unrelated context.
		if v := ctx.Value(idContextKey{}); v != nil {
			relatedRequest = v.(jsonrpc.ID)
		}
	}

	// If the stream is application/json, but the message is not a response, we
	// must send it out of band to the standalone SSE stream.
	if c.jsonResponse && !responseTo.IsValid() {
		relatedRequest = jsonrpc.ID{}
	}

	// Write the message to the stream.
	var s *stream
	c.mu.Lock()
	if relatedRequest.IsValid() {
		if streamID, ok := c.requestStreams[relatedRequest]; ok {
			s = c.streams[streamID]
		}
	} else {
		s = c.streams[""] // standalone SSE stream
	}
	if responseTo.IsValid() {
		// Once we've responded to a request, disallow related messages by removing
		// the stream association. This also releases memory.
		delete(c.requestStreams, responseTo)
	}
	sessionClosed := c.isDone
	c.mu.Unlock()

	if s == nil {
		// The request was made in the context of an ongoing request, but that
		// request is complete.
		//
		// In the future, we could be less strict and allow the request to land on
		// the standalone SSE stream.
		return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected)
	}
	if sessionClosed {
		return errors.New("session is closed")
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	// Store in eventStore before delivering.
	// TODO(rfindley): we should only append if the response is SSE, not JSON, by
	// pushing down into the delivery layer.
	delivered := false
	var errs []error
	if c.eventStore != nil {
		if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil {
			errs = append(errs, err)
		} else {
			delivered = true
		}
	}

	// Compute eventID for SSE streams with event store.
	// Use s.lastIdx + 1 because deliverLocked increments before writing.
	var eventID string
	if c.eventStore != nil {
		eventID = formatEventID(s.id, s.lastIdx+1)
	}

	done, err := s.deliverLocked(data, eventID, responseTo)
	if err != nil {
		errs = append(errs, err)
	} else {
		delivered = true
	}

	if done {
		c.mu.Lock()
		delete(c.streams, s.id)
		c.mu.Unlock()
	}

	if !delivered {
		return fmt.Errorf("%w: undelivered message: %v", jsonrpc2.ErrRejected, errors.Join(errs...))
	}
	return nil
}

// Close implements the [Connection] interface.
func (c *streamableServerConn) Close() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.isDone {
		c.isDone = true
		close(c.done)
		if c.eventStore != nil {
			// TODO: find a way to plumb a context here, or an event store with a long-running
			// close operation can take arbitrary time. Alternative: impose a fixed timeout here.
			return c.eventStore.SessionClosed(context.TODO(), c.sessionID)
		}
	}
	return nil
}

// A StreamableClientTransport is a [Transport] that can communicate with an MCP
// endpoint serving the streamable HTTP transport defined by the 2025-03-26
// version of the spec.
type StreamableClientTransport struct {
	Endpoint   string
	HTTPClient *http.Client
	// MaxRetries is the maximum number of times to attempt a reconnect before giving up.
	// It defaults to 5. To disable retries, use a negative number.
	MaxRetries int

	// DisableStandaloneSSE controls whether the client establishes a standalone SSE stream
	// for receiving server-initiated messages.
	//
	// When false (the default), after initialization the client sends an HTTP GET request
	// to establish a persistent server-sent events (SSE) connection. This allows the server
	// to send messages to the client at any time, such as ToolListChangedNotification or
	// other server-initiated requests and notifications. The connection persists for the
	// lifetime of the session and automatically reconnects if interrupted.
	//
	// When true, the client does not establish the standalone SSE stream. The client will
	// only receive responses to its own POST requests. Server-initiated messages will not
	// be received.
	//
	// According to the MCP specification, the standalone SSE stream is optional.
	// Setting DisableStandaloneSSE to true is useful when:
	//   - You only need request-response communication and don't need server-initiated notifications
	//   - The server doesn't properly handle GET requests for SSE streams
	//   - You want to avoid maintaining a persistent connection
	DisableStandaloneSSE bool

	// OAuthHandler is an optional field that, if provided, will be used to authorize the requests.
	OAuthHandler auth.OAuthHandler

	// TODO(rfindley): propose exporting these.
	// If strict is set, the transport is in 'strict mode', where any violation
	// of the MCP spec causes a failure.
	strict bool
	// If logger is set, it is used to log aspects of the transport, such as spec
	// violations that were ignored.
	logger *slog.Logger
}

// These settings are not (yet) exposed to the user in
// StreamableClientTransport.
const (
	// reconnectGrowFactor is the multiplicative factor by which the delay increases after each attempt.
	// A value of 1.0 results in a constant delay, while a value of 2.0 would double it each time.
	// It must be 1.0 or greater if MaxRetries is greater than 0.
	reconnectGrowFactor = 1.5
	// reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely.
	reconnectMaxDelay = 30 * time.Second
)

var (
	// reconnectInitialDelay is the base delay for the first reconnect attempt.
	//
	// Mutable for testing.
	reconnectInitialDelay atomic.Int64
)

func init() {
	reconnectInitialDelay.Store(int64(1 * time.Second))
}

// Connect implements the [Transport] interface.
//
// The resulting [Connection] writes messages via POST requests to the
// transport URL with the Mcp-Session-Id header set, and reads messages from
// hanging requests.
//
// When closed, the connection issues a DELETE request to terminate the logical
// session.
func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, error) {
	client := t.HTTPClient
	if client == nil {
		client = http.DefaultClient
	}
	maxRetries := t.MaxRetries
	if maxRetries == 0 {
		maxRetries = 5
	} else if maxRetries < 0 {
		maxRetries = 0
	}
	// Create a new cancellable context that will manage the connection's lifecycle.
	// This is crucial for cleanly shutting down the background SSE listener by
	// cancelling its blocking network operations, which prevents hangs on exit.
	//
	// This context should be detached from the incoming context: the standalone
	// SSE request should not break when the connection context is done.
	//
	// For example, consider that the user may want to wait at most 5s to connect
	// to the server, and therefore uses a context with a 5s timeout when calling
	// client.Connect. Let's suppose that Connect returns after 1s, and the user
	// starts using the resulting session. If we didn't detach here, the session
	// would break after 4s, when the background SSE stream is terminated.
	//
	// Instead, creating a cancellable context detached from the incoming context
	// allows us to preserve context values (which may be necessary for auth
	// middleware), yet only cancel the standalone stream when the connection is closed.
	connCtx, cancel := context.WithCancel(xcontext.Detach(ctx))
	conn := &streamableClientConn{
		url:                  t.Endpoint,
		client:               client,
		incoming:             make(chan jsonrpc.Message, 10),
		done:                 make(chan struct{}),
		maxRetries:           maxRetries,
		strict:               t.strict,
		logger:               ensureLogger(t.logger), // must be non-nil for safe logging
		ctx:                  connCtx,
		cancel:               cancel,
		failed:               make(chan struct{}),
		disableStandaloneSSE: t.DisableStandaloneSSE,
		oauthHandler:         t.OAuthHandler,
	}
	return conn, nil
}

type streamableClientConn struct {
	url        string
	client     *http.Client
	ctx        context.Context    // connection context, detached from Connect
	cancel     context.CancelFunc // cancels ctx
	incoming   chan jsonrpc.Message
	maxRetries int
	strict     bool         // from [StreamableClientTransport.strict]
	logger     *slog.Logger // from [StreamableClientTransport.logger]

	// disableStandaloneSSE controls whether to disable the standalone SSE stream
	// for receiving server-to-client notifications when no request is in flight.
	disableStandaloneSSE bool // from [StreamableClientTransport.DisableStandaloneSSE]

	// oauthHandler is the OAuth handler for the connection.
	oauthHandler auth.OAuthHandler // from [StreamableClientTransport.OAuthHandler]

	// Guard calls to Close, as it may be called multiple times.
	closeOnce sync.Once
	closeErr  error
	done      chan struct{} // signal graceful termination

	// Logical reads are distributed across multiple http requests. Whenever any
	// of them fails to process their response, we must break the connection, by
	// failing the pending Read.
	//
	// Achieve this by storing the failure message, and signalling when reads are
	// broken. See also [streamableClientConn.fail] and
	// [streamableClientConn.failure].
	failOnce sync.Once
	_failure error
	failed   chan struct{} // signal failure

	// Guard the initialization state.
	mu                sync.Mutex
	initializedResult *InitializeResult
	sessionID         string
}

var _ clientConnection = (*streamableClientConn)(nil)

func (c *streamableClientConn) sessionUpdated(state clientSessionState) {
	c.mu.Lock()
	c.initializedResult = state.InitializeResult
	c.mu.Unlock()

	// Start the standalone SSE stream as soon as we have the initialized
	// result, if continuous listening is enabled.
	//
	// § 2.2: The client MAY issue an HTTP GET to the MCP endpoint. This can be
	// used to open an SSE stream, allowing the server to communicate to the
	// client, without the client first sending data via HTTP POST.
	//
	// We have to wait for initialized, because until we've received
	// initialized, we don't know whether the server requires a sessionID.
	//
	// § 2.5: A server using the Streamable HTTP transport MAY assign a session
	// ID at initialization time, by including it in a Mcp-Session-Id header
	// on the HTTP response containing the InitializeResult.
	if !c.disableStandaloneSSE {
		c.connectStandaloneSSE()
	}
}

func (c *streamableClientConn) connectStandaloneSSE() {
	resp, err := c.connectSSE(c.ctx, "", 0, true)
	if err != nil {
		// If the client didn't cancel the request, and failure breaks the logical
		// session.
		if c.ctx.Err() == nil {
			c.fail(fmt.Errorf("standalone SSE request failed (session ID: %v): %v", c.sessionID, err))
		}
		return
	}

	// [§2.2.3]: "The server MUST either return Content-Type:
	// text/event-stream in response to this HTTP GET, or else return HTTP
	// 405 Method Not Allowed, indicating that the server does not offer an
	// SSE stream at this endpoint."
	//
	// [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server
	if resp.StatusCode == http.StatusMethodNotAllowed {
		// The server doesn't support the standalone SSE stream.
		resp.Body.Close()
		return
	}
	if resp.Header.Get("Content-Type") != "text/event-stream" {
		// modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with
		// non-SSE content type instead of text/event-stream for the standalone
		// SSE stream.
		c.logger.Warn(fmt.Sprintf("got Content-Type %s instead of text/event-stream for standalone SSE stream", resp.Header.Get("Content-Type")))
		resp.Body.Close()
		return
	}
	if resp.StatusCode >= 400 && resp.StatusCode < 500 && !c.strict {
		// modelcontextprotocol/go-sdk#393,#610: some servers return NotFound or
		// other status codes instead of MethodNotAllowed for the standalone SSE
		// stream.
		//
		// Treat this like MethodNotAllowed in non-strict mode.
		c.logger.Warn(fmt.Sprintf("got %d instead of 405 for standalone SSE stream", resp.StatusCode))
		resp.Body.Close()
		return
	}
	summary := "standalone SSE stream"
	if err := c.checkResponse(summary, resp); err != nil {
		c.fail(err)
		return
	}
	go c.handleSSE(c.ctx, summary, resp, nil)
}

// fail handles an asynchronous error while reading.
//
// If err is non-nil, it is terminal, and subsequent (or pending) Reads will
// fail.
//
// If err wraps ErrSessionMissing, the failure indicates that the session is no
// longer present on the server, and no final DELETE will be performed when
// closing the connection.
func (c *streamableClientConn) fail(err error) {
	if err != nil {
		c.failOnce.Do(func() {
			c._failure = err
			close(c.failed)
		})
	}
}

func (c *streamableClientConn) failure() error {
	select {
	case <-c.failed:
		return c._failure
	default:
		return nil
	}
}

func (c *streamableClientConn) SessionID() string {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.sessionID
}

// Read implements the [Connection] interface.
func (c *streamableClientConn) Read(ctx context.Context) (jsonrpc.Message, error) {
	if err := c.failure(); err != nil {
		return nil, err
	}
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case <-c.failed:
		return nil, c.failure()
	case <-c.done:
		return nil, io.EOF
	case msg := <-c.incoming:
		return msg, nil
	}
}

// Write implements the [Connection] interface.
func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) error {
	if err := c.failure(); err != nil {
		return err
	}

	var requestSummary string
	var forCall *jsonrpc.Request
	switch msg := msg.(type) {
	case *jsonrpc.Request:
		requestSummary = fmt.Sprintf("sending %q", msg.Method)
		if msg.IsCall() {
			forCall = msg
		}
	case *jsonrpc.Response:
		requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID)
	default:
		panic("unreachable")
	}

	data, err := jsonrpc.EncodeMessage(msg)
	if err != nil {
		return fmt.Errorf("%s: %v", requestSummary, err)
	}

	doRequest := func() (*http.Request, *http.Response, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(data))
		if err != nil {
			return nil, nil, err
		}
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Accept", "application/json, text/event-stream")
		if err := c.setMCPHeaders(req); err != nil {
			// Failure to set headers means that the request was not sent.
			// Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr
			// and permanently break the connection.
			return nil, nil, fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err)
		}
		resp, err := c.client.Do(req)
		if err != nil {
			// Any error from client.Do means the request didn't reach the server.
			// Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr
			// and permanently break the connection.
			err = fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err)
		}
		return req, resp, err
	}

	req, resp, err := doRequest()
	if err != nil {
		return err
	}

	if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && c.oauthHandler != nil {
		if err := c.oauthHandler.Authorize(ctx, req, resp); err != nil {
			// Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr
			// and permanently break the connection.
			// Wrap the authorization error as well for client inspection.
			return fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err)
		}
		// Retry the request after successful authorization.
		_, resp, err = doRequest()
		if err != nil {
			return err
		}
	}

	if err := c.checkResponse(requestSummary, resp); err != nil {
		// Only fail the connection for non-transient errors.
		// Transient errors (wrapped with ErrRejected) should not break the connection.
		if !errors.Is(err, jsonrpc2.ErrRejected) {
			c.fail(err)
		}
		return err
	}

	if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" {
		c.mu.Lock()
		hadSessionID := c.sessionID
		if hadSessionID == "" {
			c.sessionID = sessionID
		}
		c.mu.Unlock()
		if hadSessionID != "" && hadSessionID != sessionID {
			resp.Body.Close()
			return fmt.Errorf("mismatching session IDs %q and %q", hadSessionID, sessionID)
		}
	}

	if forCall == nil {
		resp.Body.Close()

		// [§2.1.4]: "If the input is a JSON-RPC response or notification:
		// If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body."
		//
		// [§2.1.4]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server
		if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted {
			errMsg := fmt.Sprintf("unexpected status code %d from non-call", resp.StatusCode)
			// Some servers return 200, even with an empty json body.
			//
			// In strict mode, return an error to the caller.
			c.logger.Warn(errMsg)
			if c.strict {
				return errors.New(errMsg)
			}
		}
		return nil
	}

	contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
	switch contentType {
	case "application/json":
		go c.handleJSON(requestSummary, resp)

	case "text/event-stream":
		var forCall *jsonrpc.Request
		if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() {
			forCall = jsonReq
		}
		// Handle the resulting stream. Note that ctx comes from the call, and
		// therefore is already cancelled when the JSON-RPC request is cancelled
		// (or rather, context cancellation is what *triggers* JSON-RPC
		// cancellation)
		go c.handleSSE(ctx, requestSummary, resp, forCall)

	default:
		resp.Body.Close()
		return fmt.Errorf("%s: unsupported content type %q", requestSummary, contentType)
	}
	return nil
}

func (c *streamableClientConn) setMCPHeaders(req *http.Request) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	if c.oauthHandler != nil {
		ts, err := c.oauthHandler.TokenSource(c.ctx)
		if err != nil {
			return err
		}
		if ts != nil {
			token, err := ts.Token()
			if err != nil {
				return err
			}
			if token != nil {
				req.Header.Set("Authorization", "Bearer "+token.AccessToken)
			}
		}
	}
	if c.initializedResult != nil {
		req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion)
	}
	if c.sessionID != "" {
		req.Header.Set(sessionIDHeader, c.sessionID)
	}
	return nil
}

func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) {
	body, err := io.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil {
		c.fail(fmt.Errorf("%s: failed to read body: %v", requestSummary, err))
		return
	}
	msg, err := jsonrpc.DecodeMessage(body)
	if err != nil {
		c.fail(fmt.Errorf("%s: failed to decode response: %v", requestSummary, err))
		return
	}
	select {
	case c.incoming <- msg:
	case <-c.done:
		// The connection was closed by the client; exit gracefully.
	}
}

// handleSSE manages the lifecycle of an SSE connection. It can be either
// persistent (for the main GET listener) or temporary (for a POST response).
//
// If forCall is set, it is the call that initiated the stream, and the
// stream is complete when we receive its response. Otherwise, this is the
// standalone stream.
func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc2.Request) {
	// Track the last event ID to detect progress.
	// The retry counter is only reset when progress is made (lastEventID advances).
	// This prevents infinite retry loops when a server repeatedly terminates
	// connections without making progress (#679).
	var prevLastEventID string
	retriesWithoutProgress := 0

	for {
		lastEventID, reconnectDelay, clientClosed := c.processStream(ctx, requestSummary, resp, forCall)

		// If the connection was closed by the client, we're done.
		if clientClosed {
			return
		}
		// If we don't have a last event ID, we can never get the call response, so
		// there's nothing to resume. For the standalone stream, we can reconnect,
		// but we may just miss messages.
		if lastEventID == "" && forCall != nil {
			return
		}

		// Check if we made progress (lastEventID advanced).
		// Only reset the retry counter when actual progress is made.
		if lastEventID != "" && lastEventID != prevLastEventID {
			// Progress was made: reset the retry counter.
			retriesWithoutProgress = 0
			prevLastEventID = lastEventID
		} else {
			// No progress: increment the retry counter.
			retriesWithoutProgress++
			if retriesWithoutProgress > c.maxRetries {
				if ctx.Err() == nil {
					c.fail(fmt.Errorf("%s: exceeded %d retries without progress (session ID: %v)", requestSummary, c.maxRetries, c.sessionID))
				}
				return
			}
		}

		// The stream was interrupted or ended by the server. Attempt to reconnect.
		newResp, err := c.connectSSE(ctx, lastEventID, reconnectDelay, false)
		if err != nil {
			// If the client didn't cancel this request, any failure to execute it
			// breaks the logical MCP session.
			if ctx.Err() == nil {
				// All reconnection attempts failed: fail the connection.
				c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err))
			}
			return
		}

		resp = newResp
		if err := c.checkResponse(requestSummary, resp); err != nil {
			c.fail(err)
			return
		}
	}
}

// checkResponse checks the status code of the provided response, and
// translates it into an error if the request was unsuccessful.
//
// The response body is close if a non-nil error is returned.
func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.Response) (err error) {
	defer func() {
		if err != nil {
			resp.Body.Close()
		}
	}()
	// §2.5.3: "The server MAY terminate the session at any time, after
	// which it MUST respond to requests containing that session ID with HTTP
	// 404 Not Found."
	if resp.StatusCode == http.StatusNotFound {
		// Return an ErrSessionMissing to avoid sending a redundant DELETE when the
		// session is already gone.
		return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing)
	}
	// Transient server errors (502, 503, 504, 429) should not break the connection.
	// Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr.
	if isTransientHTTPStatus(resp.StatusCode) {
		return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode))
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode))
	}
	return nil
}

// processStream reads from a single response body, sending events to the
// incoming channel. It returns the ID of the last processed event and a flag
// indicating if the connection was closed by the client. If resp is nil, it
// returns "", false.
func (c *streamableClientConn) processStream(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, reconnectDelay time.Duration, clientClosed bool) {
	defer func() {
		// Drain any remaining unprocessed body. This allows the connection to be re-used after closing.
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}()
	for evt, err := range scanEvents(resp.Body) {
		if err != nil {
			if ctx.Err() != nil {
				return "", 0, true // don't reconnect: client cancelled
			}

			// Malformed events are hard errors that indicate corrupted data or protocol
			// violations. These should fail the connection permanently.
			if errors.Is(err, errMalformedEvent) {
				c.fail(fmt.Errorf("%s: %v", requestSummary, err))
				return "", 0, true
			}

			break
		}

		if evt.ID != "" {
			lastEventID = evt.ID
		}

		if evt.Retry != "" {
			if n, err := strconv.ParseInt(evt.Retry, 10, 64); err == nil {
				reconnectDelay = time.Duration(n) * time.Millisecond
			}
		}

		// According to SSE specification
		// (https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation)
		// events with an empty data buffer are allowed.
		// In MCP these can be priming events (SEP-1699) that carry only a Last-Event-ID for stream resumption.
		if len(evt.Data) == 0 {
			continue
		}

		// According to SSE spec, events with no name default to "message"
		if evt.Name != "" && evt.Name != "message" {
			continue
		}

		msg, err := jsonrpc.DecodeMessage(evt.Data)
		if err != nil {
			c.fail(fmt.Errorf("%s: failed to decode event: %v", requestSummary, err))
			return "", 0, true
		}

		select {
		case c.incoming <- msg:
			// Check if this is the response to our call, which terminates the request.
			// (it could also be a server->client request or notification).
			if jsonResp, ok := msg.(*jsonrpc.Response); ok && forCall != nil {
				// TODO: we should never get a response when forReq is nil (the standalone SSE request).
				// We should detect this case.
				if jsonResp.ID == forCall.ID {
					return "", 0, true
				}
			}

		case <-c.done:
			// The connection was closed by the client; exit gracefully.
			return "", 0, true
		}
	}
	// The loop finished without an error, indicating the server closed the stream.
	//
	// If the lastEventID is "", the stream is not retryable and we should
	// report a synthetic error for the call.
	//
	// Note that this is different from the cancellation case above, since the
	// caller is still waiting for a response that will never come.
	if lastEventID == "" && forCall != nil {
		errmsg := &jsonrpc2.Response{
			ID:    forCall.ID,
			Error: fmt.Errorf("request terminated without response"),
		}
		select {
		case c.incoming <- errmsg:
		case <-c.done:
		}
	}
	return lastEventID, reconnectDelay, false
}

// connectSSE handles the logic of connecting a text/event-stream connection.
//
// If lastEventID is set, it is the last-event ID of a stream being resumed.
//
// If connection fails, connectSSE retries with an exponential backoff
// strategy. It returns a new, valid HTTP response if successful, or an error
// if all retries are exhausted.
//
// reconnectDelay is the delay set by the server using the SSE retry field, or
// 0.
//
// If initial is set, this is the initial attempt.
//
// If connectSSE exits due to context cancellation, the result is (nil, ctx.Err()).
func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID string, reconnectDelay time.Duration, initial bool) (*http.Response, error) {
	var finalErr error
	attempt := 0
	if !initial {
		// We've already connected successfully once, so delay subsequent
		// reconnections. Otherwise, if the server returns 200 but terminates the
		// connection, we'll reconnect as fast as we can, ad infinitum.
		//
		// TODO: we should consider also setting a limit on total attempts for one
		// logical request.
		attempt = 1
	}
	delay := calculateReconnectDelay(attempt)
	if reconnectDelay > 0 {
		delay = reconnectDelay // honor the server's requested initial delay
	}
	for ; attempt <= c.maxRetries; attempt++ {
		select {
		case <-c.done:
			return nil, fmt.Errorf("connection closed by client during reconnect")

		case <-ctx.Done():
			// If the connection context is canceled, the request below will not
			// succeed anyway.
			return nil, ctx.Err()

		case <-time.After(delay):
			req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
			if err != nil {
				return nil, err
			}
			if err := c.setMCPHeaders(req); err != nil {
				return nil, err
			}
			if lastEventID != "" {
				req.Header.Set(lastEventIDHeader, lastEventID)
			}
			req.Header.Set("Accept", "text/event-stream")
			resp, err := c.client.Do(req)
			if err != nil {
				finalErr = err // Store the error and try again.
				delay = calculateReconnectDelay(attempt + 1)
				continue
			}
			return resp, nil
		}
	}
	// If the loop completes, all retries have failed, or the client is closing.
	if finalErr != nil {
		return nil, fmt.Errorf("connection failed after %d attempts: %w", c.maxRetries, finalErr)
	}
	return nil, fmt.Errorf("connection aborted after %d attempts", c.maxRetries)
}

// Close implements the [Connection] interface.
func (c *streamableClientConn) Close() error {
	c.closeOnce.Do(func() {
		if errors.Is(c.failure(), ErrSessionMissing) {
			// If the session is missing, no need to delete it.
		} else {
			req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil)
			if err != nil {
				c.closeErr = err
			} else {
				if err := c.setMCPHeaders(req); err != nil {
					c.closeErr = err
				} else if _, err := c.client.Do(req); err != nil {
					c.closeErr = err
				}
			}
		}

		// Cancel any hanging network requests after cleanup.
		c.cancel()
		close(c.done)
	})
	return c.closeErr
}

// calculateReconnectDelay calculates a delay using exponential backoff with full jitter.
func calculateReconnectDelay(attempt int) time.Duration {
	if attempt == 0 {
		return 0
	}
	// Calculate the exponential backoff using the grow factor.
	backoffDuration := time.Duration(float64(reconnectInitialDelay.Load()) * math.Pow(reconnectGrowFactor, float64(attempt-1)))
	// Cap the backoffDuration at maxDelay.
	backoffDuration = min(backoffDuration, reconnectMaxDelay)

	// Use a full jitter using backoffDuration
	jitter := rand.N(backoffDuration)

	return backoffDuration + jitter
}

// isTransientHTTPStatus reports whether the HTTP status code indicates a
// transient server error that should not permanently break the connection.
func isTransientHTTPStatus(statusCode int) bool {
	switch statusCode {
	case http.StatusInternalServerError, // 500
		http.StatusBadGateway,         // 502
		http.StatusServiceUnavailable, // 503
		http.StatusGatewayTimeout,     // 504
		http.StatusTooManyRequests:    // 429
		return true
	}
	return false
}
