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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions