Skip to content

MCP server using StdioServerTransportProvider does not support multiple asynchronous tool calls for the same session #686

Description

@aoetk

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.

[boundedElastic-1] ERROR reactor.core.publisher.Operators - Operator called default onErrorDropped
reactor.core.Exceptions$ErrorCallbackNotImplemented: java.lang.RuntimeException: Failed to enqueue message
Caused by: java.lang.RuntimeException: Failed to enqueue message
	at io.modelcontextprotocol.server.transport.StdioServerTransportProvider$StdioMcpSessionTransport.lambda$sendMessage$0(StdioServerTransportProvider.java:154)
	at reactor.core.publisher.MonoDefer.subscribe(MonoDefer.java:45)
	at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:241)
	at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.onComplete(MonoIgnoreThen.java:204)
	at reactor.core.publisher.MonoZip$ZipInner.onComplete(MonoZip.java:536)
	at reactor.core.publisher.Operators$MonoInnerProducerBase.complete(Operators.java:2869)
	at reactor.core.publisher.SinkOneMulticast.subscribe(SinkOneMulticast.java:98)
	at reactor.core.publisher.MonoZip$ZipCoordinator.request(MonoZip.java:220)
	at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.onSubscribe(MonoIgnoreThen.java:135)
	at reactor.core.publisher.MonoZip.subscribe(MonoZip.java:129)
	at reactor.core.publisher.Mono.subscribe(Mono.java:4576)
	at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:265)
	at reactor.core.publisher.MonoIgnoreThen.subscribe(MonoIgnoreThen.java:51)
	at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:165)
	at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onNext(FluxOnErrorResume.java:79)
	at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onNext(FluxOnErrorResume.java:79)
	at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:129)
	at reactor.core.publisher.MonoFlatMap$FlatMapMain.secondComplete(MonoFlatMap.java:245)
	at reactor.core.publisher.MonoFlatMap$FlatMapInner.onNext(MonoFlatMap.java:305)
	at reactor.core.publisher.FluxSubscribeOnCallable$CallableSubscribeOnSubscription.run(FluxSubscribeOnCallable.java:252)
	at reactor.core.scheduler.SchedulerTask.call(SchedulerTask.java:68)
	at reactor.core.scheduler.SchedulerTask.call(SchedulerTask.java:28)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
	at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:304)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)

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.

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;

public class Server {
    private static final Logger LOGGER = LoggerFactory.getLogger(Server.class);

    public static void main(String[] args) {
        var jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
        var transportProvider = new StdioServerTransportProvider(jsonMapper);
        var schema = """
                {
                  "type": "object",
                  "id": "urn:jsonschema:Operation",
                  "properties": {
                    "input": {
                      "type": "string"
                    }
                  }
                }
                """;
        var tool = McpSchema.Tool.builder().name("echo").description("echo back input value")
                .inputSchema(jsonMapper, schema).build();
        var toolSpec = McpServerFeatures.SyncToolSpecification.builder().tool(tool)
                .callHandler((mcpSyncServerExchange, callToolRequest) -> {
                    var arguments = callToolRequest.arguments();
                    var input = (String) arguments.get("input");
                    LOGGER.info("Echoing: {}", input);
                    try {
                        TimeUnit.SECONDS.sleep(3); // delay response
                        return new McpSchema.CallToolResult(input, false);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        return new McpSchema.CallToolResult("error", true);
                    }
                }).build();
        var server = McpServer.sync(transportProvider).serverInfo("example", "1.0.0")
                .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()).tools(toolSpec).build();
        LOGGER.info("Starting started: {}", server.getServerInfo());
    }
}

Create and execute code that makes multiple asynchronous tool calls to this MCP server.

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.Map;

public class Client {
    public static void main(String[] args) {
        var params = ServerParameters.builder("java")
                .args("-jar", "/path/to/example.jar")
                .build();
        var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(new ObjectMapper()));
        transport.setStdErrorHandler(System.err::println);
        var client = McpClient.async(transport).build();
        client.initialize().subscribe();
        client.listTools()
                .doOnNext(result -> result.tools().forEach(tool -> System.out.println(tool.name())))
                .subscribe();
        client.callTool(McpSchema.CallToolRequest.builder().name("echo").arguments(Map.of("input", "hello1")).build())
                .doOnNext(callToolResult -> callToolResult.content().forEach(System.out::println))
                .subscribe();
        client.callTool(McpSchema.CallToolRequest.builder().name("echo").arguments(Map.of("input", "hello2")).build())
                .doOnNext(callToolResult -> callToolResult.content().forEach(System.out::println))
                .subscribe();
    }
}

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".

Activity

  1. added
    bugSomething isn't working
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Feb 20, 2026
  2. added theissue type on Feb 20, 2026
  3. marekpompura commented on Aug 30, 2026

    @marekpompura

    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 diffing 2.0.0 and 2.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.outboundSink is a single Sinks.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 across explainQuery, listSchemas, getCapabilities in different testing), and confirmed the race persists even under a global lock serializing tool execution - the actual concurrent tryEmitNext call 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-side StdioClientTransport hitting the identical unicast()-sink misuse pattern (their repro traced it to SinkManySerialized'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 of tryEmitNext()) 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 plain synchronized write (below): busyLooping retries 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/McpServerTransport are public interfaces (StdioServerTransportProvider itself is not extensible - StdioMcpSessionTransport is 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 StdioServerTransportProvider dropped 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.

  4. chemicL commented on Sep 4, 2026

    @chemicL
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions