Repository navigation
MCP server using StdioServerTransportProvider does not support multiple asynchronous tool calls for the same session #686
Description
Activity
- addedbugSomething isn't workingSomething isn't workingP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable feature
on Feb 20, 2026 - added a commit that references this issue
on Feb 24, 2026 Independently reproduced and root-caused this exact bug (same stack trace,
StdioMcpSessionTransport.lambda$sendMessage$0→"Failed to enqueue message") while building an MCP server, still present as of the latest release (mcp-core:2.0.1) - confirmed by diffing2.0.0and2.0.1's published sources,StdioServerTransportProvider's outbound sink handling is byte-for-byte unchanged between them (2.0.1 only adds an unrelated inbound-message max-size guard).Root cause
StdioMcpSessionTransport.outboundSinkis a singleSinks.many().unicast().onBackpressureBuffer()shared by the whole session:this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(); // ... public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) { return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> { if (outboundSink.tryEmitNext(message).isSuccess()) { return Mono.empty(); } else { return Mono.error(new RuntimeException("Failed to enqueue message")); } })); }
unicast()sinks are documented as safe for exactly one producer thread at a time.sendMessage()gets called from whatever thread each tool call's completion lands on - concretely, this project's own server-side reproduction found two calls to the same tool completing close enough in time to trigger the race reliably (~100% reproduction acrossexplainQuery,listSchemas,getCapabilitiesin different testing), and confirmed the race persists even under a global lock serializing tool execution - the actual concurrenttryEmitNextcall happens in an async continuation downstream of the tool callback returning, a thread hop application code has no hook into. This matches #875's independent analysis of the client-sideStdioClientTransporthitting the identicalunicast()-sink misuse pattern (their repro traced it toSinkManySerialized's CAS guard specifically) - same class of bug, both sides of the stdio transport.I see #834 already proposes a fix in this same file (
emitNext()+Sinks.EmitFailureHandler.busyLooping(Duration.ofSeconds(1))instead oftryEmitNext()) and was reviewed same-day by @chemicL - looks like it's been stalled since, with merge conflicts now. If it's still on the table, one thing worth weighing against a plainsynchronizedwrite (below):busyLoopingretries by spinning with a timeout, so it still has a (very unlikely in practice, but non-zero) failure mode under sustained contention that a blocking lock doesn't - a thread waiting on a lock simply waits, with no cap. Not meant as a knock on that PR, just additional data point in case it helps get it unstuck.A workaround that closes it completely, if useful to anyone hitting this before an official fix lands
Since
McpServerTransportProvider/McpServerTransportare public interfaces (StdioServerTransportProvideritself is not extensible -StdioMcpSessionTransportis a private inner class), a drop-in replacement transport provider that performs the outbound write directly and synchronously under a plain lock - instead of through a Reactor sink at all - eliminates the race entirely, since there's no unsynchronized enqueue step left to fail:public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) { return Mono.fromRunnable(() -> writeMessage(message)); } private void writeMessage(McpSchema.JSONRPCMessage message) { if (isClosing.get()) return; String json = jsonMapper.writeValueAsString(message) .replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n"); synchronized (writeLock) { outputStream.write(json.getBytes(StandardCharsets.UTF_8)); outputStream.write('\n'); outputStream.flush(); } }
(Inbound processing - a dedicated reader thread deserializing newline-delimited JSON-RPC and dispatching to
session.handle(...)- can mirror the SDK's own approach unchanged; that side was never the problem.)Verified this approach against a real server: 21/21 concurrent-call runs across three different tools with zero dropped responses, where the stock
StdioServerTransportProviderdropped a response in the large majority of runs for the same reproduction. Happy to share the full class if it'd help toward an official fix.The issue is resolved via #935. The design with asynchronous signatures makes the solution based on synchronized blocks in direct writes in unfeasible as it could block on NIO threads in same rare cases. The Sink in the middle decouples the single consumer from multiple producers assuring that writing happens on a single thread in a serialized fashion.
Bug description
On an MCP server using StdioServerTransportProvider, if multiple asynchronous tool calls are made on the same session, the following RuntimeException may be thrown on the server side, and the server may stop responding thereafter.
When an LLM requests multiple tool calls, AI agent libraries such as LangChain may execute multiple tool calls in parallel.
In this case, a runtime exception should not occur.
Environment
MCP SDK Version: 0.16.0
JDK: 21
Steps to reproduce
Implement an MCP server with a simple tool that echoes back input.
The tool is intentionally implemented to delay responses.
Create and execute code that makes multiple asynchronous tool calls to this MCP server.
The exception shown in the report at the beginning often occurs on the server side (although it may not occur).
Expected behavior
Return the correct response for each asynchronous call.
Minimal Complete Reproducible example
As described in "Steps to reproduce".