fix(mcp): do not end an idle stdio server by closing stdin (#71)

After 30 minutes without a message the idle monitor closed stdin and expected the read loop to end. On a real, blocking stdin a read already in progress is not woken by closing the file, so the server lived on, the client's next message completed that read and the one after failed with 'read /dev/stdin: file already closed', which ended the connection mid-call.

The idle shutdown is now off by default (a stdio server lives as long as its client; orphans are still caught by the parent check). CLAUDE_MNEMONIC_MCP_IDLE_TIMEOUT (a Go duration) turns it on, and then it cancels the loop's context so Run returns nil instead of closing stdin.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Holger Reinhardt
2026-10-04 23:16:27 +01:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 9b6e5f7648
commit f2886bbedb
3 changed files with 176 additions and 8 deletions
+41 -8
View File
@@ -30,6 +30,11 @@ type Server struct {
version string
writeMu sync.Mutex
lastActivity atomic.Int64
// idleTimeout ends the server after this long without a message; 0 (the default) never does. A stdio server lives
// as long as its client: ending it while the client is still connected makes the client's next call fail.
idleTimeout time.Duration
// monitorInterval is how often the parent and idle checks run (30s when 0).
monitorInterval time.Duration
}
// NewServer creates a new MCP server that proxies to the worker HTTP API.
@@ -41,9 +46,24 @@ func NewServer(client *http.Client, workerURL, project, version string) *Server
version: version,
stdin: os.Stdin,
stdout: os.Stdout,
idleTimeout: idleTimeoutFromEnv(),
}
}
// IdleTimeoutEnv names the environment variable that turns the idle shutdown on, as a Go duration such as "2h".
// It is off by default.
const IdleTimeoutEnv = "CLAUDE_MNEMONIC_MCP_IDLE_TIMEOUT"
// idleTimeoutFromEnv reads IdleTimeoutEnv; an unset, invalid or non-positive value means no idle shutdown.
func idleTimeoutFromEnv() time.Duration {
d, err := time.ParseDuration(os.Getenv(IdleTimeoutEnv))
if err != nil || d <= 0 {
return 0
}
return d
}
// Request represents a JSON-RPC request.
type Request struct {
JSONRPC string `json:"jsonrpc"`
@@ -82,6 +102,13 @@ type Tool struct {
// Run starts the MCP server loop.
func (s *Server) Run(ctx context.Context) error {
// An idle shutdown cancels this context. Closing stdin does not do it: on a real (blocking) stdin the read stays
// blocked, the server lived on with a closed stdin, and the client's next message woke the read with "file already
// closed", which killed the connection mid-call.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var idleStopped atomic.Bool
scanner := bufio.NewScanner(s.stdin)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024) // 1MB max message size
@@ -100,11 +127,15 @@ func (s *Server) Run(ctx context.Context) error {
scanErr <- scanner.Err()
}()
// Monitor parent process liveness and idle timeout.
// If the parent dies (ppid changes) or no messages arrive for 30 minutes, shut down.
// Monitor parent process liveness and, when one is set, the idle timeout.
// If the parent dies (ppid changes) or no messages arrive for idleTimeout, shut down.
parentPID := os.Getppid()
go func() {
ticker := time.NewTicker(30 * time.Second)
interval := s.monitorInterval
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
@@ -118,11 +149,10 @@ func (s *Server) Run(ctx context.Context) error {
}
return
}
if time.Since(time.Unix(s.lastActivity.Load(), 0)) > 30*time.Minute {
log.Info().Msg("MCP server idle timeout (30m), shutting down")
if closer, ok := s.stdin.(io.Closer); ok {
_ = closer.Close()
}
if s.idleTimeout > 0 && time.Since(time.Unix(s.lastActivity.Load(), 0)) > s.idleTimeout {
log.Info().Dur("idle", s.idleTimeout).Msg("MCP server idle timeout, shutting down")
idleStopped.Store(true)
cancel()
return
}
}
@@ -140,6 +170,9 @@ func (s *Server) Run(ctx context.Context) error {
case <-ctx.Done():
// Drain in-flight requests before returning.
wg.Wait()
if idleStopped.Load() {
return nil // an idle shutdown the operator asked for is a clean exit
}
return ctx.Err()
case line, ok := <-lines:
if !ok {