Skip to content

stdio: a JSON-RPC batch holding a notification is never answered, or breaks the connection #1256

Description

@daFish

Describe the bug

ioConn.Read registers every *jsonrpc.Request in an incoming batch as awaiting a response, notifications included (transport.go#L711-L733). A notification's ID is the zero jsonrpc2.ID, which no response ever carries, so a batch holding a notification goes wrong in three ways:

  1. A batch of a call and a notification is never answered. The call's response is recorded in the batch, but the batch never completes, because unresolved still holds the notification, so nothing is written. The connection stays up and the client simply never hears back.
  2. A batch of two notifications breaks the connection. The second zero ID trips the per-batch duplicate check, Read fails with duplicate message ID {<nil>}, and Server.Run returns that error.
  3. After one such batch, the next batch holding a notification breaks the connection. The zero ID stays in ioConn.batches for good (addBatch), so the next batch is rejected with invalid 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 / StdioTransport sessions 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 main at 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

  1. Save the program below as main.go in a new module and go get github.com/modelcontextprotocol/go-sdk@main.
  2. go run . — it initializes with protocol version 2025-03-26 over mcp.IOTransport, sends each batch followed by a ping (id 9), and prints everything the server writes within two seconds.
main.go
// Command batchrepro sends JSON-RPC batches to a go-sdk server over
// mcp.IOTransport, on a protocol version that still allows batching, and prints
// everything the server writes back. Stdin stays open throughout, so end of
// input plays no part.
package main

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

const (
	initialize  = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"batchrepro","version":"0"}}}`
	initialized = `{"jsonrpc":"2.0","method":"notifications/initialized"}`
	followUp    = `{"jsonrpc":"2.0","id":9,"method":"ping"}`
)

func main() {
	cases := []struct{ name, batch string }{
		{"two calls", `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","id":3,"method":"ping"}]`},
		{"a call and a notification", `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`},
		{"two notifications", `[{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":98}},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`},
		{"a call and a notification, twice", `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]` + "\n" +
			`[{"jsonrpc":"2.0","id":3,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`},
	}
	for _, c := range cases {
		run(c.name, c.batch)
	}
}

func run(name, batch string) {
	fmt.Printf("== batch of %s\n-> %s\n-> %s (follow-up)\n", name, batch, followUp)

	stdinReader, stdinWriter := io.Pipe()
	stdoutReader, stdoutWriter := io.Pipe()
	defer stdinWriter.Close()

	server := mcp.NewServer(&mcp.Implementation{Name: "batchrepro", Version: "0"}, nil)
	done := make(chan error, 1)
	go func() {
		done <- server.Run(context.Background(), &mcp.IOTransport{Reader: stdinReader, Writer: stdoutWriter})
	}()

	lines := make(chan string)
	go func() {
		scanner := bufio.NewScanner(stdoutReader)
		for scanner.Scan() {
			lines <- scanner.Text()
		}
	}()

	go fmt.Fprintln(stdinWriter, initialize)
	<-lines // the initialize response
	go fmt.Fprintf(stdinWriter, "%s\n%s\n%s\n", initialized, batch, followUp)

	timeout := time.After(2 * time.Second)
	for {
		select {
		case line := <-lines:
			fmt.Println("<-", line)
		case err := <-done:
			fmt.Println("Run returned:", err)
			fmt.Println()
			return
		case <-timeout:
			fmt.Println("(nothing more within 2s)")
			fmt.Println()
			return
		}
	}
}

Output:

== 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":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)
Run returned: duplicate message ID {<nil>}

== 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)
Run returned: invalid request: batch contains previously seen request <nil>

Expected behavior

  • [call id 2, notification] is answered with [{"jsonrpc":"2.0","id":2,"result":{}}].
  • A batch of only notifications gets no response, and the connection stays open.
  • Later batches holding notifications are handled the same way.

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
== 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions