Skip to content

stdio: responses to already-received requests are dropped when stdin EOF arrives first (one-shot pipes get no output) #1061

Description

@Yuncun

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)

  1. The pipe closes; the read loop gets io.EOF and records it: s.readErr = err — conn.go:508 (main: 543).
  2. Handlers for the already-received requests finish and try to respond: processResultc.write(...) — conn.go:672–682 (main: 693ff).
  3. 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.

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