Fix stdio encoding issue: Enforce explicit UTF-8 for correct Unicode handling (#73)
* fix: Enforce UTF-8 encoding for stdio transport
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.
* Remove relaxed JSON encoder
* Address PR feedback
---------
Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
parent
96ba2fa076
commit
36d5019de0
5 changed files with 285 additions and 105 deletions
|
|
@ -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 @@ internal static partial class Log
|
|||
string endpointName,
|
||||
string messageId,
|
||||
string responseContent);
|
||||
|
||||
/// <summary>
|
||||
/// Logs the byte representation of a message in UTF-8 encoding.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger to use.</param>
|
||||
/// <param name="endpointName">The name of the endpoint.</param>
|
||||
/// <param name="byteRepresentation">The byte representation as a hex string.</param>
|
||||
[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);
|
||||
|
||||
/// <summary>
|
||||
/// Logs the byte representation of a message for diagnostic purposes.
|
||||
/// This is useful for diagnosing encoding issues with non-ASCII characters.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger to use.</param>
|
||||
/// <param name="endpointName">The name of the endpoint.</param>
|
||||
/// <param name="message">The message to log bytes for.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
|
||||
_shutdownCts = new CancellationTokenSource();
|
||||
|
||||
UTF8Encoding noBomUTF8 = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _options.Command,
|
||||
|
|
@ -68,6 +71,11 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
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 sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
// 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 sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
{
|
||||
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
||||
_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 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
{
|
||||
_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 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
}
|
||||
|
||||
_logger.TransportReceivedMessage(EndpointName, line);
|
||||
_logger.TransportMessageBytesUtf8(EndpointName, line);
|
||||
|
||||
await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
|
@ -230,28 +260,27 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
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 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
|
|||
_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);
|
||||
|
|
|
|||
|
|
@ -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;
|
|||
/// </summary>
|
||||
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 sealed class StdioServerTransport : TransportBase, IServerTransport
|
|||
|
||||
_serverName = serverName;
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? 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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StdioServerTransport"/> class with explicit input/output streams.
|
||||
/// </summary>
|
||||
/// <param name="serverName">The name of the server.</param>
|
||||
/// <param name="stdinStream">The input TextReader to use.</param>
|
||||
/// <param name="stdoutStream">The output TextWriter to use.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used for logging employed by the transport.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This constructor is useful for testing scenarios where you want to redirect input/output.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<StdioClientTransport>() ?? NullLogger.Instance;
|
||||
|
||||
_stdInReader = new StreamReader(stdinStream, Encoding.UTF8);
|
||||
_stdOutStream = stdoutStream;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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 sealed class StdioServerTransport : TransportBase, IServerTransport
|
|||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
||||
_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<IJsonRpcMessage>(), 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 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
|||
{
|
||||
_logger.TransportWaitingForMessage(EndpointName);
|
||||
|
||||
var reader = _stdin;
|
||||
var reader = _stdInReader;
|
||||
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line == null)
|
||||
{
|
||||
|
|
@ -160,6 +197,7 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
|||
}
|
||||
|
||||
_logger.TransportReceivedMessage(EndpointName, line);
|
||||
_logger.TransportMessageBytesUtf8(EndpointName, line);
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -207,19 +245,20 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
|||
{
|
||||
_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 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
|||
{
|
||||
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
|
||||
}
|
||||
_readTask = null;
|
||||
_logger.TransportReadTaskCleanedUp(EndpointName);
|
||||
finally
|
||||
{
|
||||
_logger.TransportReadTaskCleanedUp(EndpointName);
|
||||
_readTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
_stdInReader?.Dispose();
|
||||
_stdOutStream?.Dispose();
|
||||
|
||||
SetConnected(false);
|
||||
_logger.TransportCleanedUp(EndpointName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// <item>Enables string-based enum serialization as implemented by <see cref="JsonStringEnumConverter"/>.</item>
|
||||
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
|
||||
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
|
||||
/// <item>
|
||||
/// Enables <see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> 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.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
|
@ -58,15 +52,13 @@ public static partial class McpJsonUtilities
|
|||
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 @@ public static partial class McpJsonUtilities
|
|||
internal static JsonTypeInfo<T> GetTypeInfo<T>(this JsonSerializerOptions options) =>
|
||||
(JsonTypeInfo<T>)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 @@ public static partial class McpJsonUtilities
|
|||
Utf8JsonReader reader = new(utf8Json);
|
||||
return JsonElement.ParseValue(ref reader);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 class StdioServerTransportTests
|
|||
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();
|
||||
using var output = new MemoryStream();
|
||||
|
||||
Console.SetIn(new StringReader(""));
|
||||
Console.SetOut(output);
|
||||
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");
|
||||
|
||||
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) };
|
||||
|
||||
var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) };
|
||||
await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
|
||||
|
||||
var result = Encoding.UTF8.GetString(output.ToArray()).Trim();
|
||||
var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
|
||||
|
||||
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 +114,34 @@ public class StdioServerTransportTests
|
|||
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());
|
||||
// Use a reader that won't terminate
|
||||
Pipe pipe = new();
|
||||
using var input = pipe.Reader.AsStream();
|
||||
|
||||
await using var transport = new StdioServerTransport(_serverOptions);
|
||||
await transport.StartListeningAsync(TestContext.Current.CancellationToken);
|
||||
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");
|
||||
|
||||
var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
|
||||
// Write the message to the reader
|
||||
await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\n"), 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<JsonRpcRequest>(readMessage);
|
||||
Assert.Equal(44, ((JsonRpcRequest)readMessage).Id.AsNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(oldOut);
|
||||
Console.SetIn(oldIn);
|
||||
}
|
||||
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<JsonRpcRequest>(readMessage);
|
||||
Assert.Equal(44, ((JsonRpcRequest)readMessage).Id.AsNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -150,4 +154,82 @@ public class StdioServerTransportTests
|
|||
|
||||
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<string, JsonElement>
|
||||
{
|
||||
["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<string, JsonElement>
|
||||
{
|
||||
["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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue