Skip to content

Commit 12eca75

Browse files
committed
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.
1 parent 08e7da1 commit 12eca75

File tree

5 files changed

+302
-72
lines changed

5 files changed

+302
-72
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Microsoft.Extensions.Logging;
2+
3+
namespace ModelContextProtocol.Logging;
4+
5+
/// <summary>
6+
/// Extensions methods for ILogger instances used in MCP protocol.
7+
/// </summary>
8+
public static partial class LoggerExtensions
9+
{
10+
/// <summary>
11+
/// Logs the byte representation of a message in UTF-8 encoding.
12+
/// </summary>
13+
/// <param name="logger">The logger to use.</param>
14+
/// <param name="endpointName">The name of the endpoint.</param>
15+
/// <param name="byteRepresentation">The byte representation as a hex string.</param>
16+
[LoggerMessage(EventId = 39000, Level = LogLevel.Trace, Message = "Transport {EndpointName}: Message bytes (UTF-8): {ByteRepresentation}")]
17+
public static partial void TransportMessageBytes(this ILogger logger, string endpointName, string byteRepresentation);
18+
19+
/// <summary>
20+
/// Logs the byte representation of a message for diagnostic purposes.
21+
/// This is useful for diagnosing encoding issues with non-ASCII characters.
22+
/// </summary>
23+
/// <param name="logger">The logger to use.</param>
24+
/// <param name="endpointName">The name of the endpoint.</param>
25+
/// <param name="message">The message to log bytes for.</param>
26+
public static void TransportMessageBytesUtf8(this ILogger logger, string endpointName, string message)
27+
{
28+
if (logger.IsEnabled(LogLevel.Trace))
29+
{
30+
var bytes = System.Text.Encoding.UTF8.GetBytes(message);
31+
var byteRepresentation = string.Join(" ", bytes.Select(b => $"{b:X2}"));
32+
logger.TransportMessageBytes(endpointName, byteRepresentation);
33+
}
34+
}
35+
}

src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
using System.Diagnostics;
2-
using System.Text.Json;
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
33
using ModelContextProtocol.Configuration;
44
using ModelContextProtocol.Logging;
55
using ModelContextProtocol.Protocol.Messages;
66
using ModelContextProtocol.Utils;
77
using ModelContextProtocol.Utils.Json;
8-
using Microsoft.Extensions.Logging;
9-
using Microsoft.Extensions.Logging.Abstractions;
8+
using System.Diagnostics;
9+
using System.Text;
10+
using System.Text.Json;
1011

1112
namespace ModelContextProtocol.Protocol.Transport;
1213

@@ -20,6 +21,8 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
2021
private readonly ILogger _logger;
2122
private readonly JsonSerializerOptions _jsonOptions;
2223
private Process? _process;
24+
private StreamWriter? _stdInWriter;
25+
private StreamReader? _stdOutReader;
2326
private Task? _readTask;
2427
private CancellationTokenSource? _shutdownCts;
2528
private bool _processStarted;
@@ -99,6 +102,13 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
99102
}
100103
_logger.TransportProcessStarted(EndpointName, _process.Id);
101104
_processStarted = true;
105+
106+
// Create streams with explicit UTF-8 encoding to ensure proper Unicode character handling
107+
// This is especially important for non-ASCII characters like Chinese text and emoji
108+
var utf8Encoding = new UTF8Encoding(false); // No BOM
109+
_stdInWriter = new StreamWriter(_process.StandardInput.BaseStream, utf8Encoding) { AutoFlush = true };
110+
_stdOutReader = new StreamReader(_process.StandardOutput.BaseStream, utf8Encoding);
111+
102112
_process.BeginErrorReadLine();
103113

