== batch of two calls
-> [{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","id":3,"method":"ping"}]
-> {"jsonrpc":"2.0","id":9,"method":"ping"} (follow-up)
<- [{"jsonrpc":"2.0","id":2,"result":{}},{"jsonrpc":"2.0","id":3,"result":{}}]
<- {"jsonrpc":"2.0","id":9,"result":{}}
(nothing more within 2s)
== batch of a call and a notification
-> [{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]
-> {"jsonrpc":"2.0","id":9,"method":"ping"} (follow-up)
<- [{"jsonrpc":"2.0","id":2,"result":{}}]
<- {"jsonrpc":"2.0","id":9,"result":{}}
(nothing more within 2s)
== batch of two notifications
-> [{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":98}},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]
-> {"jsonrpc":"2.0","id":9,"method":"ping"} (follow-up)
<- {"jsonrpc":"2.0","id":9,"result":{}}
(nothing more within 2s)
== batch of a call and a notification, twice
-> [{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]
[{"jsonrpc":"2.0","id":3,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]
-> {"jsonrpc":"2.0","id":9,"method":"ping"} (follow-up)
<- [{"jsonrpc":"2.0","id":2,"result":{}}]
<- [{"jsonrpc":"2.0","id":3,"result":{}}]
<- {"jsonrpc":"2.0","id":9,"result":{}}
(nothing more within 2s)
Describe the bug
ioConn.Readregisters every*jsonrpc.Requestin an incoming batch as awaiting a response, notifications included (transport.go#L711-L733). A notification's ID is the zerojsonrpc2.ID, which no response ever carries, so a batch holding a notification goes wrong in three ways:unresolvedstill holds the notification, so nothing is written. The connection stays up and the client simply never hears back.Readfails withduplicate message ID {<nil>}, andServer.Runreturns that error.ioConn.batchesfor good (addBatch), so the next batch is rejected withinvalid request: batch contains previously seen request <nil>.JSON-RPC 2.0 allows notifications in a batch; the response array holds responses for the calls only. This affects
IOTransport/StdioTransportsessions on protocol versions before 2025-06-18, where batching is still accepted. The streamable HTTP transport already records only calls (streamable.go#L1589-L1590) and is not affected.Reproduced on
mainat 5bc078a and on v1.6.1. Found while working around #1061; this is independent of it — stdin stays open throughout the reproduction below.To Reproduce
main.goin a new module andgo get github.com/modelcontextprotocol/go-sdk@main.go run .— it initializes with protocol version 2025-03-26 overmcp.IOTransport, sends each batch followed by aping(id 9), and prints everything the server writes within two seconds.main.go
Output:
Expected behavior
[call id 2, notification]is answered with[{"jsonrpc":"2.0","id":2,"result":{}}].Additional context
Tracking only calls in that loop fixes all three cases in the reproduction:
for _, msg := range msgs { - if req, ok := msg.(*jsonrpc.Request); ok { + if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() { if respBatch == nil {Output with that change