Summary
When a connection shuts down because its write side failed, internal/jsonrpc2/conn.go builds the error like this (line 674 at current HEAD, same in v1.6.1):
err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr)
The write error — typically io.EOF when a stdio host closes the pipe — is formatted with %v, not %w, so it is not in the error chain: errors.Is(err, io.EOF) is false for every consumer.
ErrServerClosing itself lives in the internal/jsonrpc2 tree, so downstream code cannot name it either. The combination leaves message-text matching as the only way to classify this error, which is exactly what nobody should do.
Why it matters
A stdio MCP server that wants to exit 0 on a clean host disconnect ("the session ended") and exit 1 on a real failure cannot tell the two apart. Concretely: our CLI's MCP server received a request, the host closed stdin while the response was in flight, and the server exited 1 printing server is closing: EOF — reporting a crash where the session merely ended. (We observed this deterministically: send one initialize request through a pipe and close stdin immediately.)
We've since worked around it by classifying shutdown from our own transport observation rather than from the returned error, so this is a good-citizenship report rather than a blocker.
Suggested fix
One character: %v → %w on the s.writeErr. That makes errors.Is(err, io.EOF) (and any other sentinel the underlying writer returns) work as intended.
Possibly worth considering alongside: mcp/transport.go internally checks errors.Is(err, jsonrpc2.ErrServerClosing), which downstreams cannot express — a public predicate (or re-exported sentinel) for "the server is closing" would remove the last reason to look at the message text.
Related but distinct: #1061 describes response loss in the same stdin-EOF-while-in-flight scenario; this issue is only about the wrapping of the returned error.
Versions
github.com/modelcontextprotocol/go-sdk v1.6.1 (latest release); still present at HEAD (internal/jsonrpc2/conn.go:674).
Summary
When a connection shuts down because its write side failed,
internal/jsonrpc2/conn.gobuilds the error like this (line 674 at current HEAD, same in v1.6.1):The write error — typically
io.EOFwhen a stdio host closes the pipe — is formatted with%v, not%w, so it is not in the error chain:errors.Is(err, io.EOF)isfalsefor every consumer.ErrServerClosingitself lives in theinternal/jsonrpc2tree, so downstream code cannot name it either. The combination leaves message-text matching as the only way to classify this error, which is exactly what nobody should do.Why it matters
A stdio MCP server that wants to exit 0 on a clean host disconnect ("the session ended") and exit 1 on a real failure cannot tell the two apart. Concretely: our CLI's MCP server received a request, the host closed stdin while the response was in flight, and the server exited 1 printing
server is closing: EOF— reporting a crash where the session merely ended. (We observed this deterministically: send oneinitializerequest through a pipe and close stdin immediately.)We've since worked around it by classifying shutdown from our own transport observation rather than from the returned error, so this is a good-citizenship report rather than a blocker.
Suggested fix
One character:
%v→%won thes.writeErr. That makeserrors.Is(err, io.EOF)(and any other sentinel the underlying writer returns) work as intended.Possibly worth considering alongside:
mcp/transport.gointernally checkserrors.Is(err, jsonrpc2.ErrServerClosing), which downstreams cannot express — a public predicate (or re-exported sentinel) for "the server is closing" would remove the last reason to look at the message text.Related but distinct: #1061 describes response loss in the same stdin-EOF-while-in-flight scenario; this issue is only about the wrapping of the returned error.
Versions
github.com/modelcontextprotocol/go-sdkv1.6.1 (latest release); still present at HEAD (internal/jsonrpc2/conn.go:674).