Bound STDIO server reads
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
ef037dae7b
commit
3b72eec0b8
2 changed files with 101 additions and 3 deletions
|
|
@ -41,6 +41,8 @@ import reactor.core.scheduler.Schedulers;
|
||||||
*/
|
*/
|
||||||
public class StdioServerTransportProvider implements McpServerTransportProvider {
|
public class StdioServerTransportProvider implements McpServerTransportProvider {
|
||||||
|
|
||||||
|
private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; // 16MB
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(StdioServerTransportProvider.class);
|
private static final Logger logger = LoggerFactory.getLogger(StdioServerTransportProvider.class);
|
||||||
|
|
||||||
private final McpJsonMapper jsonMapper;
|
private final McpJsonMapper jsonMapper;
|
||||||
|
|
@ -49,6 +51,8 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
|
||||||
|
|
||||||
private final OutputStream outputStream;
|
private final OutputStream outputStream;
|
||||||
|
|
||||||
|
private final int inputMaxSize;
|
||||||
|
|
||||||
private McpServerSession session;
|
private McpServerSession session;
|
||||||
|
|
||||||
private final AtomicBoolean isClosing = new AtomicBoolean(false);
|
private final AtomicBoolean isClosing = new AtomicBoolean(false);
|
||||||
|
|
@ -72,13 +76,29 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
|
||||||
* @param outputStream The output stream to write to
|
* @param outputStream The output stream to write to
|
||||||
*/
|
*/
|
||||||
public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream) {
|
public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream) {
|
||||||
|
this(jsonMapper, inputStream, outputStream, DEFAULT_INPUT_MAX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new StdioServerTransportProvider.
|
||||||
|
* @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
|
||||||
|
* @param inputStream The input stream to read from
|
||||||
|
* @param outputStream The output stream to write to
|
||||||
|
* @param inputMaxSize The maximum number of characters read for a single inbound
|
||||||
|
* message. A peer that sends a longer message (or never terminates a line) has its
|
||||||
|
* message rejected instead of forcing the transport to buffer it in memory.
|
||||||
|
*/
|
||||||
|
public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream,
|
||||||
|
int inputMaxSize) {
|
||||||
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
|
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
|
||||||
Assert.notNull(inputStream, "The InputStream can not be null");
|
Assert.notNull(inputStream, "The InputStream can not be null");
|
||||||
Assert.notNull(outputStream, "The OutputStream can not be null");
|
Assert.notNull(outputStream, "The OutputStream can not be null");
|
||||||
|
Assert.isTrue(inputMaxSize > 0, "inputMaxSize must be positive");
|
||||||
|
|
||||||
this.jsonMapper = jsonMapper;
|
this.jsonMapper = jsonMapper;
|
||||||
this.inputStream = inputStream;
|
this.inputStream = inputStream;
|
||||||
this.outputStream = outputStream;
|
this.outputStream = outputStream;
|
||||||
|
this.inputMaxSize = inputMaxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -211,7 +231,7 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
|
||||||
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
|
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
|
||||||
while (!isClosing.get()) {
|
while (!isClosing.get()) {
|
||||||
try {
|
try {
|
||||||
String line = reader.readLine();
|
String line = readLine(reader, inputMaxSize);
|
||||||
if (line == null || isClosing.get()) {
|
if (line == null || isClosing.get()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +252,10 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (MaxSizeExceededException e) {
|
||||||
|
logIfNotClosing("Inbound message exceeds the maximum allowed size", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
catch (IOException e) {
|
catch (IOException e) {
|
||||||
logIfNotClosing("Error reading from stdin", e);
|
logIfNotClosing("Error reading from stdin", e);
|
||||||
break;
|
break;
|
||||||
|
|
@ -304,6 +328,36 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
|
||||||
outboundConsumer.apply(outboundSink.asFlux()).subscribe();
|
outboundConsumer.apply(outboundSink.asFlux()).subscribe();
|
||||||
} // @formatter:on
|
} // @formatter:on
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read line with a max size.
|
||||||
|
*/
|
||||||
|
private static String readLine(BufferedReader reader, int maxSize)
|
||||||
|
throws IOException, MaxSizeExceededException {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int c;
|
||||||
|
while ((c = reader.read()) != -1) {
|
||||||
|
if (c == '\n') {
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
if (c == '\r') {
|
||||||
|
// Consume an optional trailing '\n' so that "\r\n" is treated as a
|
||||||
|
// single terminator, mirroring BufferedReader#readLine().
|
||||||
|
reader.mark(1);
|
||||||
|
int next = reader.read();
|
||||||
|
if (next != '\n' && next != -1) {
|
||||||
|
reader.reset();
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
if (sb.length() >= maxSize) {
|
||||||
|
throw new MaxSizeExceededException(
|
||||||
|
"Inbound message exceeds the maximum allowed size of " + maxSize + " characters");
|
||||||
|
}
|
||||||
|
sb.append((char) c);
|
||||||
|
}
|
||||||
|
return sb.isEmpty() ? null : sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private void logIfNotClosing(String message, Exception e) {
|
private void logIfNotClosing(String message, Exception e) {
|
||||||
if (!isClosing.get()) {
|
if (!isClosing.get()) {
|
||||||
logger.error(message, e);
|
logger.error(message, e);
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,25 @@ import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.PrintStream;
|
import java.io.PrintStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||||
import io.modelcontextprotocol.spec.McpError;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema;
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
import io.modelcontextprotocol.spec.McpServerSession;
|
import io.modelcontextprotocol.spec.McpServerSession;
|
||||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||||
|
import org.awaitility.Awaitility;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Disabled;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.test.StepVerifier;
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
@ -246,6 +247,49 @@ class StdioServerTransportProviderTests {
|
||||||
.verifyComplete();
|
.verifyComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectInboundMessageExceedingMaxSize() throws Exception {
|
||||||
|
// A line larger than the configured limit that never terminates with a newline.
|
||||||
|
// BufferedReader#readLine would buffer the whole thing; the bounded reader must
|
||||||
|
// abort instead.
|
||||||
|
int maxSize = 1024;
|
||||||
|
String oversized = "a".repeat(maxSize + 10);
|
||||||
|
InputStream stream = new ByteArrayInputStream(oversized.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), stream, testOutPrintStream,
|
||||||
|
maxSize);
|
||||||
|
|
||||||
|
AtomicReference<McpSchema.JSONRPCMessage> capturedMessage = new AtomicReference<>();
|
||||||
|
McpServerSession.Factory realSessionFactory = transport -> {
|
||||||
|
McpServerSession session = mock(McpServerSession.class);
|
||||||
|
when(session.handle(any())).thenAnswer(invocation -> {
|
||||||
|
capturedMessage.set(invocation.getArgument(0));
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
when(session.closeGracefully()).thenReturn(Mono.empty());
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
transportProvider.setSessionFactory(realSessionFactory);
|
||||||
|
|
||||||
|
Awaitility.await()
|
||||||
|
.atMost(Duration.ofSeconds(5))
|
||||||
|
.pollInterval(Duration.ofMillis(100))
|
||||||
|
.untilAsserted(
|
||||||
|
() -> assertThat(testErr.toString()).contains("Inbound message exceeds the maximum allowed size"));
|
||||||
|
|
||||||
|
// message is never processed
|
||||||
|
assertThat(capturedMessage.get()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectNonPositiveMaxSize() {
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> new StdioServerTransportProvider(McpJsonDefaults.getMapper(), System.in, System.out, 0))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("inputMaxSize must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldHandleSessionClose() throws Exception {
|
void shouldHandleSessionClose() throws Exception {
|
||||||
// Set session factory
|
// Set session factory
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue