go-sdk/mcp/streamable.go
Robert Findley 104d39c609
mcp: allow disabling stream replay; disable by default (#585)
This CL implements two changes that must be logically connected.

The first is to provide a mechanism for disabling stream replay.
Previously, our documentation stated that if
StreamableServerTransport.EventStore was nil, a default in-memory event
store would be used. In that case, there would be no way to completely
disable stream replay, and as described in #576, there are many cases
where replay is undesirable.

But of course changing the behavior of the transport zero value
implicitly affects its default behavior, and so this CL also implements
the proposal #580: stream replay is disabled by default.

Implementing this change required a significant refactoring, as
previously we were relying on the event store for serialized message
delivery from the JSON-RPC layer to the MCP layer: the connection would
write to the event store, and independently the stream (be it an
incoming POST or replay GET) would iterate and serve messages in the
stream.

In order to achieve the goals of this CL, it was necessary to decouple
storage from delivery. The 'stream' abstraction now tracks a delivery
callback that writes straight to the HTTP response. It would have been
convenient to store the ongoing http.ResponseWriter directly in the
stream (this is how typescript does it), but due to the design of our
EventStore API, only the HTTP handler knows the next event index, so a
'deliver' abstraction was an unfortunate requirement (suggestions for
how to further simplify this are welcome).

More simplification is possible: in particular, as a result of this
refactoring it should be entirely possible to clean up streams once
we've received all responses. Any replay would only need access to the
EventStore, if at all. This is left to a follow-up CL, to limit this
already significant change.

Furthermore, a nice consequence of this refactoring is that, when not
using event storage, servers can get synchronous feedback that message
delivery failed, which should avoid unnecessary work. We can
additionally cancel ongoing requests on early client termination, but
that is also left to a follow-up CL.

Throughout, the terminology 'standalone SSE stream' replaced 'hanging
GET' when referring to the non-replay GET request issued by the client.
This is consistent with other SDKs.

Fixes #576
Updates #580
2025-10-16 12:11:49 -04:00

1593 lines
51 KiB
Go

// 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.
package mcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"math/rand/v2"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
const (
protocolVersionHeader = "Mcp-Protocol-Version"
sessionIDHeader = "Mcp-Session-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 only
mu sync.Mutex
// TODO: we should store the ServerSession along with the transport, because
// we need to cancel keepalive requests when closing the transport.
transports map[string]*StreamableServerTransport // keyed by IDs (from Mcp-Session-Id header)
}
// 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
// TODO(rfindley): file a proposal to export this option, or something equivalent.
configureTransport func(req *http.Request, transport *StreamableServerTransport)
}
// 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,
transports: make(map[string]*StreamableServerTransport),
}
if opts != nil {
h.opts = *opts
}
if h.opts.Logger == nil { // ensure we have a logger
h.opts.Logger = ensureLogger(nil)
}
return h
}
// closeAll closes all ongoing sessions.
//
// 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() {
h.mu.Lock()
defer h.mu.Unlock()
for _, s := range h.transports {
s.connection.Close()
}
h.transports = nil
}
func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Allow multiple 'Accept' headers.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax
accept := strings.Split(strings.Join(req.Header.Values("Accept"), ","), ",")
var jsonOK, streamOK bool
for _, c := range accept {
switch strings.TrimSpace(c) {
case "application/json", "application/*":
jsonOK = true
case "text/event-stream", "text/*":
streamOK = true
case "*/*":
jsonOK = true
streamOK = true
}
}
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 transport *StreamableServerTransport
if sessionID != "" {
h.mu.Lock()
transport = h.transports[sessionID]
h.mu.Unlock()
if transport == 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
}
}
if req.Method == http.MethodDelete {
if sessionID == "" {
http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest)
return
}
if transport != nil { // transport may be nil in stateless mode
h.mu.Lock()
delete(h.transports, transport.SessionID)
h.mu.Unlock()
transport.connection.Close()
}
w.WriteHeader(http.StatusNoContent)
return
}
switch req.Method {
case http.MethodPost, http.MethodGet:
if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") {
http.Error(w, "GET requires an active session", http.StatusMethodNotAllowed)
return
}
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "Method Not Allowed: streamable MCP servers support GET, POST, and DELETE requests", 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 transport == 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,
jsonResponse: h.opts.JSONResponse,
logger: h.opts.Logger,
}
if h.opts.configureTransport != nil {
h.opts.configureTransport(req, transport)
}
// To support stateless mode, we initialize the session with a default
// state, so that it doesn't reject subsequent requests.
var connectOpts *ServerSessionOptions
if h.opts.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.StatusInternalServerError)
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()
delete(h.transports, transport.SessionID)
h.mu.Unlock()
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.
ss, err := server.Connect(req.Context(), transport, connectOpts)
if err != nil {
http.Error(w, "failed connection", http.StatusInternalServerError)
return
}
if h.opts.Stateless {
// Stateless mode: close the session when the request exits.
defer ss.Close() // close the fake session after handling the request
} else {
// Otherwise, save the transport so that it can be reused
h.mu.Lock()
h.transports[transport.SessionID] = transport
h.mu.Unlock()
}
}
transport.ServeHTTP(w, req)
}
// 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
// Storage for events, to enable stream resumption.
// If nil, a [MemoryEventStore] with the default maximum size will be used.
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.
// TODO: streams are never deleted, so the memory for a connection grows without
// bound. If we deleted a stream when the response is sent, we would lose the ability
// to replay if there was a cut just before the response was transmitted.
// Perhaps we could have a TTL for streams that starts just after the response.
//
// TODO(rfindley): Once all responses have been received for a stream, we can
// remove it as it is no longer necessary, even if the client wants to replay
// messages.
streams map[string]*stream
// requestStreams maps incoming requests to their logical stream ID.
//
// Lifecycle: requestStreams persist for the duration of the session.
//
// TODO(rfindley): clean up once requests are handled. See the TODO for
// streams above.
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
// mu guards the fields below, as well as storage of new messages in the
// connection's event store (if any).
mu sync.Mutex
// If non-nil, deliver writes data directly to the HTTP response.
//
// Only one HTTP response may receive messages at a given time. An active
// HTTP connection acquires ownership of the stream by setting this field.
deliver func(data []byte, final bool) error
// streamRequests is the set of unanswered incoming requests for the stream.
//
// Requests are removed when their response has been received.
requests map[jsonrpc.ID]struct{}
}
// doneLocked reports whether the stream is logically complete.
//
// 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,
}, 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("Last-Event-ID")) > 0 {
eid := req.Header.Get("Last-Event-ID")
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, cancel := context.WithCancel(req.Context())
defer cancel()
stream, done := c.acquireStream(ctx, w, streamID, &lastIdx)
if stream == nil {
return
}
// Release the stream when we're done.
defer func() {
stream.mu.Lock()
stream.deliver = nil
stream.mu.Unlock()
}()
select {
case <-ctx.Done():
// request cancelled
case <-done:
// request complete
case <-c.done:
// session closed
}
}
// writeEvent writes an SSE event to w corresponding to the given stream, data, and index.
// lastIdx is incremented before writing, so that it continues to point to the index of the
// last event written to the stream.
func (c *streamableServerConn) writeEvent(w http.ResponseWriter, stream *stream, data []byte, lastIdx *int) error {
*lastIdx++
e := Event{
Name: "message",
Data: data,
}
if c.eventStore != nil {
e.ID = formatEventID(stream.id, *lastIdx)
}
if _, err := writeEvent(w, e); err != nil {
return err
}
return nil
}
// acquireStream acquires the stream and replays all events since lastIdx, if
// any, updating lastIdx accordingly. If non-nil, the resulting stream will be
// registered for receiving new messages, and the resulting 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.
func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int) (*stream, chan struct{}) {
c.mu.Lock()
stream, ok := c.streams[streamID]
c.mu.Unlock()
if !ok {
http.Error(w, "unknown stream", http.StatusBadRequest)
return nil, nil
}
stream.mu.Lock()
defer stream.mu.Unlock()
if stream.deliver != 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(), stream.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
}
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 stream.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)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
for _, data := range toReplay {
if err := c.writeEvent(w, stream, data, lastIdx); err != nil {
return nil, nil
}
}
if stream.doneLocked() {
// Nothing more to do.
return nil, nil
}
// Finally register a delivery function and unlock the stream, allowing the
// connection to write new events.
done := make(chan struct{})
stream.deliver = func(data []byte, final bool) error {
if err := ctx.Err(); err != nil {
return err
}
err := c.writeEvent(w, stream, data, lastIdx)
if final {
close(done)
}
return err
}
return stream, 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("Last-Event-ID")) > 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
}
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
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
}
jreq.Extra = &RequestExtra{
TokenInfo: tokenInfo,
Header: req.Header,
}
if jreq.IsCall() {
calls[jreq.ID] = struct{}{}
}
}
}
// If we don't have any calls, we can just publish the incoming messages and return.
// No need to track a logical stream.
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 imcoming messages as
// soon as they're published.
stream, err := c.newStream(req.Context(), calls, randText())
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)
}
// Message delivery has two paths, depending on whether we're responding with JSON or
// event stream.
done := make(chan struct{}) // closed after the final response is written
if c.jsonResponse {
var msgs []json.RawMessage
stream.deliver = func(data []byte, final bool) error {
// Collect messages until we've received the final response.
//
// In recent protocol versions, there should only be one message as
// batching is disabled, as checked above.
msgs = append(msgs, data)
if !final {
return nil
}
defer close(done) // final response
// Write either the JSON object corresponding to the one response, or a
// JSON array corresponding to the batch response.
var toWrite []byte
if len(msgs) == 1 {
toWrite = []byte(msgs[0])
} else {
var err error
toWrite, err = json.Marshal(msgs)
if err != nil {
return err
}
}
_, err = w.Write(toWrite)
return err
}
} else {
// Write events in the order we receive them.
lastIndex := -1
stream.deliver = func(data []byte, final bool) error {
if final {
defer close(done)
}
return c.writeEvent(w, stream, data, &lastIndex)
}
}
// Release ownership of the stream by unsetting deliver.
defer func() {
stream.mu.Lock()
// TODO(rfindley): if we have no event store, we should really cancel all
// remaining requests here, since the client will never get the results.
stream.deliver = nil
stream.mu.Unlock()
}()
// 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.
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
}
}
select {
case <-req.Context().Done():
// request cancelled
case <-done:
// request complete
case <-c.done:
// session is closed
}
}
// 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.ID.IsValid() && (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[""]
}
sessionClosed := c.isDone
c.mu.Unlock()
if s == nil {
return fmt.Errorf("%w: no stream for request", jsonrpc2.ErrRejected)
}
if sessionClosed {
return errors.New("session is closed")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.doneLocked() {
return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected)
}
if responseTo.IsValid() {
delete(s.requests, responseTo)
}
delivered := false
if c.eventStore != nil {
if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil {
// TODO: report a side-channel error.
} else {
delivered = true
}
}
if s.deliver != nil {
if err := s.deliver(data, s.doneLocked()); err != nil {
// TODO: report a side-channel error.
} else {
delivered = true
}
}
if !delivered {
return fmt.Errorf("%w: undelivered message", jsonrpc2.ErrRejected)
}
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
// 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
// reconnectInitialDelay is the base delay for the first reconnect attempt.
reconnectInitialDelay = 1 * time.Second
// reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely.
reconnectMaxDelay = 30 * 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.
connCtx, cancel := context.WithCancel(ctx)
conn := &streamableClientConn{
url: t.Endpoint,
client: client,
incoming: make(chan jsonrpc.Message, 10),
done: make(chan struct{}),
maxRetries: maxRetries,
strict: t.strict,
logger: t.logger,
ctx: connCtx,
cancel: cancel,
failed: make(chan struct{}),
}
return conn, nil
}
type streamableClientConn struct {
url string
client *http.Client
ctx context.Context
cancel context.CancelFunc
incoming chan jsonrpc.Message
maxRetries int
strict bool // from [StreamableClientTransport.strict]
logger *slog.Logger // from [StreamableClientTransport.logger]
// 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
}
// errSessionMissing distinguishes if the session is known to not be present on
// the server (see [streamableClientConn.fail]).
//
// TODO(rfindley): should we expose this error value (and its corresponding
// API) to the user?
//
// The spec says that if the server returns 404, clients should reestablish
// a session. For now, we delegate that to the user, but do they need a way to
// differentiate a 'NotFound' error from other errors?
var errSessionMissing = errors.New("session not found")
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.
//
// § 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 an Mcp-Session-Id header
// on the HTTP response containing the InitializeResult.
go c.handleSSE("standalone SSE stream", nil, true, 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 isCall bool
switch msg := msg.(type) {
case *jsonrpc.Request:
requestSummary = fmt.Sprintf("sending %q", msg.Method)
isCall = msg.IsCall()
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)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
c.setMCPHeaders(req)
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("%s: %v", requestSummary, err)
}
// §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 {
// Fail the session immediately, rather than relying on jsonrpc2 to fail
// (and close) it, because we want the call to Close to know that this
// session is missing (and therefore not send the DELETE).
err := fmt.Errorf("%s: failed to send: %w", requestSummary, errSessionMissing)
c.fail(err)
resp.Body.Close()
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
return fmt.Errorf("broken session: %v", resp.Status)
}
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 resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
// [§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
resp.Body.Close()
return nil
} else if !isCall && !c.strict {
// Some servers return 200, even with an empty json body.
// Ignore this response in non-strict mode.
if c.logger != nil {
c.logger.Warn(fmt.Sprintf("unexpected status code %d from non-call", resp.StatusCode))
}
resp.Body.Close()
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":
jsonReq, _ := msg.(*jsonrpc.Request)
// TODO: should we cancel this logical SSE request if/when jsonReq is canceled?
go c.handleSSE(requestSummary, resp, false, jsonReq)
default:
resp.Body.Close()
return fmt.Errorf("%s: unsupported content type %q", requestSummary, contentType)
}
return nil
}
// testAuth controls whether a fake Authorization header is added to outgoing requests.
// TODO: replace with a better mechanism when client-side auth is in place.
var testAuth atomic.Bool
func (c *streamableClientConn) setMCPHeaders(req *http.Request) {
c.mu.Lock()
defer c.mu.Unlock()
if c.initializedResult != nil {
req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion)
}
if c.sessionID != "" {
req.Header.Set(sessionIDHeader, c.sessionID)
}
if testAuth.Load() {
req.Header.Set("Authorization", "Bearer foo")
}
}
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 forReq is set, it is the request that initiated the stream, and the
// stream is complete when we receive its response.
func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *http.Response, persistent bool, forReq *jsonrpc2.Request) {
resp := initialResp
var lastEventID string
for {
// TODO: we should set a reasonable limit on the number of times we'll try
// getting a response for a given request.
//
// Eventually, if we don't get the response, we should stop trying and
// fail the request.
if resp != nil {
eventID, clientClosed := c.processStream(requestSummary, resp, forReq)
lastEventID = eventID
// If the connection was closed by the client, we're done.
if clientClosed {
return
}
// If the stream has ended, then do not reconnect if the stream is
// temporary (POST initiated SSE).
if lastEventID == "" && !persistent {
return
}
}
// The stream was interrupted or ended by the server. Attempt to reconnect.
newResp, err := c.reconnect(lastEventID)
if 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 resp.StatusCode == http.StatusMethodNotAllowed && persistent {
// [§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
// The server doesn't support the standalone SSE stream.
resp.Body.Close()
return
}
if resp.StatusCode == http.StatusNotFound && persistent && !c.strict {
// modelcontextprotocol/gosdk#393: some servers return NotFound instead
// of MethodNotAllowed for the standalone SSE stream.
//
// Treat this like MethodNotAllowed in non-strict mode.
if c.logger != nil {
c.logger.Warn("got 404 instead of 405 for standalonw SSE stream")
}
resp.Body.Close()
return
}
// (see equivalent handling in [streamableClientConn.Write]).
if resp.StatusCode == http.StatusNotFound {
c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing))
return
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
c.fail(fmt.Errorf("%s: failed to reconnect: %v", requestSummary, http.StatusText(resp.StatusCode)))
return
}
// Reconnection was successful. Continue the loop with the new response.
}
}
// 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(requestSummary string, resp *http.Response, forReq *jsonrpc.Request) (lastEventID string, clientClosed bool) {
defer resp.Body.Close()
for evt, err := range scanEvents(resp.Body) {
if err != nil {
return lastEventID, false
}
if evt.ID != "" {
lastEventID = evt.ID
}
msg, err := jsonrpc.DecodeMessage(evt.Data)
if err != nil {
c.fail(fmt.Errorf("%s: failed to decode event: %v", requestSummary, err))
return "", true
}
select {
case c.incoming <- msg:
if jsonResp, ok := msg.(*jsonrpc.Response); ok && forReq != nil {
// TODO: we should never get a response when forReq is nil (the standalone SSE request).
// We should detect this case.
if jsonResp.ID == forReq.ID {
return "", true
}
}
case <-c.done:
// The connection was closed by the client; exit gracefully.
return "", true
}
}
// The loop finished without an error, indicating the server closed the stream.
return "", false
}
// reconnect handles the logic of retrying a connection with an exponential
// backoff strategy. It returns a new, valid HTTP response if successful, or
// an error if all retries are exhausted.
func (c *streamableClientConn) reconnect(lastEventID string) (*http.Response, error) {
var finalErr error
// We can reach the 'reconnect' path through the standlone SSE request, in which case
// lastEventID will be "".
//
// In this case, we need an initial attempt.
attempt := 0
if lastEventID != "" {
attempt = 1
}
for ; attempt <= c.maxRetries; attempt++ {
select {
case <-c.done:
return nil, fmt.Errorf("connection closed by client during reconnect")
case <-time.After(calculateReconnectDelay(attempt)):
resp, err := c.establishSSE(lastEventID)
if err != nil {
finalErr = err // Store the error and try again.
continue
}
return resp, nil
}
}
// If the loop completes, all retries have failed.
if finalErr != nil {
return nil, fmt.Errorf("connection failed after %d attempts: %w", c.maxRetries, finalErr)
}
return nil, fmt.Errorf("connection failed 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 {
c.setMCPHeaders(req)
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
}
// establishSSE establishes the persistent SSE listening stream.
// It is used for reconnect attempts using the Last-Event-ID header to
// resume a broken stream where it left off.
func (c *streamableClientConn) establishSSE(lastEventID string) (*http.Response, error) {
req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url, nil)
if err != nil {
return nil, err
}
c.setMCPHeaders(req)
if lastEventID != "" {
req.Header.Set("Last-Event-ID", lastEventID)
}
req.Header.Set("Accept", "text/event-stream")
return c.client.Do(req)
}
// 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) * 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
}