Summary
A stdio server driven by a one-shot pipe — stdin closes right after the last request — writes no responses at all, even though every request was received intact and stdout is perfectly writable. The read-side EOF marks the connection as shutting down, and the shutdown check then refuses the write of responses the handlers have already computed. Holding stdin open a fraction of a second longer makes every response appear, so this is purely a shutdown-ordering issue, not lost input.
Versions
github.com/modelcontextprotocol/go-sdk v1.6.1 (latest release; the relevant code is unchanged on main as of filing)
- Go 1.26, darwin/arm64 (same behavior on linux)
Reproduction
Minimal server:
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
s := mcp.NewServer(&mcp.Implementation{Name: "repro", Version: "0.0.1"}, nil)
if err := s.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
Driver:
REQS='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# One-shot pipe: stdin closes immediately after the last request.
printf '%s\n' "$REQS" | ./repro | grep -c '"id"'
# => 0 (no responses at all; 100% reproducible)
# Same requests, stdin held open 0.3s after the last one.
{ printf '%s\n' "$REQS"; sleep 0.3; } | ./repro | grep -c '"id"'
# => 2 (both responses arrive)
Mechanism (internal/jsonrpc2/conn.go; line numbers are v1.6.1, with main as of 2026-07-08 in parentheses)
- The pipe closes; the read loop gets
io.EOF and records it: s.readErr = err — conn.go:508 (main: 543).
- Handlers for the already-received requests finish and try to respond:
processResult → c.write(...) — conn.go:672–682 (main: 693ff).
write first checks s.shuttingDown(ErrServerClosing) — conn.go:707–719 (main: 749ff) — and shuttingDown returns an error whenever readErr != nil — conn.go:141–145 (main: 158–162) — so the computed response is discarded even though the writer is healthy.
The readErr branch's own comment explains its intent — "we cannot read new call requests, and cannot read responses to our outgoing calls" — both about the read side. Refusing writes of responses to requests that were already read goes beyond that intent.
Why this matters
Any one-shot or batch caller of a stdio MCP server gets empty output: shell scripting, smoke tests in CI, printf | server probes while debugging. Persistent clients that keep stdin open (every real MCP host) are unaffected, which makes the failure look like the caller's bug — it cost us a while to trace it into the connection state machine.
Expected behavior
Read-side EOF should stop accepting new requests, but responses to requests already received should drain to the writer before the connection reports closed (the common half-close contract: EOF on input, flush output, then exit).
Workaround we use
Keep stdin open briefly after the final request (sleep 0.3 before closing the pipe), or drive the server through a persistent client.
Summary
A stdio server driven by a one-shot pipe — stdin closes right after the last request — writes no responses at all, even though every request was received intact and stdout is perfectly writable. The read-side EOF marks the connection as shutting down, and the shutdown check then refuses the write of responses the handlers have already computed. Holding stdin open a fraction of a second longer makes every response appear, so this is purely a shutdown-ordering issue, not lost input.
Versions
github.com/modelcontextprotocol/go-sdkv1.6.1 (latest release; the relevant code is unchanged onmainas of filing)Reproduction
Minimal server:
Driver:
Mechanism (internal/jsonrpc2/conn.go; line numbers are v1.6.1, with
mainas of 2026-07-08 in parentheses)io.EOFand records it:s.readErr = err— conn.go:508 (main: 543).processResult→c.write(...)— conn.go:672–682 (main: 693ff).writefirst checkss.shuttingDown(ErrServerClosing)— conn.go:707–719 (main: 749ff) — andshuttingDownreturns an error wheneverreadErr != nil— conn.go:141–145 (main: 158–162) — so the computed response is discarded even though the writer is healthy.The
readErrbranch's own comment explains its intent — "we cannot read new call requests, and cannot read responses to our outgoing calls" — both about the read side. Refusing writes of responses to requests that were already read goes beyond that intent.Why this matters
Any one-shot or batch caller of a stdio MCP server gets empty output: shell scripting, smoke tests in CI,
printf | serverprobes while debugging. Persistent clients that keep stdin open (every real MCP host) are unaffected, which makes the failure look like the caller's bug — it cost us a while to trace it into the connection state machine.Expected behavior
Read-side EOF should stop accepting new requests, but responses to requests already received should drain to the writer before the connection reports closed (the common half-close contract: EOF on input, flush output, then exit).
Workaround we use
Keep stdin open briefly after the final request (
sleep 0.3before closing the pipe), or drive the server through a persistent client.