* 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>
235 lines
8.9 KiB
C#
235 lines
8.9 KiB
C#
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 System.IO.Pipelines;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace ModelContextProtocol.Tests.Transport;
|
|
|
|
public class StdioServerTransportTests
|
|
{
|
|
private readonly McpServerOptions _serverOptions;
|
|
|
|
public StdioServerTransportTests()
|
|
{
|
|
_serverOptions = new McpServerOptions
|
|
{
|
|
ServerInfo = new Implementation
|
|
{
|
|
Name = "Test Server",
|
|
Version = "1.0"
|
|
},
|
|
ProtocolVersion = "2.0",
|
|
InitializationTimeout = TimeSpan.FromSeconds(10),
|
|
ServerInstructions = "Test Instructions"
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
|
|
{
|
|
// Act
|
|
await using var transport = new StdioServerTransport(_serverOptions);
|
|
|
|
// Assert
|
|
Assert.NotNull(transport);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_Throws_For_Null_Options()
|
|
{
|
|
Assert.Throws<ArgumentNullException>("serverName", () => new StdioServerTransport((string)null!));
|
|
|
|
Assert.Throws<ArgumentNullException>("serverOptions", () => new StdioServerTransport((McpServerOptions)null!));
|
|
Assert.Throws<ArgumentNullException>("serverOptions.ServerInfo", () => new StdioServerTransport(new McpServerOptions() { ServerInfo = null! }));
|
|
Assert.Throws<ArgumentNullException>("serverName", () => new StdioServerTransport(new McpServerOptions() { ServerInfo = new() { Name = null!, Version = "" } }));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartListeningAsync_Should_Set_Connected_State()
|
|
{
|
|
await using var transport = new StdioServerTransport(_serverOptions);
|
|
|
|
await transport.StartListeningAsync(TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(transport.IsConnected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendMessageAsync_Should_Send_Message()
|
|
{
|
|
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);
|
|
|
|
var result = Encoding.UTF8.GetString(output.ToArray()).Trim();
|
|
var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
|
|
|
|
Assert.Equal(expected, result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendMessageAsync_Throws_Exception_If_Not_Connected()
|
|
{
|
|
await using var transport = new StdioServerTransport(_serverOptions);
|
|
|
|
var message = new JsonRpcRequest { Method = "test" };
|
|
|
|
await Assert.ThrowsAsync<McpTransportException>(() => transport.SendMessageAsync(message, TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisposeAsync_Should_Dispose_Resources()
|
|
{
|
|
await using var transport = new StdioServerTransport(_serverOptions);
|
|
|
|
await transport.DisposeAsync();
|
|
|
|
Assert.False(transport.IsConnected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadMessagesAsync_Should_Read_Messages()
|
|
{
|
|
var message = new JsonRpcRequest { Method = "test", Id = RequestId.FromNumber(44) };
|
|
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
|
|
|
|
// Use a reader that won't terminate
|
|
Pipe pipe = new();
|
|
using var input = pipe.Reader.AsStream();
|
|
|
|
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<JsonRpcRequest>(readMessage);
|
|
Assert.Equal(44, ((JsonRpcRequest)readMessage).Id.AsNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanupAsync_Should_Cleanup_Resources()
|
|
{
|
|
var transport = new StdioServerTransport(_serverOptions);
|
|
await transport.StartListeningAsync(TestContext.Current.CancellationToken);
|
|
|
|
await transport.DisposeAsync();
|
|
|
|
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");
|
|
}
|
|
}
|