Skip to content

Commit 3840846

Browse files
committed
mcp: remove support for batching on streamable connections
Batching was already deprecated and rejected for protocol versions >= 2025-06-18. Remove it entirely to simplify the streamable transport code. Replace readBatch calls with single message decoding, remove batch-related checks and comments, and delete batch-specific tests. Closes #911 Signed-off-by: wucm667 <stevenwucongmin@gmail.com>
1 parent 65ece59 commit 3840846

2 files changed

Lines changed: 148 additions & 221 deletions

File tree

mcp/streamable.go

Lines changed: 148 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -439,21 +439,19 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*epheme
439439
}
440440
req.Body.Close()
441441
req.Body = io.NopCloser(bytes.NewBuffer(body))
442-
msgs, _, err := readBatch(body)
442+
msg, err := jsonrpc2.DecodeMessage(body)
443443
if err == nil {
444-
for _, msg := range msgs {
445-
if r, ok := msg.(*jsonrpc.Request); ok {
446-
switch r.Method {
447-
case methodInitialize:
448-
hasInitialize = true
449-
case notificationInitialized:
450-
hasInitialized = true
451-
case methodSubscriptionsListen:
452-
isSubscriptionsListen = true
453-
}
454-
if protocolVersion >= protocolVersion20260728 {
455-
usesNewProtocol = true
456-
}
444+
if r, ok := msg.(*jsonrpc.Request); ok {
445+
switch r.Method {
446+
case methodInitialize:
447+
hasInitialize = true
448+
case notificationInitialized:
449+
hasInitialized = true
450+
case methodSubscriptionsListen:
451+
isSubscriptionsListen = true
452+
}
453+
if protocolVersion >= protocolVersion20260728 {
454+
usesNewProtocol = true
457455
}
458456
}
459457
}
@@ -916,8 +914,6 @@ type stream struct {
916914
// collected here until the stream is complete, at which point they are
917915
// flushed as a single JSON response. Note that the non-nilness of this field
918916
// is significant, as it signals the expected content type.
919-
//
920-
// Note: if we remove support for batching, this could just be a bool.
921917
pendingJSONMessages []json.RawMessage
922918

923919
// w is the HTTP response writer for this stream. A non-nil w indicates
@@ -942,9 +938,6 @@ type stream struct {
942938
// requests is the set of unanswered incoming requests for the stream.
943939
//
944940
// Requests are removed when their response has been received.
945-
// In practice, there is only one request, but in the 2025-03-26 version of
946-
// the spec and earlier there was a concept of batching, in which POST
947-
// payloads could hold multiple requests or responses.
948941
requests map[jsonrpc.ID]struct{}
949942

950943
// isListen reports whether this stream was opened by a
@@ -1381,10 +1374,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
13811374
http.Error(w, "POST requires a non-empty body", http.StatusBadRequest)
13821375
return
13831376
}
1384-
// TODO(#674): once we've documented the support matrix for 2025-03-26 and
1385-
// earlier, drop support for matching entirely; that will simplify this
1386-
// logic.
1387-
incoming, isBatch, err := readBatch(body)
1377+
incoming, err := jsonrpc2.DecodeMessage(body)
13881378
if err != nil {
13891379
http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest)
13901380
return
@@ -1395,160 +1385,144 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
13951385
protocolVersion = protocolVersion20250326
13961386
}
13971387

1398-
if isBatch && protocolVersion >= protocolVersion20250618 {
1399-
http.Error(w, fmt.Sprintf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, protocolVersion), http.StatusBadRequest)
1400-
return
1401-
}
1402-
1403-
// TODO(rfindley): no tests fail if we reject batch JSON requests entirely.
1404-
// We need to test this with older protocol versions.
1405-
// if isBatch && c.jsonResponse {
1406-
// http.Error(w, "server does not support batch requests", http.StatusBadRequest)
1407-
// return
1408-
// }
1409-
14101388
calls := make(map[jsonrpc.ID]struct{})
14111389
tokenInfo := auth.TokenInfoFromContext(req.Context())
14121390
isInitialize := false
14131391
isSubscriptionsListen := false
14141392
var initializeProtocolVersion string
1415-
for _, msg := range incoming {
1416-
if jreq, ok := msg.(*jsonrpc.Request); ok {
1417-
// Preemptively check that this is a valid request, so that we can fail
1418-
// the HTTP request. If we didn't do this, a request with a bad method or
1419-
// missing ID could be silently swallowed.
1420-
// Use the server's receiving method infos (which include any custom
1421-
// methods registered via AddReceivingCustomMethod) when available;
1422-
// fall back to the standard methods otherwise, e.g. in tests that
1423-
// exercise streamableServerConn directly without a server.
1424-
methodInfos := serverMethodInfos
1425-
if c.server != nil {
1426-
methodInfos = c.server.receivingMethodInfos()
1393+
if jreq, ok := incoming.(*jsonrpc.Request); ok {
1394+
// Preemptively check that this is a valid request, so that we can fail
1395+
// the HTTP request. If we didn't do this, a request with a bad method or
1396+
// missing ID could be silently swallowed.
1397+
// Use the server's receiving method infos (which include any custom
1398+
// methods registered via AddReceivingCustomMethod) when available;
1399+
// fall back to the standard methods otherwise, e.g. in tests that
1400+
// exercise streamableServerConn directly without a server.
1401+
methodInfos := serverMethodInfos
1402+
if c.server != nil {
1403+
methodInfos = c.server.receivingMethodInfos()
1404+
}
1405+
if _, err := checkRequest(jreq, methodInfos); err != nil {
1406+
if protocolVersion >= protocolVersion20260728 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() {
1407+
writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{
1408+
Code: jsonrpc.CodeMethodNotFound,
1409+
Message: err.Error(),
1410+
})
1411+
return
14271412
}
1428-
if _, err := checkRequest(jreq, methodInfos); err != nil {
1429-
if protocolVersion >= protocolVersion20260728 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() {
1430-
writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{
1431-
Code: jsonrpc.CodeMethodNotFound,
1432-
Message: err.Error(),
1433-
})
1434-
return
1435-
}
1436-
http.Error(w, err.Error(), http.StatusBadRequest)
1413+
http.Error(w, err.Error(), http.StatusBadRequest)
1414+
return
1415+
}
1416+
if jreq.Method == methodInitialize {
1417+
isInitialize = true
1418+
// Extract the protocol version from InitializeParams.
1419+
var params InitializeParams
1420+
if err := internaljson.Unmarshal(jreq.Params, &params); err == nil {
1421+
initializeProtocolVersion = params.ProtocolVersion
1422+
}
1423+
}
1424+
if jreq.Method == methodSubscriptionsListen {
1425+
isSubscriptionsListen = true
1426+
}
1427+
// SEP-2575: requests carrying `_meta.protocolVersion` require the
1428+
// Mcp-Protocol-Version HTTP header to be present and to match the
1429+
// per-request `_meta.protocolVersion` value.
1430+
// The new (>= 2026-07-28) protocol is supported on the HTTP transport
1431+
// only when [StreamableHTTPOptions.Stateless] is true.
1432+
//
1433+
// TODO: this validation can be moved within validateMcpHeaders.
1434+
var metaVersion string
1435+
if meta := extractRequestMeta(jreq.Params); meta != nil {
1436+
metaVersion, _ = meta[MetaKeyProtocolVersion].(string)
1437+
}
1438+
if protocolVersion >= protocolVersion20260728 || metaVersion != "" {
1439+
// Extract again the protcol version from the context to see what the client
1440+
// is advertising in the Mcp-Protocol-Version HTTP header.
1441+
headerVersion := protocolVersionFromContext(req.Context())
1442+
// server/discover is exempt from the stateful
1443+
// rejection as it should learn about the supported protocols from the
1444+
// DiscoverResult response.
1445+
if !c.stateless && jreq.Method != methodDiscover {
1446+
http.Error(w, fmt.Sprintf(
1447+
"Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)",
1448+
protocolVersion),
1449+
http.StatusBadRequest)
14371450
return
14381451
}
1439-
if jreq.Method == methodInitialize {
1440-
isInitialize = true
1441-
// Extract the protocol version from InitializeParams.
1442-
var params InitializeParams
1443-
if err := internaljson.Unmarshal(jreq.Params, &params); err == nil {
1444-
initializeProtocolVersion = params.ProtocolVersion
1445-
}
1452+
if headerVersion == "" {
1453+
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
1454+
Code: CodeHeaderMismatch,
1455+
Message: fmt.Sprintf(
1456+
"%s header is required for requests carrying %q",
1457+
protocolVersionHeader, MetaKeyProtocolVersion),
1458+
})
1459+
return
14461460
}
1447-
if jreq.Method == methodSubscriptionsListen {
1448-
isSubscriptionsListen = true
1461+
if metaVersion == "" {
1462+
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
1463+
Code: jsonrpc.CodeInvalidParams,
1464+
Message: fmt.Sprintf(
1465+
"missing or invalid _meta field %q",
1466+
MetaKeyProtocolVersion),
1467+
})
1468+
return
14491469
}
1450-
// SEP-2575: requests carrying `_meta.protocolVersion` require the
1451-
// Mcp-Protocol-Version HTTP header to be present and to match the
1452-
// per-request `_meta.protocolVersion` value.
1453-
// The new (>= 2026-07-28) protocol is supported on the HTTP transport
1454-
// only when [StreamableHTTPOptions.Stateless] is true.
1455-
//
1456-
// TODO: this validation can be moved within validateMcpHeaders.
1457-
var metaVersion string
1458-
if meta := extractRequestMeta(jreq.Params); meta != nil {
1459-
metaVersion, _ = meta[MetaKeyProtocolVersion].(string)
1470+
if headerVersion != metaVersion {
1471+
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
1472+
Code: CodeHeaderMismatch,
1473+
Message: fmt.Sprintf(
1474+
"%s header %q does not match request %s %q",
1475+
protocolVersionHeader, headerVersion,
1476+
MetaKeyProtocolVersion, metaVersion),
1477+
})
1478+
return
14601479
}
1461-
if protocolVersion >= protocolVersion20260728 || metaVersion != "" {
1462-
// Extract again the protcol version from the context to see what the client
1463-
// is advertising in the Mcp-Protocol-Version HTTP header.
1464-
headerVersion := protocolVersionFromContext(req.Context())
1465-
// server/discover is exempt from the stateful
1466-
// rejection as it should learn about the supported protocols from the
1467-
// DiscoverResult response.
1468-
if !c.stateless && jreq.Method != methodDiscover {
1469-
http.Error(w, fmt.Sprintf(
1470-
"Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)",
1471-
protocolVersion),
1472-
http.StatusBadRequest)
1473-
return
1474-
}
1475-
if headerVersion == "" {
1476-
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
1477-
Code: CodeHeaderMismatch,
1478-
Message: fmt.Sprintf(
1479-
"%s header is required for requests carrying %q",
1480-
protocolVersionHeader, MetaKeyProtocolVersion),
1481-
})
1482-
return
1483-
}
1484-
if metaVersion == "" {
1485-
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
1486-
Code: jsonrpc.CodeInvalidParams,
1487-
Message: fmt.Sprintf(
1488-
"missing or invalid _meta field %q",
1489-
MetaKeyProtocolVersion),
1490-
})
1480+
}
1481+
// Include metadata for all requests (including notifications).
1482+
jreq.Extra = &RequestExtra{
1483+
TokenInfo: tokenInfo,
1484+
Header: req.Header,
1485+
}
1486+
if jreq.IsCall() {
1487+
calls[jreq.ID] = struct{}{}
1488+
// See the doc for CloseSSEStream: allow the request handler to
1489+
// explicitly close the ongoing stream.
1490+
jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) {
1491+
// This mechanism was designed to trigger client reconnection with
1492+
// Last-Event-ID for server-initiated disconnect scenarios. It is
1493+
// deprecated in protocol version 2026-07-28.
1494+
if protocolVersion >= protocolVersion20260728 {
14911495
return
14921496
}
1493-
if headerVersion != metaVersion {
1494-
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
1495-
Code: CodeHeaderMismatch,
1496-
Message: fmt.Sprintf(
1497-
"%s header %q does not match request %s %q",
1498-
protocolVersionHeader, headerVersion,
1499-
MetaKeyProtocolVersion, metaVersion),
1500-
})
1501-
return
1497+
c.mu.Lock()
1498+
streamID, ok := c.requestStreams[jreq.ID]
1499+
var stream *stream
1500+
if ok {
1501+
stream = c.streams[streamID]
15021502
}
1503-
}
1504-
// Include metadata for all requests (including notifications).
1505-
jreq.Extra = &RequestExtra{
1506-
TokenInfo: tokenInfo,
1507-
Header: req.Header,
1508-
}
1509-
if jreq.IsCall() {
1510-
calls[jreq.ID] = struct{}{}
1511-
// See the doc for CloseSSEStream: allow the request handler to
1512-
// explicitly close the ongoing stream.
1513-
jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) {
1514-
// This mechanism was designed to trigger client reconnection with
1515-
// Last-Event-ID for server-initiated disconnect scenarios. It is
1516-
// deprecated in protocol version 2026-07-28.
1517-
if protocolVersion >= protocolVersion20260728 {
1518-
return
1519-
}
1520-
c.mu.Lock()
1521-
streamID, ok := c.requestStreams[jreq.ID]
1522-
var stream *stream
1523-
if ok {
1524-
stream = c.streams[streamID]
1525-
}
1526-
c.mu.Unlock()
1527-
1528-
if stream != nil {
1529-
stream.close(args.RetryAfter)
1530-
}
1503+
c.mu.Unlock()
1504+
1505+
if stream != nil {
1506+
stream.close(args.RetryAfter)
15311507
}
15321508
}
15331509
}
15341510
}
15351511