104114
// Start reading messages in the background
@@ -118,7 +128,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
118128
/// <inheritdoc/>
119129
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
120130
{
121-
if (!IsConnected || _process?.HasExited == true)
131+
if (!IsConnected || _process?.HasExited == true || _stdInWriter == null)
122132
{
123133
_logger.TransportNotConnected(EndpointName);
124134
throw new McpTransportException("Transport is not connected");
@@ -134,10 +144,11 @@ public override async Task SendMessageAsync(IJsonRpcMessage message, Cancellatio
134144
{
135145
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
136146
_logger.TransportSendingMessage(EndpointName, id, json);
147+
_logger.TransportMessageBytesUtf8(EndpointName, json);
137148

138-
// Write the message followed by a newline
139-
await _process!.StandardInput.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
140-
await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
149+
// Write the message followed by a newline using our UTF-8 writer
150+
await _stdInWriter.WriteLineAsync(json).ConfigureAwait(false);
151+
await _stdInWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
141152

142153
_logger.TransportSentMessage(EndpointName, id);
143154
}
@@ -161,12 +172,10 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
161172
{
162173
_logger.TransportEnteringReadMessagesLoop(EndpointName);
163174

164-
using var reader = _process!.StandardOutput;
165-
166-
while (!cancellationToken.IsCancellationRequested && !_process.HasExited)
175+
while (!cancellationToken.IsCancellationRequested && !_process!.HasExited && _stdOutReader != null)
167176
{
168177
_logger.TransportWaitingForMessage(EndpointName);
169-
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
178+
var line = await _stdOutReader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
170179
if (line == null)
171180
{
172181
_logger.TransportEndOfStream(EndpointName);
@@ -179,6 +188,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
179188
}
180189

181190
_logger.TransportReceivedMessage(EndpointName, line);
191+
_logger.TransportMessageBytesUtf8(EndpointName, line);
182192

183193
await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false);
184194
}
@@ -230,14 +240,28 @@ private async Task ProcessMessageAsync(string line, CancellationToken cancellati
230240
private async Task CleanupAsync(CancellationToken cancellationToken)
231241
{
232242
_logger.TransportCleaningUp(EndpointName);
233-
if (_process != null && _processStarted && !_process.HasExited)
243+
244+
if (_stdInWriter != null)
234245
{
235246
try
236247
{
237-
// Try to close stdin to signal the process to exit
238248
_logger.TransportClosingStdin(EndpointName);
239-
_process.StandardInput.Close();
249+
_stdInWriter.Close();
250+
}
251+
catch (Exception ex)
252+
{
253+
_logger.TransportShutdownFailed(EndpointName, ex);
254+
}
240255

256+
_stdInWriter = null;
257+
}
258+
259+
_stdOutReader = null;
260+
261+
if (_process != null && _processStarted && !_process.HasExited)
262+
{
263+
try
264+
{
241265
// Wait for the process to exit
242266
_logger.TransportWaitingForShutdown(EndpointName);
243267

src/ModelContextProtocol/Protocol/Transport/StdioServerTransport.cs

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
using System.Text.Json;
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using Microsoft.Extensions.Options;
24
using ModelContextProtocol.Logging;
35
using ModelContextProtocol.Protocol.Messages;
46
using ModelContextProtocol.Server;
57
using ModelContextProtocol.Utils;
68
using ModelContextProtocol.Utils.Json;
7-
using Microsoft.Extensions.Logging;
8-
using Microsoft.Extensions.Logging.Abstractions;
9-
using Microsoft.Extensions.Options;
9+
using System.Text;
10+
using System.Text.Json;
1011

1112
namespace ModelContextProtocol.Protocol.Transport;
1213

@@ -19,8 +20,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
1920
private readonly ILogger _logger;
2021

2122
private readonly JsonSerializerOptions _jsonOptions = McpJsonUtilities.DefaultOptions;
22-
private readonly TextReader _stdin = Console.In;
23-
private readonly TextWriter _stdout = Console.Out;
23+
private readonly TextReader _stdInReader;
24+
private readonly TextWriter _stdOutWriter;
2425

2526
private Task? _readTask;
2627
private CancellationTokenSource? _shutdownCts;
@@ -83,16 +84,57 @@ public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory = n
8384

8485
_serverName = serverName;
8586
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
87+
88+
// Create console streams with explicit UTF-8 encoding to ensure proper Unicode character handling
89+
// This is especially important for non-ASCII characters like Chinese text and emoji
90+
var utf8Encoding = new UTF8Encoding(false); // No BOM
91+
92+
// Get raw console streams and wrap them with UTF-8 encoding
93+
Stream inputStream = Console.OpenStandardInput();
94+
Stream outputStream = Console.OpenStandardOutput();
95+
96+
_stdInReader = new StreamReader(inputStream, utf8Encoding);
97+
_stdOutWriter = new StreamWriter(outputStream, utf8Encoding) { AutoFlush = true };
98+
}
99+
100+
/// <summary>
101+
/// Initializes a new instance of the <see cref="StdioServerTransport"/> class with explicit input/output streams.
102+
/// </summary>
103+
/// <param name="serverName">The name of the server.</param>
104+
/// <param name="input">The input TextReader to use.</param>
105+
/// <param name="output">The output TextWriter to use.</param>
106+
/// <param name="loggerFactory">Optional logger factory used for logging employed by the transport.</param>
107+
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> is <see langword="null"/>.</exception>
108+
/// <remarks>
109+
/// <para>
110+
/// This constructor is useful for testing scenarios where you want to redirect input/output.
111+
/// </para>
112+
/// </remarks>
113+
public StdioServerTransport(string serverName, TextReader input, TextWriter output, ILoggerFactory? loggerFactory = null)
114+
: base(loggerFactory)
115+
{
116+
Throw.IfNull(serverName);
117+
Throw.IfNull(input);
118+
Throw.IfNull(output);
119+
120+
_serverName = serverName;
121+
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
122+
123+
_stdInReader = input;
124+
_stdOutWriter = output;
86125
}
87126

88127
/// <inheritdoc/>
89128
public Task StartListeningAsync(CancellationToken cancellationToken = default)
90129
{
130+
_logger.LogDebug("Starting StdioServerTransport listener for {EndpointName}", EndpointName);
131+
91132
_shutdownCts = new CancellationTokenSource();
92133

93134
_readTask = Task.Run(async () => await ReadMessagesAsync(_shutdownCts.Token).ConfigureAwait(false), CancellationToken.None);
94135

95136
SetConnected(true);
137+
_logger.LogDebug("StdioServerTransport now connected for {EndpointName}", EndpointName);
96138

97139
return Task.CompletedTask;
98140
}
@@ -116,9 +158,10 @@ public override async Task SendMessageAsync(IJsonRpcMessage message, Cancellatio
116158
{
117159
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
118160
_logger.TransportSendingMessage(EndpointName, id, json);
161+
_logger.TransportMessageBytesUtf8(EndpointName, json);
119162

120-
await _stdout.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
121-
await _stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
163+
await _stdOutWriter.WriteLineAsync(json).ConfigureAwait(false);
164+
await _stdOutWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
122165

123166
_logger.TransportSentMessage(EndpointName, id);
124167
}
@@ -146,7 +189,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
146189
{
147190
_logger.TransportWaitingForMessage(EndpointName);
148191

149-
var reader = _stdin;
192+
var reader = _stdInReader;
150193
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
151194
if (line == null)
152195
{
@@ -160,6 +203,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
160203
}
161204

162205
_logger.TransportReceivedMessage(EndpointName, line);
206+
_logger.TransportMessageBytesUtf8(EndpointName, line);
163207

164208
try
165209
{

0 commit comments

Comments
 (0)