Skip to content

Commit 3477fcd

Browse files
committed
Bound STDIO client reads
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 3b72eec commit 3477fcd

3 files changed

Lines changed: 144 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client.transport;
6+
7+
/**
8+
* Thrown when reading an inbound message would exceed the configured maximum size.
9+
*
10+
* @author Daniel Garnier-Moiroux
11+
*/
12+
class MaxSizeExceededException extends Exception {
13+
14+
MaxSizeExceededException(String message) {
15+
super(message);
16+
}
17+
18+
}

mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ public class StdioClientTransport implements McpClientTransport {
4444

4545
private static final Logger logger = LoggerFactory.getLogger(StdioClientTransport.class);
4646

47+
private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; // 16MB
48+
4749
// @formatter:off
4850
private static final Set<Integer> EXIT_SUCCESS_CODES = Set.of(
4951
0, // success
@@ -76,6 +78,8 @@ public class StdioClientTransport implements McpClientTransport {
7678

7779
private final Sinks.Many<String> errorSink;
7880

81+
private final int inputMaxSize;
82+
7983
private volatile boolean isClosing = false;
8084

8185
// visible for tests
@@ -87,8 +91,21 @@ public class StdioClientTransport implements McpClientTransport {
8791
* @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
8892
*/
8993
public StdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper) {
94+
this(params, jsonMapper, DEFAULT_INPUT_MAX_SIZE);
95+
}
96+
97+
/**
98+
* Creates a new StdioClientTransport with the specified parameters and JsonMapper.
99+
* @param params The parameters for configuring the server process
100+
* @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
101+
* @param inputMaxSize The maximum number of characters read for a single inbound
102+
* message. A peer that sends a longer message (or never terminates a line) has its
103+
* message rejected instead of forcing the transport to buffer it in memory.
104+
*/
105+
public StdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper, int inputMaxSize) {
90106
Assert.notNull(params, "The params can not be null");
91107
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
108+
Assert.isTrue(inputMaxSize > 0, "inputMaxSize must be positive");
92109

93110
this.inboundSink = Sinks.many().unicast().onBackpressureBuffer();
94111
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
@@ -97,6 +114,8 @@ public StdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper) {
97114

98115
this.jsonMapper = jsonMapper;
99116

117+
this.inputMaxSize = inputMaxSize;
118+
100119
this.errorSink = Sinks.many().unicast().onBackpressureBuffer();
101120

102121
// Start threads
@@ -260,7 +279,7 @@ private void startInboundProcessing() {
260279
this.inboundScheduler.schedule(() -> {
261280
try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
262281
String line;
263-
while (!isClosing && (line = processReader.readLine()) != null) {
282+
while (!isClosing && (line = readLine(processReader, inputMaxSize)) != null) {
264283
try {
265284
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, line);
266285
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
@@ -278,6 +297,11 @@ private void startInboundProcessing() {
278297
}
279298
}
280299
}
300+
catch (MaxSizeExceededException e) {
301+
if (!isClosing) {
302+
logger.error("Inbound message exceeds the maximum allowed size", e);
303+
}
304+
}
281305
catch (IOException e) {
282306
if (!isClosing) {
283307
logger.error("Error reading from input stream", e);
@@ -290,6 +314,37 @@ private void startInboundProcessing() {
290314
});
291315
}
292316

317+
/**
318+
* Reads a single line, mirroring {@link BufferedReader#readLine()}, but aborting once
319+
* more than {@code maxSize} characters have been read without encountering a line
320+
* terminator. This bounds how much memory a single inbound message can occupy.
321+
*/
322+
static String readLine(BufferedReader reader, int maxSize) throws IOException, MaxSizeExceededException {
323+
StringBuilder sb = new StringBuilder();
324+
int c;
325+
while ((c = reader.read()) != -1) {
326+
if (c == '\n') {
327+
return sb.toString();
328+
}
329+
if (c == '\r') {
330+
// Consume an optional trailing '\n' so that "\r\n" is treated as a
331+
// single terminator, mirroring BufferedReader#readLine().
332+
reader.mark(1);
333+
int next = reader.read();
334+
if (next != '\n' && next != -1) {
335+
reader.reset();
336+
}
337+
return sb.toString();
338+
}
339+
if (sb.length() >= maxSize) {
340+
throw new MaxSizeExceededException(
341+
"Inbound message exceeds the maximum allowed size of " + maxSize + " characters");
342+
}
343+
sb.append((char) c);
344+
}
345+
return sb.isEmpty() ? null : sb.toString();
346+
}
347+
293348
/**
294349
* Starts the outbound processing thread that writes JSON-RPC messages to the
295350
* process's output stream. Messages are serialized to JSON and written with a newline
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client.transport;
6+
7+
import java.io.ByteArrayOutputStream;
8+
import java.io.PrintStream;
9+
import java.time.Duration;
10+
11+
import org.awaitility.Awaitility;
12+
import org.junit.jupiter.api.AfterEach;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.Test;
15+
import reactor.test.StepVerifier;
16+
17+
import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER;
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
/**
21+
* Integration tests for {@link StdioClientTransport}.
22+
*
23+
* @author Daniel Garnier-Moiroux
24+
*/
25+
class StdioClientTransportTests {
26+
27+
private final PrintStream originalOut = System.out;
28+
29+
private final PrintStream originalErr = System.err;
30+
31+
private ByteArrayOutputStream testErr;
32+
33+
@BeforeEach
34+
void setUp() {
35+
testErr = new ByteArrayOutputStream();
36+
PrintStream testOutPrintStream = new PrintStream(testErr, true);
37+
System.setOut(testOutPrintStream);
38+
System.setErr(testOutPrintStream);
39+
}
40+
41+
@AfterEach
42+
void tearDown() {
43+
System.setOut(originalOut);
44+
System.setErr(originalErr);
45+
}
46+
47+
@Test
48+
void shouldRejectInboundMessageExceedingMaxSize() throws Exception {
49+
// A server process that emits an endless line with no newline terminator. A
50+
// plain BufferedReader#readLine would buffer it all; the bounded reader must
51+
// abort instead of exhausting memory.
52+
int maxSize = 1024;
53+
ServerParameters params = ServerParameters.builder("sh").args("-c", "while :; do printf a; done").build();
54+
55+
StdioClientTransport transport = new StdioClientTransport(params, JSON_MAPPER, maxSize);
56+
try {
57+
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
58+
59+
Awaitility.await()
60+
.atMost(Duration.ofSeconds(5))
61+
.pollInterval(Duration.ofMillis(100))
62+
.untilAsserted(() -> assertThat(testErr.toString())
63+
.contains("Inbound message exceeds the maximum allowed size"));
64+
}
65+
finally {
66+
StepVerifier.create(transport.closeGracefully()).verifyComplete();
67+
}
68+
}
69+
70+
}

0 commit comments

Comments
 (0)