modelcontextprotocol / modelcontextprotocol/go-sdk

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

Open
#1,061 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

P2
Dominant language
Go
Stars
5.1k
Forks
543
Avg merge
1d 17h
Merged PRs (30d)
37

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in internal/jsonrpc2/conn.go, reading the read loop around lines 508/543, processResult around 672–682/693ff, write around 707–719/749ff, and shuttingDown around 141–145/158–162. Run the one-shot pipe reproduction and compare it with the delayed-stdin case. Done means responses for requests received before stdin EOF are written before the connection closes.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.