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"); + } }