15361512
// Validate MCP standard headers (Mcp-Method, Mcp-Name, Mcp-Param-*)
1537-
if !isBatch && len(incoming) == 1 {
1538-
if err := validateMcpHeaders(req.Header, incoming[0], c.toolLookup); err != nil {
1539-
resp := &jsonrpc.Response{
1540-
Error: jsonrpc2.NewError(CodeHeaderMismatch, err.Error()),
1541-
}
1542-
if jreq, ok := incoming[0].(*jsonrpc.Request); ok {
1543-
resp.ID = jreq.ID
1544-
}
1545-
w.Header().Set("Content-Type", "application/json")
1546-
w.WriteHeader(http.StatusBadRequest)
1547-
if data, err := jsonrpc2.EncodeMessage(resp); err == nil {
1548-
w.Write(data)
1549-
}
1550-
return
1513+
if err := validateMcpHeaders(req.Header, incoming, c.toolLookup); err != nil {
1514+
resp := &jsonrpc.Response{
1515+
Error: jsonrpc2.NewError(CodeHeaderMismatch, err.Error()),
15511516
}
1517+
if jreq, ok := incoming.(*jsonrpc.Request); ok {
1518+
resp.ID = jreq.ID
1519+
}
1520+
w.Header().Set("Content-Type", "application/json")
1521+
w.WriteHeader(http.StatusBadRequest)
1522+
if data, err := jsonrpc2.EncodeMessage(resp); err == nil {
1523+
w.Write(data)
1524+
}
1525+
return
15521526
}
15531527

15541528
// The prime and close events were added in protocol version 2025-11-25 (SEP-1699).
@@ -1567,15 +1541,13 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
15671541
//
15681542
// [§2.1.4]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
15691543
if len(calls) == 0 {
1570-
for _, msg := range incoming {
1571-
select {
1572-
case c.incoming <- msg:
1573-
case <-c.done:
1574-
// The session is closing. Since we haven't yet written any data to the
1575-
// response, we can signal to the client that the session is gone.
1576-
http.Error(w, "session is closing", http.StatusNotFound)
1577-
return
1578-
}
1544+
select {
1545+
case c.incoming <- incoming:
1546+
case <-c.done:
1547+
// The session is closing. Since we haven't yet written any data to the
1548+
// response, we can signal to the client that the session is gone.
1549+
http.Error(w, "session is closing", http.StatusNotFound)
1550+
return
15791551
}
15801552
w.WriteHeader(http.StatusAccepted)
15811553
return
@@ -1671,19 +1643,17 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
16711643
}
16721644

16731645
// Publish incoming messages.
1674-
for _, msg := range incoming {
1675-
select {
1676-
case c.incoming <- msg:
1677-
// Note: don't select on req.Context().Done() here, since we've already
1678-
// received the requests and may have already published a response message
1679-
// or notification. The client could resume the stream.
1680-
//
1681-
// In fact, this send could be in a separate goroutine.
1682-
case <-c.done:
1683-
// Session closed: we don't know if any data has been written, so it's
1684-
// too late to write a status code here.
1685-
return
1686-
}
1646+
select {
1647+
case c.incoming <- incoming:
1648+
// Note: don't select on req.Context().Done() here, since we've already
1649+
// received the requests and may have already published a response message
1650+
// or notification. The client could resume the stream.
1651+
//
1652+
// In fact, this send could be in a separate goroutine.
1653+
case <-c.done:
1654+
// Session closed: we don't know if any data has been written, so it's
1655+
// too late to write a status code here.
1656+
return
16871657
}
16881658

16891659
c.hangResponse(req.Context(), done)

0 commit comments

Comments
 (0)