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:
parent
b8c21e9fc0
commit
dd5a8ff77b
5 changed files with 462 additions and 23 deletions
|
|
@ -55,9 +55,16 @@ type ConnectionOptions struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection manages the jsonrpc2 protocol, connecting responses back to their
|
// Connection manages the jsonrpc2 protocol, connecting responses back to their
|
||||||
// calls.
|
// calls. Connection is bidirectional; it does not have a designated server or
|
||||||
// Connection is bidirectional; it does not have a designated server or client
|
// client end.
|
||||||
// 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 {
|
type Connection struct {
|
||||||
seq int64 // must only be accessed using atomic operations
|
seq int64 // must only be accessed using atomic operations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -394,7 +394,7 @@ func notifySessions[S Session, P Params](sessions []S, method string, params P)
|
||||||
for _, s := range sessions {
|
for _, s := range sessions {
|
||||||
req := newRequest(s, params)
|
req := newRequest(s, params)
|
||||||
if err := handleNotify(ctx, method, req); err != nil {
|
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)
|
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
|
TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any
|
||||||
Header http.Header // header from HTTP request, if any
|
Header http.Header // header from HTTP request, if any
|
||||||
|
|
||||||
// CloseStream closes the current request stream, if the current transport
|
// If set, CloseStream explicitly closes the current request stream.
|
||||||
// supports replaying requests.
|
|
||||||
//
|
//
|
||||||
// If reconnectAfter is nonzero, it signals to the client to reconnect after
|
// [SEP-1699] introduced server-side SSE stream disconnection: for
|
||||||
// the given duration. Otherwise, clients may determine their own
|
// long-running requests, servers may opt to close the SSE stream and
|
||||||
// reconnection policy.
|
// 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)
|
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 (*ClientRequest[P]) isRequest() {}
|
||||||
func (*ServerRequest[P]) isRequest() {}
|
func (*ServerRequest[P]) isRequest() {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
// Use of this source code is governed by an MIT-style
|
// Use of this source code is governed by an MIT-style
|
||||||
// license that can be found in the LICENSE file.
|
// 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
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -632,6 +636,9 @@ type stream struct {
|
||||||
// streamRequests is the set of unanswered incoming requests for the stream.
|
// streamRequests is the set of unanswered incoming requests for the stream.
|
||||||
//
|
//
|
||||||
// Requests are removed when their response has been received.
|
// 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{}
|
requests map[jsonrpc.ID]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -645,6 +652,10 @@ func (s *stream) close(reconnectAfter time.Duration) {
|
||||||
|
|
||||||
// doneLocked reports whether the stream is logically complete.
|
// 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.
|
// s.mu must be held while calling this function.
|
||||||
func (s *stream) doneLocked() bool {
|
func (s *stream) doneLocked() bool {
|
||||||
return len(s.requests) == 0 && s.id != ""
|
return len(s.requests) == 0 && s.id != ""
|
||||||
|
|
@ -751,6 +762,7 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request
|
||||||
stream.mu.Unlock()
|
stream.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// TODO(cleanup): factor out, to clarify closing patterns.
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// request cancelled
|
// 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
|
// acquireStream replays all events since lastIdx, and acquires the ongoing
|
||||||
// any, updating lastIdx accordingly. If non-nil, the resulting stream will be
|
// stream, if any, updating lastIdx accordingly. If non-nil, the resulting
|
||||||
// registered for receiving new messages, and the resulting done channel will
|
// stream will be registered for receiving new messages, and the resulting done
|
||||||
// be closed when all related messages have been delivered.
|
// 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
|
// 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.
|
// 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.
|
// the stream is still replaying.
|
||||||
//
|
//
|
||||||
// supportsPrimeClose indicates whether the client supports the prime and close
|
// 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{}) {
|
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.
|
// 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
|
tempStream := false
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
s, ok := c.streams[streamID]
|
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
|
// The stream is not done: register a delivery function before the stream is
|
||||||
// unlocked, allowing the connection to write new events.
|
// unlocked, allowing the connection to write new events.
|
||||||
var done = make(chan struct{})
|
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 {
|
s.deliver = func(data []byte, final bool) error {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
return fmt.Errorf("stream closed")
|
return fmt.Errorf("stream closed")
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if final {
|
if final {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
}
|
}
|
||||||
return c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx)
|
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 {
|
if supportsPrimeClose {
|
||||||
s.closeLocked = func(reconnectAfter time.Duration) {
|
s.closeLocked = func(reconnectAfter time.Duration) {
|
||||||
select {
|
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)
|
http.Error(w, "POST requires a non-empty body", http.StatusBadRequest)
|
||||||
return
|
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)
|
incoming, isBatch, err := readBatch(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest)
|
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
|
initializeProtocolVersion = params.ProtocolVersion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Include metadata for all requests (including notifications).
|
||||||
jreq.Extra = &RequestExtra{
|
jreq.Extra = &RequestExtra{
|
||||||
TokenInfo: tokenInfo,
|
TokenInfo: tokenInfo,
|
||||||
Header: req.Header,
|
Header: req.Header,
|
||||||
}
|
}
|
||||||
if jreq.IsCall() {
|
if jreq.IsCall() {
|
||||||
calls[jreq.ID] = struct{}{}
|
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) {
|
jreq.Extra.(*RequestExtra).CloseStream = func(reconnectAfter time.Duration) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
streamID, ok := c.requestStreams[jreq.ID]
|
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.
|
// If we don't have any calls, we can just publish the incoming messages and return.
|
||||||
// No need to track a logical stream.
|
// 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 {
|
if len(calls) == 0 {
|
||||||
for _, msg := range incoming {
|
for _, msg := range incoming {
|
||||||
select {
|
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
|
// In recent protocol versions, there should only be one message as
|
||||||
// batching is disabled, as checked above.
|
// 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)
|
msgs = append(msgs, data)
|
||||||
if !final {
|
if !final {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1099,7 +1131,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
|
||||||
// Write events in the order we receive them.
|
// Write events in the order we receive them.
|
||||||
lastIndex := -1
|
lastIndex := -1
|
||||||
if c.eventStore != nil && supportsPrimeClose {
|
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
|
// We must also write it to the event store in order for indexes to
|
||||||
// align.
|
// align.
|
||||||
if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil {
|
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 {
|
stream.deliver = func(data []byte, final bool) error {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
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)
|
return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex)
|
||||||
}
|
}
|
||||||
|
// TODO(cleanup): consolidate per other TODO.
|
||||||
if supportsPrimeClose {
|
if supportsPrimeClose {
|
||||||
stream.closeLocked = func(reconnectAfter time.Duration) {
|
stream.closeLocked = func(reconnectAfter time.Duration) {
|
||||||
select {
|
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
|
// Note: don't select on req.Context().Done() here, since we've already
|
||||||
// received the requests and may have already published a response message
|
// received the requests and may have already published a response message
|
||||||
// or notification. The client could resume the stream.
|
// or notification. The client could resume the stream.
|
||||||
|
//
|
||||||
|
// In fact, this send could be in a separate goroutine.
|
||||||
case <-c.done:
|
case <-c.done:
|
||||||
// Session closed: we don't know if any data has been written, so it's
|
// Session closed: we don't know if any data has been written, so it's
|
||||||
// too late to write a status code here.
|
// 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 {
|
select {
|
||||||
case <-req.Context().Done():
|
case <-req.Context().Done():
|
||||||
// request cancelled
|
// request cancelled
|
||||||
|
|
@ -1236,7 +1276,7 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e
|
||||||
return err
|
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.
|
// 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)
|
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() {
|
if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() {
|
||||||
forCall = jsonReq
|
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)
|
go c.handleSSE(ctx, requestSummary, resp, false, forCall)
|
||||||
|
|
||||||
default:
|
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
|
// If forCall is set, it is the call that initiated the stream, and the
|
||||||
// stream is complete when we receive its response.
|
// 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 {
|
for {
|
||||||
// Connection was successful. Continue the loop with the new response.
|
// 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
|
// If the stream has ended, then do not reconnect if the stream is
|
||||||
// temporary (POST initiated SSE).
|
// temporary (POST initiated SSE).
|
||||||
if lastEventID == "" && !persistent {
|
if lastEventID == "" && !standalone {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1844,6 +1887,8 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case c.incoming <- msg:
|
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 {
|
if jsonResp, ok := msg.(*jsonrpc.Response); ok && forCall != nil {
|
||||||
// TODO: we should never get a response when forReq is nil (the standalone SSE request).
|
// TODO: we should never get a response when forReq is nil (the standalone SSE request).
|
||||||
// We should detect this case.
|
// We should detect this case.
|
||||||
|
|
|
||||||
226
mcp/streamable_client.go
Normal file
226
mcp/streamable_client.go
Normal 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
153
mcp/streamable_server.go
Normal 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]
|
||||||
|
*/
|
||||||
Loading…
Reference in a new issue