@@ -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