mcp: clarify documentation for the streamable transports

After a deep dive with the team, document areas of the codebase
identified as unclear. For now, this is a documentation-only commit (apart
from one switch of jsonrpc2.Request.ID.IsValid->Request.IsCall). Special
TODO(cleanup) comments are added for additional code changes in
follow-up commits.

Also, include high level doc comments for server and client transports,
in new streamable_server.go and streamable_client.go. Eventually, the
server and client transport logic should be migrated to these files, as
streamable.go has gotten too large. However, we can do that at a future
date, to minimize diff.
This commit is contained in:
Markus Kusano 2025-12-05 17:49:54 +00:00 • committed by Robert Findley
commit dd5a8ff77b
5 changed files with 462 additions and 23 deletions

View file

@ -55,9 +55,16 @@ type ConnectionOptions struct {
}
// Connection manages the jsonrpc2 protocol, connecting responses back to their
// calls.
// Connection is bidirectional; it does not have a designated server or client
// end.
// calls. Connection is bidirectional; it does not have a designated server or
// client end.
//
// Note that the word 'Connection' is overloaded: the mcp.Connection represents
// the bidirectional stream of messages between client an server. The
// jsonrpc2.Connection layers RPC logic on top of that stream, dispatching RPC
// handlers, and correlating requests with responses from the peer.
//
// Some of the complexity of the Connection type is grown out of its usage in
// gopls: it could probably be simplified based on our usage in MCP.
type Connection struct {
seq int64 // must only be accessed using atomic operations

View file

@ -394,7 +394,7 @@ func notifySessions[S Session, P Params](sessions []S, method string, params P)
for _, s := range sessions {
req := newRequest(s, params)
if err := handleNotify(ctx, method, req); err != nil {
// TODO(jba): surface this error better
// TODO(#218): surface this error better
log.Printf("calling %s: %v", method, err)
}
}
@ -469,15 +469,23 @@ type RequestExtra struct {
TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any
Header http.Header // header from HTTP request, if any
// CloseStream closes the current request stream, if the current transport
// supports replaying requests.
// If set, CloseStream explicitly closes the current request stream.
//
// If reconnectAfter is nonzero, it signals to the client to reconnect after
// the given duration. Otherwise, clients may determine their own
// reconnection policy.
// [SEP-1699] introduced server-side SSE stream disconnection: for
// long-running requests, servers may opt to close the SSE stream and
// ask the client to retry at a later time. CloseStream implements this
// feature; if reconnectAfter is set, an event is sent with a `retry:` field
// to configure the reconnection delay.
//
// [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
CloseStream func(reconnectAfter time.Duration)
}
// TODO(cleanup): switch to an args struct here for forwards compatibility.
// type CloseStreamArgs struct {
// After time.Duration
// }
func (*ClientRequest[P]) isRequest() {}
func (*ServerRequest[P]) isRequest() {}

View file

@ -2,6 +2,10 @@
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
// NOTE: see streamable_server.go and streamable_client.go for detailed
// documentation of the streamable server design.
// TODO(cleanup): move the client and server logic into those files.
package mcp
import (
@ -632,6 +636,9 @@ type stream struct {
// streamRequests is the set of unanswered incoming requests for the stream.
//
// Requests are removed when their response has been received.
// In practice, there is only one request, but in the 2025-03-26 version of
// the spec and earlier there was a concept of batching, in which POST
// payloads could hold multiple requests or responses.
requests map[jsonrpc.ID]struct{}
}
@ -645,6 +652,10 @@ func (s *stream) close(reconnectAfter time.Duration) {
// doneLocked reports whether the stream is logically complete.
//
// s.requests was populated when reading the POST body, requests are deleted as
// they are responded to. Once all requests have been responded to, the stream
// is done.
//
// s.mu must be held while calling this function.
func (s *stream) doneLocked() bool {
return len(s.requests) == 0 && s.id != ""
@ -751,6 +762,7 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request
stream.mu.Unlock()
}()
// TODO(cleanup): factor out, to clarify closing patterns.
select {
case <-ctx.Done():
// request cancelled
@ -790,10 +802,10 @@ func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectA
}
}
// 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.
// acquireStream replays all events since lastIdx, and acquires the ongoing
// stream, 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.
@ -803,11 +815,13 @@ func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectA
// the stream is still replaying.
//
// supportsPrimeClose indicates whether the client supports the prime and close
// events (protocol version 2025-11-25 or later).
// events (SEP-1699, added in protocol version 2025-11-25 or later).
func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int, supportsPrimeClose bool) (*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.
// We record a temporary stream to claim exclusive replay rights. The spec
// (https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery)
// does not explicitly require exclusive replay, but we enforce it defensively.
tempStream := false
c.mu.Lock()
s, ok := c.streams[streamID]
@ -896,20 +910,23 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons
// 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{})
// TODO(cleanup): consolidate deliver functions, and be consistent about
// checking ctx.Err().
s.deliver = func(data []byte, final bool) error {
select {
case <-done:
return fmt.Errorf("stream closed")
case <-ctx.Done():
return ctx.Err()
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)
}
// TODO(cleanup): consolidate closeLocked functions, and be consistent about
// checking ctx.Err().
if supportsPrimeClose {
s.closeLocked = func(reconnectAfter time.Duration) {
select {
@ -947,6 +964,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
http.Error(w, "POST requires a non-empty body", http.StatusBadRequest)
return
}
// TODO(#674): once we've documented the support matrix for 2025-03-26 and
// earlier, drop support for matching entirely; that will simplify this
// logic.
incoming, isBatch, err := readBatch(body)
if err != nil {
http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest)
@ -991,12 +1011,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
initializeProtocolVersion = params.ProtocolVersion
}
}
// Include metadata for all requests (including notifications).
jreq.Extra = &RequestExtra{
TokenInfo: tokenInfo,
Header: req.Header,
}
if jreq.IsCall() {
calls[jreq.ID] = struct{}{}
// See the doc for CloseStream: allow the request handler to explicitly
// close the ongoing stream.
jreq.Extra.(*RequestExtra).CloseStream = func(reconnectAfter time.Duration) {
c.mu.Lock()
streamID, ok := c.requestStreams[jreq.ID]
@ -1025,6 +1048,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
// If we don't have any calls, we can just publish the incoming messages and return.
// No need to track a logical stream.
//
// See section [§2.1.4] of the spec: "If the server accepts the input, the
// server MUST return HTTP status code 202 Accepted with no body."
//
// [§2.1.4]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
if len(calls) == 0 {
for _, msg := range incoming {
select {
@ -1074,6 +1102,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
//
// In recent protocol versions, there should only be one message as
// batching is disabled, as checked above.
//
// Note that in [streamableClientConn.Write], any non-response message is
// sent to the standalone stream (if any), when the connection is
// configured to respond with application/json.
msgs = append(msgs, data)
if !final {
return nil
@ -1099,7 +1131,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
// Write events in the order we receive them.
lastIndex := -1
if c.eventStore != nil && supportsPrimeClose {
// Write a priming event.
// Write a priming event, as defined by [§2.1.6] of the spec.
//
// [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
//
// We must also write it to the event store in order for indexes to
// align.
if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil {
@ -1110,6 +1145,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
}
}
// TODO(cleanup): consolidate per other TODO, check ctx.Err()
stream.deliver = func(data []byte, final bool) error {
select {
case <-done:
@ -1121,6 +1157,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
}
return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex)
}
// TODO(cleanup): consolidate per other TODO.
if supportsPrimeClose {
stream.closeLocked = func(reconnectAfter time.Duration) {
select {
@ -1163,6 +1200,8 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
// Note: don't select on req.Context().Done() here, since we've already
// received the requests and may have already published a response message
// or notification. The client could resume the stream.
//
// In fact, this send could be in a separate goroutine.
case <-c.done:
// Session closed: we don't know if any data has been written, so it's
// too late to write a status code here.
@ -1170,6 +1209,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
}
}
// TODO(cleanup): factor this out, per other TODO.
select {
case <-req.Context().Done():
// request cancelled
@ -1236,7 +1276,7 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e
return err
}
if req, ok := msg.(*jsonrpc.Request); ok && req.ID.IsValid() && (c.stateless || c.sessionID == "") {
if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() && (c.stateless || c.sessionID == "") {
// Requests aren't possible with stateless servers, or when there's no session ID.
return fmt.Errorf("%w: stateless servers cannot make requests", jsonrpc2.ErrRejected)
}
@ -1689,7 +1729,8 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() {
forCall = jsonReq
}
// TODO: should we cancel this logical SSE request if/when jsonReq is canceled?
// TODO(cleanup): decide if we should cancel this logical SSE request
// if/when jsonReq is canceled.
go c.handleSSE(ctx, requestSummary, resp, false, forCall)
default:
@ -1742,7 +1783,9 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp
//
// 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) {
func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, standalone bool, forCall *jsonrpc2.Request) {
// TODO(cleanup): standalone should be completely equivalent to forCall == nil.
// Enforce that at the callsite, and remove the standalone flag argument.
for {
// Connection was successful. Continue the loop with the new response.
//
@ -1760,7 +1803,7 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str
}
// If the stream has ended, then do not reconnect if the stream is
// temporary (POST initiated SSE).
if lastEventID == "" && !persistent {
if lastEventID == "" && !standalone {
return
}
@ -1844,6 +1887,8 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary
select {
case c.incoming <- msg:
// Check if this is the response to our call, which terminates the request.
// (it could also be a server->client request or notification).
if jsonResp, ok := msg.(*jsonrpc.Response); ok && forCall != nil {
// TODO: we should never get a response when forReq is nil (the standalone SSE request).
// We should detect this case.

226
mcp/streamable_client.go Normal file
View file

@ -0,0 +1,226 @@
// 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.
// TODO: move client-side streamable HTTP logic from streamable.go to this file.
package mcp
/*
Streamable HTTP Client Design
This document describes the client-side implementation of the MCP streamable
HTTP transport, as defined by the MCP spec:
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
# Overview
The client-side streamable transport allows an MCP client to communicate with a
server over HTTP, sending messages via POST and receiving responses via either
JSON or server-sent events (SSE). The implementation consists of two main
components:
┌─────────────────────────────────────────────────────────────────┐
│ [StreamableClientTransport] │
│ Transport configuration; creates connections via Connect() │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ [streamableClientConn] │
│ Connection implementation; handles HTTP request/response │
└─────────────────────────────────────────────────────────────────┘
│
├──────────────────────────────────────┐
▼ ▼
┌─────────────────────────────────────────┐ ┌────────────────────────────────────┐
│ POST request handlers │ │ Standalone SSE stream │
│ (one per outgoing message/call) │ │ (server-initiated messages) │
└─────────────────────────────────────────┘ └────────────────────────────────────┘
# Sessions
The client maintains a session with the server, identified by a session ID
(Mcp-Session-Id header):
- Session ID is received from the server after initialization
- Client includes the session ID in all subsequent requests
- Session ends when the client calls Close() (sends DELETE) or server returns 404
[streamableClientConn] stores the session state:
- [streamableClientConn.sessionID]: Server-assigned session identifier
- [streamableClientConn.initializedResult]: Protocol version and server capabilities
# Connection Lifecycle
1. Connect: [StreamableClientTransport.Connect] creates a [streamableClientConn]
with a detached context for the connection's lifetime. The context is detached
to prevent the standalone SSE stream from being cancelled when the original
Connect context times out.
2. Initialize: The MCP client sends initialize/initialized messages. Upon
receiving [InitializeResult], the connection:
- Stores the negotiated protocol version for the Mcp-Protocol-Version header
- Captures the session ID from the Mcp-Session-Id response header
- Starts the standalone SSE stream via [streamableClientConn.connectStandaloneSSE]
3. Operation: Messages are sent via POST, responses received via JSON or SSE.
4. Close: [streamableClientConn.Close] sends a DELETE request to terminate
the session (unless the session is already gone), then cancels the connection
context to clean up the standalone SSE stream.
# Sending Messages (Write)
[streamableClientConn.Write] sends all outgoing messages via HTTP POST:
POST /endpoint
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Protocol-Version: <negotiated version>
Mcp-Session-Id: <session ID, if established>
<JSON-RPC message>
The server may respond with:
- 202 Accepted: Message received, no response body (notifications/responses)
- 200 OK with application/json: Single JSON-RPC response
- 200 OK with text/event-stream: SSE stream of responses
# Receiving Messages (Read)
[streamableClientConn.Read] returns messages from the [streamableClientConn.incoming]
channel, which is populated by multiple concurrent goroutines:
1. POST response handlers ([streamableClientConn.handleJSON] and
[streamableClientConn.handleSSE]): Process responses from POST requests
2. Standalone SSE stream: Receives server-initiated requests and notifications
The client handles both response formats:
- JSON: [streamableClientConn.handleJSON] reads body, decodes message
- SSE: [streamableClientConn.handleSSE] scans events, decodes each message
# Standalone SSE Stream
After initialization, [streamableClientConn.sessionUpdated] triggers
[streamableClientConn.connectStandaloneSSE] to open a GET request for
server-initiated messages:
GET /endpoint
Accept: text/event-stream
Mcp-Session-Id: <session ID>
Stream behavior:
- Optional: Server may return 405 Method Not Allowed (spec-compliant) or
other 4xx errors (tolerated in non-strict mode for compatibility)
- Persistent: Runs for the connection lifetime in a background goroutine
- Resumable: Uses Last-Event-ID header on reconnection if server provides event IDs
- Reconnects: Automatic reconnection with exponential backoff on interruption
# Stream Resumption
When an SSE stream (standalone or POST response) is interrupted, the client
attempts to reconnect using [streamableClientConn.connectSSE]:
Event ID tracking:
- [streamableClientConn.processStream] tracks the last received event ID
- On reconnection, the Last-Event-ID header is set to resume from that point
- Server replays missed events if it has an [EventStore] configured
See [calculateReconnectDelay] for the reconnect delay details.
Server-initiated reconnection (SEP-1699)
- SSE retry field: Sets the delay for the next reconnect attempt
- If server doesn't provide event IDs, non-standalone streams don't reconnect
# Response Formats
The client must handle two response formats from POST requests:
1. application/json: Single JSON-RPC response
- Body contains one JSON-RPC message
- Handled by [streamableClientConn.handleJSON]
- Simpler but doesn't support streaming or server-initiated messages
2. text/event-stream: SSE stream of messages
- Body contains SSE events with JSON-RPC messages
- Handled by [streamableClientConn.handleSSE]
- Supports multiple messages and server-initiated communication
- Stream completes when the response to the originating call is received
# HTTP Methods
- POST: Send JSON-RPC messages (requests, responses, notifications)
- Used by [streamableClientConn.Write]
- Response may be JSON or SSE
- GET: Open or resume SSE stream for server-initiated messages
- Used by [streamableClientConn.connectSSE]
- Always expects text/event-stream response (or 405)
- DELETE: Terminate the session
- Used by [streamableClientConn.Close]
- Skipped if session is already known to be gone ([errSessionMissing])
# Error Handling
Errors are categorized and handled differently:
1. Transient (recoverable via reconnection):
- Network interruption during SSE streaming
- Connection reset or timeout
- Triggers reconnection in [streamableClientConn.handleSSE]
2. Terminal (breaks the connection):
- 404 Not Found: Session terminated by server ([errSessionMissing])
- Message decode errors: Protocol violation
- Context cancellation: Client closed connection
- Mismatched session IDs: Protocol error
- See issue #683: our terminal errors are too strict.
Terminal errors are stored via [streamableClientConn.fail] and returned by
subsequent [streamableClientConn.Read] calls. The [streamableClientConn.failed]
channel signals that the connection is broken.
Special case: [errSessionMissing] indicates the server has terminated the session,
so [streamableClientConn.Close] skips the DELETE request.
# Protocol Version Header
After initialization, all requests include:
Mcp-Protocol-Version: <negotiated version>
This header (set by [streamableClientConn.setMCPHeaders]):
- Allows the server to handle requests per the negotiated protocol
- Is omitted before initialization completes
- Uses the version from [streamableClientConn.initializedResult]
# Key Implementation Details
[StreamableClientTransport] configuration:
- [StreamableClientTransport.Endpoint]: URL of the MCP server
- [StreamableClientTransport.HTTPClient]: Custom HTTP client (optional)
- [StreamableClientTransport.MaxRetries]: Reconnection attempts (default 5)
[streamableClientConn] handles the [Connection] interface:
- [streamableClientConn.Read]: Returns messages from incoming channel
- [streamableClientConn.Write]: Sends messages via POST, starts response handlers
- [streamableClientConn.Close]: Sends DELETE, cancels context, closes done channel
State management:
- [streamableClientConn.incoming]: Buffered channel for received messages
- [streamableClientConn.sessionID]: Server-assigned session identifier
- [streamableClientConn.initializedResult]: Cached for protocol version header
- [streamableClientConn.failed]: Channel closed on terminal error
- [streamableClientConn.done]: Channel closed on graceful shutdown
- [streamableClientConn.ctx]: Detached context for connection lifetime
- [streamableClientConn.cancel]: Cancels ctx to terminate SSE streams
Context handling:
- Connection context is detached from [StreamableClientTransport.Connect] context
using [xcontext.Detach] to preserve context values (for auth middleware) while
preventing premature cancellation of the standalone SSE stream
- Individual POST requests use caller-provided contexts for cancellation
*/

153
mcp/streamable_server.go Normal file
View file

@ -0,0 +1,153 @@
// 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.
// TODO: move server-side streamable HTTP logic from streamable.go to this file.
package mcp
/*
Streamable HTTP Server Design
This document describes the server-side implementation of the MCP streamable
HTTP transport, as defined by the MCP spec:
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
# Overview
The streamable HTTP transport enables MCP communication over HTTP, with
server-sent events (SSE) for server-to-client messages. The implementation
consists of several layered components:
┌─────────────────────────────────────────────────────────────────┐
│ [StreamableHTTPHandler] │
│ http.Handler that manages sessions and routes HTTP requests │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ [StreamableServerTransport] │
│ transport implementation, one per session; exposes ServeHTTP │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ [streamableServerConn] │
│ Connection implementation, handles message routing │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ [stream] │
│ Logical message channel within a session, may be resumed │
└─────────────────────────────────────────────────────────────────┘
# Sessions
As with other transports, a session represents a logical MCP connection between
a client and server. In the streamable transport, sessions are identified by a
unique session ID (Mcp-Session-Id header) and persist across multiple HTTP
requests.
[StreamableHTTPHandler] maintains a map of active sessions ([sessionInfo]),
each containing:
- The [ServerSession] (MCP-level session state)
- The [StreamableServerTransport] (for message I/O)
- Optional timeout management for idle session cleanup
Sessions are created on the first POST request (typically containing the
initialize request) and destroyed either by:
- Client sending a DELETE request
- Session timeout due to inactivity
- Server explicitly closing the session
# Streams
Within a session, there can be multiple concurrent "streams" - logical channels
for message delivery. This is distinct from HTTP streams; a single [stream] may
span multiple HTTP request/response cycles (via resumption).
There are two types of streams:
1. Optional standalone SSE stream (id = ""):
- Created when client sends a GET request to the endpoint
- Used for server-initiated messages (requests/notifications to client)
- Persists for the lifetime of the session
- Only one standalone stream per session
2. Request streams (id = random string):
- Created for each POST request containing JSON-RPC calls
- Used to route responses back to the originating HTTP request
- Completed when all responses have been sent
- Can be resumed via GET with Last-Event-ID if interrupted
# Message Routing
When the server writes a message, it must be routed to the correct [stream]:
- Responses: Routed to the stream that originated the request
- Requests/Notifications made during request handling: Routed to the same
stream as the triggering request (via context)
- Requests/Notifications made outside request handling: Routed to the
standalone SSE stream
This routing is implemented using:
- [streamableServerConn.requestStreams] maps request IDs to stream IDs
- [idContextKey] is used to store the originating request ID in Context
- [streamableServerConn.streams] maps stream IDs to [stream] objects
# Stream Resumption
If an HTTP connection is interrupted (network issues, etc.), clients can
resume a stream by sending a GET request with the Last-Event-ID header.
This requires an [EventStore] to be configured on the server.
- [EventStore.Open] is called when a new stream is created
- [EventStore.Append] is called for each message written to the stream
- [EventStore.After] is called to replay messages after a given index
- [EventStore.SessionClosed] is called when the session ends
Event IDs are formatted as "<streamID>_<index>" to identify both the
stream and position within that stream (see [formatEventID] and [parseEventID]).
# Stateless Mode
For simpler deployments, the handler supports "stateless" mode
([StreamableHTTPOptions.Stateless]) where:
- No session ID validation is performed
- Each request creates a temporary session that's closed after the request
- Server-to-client requests are not supported (no way to receive response)
This mode is useful for simple tool servers that don't need bidirectional
communication.
# Response Formats
The server can respond to POST requests in two formats:
1. text/event-stream (default): Messages sent as SSE events, supports
streaming multiple messages and server-initiated communication during
request handling.
2. application/json ([StreamableHTTPOptions.JSONResponse]): Single JSON
response, simpler but doesn't support streaming. Server-initiated messages
during request handling go to the standalone SSE stream instead.
# HTTP Methods
- POST: Send JSON-RPC messages (requests, responses, notifications)
- GET: Open standalone SSE stream or resume an interrupted stream
- DELETE: Terminate the session
# Key Implementation Details
The [stream] struct manages delivery of messages to HTTP responses:
- [stream.deliver], if set, writes data to the current HTTP response
- [stream.closeLocked] sends a close event with retry delay
- [stream.requests] tracks pending request IDs (stream completes when empty)
[streamableServerConn] handles the [Connection] interface:
- [streamableServerConn.Read] receives messages from the incoming channel (fed by POST handlers)
- [streamableServerConn.Write] routes messages to appropriate streams
- [streamableServerConn.Close] terminates the session and notifies the [EventStore]
*/