diff --git a/src/ModelContextProtocol/Logging/Log.cs b/src/ModelContextProtocol/Logging/Log.cs
index 7a240eee..b49b4cb5 100644
--- a/src/ModelContextProtocol/Logging/Log.cs
+++ b/src/ModelContextProtocol/Logging/Log.cs
@@ -114,7 +114,7 @@ internal static partial class Log
internal static partial void TransportNotConnected(this ILogger logger, string endpointName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport sending message for {endpointName} with ID {messageId}, JSON {json}")]
- internal static partial void TransportSendingMessage(this ILogger logger, string endpointName, string messageId, string json);
+ internal static partial void TransportSendingMessage(this ILogger logger, string endpointName, string messageId, string? json = null);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport message sent for {endpointName} with ID {messageId}")]
internal static partial void TransportSentMessage(this ILogger logger, string endpointName, string messageId);
@@ -347,4 +347,35 @@ public static partial void SSETransportPostNotAccepted(
string endpointName,
string messageId,
string responseContent);
+
+ ///
+ /// Logs the byte representation of a message in UTF-8 encoding.
+ ///
+ /// The logger to use.
+ /// The name of the endpoint.
+ /// The byte representation as a hex string.
+ [LoggerMessage(EventId = 39000, Level = LogLevel.Trace, Message = "Transport {EndpointName}: Message bytes (UTF-8): {ByteRepresentation}")]
+ private static partial void TransportMessageBytes(this ILogger logger, string endpointName, string byteRepresentation);
+
+ ///
+ /// Logs the byte representation of a message for diagnostic purposes.
+ /// This is useful for diagnosing encoding issues with non-ASCII characters.
+ ///
+ /// The logger to use.
+ /// The name of the endpoint.
+ /// The message to log bytes for.
+ internal static void TransportMessageBytesUtf8(this ILogger logger, string endpointName, string message)
+ {
+ if (logger.IsEnabled(LogLevel.Trace))
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(message);
+ var byteRepresentation =
+#if NET
+ Convert.ToHexString(bytes);
+#else
+ BitConverter.ToString(bytes).Replace("-", " ");
+#endif
+ logger.TransportMessageBytes(endpointName, byteRepresentation);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs
index 5479a306..49e43301 100644
--- a/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs
+++ b/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs
@@ -1,12 +1,13 @@
-using System.Diagnostics;
-using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Configuration;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
namespace ModelContextProtocol.Protocol.Transport;
@@ -59,6 +60,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
_shutdownCts = new CancellationTokenSource();
+ UTF8Encoding noBomUTF8 = new(encoderShouldEmitUTF8Identifier: false);
+
var startInfo = new ProcessStartInfo
{
FileName = _options.Command,
@@ -68,6 +71,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = _options.WorkingDirectory ?? Environment.CurrentDirectory,
+ StandardOutputEncoding = noBomUTF8,
+ StandardErrorEncoding = noBomUTF8,
+#if NET
+ StandardInputEncoding = noBomUTF8,
+#endif
};
if (!string.IsNullOrWhiteSpace(_options.Arguments))
@@ -92,13 +100,35 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// Set up error logging
_process.ErrorDataReceived += (sender, args) => _logger.TransportError(EndpointName, args.Data ?? "(no data)");
- if (!_process.Start())
+ // We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
+ // we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
+ // StandardInputEncoding doesn't exist on .NET Framework; instead, it always picks
+ // up the encoding from Console.InputEncoding. As such, when not targeting .NET Core,
+ // we temporarily change Console.InputEncoding to no-BOM UTF-8 around the Process.Start
+ // call, to ensure it picks up the correct encoding.
+#if NET
+ _processStarted = _process.Start();
+#else
+ Encoding originalInputEncoding = Console.InputEncoding;
+ try
+ {
+ Console.InputEncoding = noBomUTF8;
+ _processStarted = _process.Start();
+ }
+ finally
+ {
+ Console.InputEncoding = originalInputEncoding;
+ }
+#endif
+
+ if (!_processStarted)
{
_logger.TransportProcessStartFailed(EndpointName);
throw new McpTransportException("Failed to start MCP server process");
}
+
_logger.TransportProcessStarted(EndpointName, _process.Id);
- _processStarted = true;
+
_process.BeginErrorReadLine();
// Start reading messages in the background
@@ -134,9 +164,10 @@ public override async Task SendMessageAsync(IJsonRpcMessage message, Cancellatio
{
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo());
_logger.TransportSendingMessage(EndpointName, id, json);
+ _logger.TransportMessageBytesUtf8(EndpointName, json);
- // Write the message followed by a newline
- await _process!.StandardInput.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
+ // Write the message followed by a newline using our UTF-8 writer
+ await _process!.StandardInput.WriteLineAsync(json).ConfigureAwait(false);
await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
_logger.TransportSentMessage(EndpointName, id);
@@ -161,12 +192,10 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
{
_logger.TransportEnteringReadMessagesLoop(EndpointName);
- using var reader = _process!.StandardOutput;
-
- while (!cancellationToken.IsCancellationRequested && !_process.HasExited)
+ while (!cancellationToken.IsCancellationRequested && !_process!.HasExited)
{
_logger.TransportWaitingForMessage(EndpointName);
- var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ var line = await _process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line == null)
{
_logger.TransportEndOfStream(EndpointName);
@@ -179,6 +208,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
}
_logger.TransportReceivedMessage(EndpointName, line);
+ _logger.TransportMessageBytesUtf8(EndpointName, line);
await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false);
}
@@ -230,28 +260,27 @@ private async Task ProcessMessageAsync(string line, CancellationToken cancellati
private async Task CleanupAsync(CancellationToken cancellationToken)
{
_logger.TransportCleaningUp(EndpointName);
- if (_process != null && _processStarted && !_process.HasExited)
+
+ if (_process is Process process && _processStarted && !process.HasExited)
{
try
{
- // Try to close stdin to signal the process to exit
- _logger.TransportClosingStdin(EndpointName);
- _process.StandardInput.Close();
-
// Wait for the process to exit
_logger.TransportWaitingForShutdown(EndpointName);
// Kill the while process tree because the process may spawn child processes
// and Node.js does not kill its children when it exits properly
- _process.KillTree(_options.ShutdownTimeout);
+ process.KillTree(_options.ShutdownTimeout);
}
catch (Exception ex)
{
_logger.TransportShutdownFailed(EndpointName, ex);
}
-
- _process.Dispose();
- _process = null;
+ finally
+ {
+ process.Dispose();
+ _process = null;
+ }
}
if (_shutdownCts is { } shutdownCts)
@@ -261,29 +290,30 @@ private async Task CleanupAsync(CancellationToken cancellationToken)
_shutdownCts = null;
}
- if (_readTask != null)
+ if (_readTask is Task readTask)
{
try
{
_logger.TransportWaitingForReadTask(EndpointName);
- await _readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
+ await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
_logger.TransportCleanupReadTaskTimeout(EndpointName);
- // Continue with cleanup
}
catch (OperationCanceledException)
{
_logger.TransportCleanupReadTaskCancelled(EndpointName);
- // Ignore cancellation
}
catch (Exception ex)
{
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
}
- _readTask = null;
- _logger.TransportReadTaskCleanedUp(EndpointName);
+ finally
+ {
+ _logger.TransportReadTaskCleanedUp(EndpointName);
+ _readTask = null;
+ }
}
SetConnected(false);
diff --git a/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs b/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs
index d63fe38e..9b82d4ea 100644
--- a/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs
+++ b/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs
@@ -1,12 +1,13 @@
-using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Microsoft.Extensions.Options;
+using System.Text;
+using System.Text.Json;
namespace ModelContextProtocol.Protocol.Transport;
@@ -15,12 +16,14 @@ namespace ModelContextProtocol.Protocol.Transport;
///
public sealed class StdioServerTransport : TransportBase, IServerTransport
{
+ private static readonly byte[] s_newlineBytes = "\n"u8.ToArray();
+
private readonly string _serverName;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonOptions = McpJsonUtilities.DefaultOptions;
- private readonly TextReader _stdin = Console.In;
- private readonly TextWriter _stdout = Console.Out;
+ private readonly TextReader _stdInReader;
+ private readonly Stream _stdOutStream;
private Task? _readTask;
private CancellationTokenSource? _shutdownCts;
@@ -83,16 +86,50 @@ public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory = n
_serverName = serverName;
_logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance;
+
+ // Get raw console streams and wrap them with UTF-8 encoding
+ _stdInReader = new StreamReader(Console.OpenStandardInput(), Encoding.UTF8);
+ _stdOutStream = new BufferedStream(Console.OpenStandardOutput());
+ }
+
+ ///
+ /// Initializes a new instance of the class with explicit input/output streams.
+ ///
+ /// The name of the server.
+ /// The input TextReader to use.
+ /// The output TextWriter to use.
+ /// Optional logger factory used for logging employed by the transport.
+ /// is .
+ ///
+ ///
+ /// This constructor is useful for testing scenarios where you want to redirect input/output.
+ ///
+ ///
+ public StdioServerTransport(string serverName, Stream stdinStream, Stream stdoutStream, ILoggerFactory? loggerFactory = null)
+ : base(loggerFactory)
+ {
+ Throw.IfNull(serverName);
+ Throw.IfNull(stdinStream);
+ Throw.IfNull(stdoutStream);
+
+ _serverName = serverName;
+ _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance;
+
+ _stdInReader = new StreamReader(stdinStream, Encoding.UTF8);
+ _stdOutStream = stdoutStream;
}
///
public Task StartListeningAsync(CancellationToken cancellationToken = default)
{
+ _logger.LogDebug("Starting StdioServerTransport listener for {EndpointName}", EndpointName);
+
_shutdownCts = new CancellationTokenSource();
_readTask = Task.Run(async () => await ReadMessagesAsync(_shutdownCts.Token).ConfigureAwait(false), CancellationToken.None);
SetConnected(true);
+ _logger.LogDebug("StdioServerTransport now connected for {EndpointName}", EndpointName);
return Task.CompletedTask;
}
@@ -114,11 +151,11 @@ public override async Task SendMessageAsync(IJsonRpcMessage message, Cancellatio
try
{
- var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo());
- _logger.TransportSendingMessage(EndpointName, id, json);
+ _logger.TransportSendingMessage(EndpointName, id);
- await _stdout.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
- await _stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
+ await JsonSerializer.SerializeAsync(_stdOutStream, message, _jsonOptions.GetTypeInfo(), cancellationToken).ConfigureAwait(false);
+ await _stdOutStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
+ await _stdOutStream.FlushAsync(cancellationToken).ConfigureAwait(false);;
_logger.TransportSentMessage(EndpointName, id);
}
@@ -146,7 +183,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
{
_logger.TransportWaitingForMessage(EndpointName);
- var reader = _stdin;
+ var reader = _stdInReader;
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line == null)
{
@@ -160,6 +197,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
}
_logger.TransportReceivedMessage(EndpointName, line);
+ _logger.TransportMessageBytesUtf8(EndpointName, line);
try
{
@@ -207,19 +245,20 @@ private async Task CleanupAsync(CancellationToken cancellationToken)
{
_logger.TransportCleaningUp(EndpointName);
- if (_shutdownCts != null)
+ if (_shutdownCts is { } shutdownCts)
{
- await _shutdownCts.CancelAsync().ConfigureAwait(false);
- _shutdownCts.Dispose();
+ await shutdownCts.CancelAsync().ConfigureAwait(false);
+ shutdownCts.Dispose();
+
_shutdownCts = null;
}
- if (_readTask != null)
+ if (_readTask is { } readTask)
{
try
{
_logger.TransportWaitingForReadTask(EndpointName);
- await _readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
+ await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
@@ -235,10 +274,16 @@ private async Task CleanupAsync(CancellationToken cancellationToken)
{
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
}
- _readTask = null;
- _logger.TransportReadTaskCleanedUp(EndpointName);
+ finally
+ {
+ _logger.TransportReadTaskCleanedUp(EndpointName);
+ _readTask = null;
+ }
}
+ _stdInReader?.Dispose();
+ _stdOutStream?.Dispose();
+
SetConnected(false);
_logger.TransportCleanedUp(EndpointName);
}
diff --git a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs
index 7a868a6d..78f98483 100644
--- a/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs
@@ -1,7 +1,6 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
using System.Diagnostics.CodeAnalysis;
-using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -26,11 +25,6 @@ public static partial class McpJsonUtilities
/// - Enables string-based enum serialization as implemented by .
/// - Enables as the default ignore condition for properties.
/// - Enables as the default number handling for number types.
- /// -
- /// Enables when escaping JSON strings.
- /// Consuming applications must ensure that JSON outputs are adequately escaped before embedding in other document formats,
- /// such as HTML and XML.
- ///
///
///
///
@@ -58,15 +52,13 @@ private static JsonSerializerOptions CreateDefaultOptions()
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
}
else
{
+ // Keep in sync with any additional settings above beyond what's in JsonContext below.
options = new(JsonContext.Default.Options)
{
- // Compile-time encoder setting not yet available
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
}
@@ -77,7 +69,8 @@ private static JsonSerializerOptions CreateDefaultOptions()
internal static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) =>
(JsonTypeInfo)options.GetTypeInfo(typeof(T));
- internal static JsonElement DefaultMcpToolSchema = ParseJsonElement("{\"type\":\"object\"}"u8);
+ internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8);
+
internal static bool IsValidMcpToolSchema(JsonElement element)
{
if (element.ValueKind is not JsonValueKind.Object)
@@ -129,5 +122,4 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json)
Utf8JsonReader reader = new(utf8Json);
return JsonElement.ParseValue(ref reader);
}
-
}
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
index 82ce8210..9a6cd64b 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
@@ -1,10 +1,12 @@
-using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
-using Microsoft.Extensions.Logging.Abstractions;
+using System.IO.Pipelines;
+using System.Text;
+using System.Text.Json;
namespace ModelContextProtocol.Tests.Transport;
@@ -57,36 +59,33 @@ public async Task StartListeningAsync_Should_Set_Connected_State()
Assert.True(transport.IsConnected);
}
- [Fact(Skip = "https://github.com/modelcontextprotocol/csharp-sdk/issues/1")]
+ [Fact]
public async Task SendMessageAsync_Should_Send_Message()
{
- TextReader oldIn = Console.In;
- TextWriter oldOut = Console.Out;
- try
- {
- using var output = new StringWriter();
-
- Console.SetIn(new StringReader(""));
- Console.SetOut(output);
-
- await using var transport = new StdioServerTransport(_serverOptions, NullLoggerFactory.Instance);
- await transport.StartListeningAsync(TestContext.Current.CancellationToken);
-
- var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) };
+ using var output = new MemoryStream();
+
+ await using var transport = new StdioServerTransport(
+ _serverOptions.ServerInfo.Name,
+ new Pipe().Reader.AsStream(),
+ output,
+ NullLoggerFactory.Instance);
+
+ await transport.StartListeningAsync(TestContext.Current.CancellationToken);
+
+ // Ensure transport is fully initialized
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+
+ // Verify transport is connected
+ Assert.True(transport.IsConnected, "Transport should be connected after StartListeningAsync");
+ var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) };
- await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
+ await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
- var result = output.ToString()?.Trim();
- var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
+ var result = Encoding.UTF8.GetString(output.ToArray()).Trim();
+ var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
- Assert.Equal(expected, result);
- }
- finally
- {
- Console.SetOut(oldOut);
- Console.SetIn(oldIn);
- }
+ Assert.Equal(expected, result);
}
[Fact]
@@ -115,29 +114,34 @@ public async Task ReadMessagesAsync_Should_Read_Messages()
var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) };
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
- TextReader oldIn = Console.In;
- TextWriter oldOut = Console.Out;
- try
- {
- Console.SetIn(new StringReader(json));
- Console.SetOut(new StringWriter());
-
- await using var transport = new StdioServerTransport(_serverOptions);
- await transport.StartListeningAsync(TestContext.Current.CancellationToken);
-
- var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
+ // Use a reader that won't terminate
+ Pipe pipe = new();
+ using var input = pipe.Reader.AsStream();
- Assert.True(canRead, "Nothing to read here from transport message reader");
- Assert.True(transport.MessageReader.TryPeek(out var readMessage));
- Assert.NotNull(readMessage);
- Assert.IsType(readMessage);
- Assert.Equal(44, ((JsonRpcRequest)readMessage).Id.AsNumber);
- }
- finally
- {
- Console.SetOut(oldOut);
- Console.SetIn(oldIn);
- }
+ await using var transport = new StdioServerTransport(
+ _serverOptions.ServerInfo.Name,
+ input,
+ Stream.Null,
+ NullLoggerFactory.Instance);
+
+ await transport.StartListeningAsync(TestContext.Current.CancellationToken);
+
+ // Ensure transport is fully initialized
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+
+ // Verify transport is connected
+ Assert.True(transport.IsConnected, "Transport should be connected after StartListeningAsync");
+
+ // Write the message to the reader
+ await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\n"), TestContext.Current.CancellationToken);
+
+ var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(canRead, "Nothing to read here from transport message reader");
+ Assert.True(transport.MessageReader.TryPeek(out var readMessage));
+ Assert.NotNull(readMessage);
+ Assert.IsType(readMessage);
+ Assert.Equal(44, ((JsonRpcRequest)readMessage).Id.AsNumber);
}
[Fact]
@@ -150,4 +154,82 @@ public async Task CleanupAsync_Should_Cleanup_Resources()
Assert.False(transport.IsConnected);
}
+
+ [Fact]
+ public async Task SendMessageAsync_Should_Preserve_Unicode_Characters()
+ {
+ // Use a reader that won't terminate
+ using var output = new MemoryStream();
+
+ await using var transport = new StdioServerTransport(
+ _serverOptions.ServerInfo.Name,
+ new Pipe().Reader.AsStream(),
+ output,
+ NullLoggerFactory.Instance);
+
+ await transport.StartListeningAsync(TestContext.Current.CancellationToken);
+
+ // Ensure transport is fully initialized
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+
+ // Verify transport is connected
+ Assert.True(transport.IsConnected, "Transport should be connected after StartListeningAsync");
+
+ // Test 1: Chinese characters (BMP Unicode)
+ var chineseText = "上下文伺服器"; // "Context Server" in Chinese
+ var chineseMessage = new JsonRpcRequest
+ {
+ Method = "test",
+ Id = RequestId.FromNumber(44),
+ Params = new Dictionary
+ {
+ ["text"] = JsonSerializer.SerializeToElement(chineseText)
+ }
+ };
+
+ // Clear output and send message
+ output.SetLength(0);
+ await transport.SendMessageAsync(chineseMessage, TestContext.Current.CancellationToken);
+
+ // Verify Chinese characters preserved but encoded
+ var chineseResult = Encoding.UTF8.GetString(output.ToArray()).Trim();
+ var expectedChinese = JsonSerializer.Serialize(chineseMessage, McpJsonUtilities.DefaultOptions);
+ Assert.Equal(expectedChinese, chineseResult);
+ Assert.Contains(JsonSerializer.Serialize(chineseText), chineseResult);
+
+ // Test 2: Emoji (non-BMP Unicode using surrogate pairs)
+ var emojiText = "🔍 🚀 👍"; // Magnifying glass, rocket, thumbs up
+ var emojiMessage = new JsonRpcRequest
+ {
+ Method = "test",
+ Id = RequestId.FromNumber(45),
+ Params = new Dictionary
+ {
+ ["text"] = JsonSerializer.SerializeToElement(emojiText)
+ }
+ };
+
+ // Clear output and send message
+ output.SetLength(0);
+ await transport.SendMessageAsync(emojiMessage, TestContext.Current.CancellationToken);
+
+ // Verify emoji preserved - might be as either direct characters or escape sequences
+ var emojiResult = Encoding.UTF8.GetString(output.ToArray()).Trim();
+ var expectedEmoji = JsonSerializer.Serialize(emojiMessage, McpJsonUtilities.DefaultOptions);
+ Assert.Equal(expectedEmoji, emojiResult);
+
+ // Verify surrogate pairs in different possible formats
+ // Magnifying glass emoji: 🔍 (U+1F50D)
+ bool magnifyingGlassFound =
+ emojiResult.Contains("🔍") ||
+ emojiResult.Contains("\\ud83d\\udd0d", StringComparison.OrdinalIgnoreCase);
+
+ // Rocket emoji: 🚀 (U+1F680)
+ bool rocketFound =
+ emojiResult.Contains("🚀") ||
+ emojiResult.Contains("\\ud83d\\ude80", StringComparison.OrdinalIgnoreCase);
+
+ Assert.True(magnifyingGlassFound, "Magnifying glass emoji not found in result");
+ Assert.True(rocketFound, "Rocket emoji not found in result");
+ }
}