From 11408a9cfad354114d41a6cf854876b1776b2928 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 23 Mar 2025 15:48:08 -0700 Subject: [PATCH 1/3] fix: Enforce UTF-8 encoding for stdio transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change replaces the default system encoding with an explicit UTF8Encoding (without BOM) for both client and server transports. This ensures proper handling of Unicode characters, including Chinese characters and emoji. - Use UTF8Encoding explicitly for StreamReader and StreamWriter. - Add tests for Chinese characters ("上下文伺服器") and emoji (🔍🚀👍) to confirm the fix. Fixes #35. --- .../Logging/LoggerExtensions.cs | 35 ++++ .../Transport/StdioClientTransport.cs | 54 ++++-- .../Transport/StdioServerTransport.cs | 62 +++++- .../Transport/StdioServerTransportTests.cs | 180 +++++++++++++----- .../Utils/NonEndingTextReader.cs | 43 +++++ 5 files changed, 302 insertions(+), 72 deletions(-) create mode 100644 src/ModelContextProtocol/Logging/LoggerExtensions.cs create mode 100644 tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs diff --git a/src/ModelContextProtocol/Logging/LoggerExtensions.cs b/src/ModelContextProtocol/Logging/LoggerExtensions.cs new file mode 100644 index 00000000..ff8c84c9 --- /dev/null +++ b/src/ModelContextProtocol/Logging/LoggerExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; + +namespace ModelContextProtocol.Logging; + +/// +/// Extensions methods for ILogger instances used in MCP protocol. +/// +public static partial class LoggerExtensions +{ + /// + /// 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}")] + public 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. + public 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 = string.Join(" ", bytes.Select(b => $"{b:X2}")); + logger.TransportMessageBytes(endpointName, byteRepresentation); + } + } +} diff --git a/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs index 5479a306..d6af6ed2 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; @@ -20,6 +21,8 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOptions; private Process? _process; + private StreamWriter? _stdInWriter; + private StreamReader? _stdOutReader; private Task? _readTask; private CancellationTokenSource? _shutdownCts; private bool _processStarted; @@ -99,6 +102,13 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } _logger.TransportProcessStarted(EndpointName, _process.Id); _processStarted = true; + + // Create streams with explicit UTF-8 encoding to ensure proper Unicode character handling + // This is especially important for non-ASCII characters like Chinese text and emoji + var utf8Encoding = new UTF8Encoding(false); // No BOM + _stdInWriter = new StreamWriter(_process.StandardInput.BaseStream, utf8Encoding) { AutoFlush = true }; + _stdOutReader = new StreamReader(_process.StandardOutput.BaseStream, utf8Encoding); + _process.BeginErrorReadLine(); // Start reading messages in the background @@ -118,7 +128,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) /// public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) { - if (!IsConnected || _process?.HasExited == true) + if (!IsConnected || _process?.HasExited == true || _stdInWriter == null) { _logger.TransportNotConnected(EndpointName); throw new McpTransportException("Transport is not connected"); @@ -134,10 +144,11 @@ 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); - await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); + // Write the message followed by a newline using our UTF-8 writer + await _stdInWriter.WriteLineAsync(json).ConfigureAwait(false); + await _stdInWriter.FlushAsync(cancellationToken).ConfigureAwait(false); _logger.TransportSentMessage(EndpointName, id); } @@ -161,12 +172,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 && _stdOutReader != null) { _logger.TransportWaitingForMessage(EndpointName); - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + var line = await _stdOutReader.ReadLineAsync(cancellationToken).ConfigureAwait(false); if (line == null) { _logger.TransportEndOfStream(EndpointName); @@ -179,6 +188,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken) } _logger.TransportReceivedMessage(EndpointName, line); + _logger.TransportMessageBytesUtf8(EndpointName, line); await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false); } @@ -230,14 +240,28 @@ private async Task ProcessMessageAsync(string line, CancellationToken cancellati private async Task CleanupAsync(CancellationToken cancellationToken) { _logger.TransportCleaningUp(EndpointName); - if (_process != null && _processStarted && !_process.HasExited) + + if (_stdInWriter != null) { try { - // Try to close stdin to signal the process to exit _logger.TransportClosingStdin(EndpointName); - _process.StandardInput.Close(); + _stdInWriter.Close(); + } + catch (Exception ex) + { + _logger.TransportShutdownFailed(EndpointName, ex); + } + _stdInWriter = null; + } + + _stdOutReader = null; + + if (_process != null && _processStarted && !_process.HasExited) + { + try + { // Wait for the process to exit _logger.TransportWaitingForShutdown(EndpointName); diff --git a/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs b/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs index d63fe38e..28f99356 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; @@ -19,8 +20,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport 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 TextWriter _stdOutWriter; private Task? _readTask; private CancellationTokenSource? _shutdownCts; @@ -83,16 +84,57 @@ public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory = n _serverName = serverName; _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + + // Create console streams with explicit UTF-8 encoding to ensure proper Unicode character handling + // This is especially important for non-ASCII characters like Chinese text and emoji + var utf8Encoding = new UTF8Encoding(false); // No BOM + + // Get raw console streams and wrap them with UTF-8 encoding + Stream inputStream = Console.OpenStandardInput(); + Stream outputStream = Console.OpenStandardOutput(); + + _stdInReader = new StreamReader(inputStream, utf8Encoding); + _stdOutWriter = new StreamWriter(outputStream, utf8Encoding) { AutoFlush = true }; + } + + /// + /// 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, TextReader input, TextWriter output, ILoggerFactory? loggerFactory = null) + : base(loggerFactory) + { + Throw.IfNull(serverName); + Throw.IfNull(input); + Throw.IfNull(output); + + _serverName = serverName; + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + + _stdInReader = input; + _stdOutWriter = output; } /// 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; } @@ -116,9 +158,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); - await _stdout.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); - await _stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + await _stdOutWriter.WriteLineAsync(json).ConfigureAwait(false); + await _stdOutWriter.FlushAsync(cancellationToken).ConfigureAwait(false); _logger.TransportSentMessage(EndpointName, id); } @@ -146,7 +189,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 +203,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken) } _logger.TransportReceivedMessage(EndpointName, line); + _logger.TransportMessageBytesUtf8(EndpointName, line); try { diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs index 82ce8210..0e8801ea 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs @@ -1,10 +1,11 @@ -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.Tests.Utils; using ModelContextProtocol.Utils.Json; -using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; namespace ModelContextProtocol.Tests.Transport; @@ -57,36 +58,35 @@ 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) }; + // Use a reader that won't terminate + using var input = new NonEndingTextReader(TestContext.Current.CancellationToken); + using var output = new StringWriter(); + + await using var transport = new StdioServerTransport( + _serverOptions.ServerInfo.Name, + input, + 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 = output.ToString()?.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 +115,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 + using var input = new NonEndingTextReader(TestContext.Current.CancellationToken); + using var output = new StringWriter(); - 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, + 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"); + + // Write the message to the reader + input.WriteLine(json); + + 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 +155,83 @@ 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 input = new NonEndingTextReader(TestContext.Current.CancellationToken); + using var output = new StringWriter(); + + await using var transport = new StdioServerTransport( + _serverOptions.ServerInfo.Name, + input, + 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.GetStringBuilder().Clear(); + await transport.SendMessageAsync(chineseMessage, TestContext.Current.CancellationToken); + + // Verify Chinese characters preserved + var chineseResult = output.ToString().Trim(); + var expectedChinese = JsonSerializer.Serialize(chineseMessage, McpJsonUtilities.DefaultOptions); + Assert.Equal(expectedChinese, chineseResult); + Assert.Contains(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.GetStringBuilder().Clear(); + await transport.SendMessageAsync(emojiMessage, TestContext.Current.CancellationToken); + + // Verify emoji preserved - might be as either direct characters or escape sequences + var emojiResult = output.ToString().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"); + } } diff --git a/tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs b/tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs new file mode 100644 index 00000000..09398861 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs @@ -0,0 +1,43 @@ +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Utils; + +/// +/// A special TextReader that can be used in tests to simulate stdin without reaching EOF. +/// Particularly useful for testing transports that need to maintain an active connection. +/// +public class NonEndingTextReader(CancellationToken cancellationToken = default) : TextReader +{ + private readonly Channel _channel = Channel.CreateUnbounded(); + + public override Task ReadLineAsync() + { + return ReadLineAsync(cancellationToken).AsTask(); + } + + public override ValueTask ReadLineAsync(CancellationToken cancellationToken) + { + return _channel.Reader.ReadAsync(cancellationToken); + } + + public void WriteLine(string line) + { + _channel.Writer.TryWrite(line); + } + + public override void Close() + { + _channel.Writer.Complete(); + base.Close(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _channel.Writer.Complete(); + } + + base.Dispose(disposing); + } +} From d2852c08f31db8fe50850a1efa85838e8f986e5a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 24 Mar 2025 08:59:58 -0400 Subject: [PATCH 2/3] Remove relaxed JSON encoder --- .../Utils/Json/McpJsonUtilities.cs | 14 +++----------- .../Transport/StdioServerTransportTests.cs | 7 ++++--- 2 files changed, 7 insertions(+), 14 deletions(-) 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 0e8801ea..94adb012 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Transport; using ModelContextProtocol.Protocol.Types; @@ -193,11 +194,11 @@ public async Task SendMessageAsync_Should_Preserve_Unicode_Characters() output.GetStringBuilder().Clear(); await transport.SendMessageAsync(chineseMessage, TestContext.Current.CancellationToken); - // Verify Chinese characters preserved + // Verify Chinese characters preserved but encoded var chineseResult = output.ToString().Trim(); var expectedChinese = JsonSerializer.Serialize(chineseMessage, McpJsonUtilities.DefaultOptions); Assert.Equal(expectedChinese, chineseResult); - Assert.Contains(chineseText, chineseResult); + Assert.Contains(JsonSerializer.Serialize(chineseText), chineseResult); // Test 2: Emoji (non-BMP Unicode using surrogate pairs) var emojiText = "🔍 🚀 👍"; // Magnifying glass, rocket, thumbs up From f47d99fa4a37ec38df4fc84a2fe166306f5792c0 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 24 Mar 2025 09:14:56 -0400 Subject: [PATCH 3/3] Address PR feedback --- src/ModelContextProtocol/Logging/Log.cs | 33 ++++++- .../Logging/LoggerExtensions.cs | 35 ------- .../Transport/StdioClientTransport.cs | 92 ++++++++++--------- .../Transport/StdioServerTransport.cs | 59 ++++++------ .../Transport/StdioServerTransportTests.cs | 35 ++++--- .../Utils/NonEndingTextReader.cs | 43 --------- 6 files changed, 127 insertions(+), 170 deletions(-) delete mode 100644 src/ModelContextProtocol/Logging/LoggerExtensions.cs delete mode 100644 tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs 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/Logging/LoggerExtensions.cs b/src/ModelContextProtocol/Logging/LoggerExtensions.cs deleted file mode 100644 index ff8c84c9..00000000 --- a/src/ModelContextProtocol/Logging/LoggerExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace ModelContextProtocol.Logging; - -/// -/// Extensions methods for ILogger instances used in MCP protocol. -/// -public static partial class LoggerExtensions -{ - /// - /// 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}")] - public 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. - public 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 = string.Join(" ", bytes.Select(b => $"{b:X2}")); - logger.TransportMessageBytes(endpointName, byteRepresentation); - } - } -} diff --git a/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs b/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs index d6af6ed2..49e43301 100644 --- a/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs @@ -21,8 +21,6 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOptions; private Process? _process; - private StreamWriter? _stdInWriter; - private StreamReader? _stdOutReader; private Task? _readTask; private CancellationTokenSource? _shutdownCts; private bool _processStarted; @@ -62,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, @@ -71,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)) @@ -95,19 +100,34 @@ 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; - - // Create streams with explicit UTF-8 encoding to ensure proper Unicode character handling - // This is especially important for non-ASCII characters like Chinese text and emoji - var utf8Encoding = new UTF8Encoding(false); // No BOM - _stdInWriter = new StreamWriter(_process.StandardInput.BaseStream, utf8Encoding) { AutoFlush = true }; - _stdOutReader = new StreamReader(_process.StandardOutput.BaseStream, utf8Encoding); _process.BeginErrorReadLine(); @@ -128,7 +148,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) /// public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) { - if (!IsConnected || _process?.HasExited == true || _stdInWriter == null) + if (!IsConnected || _process?.HasExited == true) { _logger.TransportNotConnected(EndpointName); throw new McpTransportException("Transport is not connected"); @@ -147,8 +167,8 @@ public override async Task SendMessageAsync(IJsonRpcMessage message, Cancellatio _logger.TransportMessageBytesUtf8(EndpointName, json); // Write the message followed by a newline using our UTF-8 writer - await _stdInWriter.WriteLineAsync(json).ConfigureAwait(false); - await _stdInWriter.FlushAsync(cancellationToken).ConfigureAwait(false); + await _process!.StandardInput.WriteLineAsync(json).ConfigureAwait(false); + await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); _logger.TransportSentMessage(EndpointName, id); } @@ -172,10 +192,10 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken) { _logger.TransportEnteringReadMessagesLoop(EndpointName); - while (!cancellationToken.IsCancellationRequested && !_process!.HasExited && _stdOutReader != null) + while (!cancellationToken.IsCancellationRequested && !_process!.HasExited) { _logger.TransportWaitingForMessage(EndpointName); - var line = await _stdOutReader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + var line = await _process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false); if (line == null) { _logger.TransportEndOfStream(EndpointName); @@ -240,25 +260,8 @@ private async Task ProcessMessageAsync(string line, CancellationToken cancellati private async Task CleanupAsync(CancellationToken cancellationToken) { _logger.TransportCleaningUp(EndpointName); - - if (_stdInWriter != null) - { - try - { - _logger.TransportClosingStdin(EndpointName); - _stdInWriter.Close(); - } - catch (Exception ex) - { - _logger.TransportShutdownFailed(EndpointName, ex); - } - _stdInWriter = null; - } - - _stdOutReader = null; - - if (_process != null && _processStarted && !_process.HasExited) + if (_process is Process process && _processStarted && !process.HasExited) { try { @@ -267,15 +270,17 @@ private async Task CleanupAsync(CancellationToken cancellationToken) // 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) @@ -285,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 28f99356..9b82d4ea 100644 --- a/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs +++ b/src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs @@ -16,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 _stdInReader; - private readonly TextWriter _stdOutWriter; + private readonly Stream _stdOutStream; private Task? _readTask; private CancellationTokenSource? _shutdownCts; @@ -85,24 +87,17 @@ public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory = n _serverName = serverName; _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - // Create console streams with explicit UTF-8 encoding to ensure proper Unicode character handling - // This is especially important for non-ASCII characters like Chinese text and emoji - var utf8Encoding = new UTF8Encoding(false); // No BOM - // Get raw console streams and wrap them with UTF-8 encoding - Stream inputStream = Console.OpenStandardInput(); - Stream outputStream = Console.OpenStandardOutput(); - - _stdInReader = new StreamReader(inputStream, utf8Encoding); - _stdOutWriter = new StreamWriter(outputStream, utf8Encoding) { AutoFlush = true }; + _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. + /// The input TextReader to use. + /// The output TextWriter to use. /// Optional logger factory used for logging employed by the transport. /// is . /// @@ -110,18 +105,18 @@ public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory = n /// This constructor is useful for testing scenarios where you want to redirect input/output. /// /// - public StdioServerTransport(string serverName, TextReader input, TextWriter output, ILoggerFactory? loggerFactory = null) + public StdioServerTransport(string serverName, Stream stdinStream, Stream stdoutStream, ILoggerFactory? loggerFactory = null) : base(loggerFactory) { Throw.IfNull(serverName); - Throw.IfNull(input); - Throw.IfNull(output); + Throw.IfNull(stdinStream); + Throw.IfNull(stdoutStream); _serverName = serverName; _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - _stdInReader = input; - _stdOutWriter = output; + _stdInReader = new StreamReader(stdinStream, Encoding.UTF8); + _stdOutStream = stdoutStream; } /// @@ -156,12 +151,11 @@ public override async Task SendMessageAsync(IJsonRpcMessage message, Cancellatio try { - var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo()); - _logger.TransportSendingMessage(EndpointName, id, json); - _logger.TransportMessageBytesUtf8(EndpointName, json); + _logger.TransportSendingMessage(EndpointName, id); - await _stdOutWriter.WriteLineAsync(json).ConfigureAwait(false); - await _stdOutWriter.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); } @@ -251,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) { @@ -279,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/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs index 94adb012..9a6cd64b 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs @@ -1,11 +1,11 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Transport; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; using ModelContextProtocol.Utils.Json; +using System.IO.Pipelines; +using System.Text; using System.Text.Json; namespace ModelContextProtocol.Tests.Transport; @@ -62,13 +62,11 @@ public async Task StartListeningAsync_Should_Set_Connected_State() [Fact] public async Task SendMessageAsync_Should_Send_Message() { - // Use a reader that won't terminate - using var input = new NonEndingTextReader(TestContext.Current.CancellationToken); - using var output = new StringWriter(); + using var output = new MemoryStream(); await using var transport = new StdioServerTransport( _serverOptions.ServerInfo.Name, - input, + new Pipe().Reader.AsStream(), output, NullLoggerFactory.Instance); @@ -84,7 +82,7 @@ public async Task SendMessageAsync_Should_Send_Message() await transport.SendMessageAsync(message, TestContext.Current.CancellationToken); - var result = output.ToString()?.Trim(); + var result = Encoding.UTF8.GetString(output.ToArray()).Trim(); var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions); Assert.Equal(expected, result); @@ -117,13 +115,13 @@ public async Task ReadMessagesAsync_Should_Read_Messages() var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions); // Use a reader that won't terminate - using var input = new NonEndingTextReader(TestContext.Current.CancellationToken); - using var output = new StringWriter(); + Pipe pipe = new(); + using var input = pipe.Reader.AsStream(); await using var transport = new StdioServerTransport( _serverOptions.ServerInfo.Name, input, - output, + Stream.Null, NullLoggerFactory.Instance); await transport.StartListeningAsync(TestContext.Current.CancellationToken); @@ -135,7 +133,7 @@ public async Task ReadMessagesAsync_Should_Read_Messages() Assert.True(transport.IsConnected, "Transport should be connected after StartListeningAsync"); // Write the message to the reader - input.WriteLine(json); + await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\n"), TestContext.Current.CancellationToken); var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken); @@ -161,12 +159,11 @@ public async Task CleanupAsync_Should_Cleanup_Resources() public async Task SendMessageAsync_Should_Preserve_Unicode_Characters() { // Use a reader that won't terminate - using var input = new NonEndingTextReader(TestContext.Current.CancellationToken); - using var output = new StringWriter(); + using var output = new MemoryStream(); await using var transport = new StdioServerTransport( _serverOptions.ServerInfo.Name, - input, + new Pipe().Reader.AsStream(), output, NullLoggerFactory.Instance); @@ -191,11 +188,11 @@ public async Task SendMessageAsync_Should_Preserve_Unicode_Characters() }; // Clear output and send message - output.GetStringBuilder().Clear(); + output.SetLength(0); await transport.SendMessageAsync(chineseMessage, TestContext.Current.CancellationToken); // Verify Chinese characters preserved but encoded - var chineseResult = output.ToString().Trim(); + 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); @@ -213,11 +210,11 @@ public async Task SendMessageAsync_Should_Preserve_Unicode_Characters() }; // Clear output and send message - output.GetStringBuilder().Clear(); + output.SetLength(0); await transport.SendMessageAsync(emojiMessage, TestContext.Current.CancellationToken); // Verify emoji preserved - might be as either direct characters or escape sequences - var emojiResult = output.ToString().Trim(); + var emojiResult = Encoding.UTF8.GetString(output.ToArray()).Trim(); var expectedEmoji = JsonSerializer.Serialize(emojiMessage, McpJsonUtilities.DefaultOptions); Assert.Equal(expectedEmoji, emojiResult); diff --git a/tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs b/tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs deleted file mode 100644 index 09398861..00000000 --- a/tests/ModelContextProtocol.Tests/Utils/NonEndingTextReader.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Threading.Channels; - -namespace ModelContextProtocol.Tests.Utils; - -/// -/// A special TextReader that can be used in tests to simulate stdin without reaching EOF. -/// Particularly useful for testing transports that need to maintain an active connection. -/// -public class NonEndingTextReader(CancellationToken cancellationToken = default) : TextReader -{ - private readonly Channel _channel = Channel.CreateUnbounded(); - - public override Task ReadLineAsync() - { - return ReadLineAsync(cancellationToken).AsTask(); - } - - public override ValueTask ReadLineAsync(CancellationToken cancellationToken) - { - return _channel.Reader.ReadAsync(cancellationToken); - } - - public void WriteLine(string line) - { - _channel.Writer.TryWrite(line); - } - - public override void Close() - { - _channel.Writer.Complete(); - base.Close(); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _channel.Writer.Complete(); - } - - base.Dispose(disposing); - } -}