mcp: preserve single-request batch responses (#1214)

## What does this PR do?

Preserve JSON-RPC batch response arrays when a batch contains a single
request.

## Why is this needed?

A single-request legacy batch must be returned as a one-element JSON
array, but the JSON response path currently unwraps it into a single
JSON object.

## Changes

- Preserve the original batch state on the logical stream
- Keep single-request batch responses as arrays
- Add a regression test for legacy initialization batches

## Testing

- `go test ./mcp -count=1`
- `go test ./... -count=1`
- `git diff --check`

Fixes #1211
This commit is contained in:
Minghui Wang 2026-08-31 18:00:32 +08:00 • committed by GitHub
commit d19bfebab9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 52 additions and 1 deletions

View file

@ -988,6 +988,10 @@ type stream struct {
// Note: if we remove support for batching, this could just be a bool.
pendingJSONMessages []json.RawMessage
// isBatch reports whether the request that opened the stream was a JSON-RPC
// batch. Batch responses must remain arrays even when they contain one item.
isBatch bool
// w is the HTTP response writer for this stream. A non-nil w indicates
// that the stream is claimed by an HTTP request (the hanging POST or GET);
// it is set to nil when the request completes.
@ -1138,7 +1142,7 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I
if done {
// Flush all pending messages as JSON response.
var toWrite []byte
if len(s.pendingJSONMessages) == 1 {
if len(s.pendingJSONMessages) == 1 && !s.isBatch {
toWrite = s.pendingJSONMessages[0]
} else {
toWrite, err = json.Marshal(s.pendingJSONMessages)
@ -1691,6 +1695,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
return
}
stream.isListen = isSubscriptionsListen
stream.isBatch = isBatch
// subscriptions/listen is inherently a long-lived SSE endpoint (SEP-2575):
// it has no synchronous result, the response stream stays open until the

View file

@ -4040,6 +4040,52 @@ func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) {
}
}
// TestStreamableServerPreservesSingleRequestBatchResponse verifies that a
// single-request JSON-RPC batch is returned as a one-element JSON array.
func TestStreamableServerPreservesSingleRequestBatchResponse(t *testing.T) {
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
handler := NewStreamableHTTPHandler(
func(*http.Request) *Server { return server },
&StreamableHTTPOptions{JSONResponse: true},
)
defer handler.closeAll()
httpServer := httptest.NewServer(handler)
defer httpServer.Close()
body := []byte(`[{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0"}}}]`)
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusOK, data)
}
var messages []json.RawMessage
if err := json.Unmarshal(data, &messages); err != nil {
t.Fatalf("response is not a JSON array: %v; body = %s", err, data)
}
if len(messages) != 1 {
t.Fatalf("response contains %d messages, want 1; body = %s", len(messages), data)
}
if _, err := jsonrpc2.DecodeMessage(messages[0]); err != nil {
t.Fatalf("response item is not a JSON-RPC message: %v", err)
}
}
// TestStreamableMaxRequestBodyBytes verifies that the streamable HTTP handler
// enforces StreamableHTTPOptions.MaxRequestBodyBytes on incoming request
// bodies. The limit must apply uniformly regardless of transfer encoding: