diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java index 1b4eaca97..163a8b46a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java @@ -48,16 +48,16 @@ public class StdioClientTransport implements McpClientTransport { /** The server process being communicated with */ private Process process; - private McpJsonMapper jsonMapper; + private final McpJsonMapper jsonMapper; /** Scheduler for handling inbound messages from the server process */ - private Scheduler inboundScheduler; + private final Scheduler inboundScheduler; /** Scheduler for handling outbound messages to the server process */ - private Scheduler outboundScheduler; + private final Scheduler outboundScheduler; /** Scheduler for handling error messages from the server process */ - private Scheduler errorScheduler; + private final Scheduler errorScheduler; /** Parameters for configuring and starting the server process */ private final ServerParameters params; @@ -180,7 +180,7 @@ public void awaitForExit() { private void startErrorProcessing() { this.errorScheduler.schedule(() -> { try (BufferedReader processErrorReader = new BufferedReader( - new InputStreamReader(process.getErrorStream()))) { + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { String line; while (!isClosing && (line = processErrorReader.readLine()) != null) { try { @@ -246,7 +246,8 @@ public Mono sendMessage(JSONRPCMessage message) { */ private void startInboundProcessing() { this.inboundScheduler.schedule(() -> { - try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + try (BufferedReader processReader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { String line; while (!isClosing && (line = processReader.readLine()) != null) { try { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioClientTransportTests.java new file mode 100644 index 000000000..c7ebefe2d --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioClientTransportTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpSchema; +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import reactor.test.StepVerifier; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StdioClientTransport}. + * + * @author Christian Tzolov + */ +@Timeout(30) +class StdioClientTransportTests { + + static final String FILE_SEPARATOR = FileSystems.getDefault().getSeparator(); + + @Test + void shouldHandleUtf8MessagesWithNonUtf8DefaultCharset() throws Exception { + String utf8Content = "한글 漢字 café 🎉"; + + String javaHome = System.getProperty("java.home"); + String classpath = System.getProperty("java.class.path"); + String javaExecutable = javaHome + FILE_SEPARATOR + "bin" + FILE_SEPARATOR + "java"; + + ServerParameters params = ServerParameters.builder(javaExecutable) + .args("-Dfile.encoding=ISO-8859-1", "-cp", classpath, StdioUtf8TestEchoServer.class.getName()) + .build(); + + StdioClientTransport transport = new StdioClientTransport(params, McpJsonDefaults.getMapper()); + + AtomicReference receivedMessage = new AtomicReference<>(); + CountDownLatch messageLatch = new CountDownLatch(1); + + StepVerifier.create(transport.connect(message -> { + return message.doOnNext(msg -> { + receivedMessage.set(msg); + messageLatch.countDown(); + }); + })).verifyComplete(); + + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "echo", 1, + Map.of("message", utf8Content)); + + StepVerifier.create(transport.sendMessage(request)).verifyComplete(); + + assertThat(messageLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + assertThat(receivedMessage.get()).isNotNull(); + assertThat(receivedMessage.get()).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) receivedMessage.get(); + assertThat(response.result()).isEqualTo(utf8Content); + + transport.closeGracefully().block(); + } + + @Test + void shouldHandleUtf8ErrorMessagesWithNonUtf8DefaultCharset() throws Exception { + String utf8ErrorContent = "错误: 한글 漢字 🎉"; + + String javaHome = System.getProperty("java.home"); + String classpath = System.getProperty("java.class.path"); + String javaExecutable = javaHome + FILE_SEPARATOR + "bin" + FILE_SEPARATOR + "java"; + + ProcessBuilder pb = new ProcessBuilder(javaExecutable, "-Dfile.encoding=ISO-8859-1", "-cp", classpath, + StdioUtf8TestEchoServer.class.getName()); + pb.redirectErrorStream(false); + + Process process = pb.start(); + + try { + process.getOutputStream() + .write(("{\"jsonrpc\":\"2.0\",\"method\":\"echo\",\"params\":{\"message\":\"test\"},\"id\":1}\n") + .getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().flush(); + + Thread errorThread = getErrorThread(process, utf8ErrorContent); + + process.waitFor(10, TimeUnit.SECONDS); + errorThread.join(1000); + } + finally { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + + private static @NonNull Thread getErrorThread(Process process, String utf8ErrorContent) { + AtomicReference errorContent = new AtomicReference<>(); + CountDownLatch errorLatch = new CountDownLatch(1); + + Thread errorThread = new Thread(() -> { + try (BufferedReader errorReader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = errorReader.readLine()) != null) { + if (line.contains(utf8ErrorContent)) { + errorContent.set(line); + errorLatch.countDown(); + break; + } + } + } + catch (Exception ignored) { + } + }); + errorThread.start(); + return errorThread; + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioUtf8TestEchoServer.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioUtf8TestEchoServer.java new file mode 100644 index 000000000..baf179840 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioUtf8TestEchoServer.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Minimal STDIO echo server for testing UTF-8 encoding behavior in StdioClientTransport. + * + *

+ * This class is spawned as a subprocess with {@code -Dfile.encoding=ISO-8859-1} to + * simulate a non-UTF-8 default charset environment. It reads JSON-RPC messages from stdin + * and echoes the {@code params.message} value back to stdout, allowing the parent test to + * verify that multi-byte UTF-8 characters are preserved. + * + * @see StdioClientTransportTests#shouldHandleUtf8MessagesWithNonUtf8DefaultCharset + */ +public class StdioUtf8TestEchoServer { + + public static void main(String[] args) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + StringBuilder receivedMessage = new StringBuilder(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("\"echo\"")) { + int start = line.indexOf("\"message\":\"") + "\"message\":\"".length(); + int end = line.indexOf("\"", start); + if (start > 0 && end > start) { + receivedMessage.append(line, start, end); + } + String response = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"" + receivedMessage + "\"}\n"; + System.out.write(response.getBytes(StandardCharsets.UTF_8)); + System.out.flush(); + latch.countDown(); + break; + } + } + } + + latch.await(5, TimeUnit.SECONDS); + } + +}