// 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: // 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 // _, 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 }