#976·mcp-go

bug: stdio deadlocks on subscriptions/listen, blocking every later request

Author: fesiqueiraCreated Sep 11, 2026Updated Sep 11, 2026

Description

On stdio, subscriptions/listen blocks the server permanently. Every request sent after it is never answered.

Surfaced downstream in grafana/mcp-grafana v1.4.0+ after its v0.58.0 → v1.0.0 bump: clients connect, then time out on tools/list and load zero tools.

Code Sample

go
package main

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"time"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
)

// Every 2026-07-28 request carries its version, client identity and
// capabilities in _meta; without them the server rejects it with -32021.
var meta = map[string]any{
	"io.modelcontextprotocol/protocolVersion":    "2026-07-28",
	"io.modelcontextprotocol/clientInfo":         map[string]any{"name": "repro", "version": "1"},
	"io.modelcontextprotocol/clientCapabilities": map[string]any{},
}

func main() {
	s := server.NewMCPServer("repro", "1.0.0", server.WithToolCapabilities(true))
	s.AddTool(mcp.NewTool("ping"), func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		return mcp.NewToolResultText("pong"), nil
	})

	inR, inW, _ := os.Pipe()
	outR, outW, _ := os.Pipe()
	go server.NewStdioServer(s).Listen(context.Background(), inR, outW)

	go func() {
		sc := bufio.NewScanner(outR)
		for sc.Scan() {
			fmt.Println("  <-", sc.Text())
		}
	}()

	send := func(id any, method string, params map[string]any) {
		params["_meta"] = meta
		b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
		fmt.Println("->", method)
		inW.Write(append(b, '\n'))
		time.Sleep(1500 * time.Millisecond)
	}

	send("p", "server/discover", map[string]any{})
	send("listen:0", "subscriptions/listen", map[string]any{
		"notifications": map[string]any{"toolsListChanged": true},
	})
	send(1, "tools/list", map[string]any{}) // no response, now or ever

	fmt.Println("tools/list still unanswered after 5s:")
	time.Sleep(5 * time.Second)
}

Output: server/discover and the acknowledgement come back, tools/list never does. Delete only the subscriptions/listen call and tools/list answers normally.

Logs or Error Messages

fatal error: all goroutines are asleep - deadlock!

goroutine 36 [chan receive]:
...handleSubscriptionsListen(...)  server/subscriptions.go:143
...HandleMessage(...)              server/request_handler.go:243
...(*StdioServer).processMessage(...)      server/stdio.go:610
...(*StdioServer).processInputStream(...)  server/stdio.go:432
...(*StdioServer).Listen(...)              server/stdio.go:539

Environment

Go 1.27.1, mcp-go v1.0.0, macOS arm64. stdio only; streamable HTTP unaffected.

Possible Solutions

  1. Stop advertising a version stdio cannot serve: Have StdioServer implement ProtocolVersionSupporter and publish it in Listen, mirroring streamable_http.go:698:
go
func (s *StdioServer) SupportsProtocolVersion(version string) bool {
      return mcp.IsValidProtocolVersion(version) && !mcp.IsModernProtocol(version)
}

// in Listen, after the contextFunc hook:
ctx = WithSupportedProtocolVersions(ctx, FilterSupportedVersions(s))

It removes the invitation, not the deadlock. I re-ran the code above with this patch applied and sent the modern _meta anyway and the server deadlocked exactly as before. Any client that skips or ignores server/discover still causes the issue.

  1. Stop dispatching requests inline on the read loop The deadlock exists because processMessage calls HandleMessage inline for everything except tools/call, so a handler that blocks blocks the reader. Serving anything with a JSON-RPC id on its own goroutine, and keeping notifications inline, removes it:
go
if parsed && baseMessage.ID != nil {
      go s.handleRequest(ctx, rawMessage, writer)
      return nil
}

handleRequest is HandleMessage + writeResponse under the same panic recovery toolCallWorker already uses. Notifications stay inline so notifications/cancelled is never queued behind the request it cancels.

One thing worth flagging: this is a goroutine per request, unbounded. The tools/call worker pool is untouched, so its tuning is unchanged. It matches what streamable HTTP already gets from net/http, and concurrency is bounded by what one client can push through one pipe.

Happy to open a PR if you think it's worth it.