bug: stdio deadlocks on subscriptions/listen, blocking every later request
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
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:539Environment
Go 1.27.1, mcp-go v1.0.0, macOS arm64. stdio only; streamable HTTP unaffected.
Possible Solutions
- Stop advertising a version stdio cannot serve: Have StdioServer implement ProtocolVersionSupporter and publish it in Listen, mirroring streamable_http.go:698:
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.
- Stop dispatching requests inline on the read loop
The deadlock exists because
processMessagecallsHandleMessageinline for everything excepttools/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:
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.
Source: mark3labs/mcp-go