From 52098e1554c074b010c8c447ceb2efd7aeafc52a Mon Sep 17 00:00:00 2001 From: Bo Zhang <187063395+Tethys0@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:17:57 -0500 Subject: [PATCH 1/2] jsonrpc2: wrap write error in server-closing error The connection's "server is closing" error formatted the underlying write error with %v, so the cause - typically io.EOF when a stdio host closes its pipe - was not in the error chain. Callers using errors.Is(err, io.EOF) to distinguish a clean host disconnect from a real failure therefore always got false. Use %w so the write error is preserved in the error chain. Fixes #1098 --- internal/jsonrpc2/conn.go | 4 ++-- internal/jsonrpc2/conn_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 internal/jsonrpc2/conn_test.go diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 4994c63b..03f2e6e4 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -163,7 +163,7 @@ func (s *inFlightState) shuttingDown(errClosing error) error { if s.writeErr != nil { // If the write side of the connection is broken, we cannot write responses // for incoming calls, and cannot write requests for outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.writeErr) + return fmt.Errorf("%w: %w", errClosing, s.writeErr) } return nil } @@ -671,7 +671,7 @@ func (c *Connection) handleAsync() { if s.writeErr != nil { // Assume that req.ctx was canceled due to s.writeErr. // TODO(#51365): use a Context API to plumb this through req.ctx. - err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr) + err = fmt.Errorf("%w: %w", ErrServerClosing, s.writeErr) } }) c.processResult("handleAsync", req, nil, err) diff --git a/internal/jsonrpc2/conn_test.go b/internal/jsonrpc2/conn_test.go new file mode 100644 index 00000000..9ad094ae --- /dev/null +++ b/internal/jsonrpc2/conn_test.go @@ -0,0 +1,27 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "errors" + "io" + "testing" +) + +// TestShuttingDownWrapsWriteError verifies that when a connection shuts down +// because its write side failed, the returned error preserves the underlying +// write error in its chain so callers can classify it with errors.Is (for +// example, distinguishing io.EOF from a clean host disconnect versus a real +// failure). +func TestShuttingDownWrapsWriteError(t *testing.T) { + s := &inFlightState{writeErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +} From aecb26d0a2f04a56c25e6e9613c0e3b1c7e2de7d Mon Sep 17 00:00:00 2001 From: Bo Zhang <187063395+Tethys0@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:06:59 -0500 Subject: [PATCH 2/2] fix(jsonrpc2): wrap read errors on shutdown --- internal/jsonrpc2/conn.go | 2 +- internal/jsonrpc2/conn_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 03f2e6e4..5a2253b1 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -158,7 +158,7 @@ func (s *inFlightState) shuttingDown(errClosing error) error { if s.readErr != nil { // If the read side of the connection is broken, we cannot read new call // requests, and cannot read responses to our outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.readErr) + return fmt.Errorf("%w: %w", errClosing, s.readErr) } if s.writeErr != nil { // If the write side of the connection is broken, we cannot write responses diff --git a/internal/jsonrpc2/conn_test.go b/internal/jsonrpc2/conn_test.go index 9ad094ae..afeb4c02 100644 --- a/internal/jsonrpc2/conn_test.go +++ b/internal/jsonrpc2/conn_test.go @@ -25,3 +25,14 @@ func TestShuttingDownWrapsWriteError(t *testing.T) { t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) } } + +func TestShuttingDownWrapsReadError(t *testing.T) { + s := &inFlightState{readErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +}