Skip to content

Commit 3b72eec

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

2 files changed

Lines changed: 101 additions & 3 deletions

File tree

mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
*/
4242
public class StdioServerTransportProvider implements McpServerTransportProvider {
4343

44+
private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; // 16MB
45+
4446
private static final Logger logger = LoggerFactory.getLogger(StdioServerTransportProvider.class);
4547

4648
private final McpJsonMapper jsonMapper;
@@ -49,6 +51,8 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
4951

5052
private final OutputStream outputStream;
5153

54+
private final int inputMaxSize;
55+
5256
private McpServerSession session;
5357

5458
private final AtomicBoolean isClosing = new AtomicBoolean(false);
@@ -72,13 +76,29 @@ public StdioServerTransportProvider(McpJsonMapper jsonMapper) {
7276
* @param outputStream The output stream to write to
7377
*/
7478
public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream) {
79+
this(jsonMapper, inputStream, outputStream, DEFAULT_INPUT_MAX_SIZE);
80+
}
81+
82+
/**
83+
* Creates a new StdioServerTransportProvider.
84+
* @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
85+
* @param inputStream The input stream to read from
86+
* @param outputStream The output stream to write to
87+
* @param inputMaxSize The maximum number of characters read for a single inbound
88+
* message. A peer that sends a longer message (or never terminates a line) has its
89+
* message rejected instead of forcing the transport to buffer it in memory.
90+
*/
91+
public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream,
92+
int inputMaxSize) {
7593
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
7694
Assert.notNull(inputStream, "The InputStream can not be null");
7795
Assert.notNull(outputStream, "The OutputStream can not be null");
96+
Assert.isTrue(inputMaxSize > 0, "inputMaxSize must be positive");
7897

7998
this.jsonMapper = jsonMapper;
8099
this.inputStream = inputStream;
81100
this.outputStream = outputStream;
101+
this.inputMaxSize = inputMaxSize;
82102
}
83103

84104
@Override
@@ -211,7 +231,7 @@ private void startInboundProcessing() {
211231
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
212232
while (!isClosing.get()) {
213233
try {
214-
String line = reader.readLine();
234+
String line = readLine(reader, inputMaxSize);
215235
if (line == null || isClosing.get()) {
216236
break;
217237
}
@@ -232,6 +252,10 @@ private void startInboundProcessing() {
232252
break;
233253
}
234254
}
255+
catch (MaxSizeExceededException e) {
256+
logIfNotClosing("Inbound message exceeds the maximum allowed size", e);
257+
break;
258+
}
235259
catch (IOException e) {
236260
logIfNotClosing("Error reading from stdin", e);
237261
break;
@@ -304,6 +328,36 @@ else if (isClosing.get()) {
304328
outboundConsumer.apply(outboundSink.asFlux()).subscribe();
305329
} // @formatter:on
306330

331+
/**
332+
* Read line with a max size.
333+
*/
334+
private static String readLine(BufferedReader reader, int maxSize)
335+
throws IOException, MaxSizeExceededException {
336+
StringBuilder sb = new StringBuilder();
337+
int c;
338+
while ((c = reader.read()) != -1) {
339+
if (c == '\n') {
340+
return sb.toString();
341+
}
342+
if (c == '\r') {
343+
// Consume an optional trailing '\n' so that "\r\n" is treated as a
344+
// single terminator, mirroring BufferedReader#readLine().
345+
reader.mark(1);
346+
int next = reader.read();
347+
if (next != '\n' && next != -1) {
348+
reader.reset();
349+
}
350+
return sb.toString();
351+
}
352+
if (sb.length() >= maxSize) {
353+
throw new MaxSizeExceededException(
354+
"Inbound message exceeds the maximum allowed size of " + maxSize + " characters");
355+
}
356+
sb.append((char) c);
357+
}
358+
return sb.isEmpty() ? null : sb.toString();
359+
}
360+
307361
private void logIfNotClosing(String message, Exception e) {
308362
if (!isClosing.get()) {
309363
logger.error(message, e);

mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,25 @@
1111
import java.io.InputStreamReader;
1212
import java.io.PrintStream;
1313
import java.nio.charset.StandardCharsets;
14+
import java.time.Duration;
1415
import java.util.Map;
1516
import java.util.concurrent.CountDownLatch;
1617
import java.util.concurrent.TimeUnit;
1718
import java.util.concurrent.atomic.AtomicReference;
1819

1920
import io.modelcontextprotocol.json.McpJsonDefaults;
20-
import io.modelcontextprotocol.spec.McpError;
2121
import io.modelcontextprotocol.spec.McpSchema;
2222
import io.modelcontextprotocol.spec.McpServerSession;
2323
import io.modelcontextprotocol.spec.McpServerTransport;
24+
import org.awaitility.Awaitility;
2425
import org.junit.jupiter.api.AfterEach;
2526
import org.junit.jupiter.api.BeforeEach;
26-
import org.junit.jupiter.api.Disabled;
2727
import org.junit.jupiter.api.Test;
2828
import reactor.core.publisher.Mono;
2929
import reactor.test.StepVerifier;
3030

3131
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3233
import static org.mockito.ArgumentMatchers.any;
3334
import static org.mockito.Mockito.mock;
3435
import static org.mockito.Mockito.verify;
@@ -246,6 +247,49 @@ void shouldHandleInvalidJsonMessage() throws Exception {
246247
.verifyComplete();
247248
}
248249

250+
@Test
251+
void shouldRejectInboundMessageExceedingMaxSize() throws Exception {
252+
// A line larger than the configured limit that never terminates with a newline.
253+
// BufferedReader#readLine would buffer the whole thing; the bounded reader must
254+
// abort instead.
255+
int maxSize = 1024;
256+
String oversized = "a".repeat(maxSize + 10);
257+
InputStream stream = new ByteArrayInputStream(oversized.getBytes(StandardCharsets.UTF_8));
258+
259+
transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), stream, testOutPrintStream,
260+
maxSize);
261+
262+
AtomicReference<McpSchema.JSONRPCMessage> capturedMessage = new AtomicReference<>();
263+
McpServerSession.Factory realSessionFactory = transport -> {
264+
McpServerSession session = mock(McpServerSession.class);
265+
when(session.handle(any())).thenAnswer(invocation -> {
266+
capturedMessage.set(invocation.getArgument(0));
267+
return Mono.empty();
268+
});
269+
when(session.closeGracefully()).thenReturn(Mono.empty());
270+
return session;
271+
};
272+
273+
transportProvider.setSessionFactory(realSessionFactory);
274+
275+
Awaitility.await()
276+
.atMost(Duration.ofSeconds(5))
277+
.pollInterval(Duration.ofMillis(100))
278+
.untilAsserted(
279+
() -> assertThat(testErr.toString()).contains("Inbound message exceeds the maximum allowed size"));
280+
281+
// message is never processed
282+
assertThat(capturedMessage.get()).isNull();
283+
}
284+
285+
@Test
286+
void shouldRejectNonPositiveMaxSize() {
287+
assertThatThrownBy(
288+
() -> new StdioServerTransportProvider(McpJsonDefaults.getMapper(), System.in, System.out, 0))
289+
.isInstanceOf(IllegalArgumentException.class)
290+
.hasMessageContaining("inputMaxSize must be positive");
291+
}
292+
249293
@Test
250294
void shouldHandleSessionClose() throws Exception {
251295
// Set session factory

0 commit comments

Comments
 (0)