77import java .io .BufferedReader ;
88import java .io .ByteArrayInputStream ;
99import java .io .ByteArrayOutputStream ;
10+ import java .io .FilterOutputStream ;
11+ import java .io .IOException ;
1012import java .io .InputStream ;
1113import java .io .InputStreamReader ;
14+ import java .io .OutputStream ;
1215import java .io .PrintStream ;
1316import java .nio .charset .StandardCharsets ;
1417import java .time .Duration ;
2528import org .junit .jupiter .api .AfterEach ;
2629import org .junit .jupiter .api .BeforeEach ;
2730import org .junit .jupiter .api .Test ;
31+ import reactor .core .publisher .Flux ;
2832import reactor .core .publisher .Mono ;
33+ import reactor .core .scheduler .Schedulers ;
2934import reactor .test .StepVerifier ;
3035
3136import static org .assertj .core .api .Assertions .assertThat ;
@@ -99,7 +104,7 @@ void shouldCreateSessionWhenSessionFactoryIsSet() {
99104 }
100105
101106 @ Test
102- void shouldHandleIncomingMessages () throws Exception {
107+ void shouldHandleIncomingMessages () {
103108
104109 String jsonMessage = "{\" jsonrpc\" :\" 2.0\" ,\" method\" :\" test\" ,\" params\" :{},\" id\" :1}\n " ;
105110 InputStream stream = new ByteArrayInputStream (jsonMessage .getBytes (StandardCharsets .UTF_8 ));
@@ -229,7 +234,7 @@ void shouldHandleNotificationBeforeSessionFactoryIsSet() {
229234 }
230235
231236 @ Test
232- void shouldHandleInvalidJsonMessage () throws Exception {
237+ void shouldHandleInvalidJsonMessage () {
233238
234239 // Write an invalid JSON message to the input stream
235240 String jsonMessage = "{invalid json}\n " ;
@@ -248,7 +253,7 @@ void shouldHandleInvalidJsonMessage() throws Exception {
248253 }
249254
250255 @ Test
251- void shouldRejectInboundMessageExceedingMaxSize () throws Exception {
256+ void shouldRejectInboundMessageExceedingMaxSize () {
252257 // A line larger than the configured limit that never terminates with a newline.
253258 // BufferedReader#readLine would buffer the whole thing; the bounded reader must
254259 // abort instead.
@@ -291,7 +296,7 @@ void shouldRejectNonPositiveMaxSize() {
291296 }
292297
293298 @ Test
294- void shouldHandleSessionClose () throws Exception {
299+ void shouldHandleSessionClose () {
295300 // Set session factory
296301 transportProvider .setSessionFactory (sessionFactory );
297302
@@ -302,4 +307,69 @@ void shouldHandleSessionClose() throws Exception {
302307 verify (mockSession ).closeGracefully ();
303308 }
304309
310+ @ Test
311+ void shouldHandleConcurrentSendMessage () throws Exception {
312+ int messageCount = 500 ;
313+ ByteArrayOutputStream output = new ByteArrayOutputStream ();
314+ CountDownLatch writtenMessages = new CountDownLatch (messageCount );
315+
316+ // Redirect the transport output to a buffer so we can verify every message lands.
317+ // Writes happen serially on the outbound scheduler, so count the
318+ // newline delimiters as they are written.
319+ OutputStream countingOutput = new FilterOutputStream (output ) {
320+
321+ @ Override
322+ public void write (int b ) throws IOException {
323+ this .out .write (b );
324+ if (b == '\n' ) {
325+ writtenMessages .countDown ();
326+ }
327+ }
328+
329+ @ Override
330+ public void write (byte [] b , int off , int len ) throws IOException {
331+ this .out .write (b , off , len );
332+ for (int i = off ; i < off + len ; i ++) {
333+ if (b [i ] == '\n' ) {
334+ writtenMessages .countDown ();
335+ }
336+ }
337+ }
338+ };
339+ transportProvider = new StdioServerTransportProvider (McpJsonDefaults .getMapper (), System .in , countingOutput );
340+
341+ // Capture the inner McpServerTransport handed to the session factory
342+ AtomicReference <McpServerTransport > transportRef = new AtomicReference <>();
343+ McpServerSession .Factory capturingFactory = transport -> {
344+ transportRef .set (transport );
345+ return mockSession ;
346+ };
347+
348+ transportProvider .setSessionFactory (capturingFactory );
349+
350+ McpServerTransport transport = transportRef .get ();
351+ assertThat (transport ).isNotNull ();
352+
353+ // Fan sendMessage out across 16 parallel rails to race against the unicast sink
354+ Flux <Integer > concurrentSends = Flux .range (0 , messageCount )
355+ .parallel (16 )
356+ .runOn (Schedulers .parallel ())
357+ .flatMap (i -> transport
358+ .sendMessage (
359+ new McpSchema .JSONRPCNotification (McpSchema .JSONRPC_VERSION , "test/notification" , Map .of ()))
360+ .thenReturn (i ))
361+ .sequential ();
362+
363+ // Every send should complete successfully (no FAIL_NON_SERIALIZED errors)
364+ StepVerifier .create (concurrentSends ).expectNextCount (messageCount ).verifyComplete ();
365+
366+ // Wait until the outbound scheduler has actually written all of them
367+ assertThat (writtenMessages .await (30 , TimeUnit .SECONDS ))
368+ .as ("all %d messages written, %d still missing" , messageCount , writtenMessages .getCount ())
369+ .isTrue ();
370+
371+ // Every message was written as its own newline-delimited JSON line
372+ assertThat (output .toString (StandardCharsets .UTF_8 ).lines ().count ()).isEqualTo (messageCount );
373+ }
374+
305375}
0 commit comments