Add a UserID field to auth.TokenInfo that TokenVerifiers can populate from JWT "sub" claims or token introspection. The streamable HTTP transport uses this to bind sessions to users, rejecting requests where the user ID doesn't match the session's original user. Fixes #589
1953 lines
62 KiB
Go
1953 lines
62 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"
|
|
"maps"
|
|
"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/internal/xcontext"
|
|
"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
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*sessionInfo // keyed by session ID
|
|
}
|
|
|
|
type sessionInfo struct {
|
|
session *ServerSession
|
|
transport *StreamableServerTransport
|
|
// userID is the user ID from the TokenInfo when the session was created.
|
|
// If non-empty, subsequent requests must have the same user ID to prevent
|
|
// session hijacking.
|
|
userID string
|
|
|
|
// If timeout is set, automatically close the session after an idle period.
|
|
timeout time.Duration
|
|
timerMu sync.Mutex
|
|
refs int // reference count
|
|
timer *time.Timer
|
|
}
|
|
|
|
// startPOST signals that a POST request for this session is starting (which
|
|
// carries a client->server message), pausing the session timeout if it was
|
|
// running.
|
|
//
|
|
// TODO: we may want to also pause the timer when resuming non-standalone SSE
|
|
// streams, but that is tricy to implement. Clients should generally make
|
|
// keepalive pings if they want to keep the session live.
|
|
func (i *sessionInfo) startPOST() {
|
|
if i.timeout <= 0 {
|
|
return
|
|
}
|
|
|
|
i.timerMu.Lock()
|
|
defer i.timerMu.Unlock()
|
|
|
|
if i.timer == nil {
|
|
return // timer stopped permanently
|
|
}
|
|
if i.refs == 0 {
|
|
i.timer.Stop()
|
|
}
|
|
i.refs++
|
|
}
|
|
|
|
// endPOST sigals that a request for this session is ending, starting the
|
|
// timeout if there are no other requests running.
|
|
func (i *sessionInfo) endPOST() {
|
|
if i.timeout <= 0 {
|
|
return
|
|
}
|
|
|
|
i.timerMu.Lock()
|
|
defer i.timerMu.Unlock()
|
|
|
|
if i.timer == nil {
|
|
return // timer stopped permanently
|
|
}
|
|
|
|
i.refs--
|
|
assert(i.refs >= 0, "negative ref count")
|
|
if i.refs == 0 {
|
|
i.timer.Reset(i.timeout)
|
|
}
|
|
}
|
|
|
|
// stopTimer stops the inactivity timer permanently.
|
|
func (i *sessionInfo) stopTimer() {
|
|
i.timerMu.Lock()
|
|
defer i.timerMu.Unlock()
|
|
if i.timer != nil {
|
|
i.timer.Stop()
|
|
i.timer = nil
|
|
}
|
|
}
|
|
|
|
// StreamableHTTPOptions configures the StreamableHTTPHandler.
|
|
type StreamableHTTPOptions struct {
|
|
// Stateless controls whether the session is 'stateless'.
|
|
//
|
|
// A stateless server does not validate the Mcp-Session-Id header, and uses a
|
|
// temporary session with default initialization parameters. Any
|
|
// server->client request is rejected immediately as there's no way for the
|
|
// client to respond. Server->Client notifications may reach the client if
|
|
// they are made in the context of an incoming request, as described in the
|
|
// documentation for [StreamableServerTransport].
|
|
Stateless bool
|
|
|
|
// TODO(#148): support session retention (?)
|
|
|
|
// JSONResponse causes streamable responses to return application/json rather
|
|
// than text/event-stream ([§2.1.5] of the spec).
|
|
//
|
|
// [§2.1.5]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
|
|
JSONResponse bool
|
|
|
|
// Logger specifies the logger to use.
|
|
// If nil, do not log.
|
|
Logger *slog.Logger
|
|
|
|
// EventStore enables stream resumption.
|
|
//
|
|
// If set, EventStore will be used to persist stream events and replay them
|
|
// upon stream resumption.
|
|
EventStore EventStore
|
|
|
|
// SessionTimeout configures a timeout for idle sessions.
|
|
//
|
|
// When sessions receive no new HTTP requests from the client for this
|
|
// duration, they are automatically closed.
|
|
//
|
|
// If SessionTimeout is the zero value, idle sessions are never closed.
|
|
SessionTimeout time.Duration
|
|
}
|
|
|
|
// NewStreamableHTTPHandler returns a new [StreamableHTTPHandler].
|
|
//
|
|
// The getServer function is used to create or look up servers for new
|
|
// sessions. It is OK for getServer to return the same server multiple times.
|
|
// If getServer returns nil, a 400 Bad Request will be served.
|
|
func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *StreamableHTTPOptions) *StreamableHTTPHandler {
|
|
h := &StreamableHTTPHandler{
|
|
getServer: getServer,
|
|
sessions: make(map[string]*sessionInfo),
|
|
}
|
|
if opts != nil {
|
|
h.opts = *opts
|
|
}
|
|
|
|
if h.opts.Logger == nil { // ensure we have a logger
|
|
h.opts.Logger = ensureLogger(nil)
|
|
}
|
|
|
|
return h
|
|
}
|
|
|
|
// closeAll closes all ongoing sessions, for tests.
|
|
//
|
|
// TODO(rfindley): investigate the best API for callers to configure their
|
|
// session lifecycle. (?)
|
|
//
|
|
// Should we allow passing in a session store? That would allow the handler to
|
|
// be stateless.
|
|
func (h *StreamableHTTPHandler) closeAll() {
|
|
// TODO: if we ever expose this outside of tests, we'll need to do better
|
|
// than simply collecting sessions while holding the lock: we need to prevent
|
|
// new sessions from being added.
|
|
//
|
|
// Currently, sessions remove themselves from h.sessions when closed, so we
|
|
// can't call Close while holding the lock.
|
|
h.mu.Lock()
|
|
sessionInfos := slices.Collect(maps.Values(h.sessions))
|
|
h.sessions = nil
|
|
h.mu.Unlock()
|
|
for _, s := range sessionInfos {
|
|
s.session.Close()
|
|
}
|
|
}
|
|
|
|
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 sessInfo *sessionInfo
|
|
if sessionID != "" {
|
|
h.mu.Lock()
|
|
sessInfo = h.sessions[sessionID]
|
|
h.mu.Unlock()
|
|
if sessInfo == nil && !h.opts.Stateless {
|
|
// Unless we're in 'stateless' mode, which doesn't perform any Session-ID
|
|
// validation, we require that the session ID matches a known session.
|
|
//
|
|
// In stateless mode, a temporary transport is be created below.
|
|
http.Error(w, "session not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
// Prevent session hijacking: if the session was created with a user ID,
|
|
// verify that subsequent requests come from the same user.
|
|
if sessInfo != nil && sessInfo.userID != "" {
|
|
tokenInfo := auth.TokenInfoFromContext(req.Context())
|
|
if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID {
|
|
http.Error(w, "session user mismatch", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.Method == http.MethodDelete {
|
|
if sessionID == "" {
|
|
http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if sessInfo != nil { // sessInfo may be nil in stateless mode
|
|
// Closing the session also removes it from h.sessions, due to the
|
|
// onClose callback.
|
|
sessInfo.session.Close()
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
switch req.Method {
|
|
case http.MethodPost, http.MethodGet:
|
|
if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") {
|
|
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 sessInfo == nil {
|
|
server := h.getServer(req)
|
|
if server == nil {
|
|
// The getServer argument to NewStreamableHTTPHandler returned nil.
|
|
http.Error(w, "no server available", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if sessionID == "" {
|
|
// In stateless mode, sessionID may be nonempty even if there's no
|
|
// existing transport.
|
|
sessionID = server.opts.GetSessionID()
|
|
}
|
|
transport := &StreamableServerTransport{
|
|
SessionID: sessionID,
|
|
Stateless: h.opts.Stateless,
|
|
EventStore: h.opts.EventStore,
|
|
jsonResponse: h.opts.JSONResponse,
|
|
logger: h.opts.Logger,
|
|
}
|
|
|
|
// Sessions without a session ID are also stateless: there's no way to
|
|
// address them.
|
|
stateless := h.opts.Stateless || sessionID == ""
|
|
// To support stateless mode, we initialize the session with a default
|
|
// state, so that it doesn't reject subsequent requests.
|
|
var connectOpts *ServerSessionOptions
|
|
if stateless {
|
|
// Peek at the body to see if it is initialize or initialized.
|
|
// We want those to be handled as usual.
|
|
var hasInitialize, hasInitialized bool
|
|
{
|
|
// TODO: verify that this allows protocol version negotiation for
|
|
// stateless servers.
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
http.Error(w, "failed to read body", http.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()
|
|
defer h.mu.Unlock()
|
|
if info, ok := h.sessions[transport.SessionID]; ok {
|
|
info.stopTimer()
|
|
delete(h.sessions, transport.SessionID)
|
|
if h.onTransportDeletion != nil {
|
|
h.onTransportDeletion(transport.SessionID)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// Pass req.Context() here, to allow middleware to add context values.
|
|
// The context is detached in the jsonrpc2 library when handling the
|
|
// long-running stream.
|
|
session, err := server.Connect(req.Context(), transport, connectOpts)
|
|
if err != nil {
|
|
http.Error(w, "failed connection", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Capture the user ID from the token info to enable session hijacking
|
|
// prevention on subsequent requests.
|
|
var userID string
|
|
if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil {
|
|
userID = tokenInfo.UserID
|
|
}
|
|
sessInfo = &sessionInfo{
|
|
session: session,
|
|
transport: transport,
|
|
userID: userID,
|
|
}
|
|
|
|
if stateless {
|
|
// Stateless mode: close the session when the request exits.
|
|
defer session.Close() // close the fake session after handling the request
|
|
} else {
|
|
// Otherwise, save the transport so that it can be reused
|
|
|
|
// Clean up the session when it times out.
|
|
//
|
|
// Note that the timer here may fire multiple times, but
|
|
// sessInfo.session.Close is idempotent.
|
|
if h.opts.SessionTimeout > 0 {
|
|
sessInfo.timeout = h.opts.SessionTimeout
|
|
sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() {
|
|
sessInfo.session.Close()
|
|
})
|
|
}
|
|
h.mu.Lock()
|
|
h.sessions[transport.SessionID] = sessInfo
|
|
h.mu.Unlock()
|
|
defer func() {
|
|
// If initialization failed, clean up the session (#578).
|
|
if session.InitializeParams() == nil {
|
|
// Initialization failed.
|
|
session.Close()
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
if req.Method == http.MethodPost {
|
|
sessInfo.startPOST()
|
|
defer sessInfo.endPOST()
|
|
}
|
|
|
|
sessInfo.transport.ServeHTTP(w, req)
|
|
}
|
|
|
|
// A StreamableServerTransport implements the server side of the MCP streamable
|
|
// transport.
|
|
//
|
|
// Each StreamableServerTransport must be connected (via [Server.Connect]) at
|
|
// most once, since [StreamableServerTransport.ServeHTTP] serves messages to
|
|
// the connected session.
|
|
//
|
|
// Reads from the streamable server connection receive messages from http POST
|
|
// requests from the client. Writes to the streamable server connection are
|
|
// sent either to the related stream, or to the standalone SSE stream,
|
|
// according to the following rules:
|
|
// - JSON-RPC responses to incoming requests are always routed to the
|
|
// appropriate HTTP response.
|
|
// - Requests or notifications made with a context.Context value derived from
|
|
// an incoming request handler, are routed to the HTTP response
|
|
// corresponding to that request, unless it has already terminated, in
|
|
// which case they are routed to the standalone SSE stream.
|
|
// - Requests or notifications made with a detached context.Context value are
|
|
// routed to the standalone SSE stream.
|
|
type StreamableServerTransport struct {
|
|
// SessionID is the ID of this session.
|
|
//
|
|
// If SessionID is the empty string, this is a 'stateless' session, which has
|
|
// limited ability to communicate with the client. Otherwise, the session ID
|
|
// must be globally unique, that is, different from any other session ID
|
|
// anywhere, past and future. (We recommend using a crypto random number
|
|
// generator to produce one, as with [crypto/rand.Text].)
|
|
SessionID string
|
|
|
|
// Stateless controls whether the eventstore is 'Stateless'. Server sessions
|
|
// connected to a stateless transport are disallowed from making outgoing
|
|
// requests.
|
|
//
|
|
// See also [StreamableHTTPOptions.Stateless].
|
|
Stateless bool
|
|
|
|
// EventStore enables stream resumption.
|
|
//
|
|
// If set, EventStore will be used to persist stream events and replay them
|
|
// upon stream resumption.
|
|
EventStore EventStore
|
|
|
|
// jsonResponse, if set, tells the server to prefer to respond to requests
|
|
// using application/json responses rather than text/event-stream.
|
|
//
|
|
// Specifically, responses will be application/json whenever incoming POST
|
|
// request contain only a single message. In this case, notifications or
|
|
// requests made within the context of a server request will be sent to the
|
|
// standalone SSE stream, if any.
|
|
//
|
|
// TODO(rfindley): jsonResponse should be exported, since
|
|
// StreamableHTTPOptions.JSONResponse is exported, and we want to allow users
|
|
// to write their own streamable HTTP handler.
|
|
jsonResponse bool
|
|
|
|
// optional logger provided through the [StreamableHTTPOptions.Logger].
|
|
//
|
|
// TODO(rfindley): logger should be exported, since we want to allow users
|
|
// to write their own streamable HTTP handler.
|
|
logger *slog.Logger
|
|
|
|
// connection is non-nil if and only if the transport has been connected.
|
|
connection *streamableServerConn
|
|
}
|
|
|
|
// Connect implements the [Transport] interface.
|
|
func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, error) {
|
|
if t.connection != nil {
|
|
return nil, fmt.Errorf("transport already connected")
|
|
}
|
|
t.connection = &streamableServerConn{
|
|
sessionID: t.SessionID,
|
|
stateless: t.Stateless,
|
|
eventStore: t.EventStore,
|
|
jsonResponse: t.jsonResponse,
|
|
logger: ensureLogger(t.logger), // see #556: must be non-nil
|
|
incoming: make(chan jsonrpc.Message, 10),
|
|
done: make(chan struct{}),
|
|
streams: make(map[string]*stream),
|
|
requestStreams: make(map[jsonrpc.ID]string),
|
|
}
|
|
// Stream 0 corresponds to the standalone SSE stream.
|
|
//
|
|
// It is always text/event-stream, since it must carry arbitrarily many
|
|
// messages.
|
|
var err error
|
|
t.connection.streams[""], err = t.connection.newStream(ctx, nil, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t.connection, nil
|
|
}
|
|
|
|
type streamableServerConn struct {
|
|
sessionID string
|
|
stateless bool
|
|
jsonResponse bool
|
|
eventStore EventStore
|
|
|
|
logger *slog.Logger
|
|
|
|
incoming chan jsonrpc.Message // messages from the client to the server
|
|
|
|
mu sync.Mutex // guards all fields below
|
|
|
|
// Sessions are closed exactly once.
|
|
isDone bool
|
|
done chan struct{}
|
|
|
|
// Sessions can have multiple logical connections (which we call streams),
|
|
// corresponding to HTTP requests. Additionally, streams may be resumed by
|
|
// subsequent HTTP requests, when the HTTP connection is terminated
|
|
// unexpectedly.
|
|
//
|
|
// Therefore, we use a logical stream ID to key the stream state, and
|
|
// perform the accounting described below when incoming HTTP requests are
|
|
// handled.
|
|
|
|
// streams holds the logical streams for this session, keyed by their ID.
|
|
//
|
|
// Lifecycle: streams persist until all of their responses are received from
|
|
// the server.
|
|
streams map[string]*stream
|
|
|
|
// requestStreams maps incoming requests to their logical stream ID.
|
|
//
|
|
// Lifecycle: requestStreams persist until their response is received.
|
|
requestStreams map[jsonrpc.ID]string
|
|
}
|
|
|
|
func (c *streamableServerConn) SessionID() string {
|
|
return c.sessionID
|
|
}
|
|
|
|
// A stream is a single logical stream of SSE events within a server session.
|
|
// A stream begins with a client request, or with a client GET that has
|
|
// no Last-Event-ID header.
|
|
//
|
|
// A stream ends only when its session ends; we cannot determine its end otherwise,
|
|
// since a client may send a GET with a Last-Event-ID that references the stream
|
|
// at any time.
|
|
type stream struct {
|
|
// id is the logical ID for the stream, unique within a session.
|
|
//
|
|
// The standalone SSE stream has id "".
|
|
id string
|
|
|
|
// 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
|
|
|
|
// closeLocked sends a 'close' event to the client with a configurable retry
|
|
// delay, if there is a delivery channel available. The stream must be
|
|
// locked.
|
|
closeLocked func(reconnectAfter time.Duration)
|
|
|
|
// 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{}
|
|
}
|
|
|
|
func (s *stream) close(reconnectAfter time.Duration) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closeLocked != nil {
|
|
s.closeLocked(reconnectAfter)
|
|
}
|
|
}
|
|
|
|
// 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.closeLocked = 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, streamID string, e Event, lastIdx *int) error {
|
|
*lastIdx++
|
|
if c.eventStore != nil {
|
|
// TODO(rfindley): this isn't quite right: we should only set the ID if the
|
|
// message was actually stored successfully.
|
|
e.ID = formatEventID(streamID, *lastIdx)
|
|
}
|
|
if _, err := writeEvent(w, e); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeCloseEvent writes a 'close' event to the stream, signaling to the
|
|
// caller that they should reconnect after the configured delay.
|
|
func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectAfter time.Duration) {
|
|
reconnectStr := strconv.FormatInt(reconnectAfter.Milliseconds(), 10)
|
|
// Note: this event is not stored, since we don't want or need to replay it.
|
|
if _, err := writeEvent(w, Event{
|
|
Name: "close", // don't make this empty, as the default event type is "message"
|
|
Retry: reconnectStr,
|
|
}); err != nil {
|
|
c.logger.Warn(fmt.Sprintf("Writing close event: %v", err))
|
|
}
|
|
}
|
|
|
|
// 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{}) {
|
|
// if tempStream is set, the stream is done and we're just replaying messages.
|
|
//
|
|
// We record a temporary stream to claim exclusive replay rights.
|
|
tempStream := false
|
|
c.mu.Lock()
|
|
s, ok := c.streams[streamID]
|
|
if !ok {
|
|
// The stream is logically done, but claim exclusive rights to replay it by
|
|
// adding a temporary entry in the streams map.
|
|
//
|
|
// We create this entry with a non-nil deliver function, to ensure it isn't
|
|
// claimed by another request before we lock it below.
|
|
tempStream = true
|
|
s = &stream{
|
|
id: streamID,
|
|
deliver: func([]byte, bool) error { return nil },
|
|
}
|
|
c.streams[streamID] = s
|
|
|
|
// Since this stream is transient, we must clean up after replaying.
|
|
defer func() {
|
|
c.mu.Lock()
|
|
delete(c.streams, streamID)
|
|
c.mu.Unlock()
|
|
}()
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Check that this stream wasn't claimed by another request.
|
|
if !tempStream && s.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(), s.id, *lastIdx) {
|
|
if err != nil {
|
|
// We can't replay events, perhaps because the underlying event store
|
|
// has garbage collected its storage.
|
|
//
|
|
// We must be careful here: any 404 will signal to the client that the
|
|
// *session* is not found, rather than the stream.
|
|
//
|
|
// 400 is not really accurate, but should at least have no side effects.
|
|
// Other SDKs (typescript) do not have a mechanism for events to be purged.
|
|
http.Error(w, "failed to replay events", http.StatusBadRequest)
|
|
return nil, nil
|
|
}
|
|
if len(data) > 0 {
|
|
toReplay = append(toReplay, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
|
w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler]
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
if s.id == "" {
|
|
// Issue #410: the standalone SSE stream is likely not to receive messages
|
|
// for a long time. Ensure that headers are flushed.
|
|
w.WriteHeader(http.StatusOK)
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
for _, data := range toReplay {
|
|
if err := c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx); err != nil {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
if tempStream || s.doneLocked() {
|
|
// Nothing more to do.
|
|
return nil, nil
|
|
}
|
|
|
|
// The stream is not done: register a delivery function before the stream is
|
|
// unlocked, allowing the connection to write new events.
|
|
var done = make(chan struct{})
|
|
s.deliver = func(data []byte, final bool) error {
|
|
select {
|
|
case <-done:
|
|
return fmt.Errorf("stream closed")
|
|
default:
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if final {
|
|
defer close(done)
|
|
}
|
|
return c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx)
|
|
}
|
|
s.closeLocked = func(reconnectAfter time.Duration) {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
}
|
|
if reconnectAfter > 0 {
|
|
c.writeCloseEvent(w, reconnectAfter)
|
|
}
|
|
close(done)
|
|
}
|
|
return s, done
|
|
}
|
|
|
|
// servePOST handles an incoming message, and replies with either an outgoing
|
|
// message stream or single response object, depending on whether the
|
|
// jsonResponse option is set.
|
|
//
|
|
// It returns an HTTP status code and error message.
|
|
func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Request) {
|
|
if len(req.Header.Values("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{}{}
|
|
jreq.Extra.(*RequestExtra).CloseStream = func(reconnectAfter time.Duration) {
|
|
c.mu.Lock()
|
|
streamID, ok := c.requestStreams[jreq.ID]
|
|
var stream *stream
|
|
if ok {
|
|
stream = c.streams[streamID]
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
if stream != nil {
|
|
stream.close(reconnectAfter)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if c.eventStore != nil {
|
|
// Write a priming event.
|
|
// We must also write it to the event store in order for indexes to
|
|
// align.
|
|
if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil {
|
|
c.logger.Warn(fmt.Sprintf("Storing priming event: %v", err))
|
|
}
|
|
if err := c.writeEvent(w, stream.id, Event{Name: "prime"}, &lastIndex); err != nil {
|
|
c.logger.Warn(fmt.Sprintf("Writing priming event: %v", err))
|
|
}
|
|
}
|
|
|
|
stream.deliver = func(data []byte, final bool) error {
|
|
select {
|
|
case <-done:
|
|
return fmt.Errorf("stream closed")
|
|
default:
|
|
}
|
|
if final {
|
|
defer close(done)
|
|
}
|
|
return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex)
|
|
}
|
|
stream.closeLocked = func(reconnectAfter time.Duration) {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
}
|
|
if reconnectAfter > 0 {
|
|
c.writeCloseEvent(w, reconnectAfter)
|
|
}
|
|
close(done)
|
|
}
|
|
}
|
|
|
|
// 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.closeLocked = 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[""] // standalone SSE stream
|
|
}
|
|
if responseTo.IsValid() {
|
|
// Once we've responded to a request, disallow related messages by removing
|
|
// the stream association. This also releases memory.
|
|
delete(c.requestStreams, responseTo)
|
|
}
|
|
sessionClosed := c.isDone
|
|
c.mu.Unlock()
|
|
|
|
if s == nil {
|
|
// The request was made in the context of an ongoing request, but that
|
|
// request is complete.
|
|
//
|
|
// In the future, we could be less strict and allow the request to land on
|
|
// the standalone SSE stream.
|
|
return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected)
|
|
}
|
|
if sessionClosed {
|
|
return errors.New("session is closed")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.doneLocked() {
|
|
// It's possible that the stream was completed in between getting s above,
|
|
// and acquiring the stream lock. In order to avoid acquiring s.mu while
|
|
// holding c.mu, we check the terminal condition again.
|
|
return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected)
|
|
}
|
|
// Perform accounting on responses.
|
|
if responseTo.IsValid() {
|
|
if _, ok := s.requests[responseTo]; !ok {
|
|
panic(fmt.Sprintf("internal error: stream %v: response to untracked request %v", s.id, responseTo))
|
|
}
|
|
if s.id == "" {
|
|
// This should be guaranteed not to happen by the stream resolution logic
|
|
// above, but be defensive: we don't ever want to delete the standalone
|
|
// stream.
|
|
panic("internal error: response on standalone stream")
|
|
}
|
|
delete(s.requests, responseTo)
|
|
if len(s.requests) == 0 {
|
|
c.mu.Lock()
|
|
delete(c.streams, s.id)
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
delivered := false
|
|
var errs []error
|
|
// TODO(rfindley): we should only append if the response is SSE, not JSON, by
|
|
// pushing down into the delivery layer.
|
|
if c.eventStore != nil {
|
|
if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil {
|
|
errs = append(errs, err)
|
|
} else {
|
|
delivered = true
|
|
}
|
|
}
|
|
if s.deliver != nil {
|
|
if err := s.deliver(data, s.doneLocked()); err != nil {
|
|
errs = append(errs, err)
|
|
} else {
|
|
delivered = true
|
|
}
|
|
}
|
|
if !delivered {
|
|
return fmt.Errorf("%w: undelivered message: %v", jsonrpc2.ErrRejected, errors.Join(errs...))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close implements the [Connection] interface.
|
|
func (c *streamableServerConn) Close() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if !c.isDone {
|
|
c.isDone = true
|
|
close(c.done)
|
|
if c.eventStore != nil {
|
|
// TODO: find a way to plumb a context here, or an event store with a long-running
|
|
// close operation can take arbitrary time. Alternative: impose a fixed timeout here.
|
|
return c.eventStore.SessionClosed(context.TODO(), c.sessionID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A StreamableClientTransport is a [Transport] that can communicate with an MCP
|
|
// endpoint serving the streamable HTTP transport defined by the 2025-03-26
|
|
// version of the spec.
|
|
type StreamableClientTransport struct {
|
|
Endpoint string
|
|
HTTPClient *http.Client
|
|
// MaxRetries is the maximum number of times to attempt a reconnect before giving up.
|
|
// It defaults to 5. To disable retries, use a negative number.
|
|
MaxRetries int
|
|
|
|
// TODO(rfindley): propose exporting these.
|
|
// If strict is set, the transport is in 'strict mode', where any violation
|
|
// of the MCP spec causes a failure.
|
|
strict bool
|
|
// If logger is set, it is used to log aspects of the transport, such as spec
|
|
// violations that were ignored.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// These settings are not (yet) exposed to the user in
|
|
// StreamableClientTransport.
|
|
const (
|
|
// reconnectGrowFactor is the multiplicative factor by which the delay increases after each attempt.
|
|
// A value of 1.0 results in a constant delay, while a value of 2.0 would double it each time.
|
|
// It must be 1.0 or greater if MaxRetries is greater than 0.
|
|
reconnectGrowFactor = 1.5
|
|
// reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely.
|
|
reconnectMaxDelay = 30 * time.Second
|
|
)
|
|
|
|
var (
|
|
// reconnectInitialDelay is the base delay for the first reconnect attempt.
|
|
//
|
|
// Mutable for testing.
|
|
reconnectInitialDelay = 1 * time.Second
|
|
)
|
|
|
|
// Connect implements the [Transport] interface.
|
|
//
|
|
// The resulting [Connection] writes messages via POST requests to the
|
|
// transport URL with the Mcp-Session-Id header set, and reads messages from
|
|
// hanging requests.
|
|
//
|
|
// When closed, the connection issues a DELETE request to terminate the logical
|
|
// session.
|
|
func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, error) {
|
|
client := t.HTTPClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
maxRetries := t.MaxRetries
|
|
if maxRetries == 0 {
|
|
maxRetries = 5
|
|
} else if maxRetries < 0 {
|
|
maxRetries = 0
|
|
}
|
|
// Create a new cancellable context that will manage the connection's lifecycle.
|
|
// This is crucial for cleanly shutting down the background SSE listener by
|
|
// cancelling its blocking network operations, which prevents hangs on exit.
|
|
//
|
|
// This context should be detached from the incoming context: the standalone
|
|
// SSE request should not break when the connection context is done.
|
|
//
|
|
// For example, consider that the user may want to wait at most 5s to connect
|
|
// to the server, and therefore uses a context with a 5s timeout when calling
|
|
// client.Connect. Let's suppose that Connect returns after 1s, and the user
|
|
// starts using the resulting session. If we didn't detach here, the session
|
|
// would break after 4s, when the background SSE stream is terminated.
|
|
//
|
|
// Instead, creating a cancellable context detached from the incoming context
|
|
// allows us to preserve context values (which may be necessary for auth
|
|
// middleware), yet only cancel the standalone stream when the connection is closed.
|
|
connCtx, cancel := context.WithCancel(xcontext.Detach(ctx))
|
|
conn := &streamableClientConn{
|
|
url: t.Endpoint,
|
|
client: client,
|
|
incoming: make(chan jsonrpc.Message, 10),
|
|
done: make(chan struct{}),
|
|
maxRetries: maxRetries,
|
|
strict: t.strict,
|
|
logger: t.logger,
|
|
ctx: connCtx,
|
|
cancel: cancel,
|
|
failed: make(chan struct{}),
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
type streamableClientConn struct {
|
|
url string
|
|
client *http.Client
|
|
ctx context.Context // connection context, detached from Connect
|
|
cancel context.CancelFunc // cancels ctx
|
|
incoming chan jsonrpc.Message
|
|
maxRetries int
|
|
strict bool // from [StreamableClientTransport.strict]
|
|
logger *slog.Logger // from [StreamableClientTransport.logger]
|
|
|
|
// 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.
|
|
c.connectStandaloneSSE()
|
|
}
|
|
|
|
func (c *streamableClientConn) connectStandaloneSSE() {
|
|
resp, err := c.connectSSE(c.ctx, "", 0, true)
|
|
if err != nil {
|
|
// If the client didn't cancel the request, and failure breaks the logical
|
|
// session.
|
|
if c.ctx.Err() == nil {
|
|
c.fail(fmt.Errorf("standalone SSE request failed (session ID: %v): %v", c.sessionID, err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// [§2.2.3]: "The server MUST either return Content-Type:
|
|
// text/event-stream in response to this HTTP GET, or else return HTTP
|
|
// 405 Method Not Allowed, indicating that the server does not offer an
|
|
// SSE stream at this endpoint."
|
|
//
|
|
// [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server
|
|
if resp.StatusCode == http.StatusMethodNotAllowed {
|
|
// The server doesn't support the standalone SSE stream.
|
|
resp.Body.Close()
|
|
return
|
|
}
|
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 && !c.strict {
|
|
// modelcontextprotocol/go-sdk#393,#610: some servers return NotFound or
|
|
// other status codes instead of MethodNotAllowed for the standalone SSE
|
|
// stream.
|
|
//
|
|
// Treat this like MethodNotAllowed in non-strict mode.
|
|
if c.logger != nil {
|
|
c.logger.Warn(fmt.Sprintf("got %d instead of 405 for standalone SSE stream", resp.StatusCode))
|
|
}
|
|
resp.Body.Close()
|
|
return
|
|
}
|
|
summary := "standalone SSE stream"
|
|
if err := c.checkResponse(summary, resp); err != nil {
|
|
c.fail(err)
|
|
return
|
|
}
|
|
go c.handleSSE(c.ctx, summary, resp, 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)
|
|
}
|
|
|
|
if err := c.checkResponse(requestSummary, resp); err != nil {
|
|
c.fail(err)
|
|
return err
|
|
}
|
|
|
|
if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" {
|
|
c.mu.Lock()
|
|
hadSessionID := c.sessionID
|
|
if hadSessionID == "" {
|
|
c.sessionID = sessionID
|
|
}
|
|
c.mu.Unlock()
|
|
if hadSessionID != "" && hadSessionID != sessionID {
|
|
resp.Body.Close()
|
|
return fmt.Errorf("mismatching session IDs %q and %q", hadSessionID, sessionID)
|
|
}
|
|
}
|
|
// TODO(rfindley): this logic isn't quite right.
|
|
// We should keep going even if the server returns 202, if we have a call.
|
|
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":
|
|
var forCall *jsonrpc.Request
|
|
if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() {
|
|
forCall = jsonReq
|
|
}
|
|
// TODO: should we cancel this logical SSE request if/when jsonReq is canceled?
|
|
go c.handleSSE(ctx, requestSummary, resp, false, forCall)
|
|
|
|
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 forCall is set, it is the call that initiated the stream, and the
|
|
// stream is complete when we receive its response.
|
|
func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, persistent bool, forCall *jsonrpc2.Request) {
|
|
for {
|
|
// Connection was successful. Continue the loop with the new response.
|
|
//
|
|
// TODO(#679): we should set a reasonable limit on the number of times
|
|
// we'll try getting a response for a given request, or enforce that we
|
|
// actually make progress.
|
|
//
|
|
// Eventually, if we don't get the response, we should stop trying and
|
|
// fail the request.
|
|
lastEventID, reconnectDelay, clientClosed := c.processStream(ctx, requestSummary, resp, forCall)
|
|
|
|
// 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.connectSSE(ctx, lastEventID, reconnectDelay, false)
|
|
if err != nil {
|
|
// If the client didn't cancel this request, any failure to execute it
|
|
// breaks the logical MCP session.
|
|
if ctx.Err() == nil {
|
|
// All reconnection attempts failed: fail the connection.
|
|
c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err))
|
|
}
|
|
return
|
|
}
|
|
|
|
resp = newResp
|
|
if err := c.checkResponse(requestSummary, resp); err != nil {
|
|
c.fail(err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkResponse checks the status code of the provided response, and
|
|
// translates it into an error if the request was unsuccessful.
|
|
//
|
|
// The response body is close if a non-nil error is returned.
|
|
func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.Response) (err error) {
|
|
defer func() {
|
|
if err != nil {
|
|
resp.Body.Close()
|
|
}
|
|
}()
|
|
// §2.5.3: "The server MAY terminate the session at any time, after
|
|
// which it MUST respond to requests containing that session ID with HTTP
|
|
// 404 Not Found."
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
// Return an errSessionMissing to avoid sending a redundant DELETE when the
|
|
// session is already gone.
|
|
return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("%s: failed to connect: %v", requestSummary, http.StatusText(resp.StatusCode))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// processStream reads from a single response body, sending events to the
|
|
// incoming channel. It returns the ID of the last processed event and a flag
|
|
// indicating if the connection was closed by the client. If resp is nil, it
|
|
// returns "", false.
|
|
func (c *streamableClientConn) processStream(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, reconnectDelay time.Duration, clientClosed bool) {
|
|
defer resp.Body.Close()
|
|
for evt, err := range scanEvents(resp.Body) {
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return "", 0, true // don't reconnect: client cancelled
|
|
}
|
|
break
|
|
}
|
|
|
|
if evt.ID != "" {
|
|
lastEventID = evt.ID
|
|
}
|
|
|
|
if evt.Retry != "" {
|
|
if n, err := strconv.ParseInt(evt.Retry, 10, 64); err == nil {
|
|
reconnectDelay = time.Duration(n) * time.Millisecond
|
|
}
|
|
}
|
|
// According to SSE spec, events with no name default to "message"
|
|
if evt.Name != "" && evt.Name != "message" {
|
|
continue
|
|
}
|
|
|
|
msg, err := jsonrpc.DecodeMessage(evt.Data)
|
|
if err != nil {
|
|
c.fail(fmt.Errorf("%s: failed to decode event: %v", requestSummary, err))
|
|
return "", 0, true
|
|
}
|
|
|
|
select {
|
|
case c.incoming <- msg:
|
|
if jsonResp, ok := msg.(*jsonrpc.Response); ok && forCall != nil {
|
|
// TODO: we should never get a response when forReq is nil (the standalone SSE request).
|
|
// We should detect this case.
|
|
if jsonResp.ID == forCall.ID {
|
|
return "", 0, true
|
|
}
|
|
}
|
|
|
|
case <-c.done:
|
|
// The connection was closed by the client; exit gracefully.
|
|
return "", 0, true
|
|
}
|
|
}
|
|
// The loop finished without an error, indicating the server closed the stream.
|
|
//
|
|
// If the lastEventID is "", the stream is not retryable and we should
|
|
// report a synthetic error for the call.
|
|
//
|
|
// Note that this is different from the cancellation case above, since the
|
|
// caller is still waiting for a response that will never come.
|
|
if lastEventID == "" && forCall != nil {
|
|
errmsg := &jsonrpc2.Response{
|
|
ID: forCall.ID,
|
|
Error: fmt.Errorf("request terminated without response"),
|
|
}
|
|
select {
|
|
case c.incoming <- errmsg:
|
|
case <-c.done:
|
|
}
|
|
}
|
|
return lastEventID, reconnectDelay, false
|
|
}
|
|
|
|
// connectSSE handles the logic of connecting a text/event-stream connection.
|
|
//
|
|
// If lastEventID is set, it is the last-event ID of a stream being resumed.
|
|
//
|
|
// If connection fails, connectSSE retries with an exponential backoff
|
|
// strategy. It returns a new, valid HTTP response if successful, or an error
|
|
// if all retries are exhausted.
|
|
//
|
|
// reconnectDelay is the delay set by the server using the SSE retry field, or
|
|
// 0.
|
|
//
|
|
// If initial is set, this is the initial attempt.
|
|
//
|
|
// If connectSSE exits due to context cancellation, the result is (nil, ctx.Err()).
|
|
func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID string, reconnectDelay time.Duration, initial bool) (*http.Response, error) {
|
|
var finalErr error
|
|
attempt := 0
|
|
if !initial {
|
|
// We've already connected successfully once, so delay subsequent
|
|
// reconnections. Otherwise, if the server returns 200 but terminates the
|
|
// connection, we'll reconnect as fast as we can, ad infinitum.
|
|
//
|
|
// TODO: we should consider also setting a limit on total attempts for one
|
|
// logical request.
|
|
attempt = 1
|
|
}
|
|
delay := calculateReconnectDelay(attempt)
|
|
if reconnectDelay > 0 {
|
|
delay = reconnectDelay // honor the server's requested initial delay
|
|
}
|
|
for ; attempt <= c.maxRetries; attempt++ {
|
|
select {
|
|
case <-c.done:
|
|
return nil, fmt.Errorf("connection closed by client during reconnect")
|
|
|
|
case <-ctx.Done():
|
|
// If the connection context is canceled, the request below will not
|
|
// succeed anyway.
|
|
return nil, ctx.Err()
|
|
|
|
case <-time.After(delay):
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.setMCPHeaders(req)
|
|
if lastEventID != "" {
|
|
req.Header.Set("Last-Event-ID", lastEventID)
|
|
}
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
finalErr = err // Store the error and try again.
|
|
delay = calculateReconnectDelay(attempt + 1)
|
|
continue
|
|
}
|
|
return resp, nil
|
|
}
|
|
}
|
|
// If the loop completes, all retries have failed, or the client is closing.
|
|
if finalErr != nil {
|
|
return nil, fmt.Errorf("connection failed after %d attempts: %w", c.maxRetries, finalErr)
|
|
}
|
|
return nil, fmt.Errorf("connection aborted after %d attempts", c.maxRetries)
|
|
}
|
|
|
|
// Close implements the [Connection] interface.
|
|
func (c *streamableClientConn) Close() error {
|
|
c.closeOnce.Do(func() {
|
|
if errors.Is(c.failure(), errSessionMissing) {
|
|
// If the session is missing, no need to delete it.
|
|
} else {
|
|
req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil)
|
|
if err != nil {
|
|
c.closeErr = err
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|