Skip to content

Commit f3da472

Browse files
Copilotstephentoubhalter73
authored
Avoid intermediate strings in MCP transport serialization (#1274)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com> Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
1 parent c3bb9c1 commit f3da472

File tree

6 files changed

+136
-63
lines changed

6 files changed

+136
-63
lines changed

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,9 @@ private async Task PerformDynamicClientRegistrationAsync(
618618
Scope = GetScopeParameter(protectedResourceMetadata),
619619
};
620620

621-
var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
622-
using var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
621+
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
622+
using var requestContent = new ByteArrayContent(requestBytes);
623+
requestContent.Headers.ContentType = McpHttpClient.s_applicationJsonContentType;
623624

624625
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.RegistrationEndpoint)
625626
{

src/ModelContextProtocol.Core/Client/McpHttpClient.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
using ModelContextProtocol.Protocol;
22
using System.Diagnostics;
3+
using System.Net.Http.Headers;
34

45
#if NET
56
using System.Net.Http.Json;
67
#else
7-
using System.Text;
88
using System.Text.Json;
99
#endif
1010

1111
namespace ModelContextProtocol.Client;
1212

1313
internal class McpHttpClient(HttpClient httpClient)
1414
{
15+
internal static readonly MediaTypeHeaderValue s_applicationJsonContentType = new("application/json") { CharSet = "utf-8" };
16+
1517
internal virtual async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
1618
{
1719
Debug.Assert(request.Content is null, "The request body should only be supplied as a JsonRpcMessage");
@@ -32,11 +34,10 @@ internal virtual async Task<HttpResponseMessage> SendAsync(HttpRequestMessage re
3234
#if NET
3335
return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
3436
#else
35-
return new StringContent(
36-
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
37-
Encoding.UTF8,
38-
"application/json"
39-
);
37+
var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
38+
var content = new ByteArrayContent(bytes);
39+
content.Headers.ContentType = s_applicationJsonContentType;
40+
return content;
4041
#endif
4142
}
4243
}

src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs

Lines changed: 23 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ namespace ModelContextProtocol.Client;
88
/// <summary>Provides the client side of a stream-based session transport.</summary>
99
internal class StreamClientSessionTransport : TransportBase
1010
{
11+
private static readonly byte[] s_newlineBytes = "\n"u8.ToArray();
12+
1113
internal static UTF8Encoding NoBomUtf8Encoding { get; } = new(encoderShouldEmitUTF8Identifier: false);
1214

1315
private readonly TextReader _serverOutput;
14-
private readonly TextWriter _serverInput;
16+
private readonly Stream _serverInputStream;
1517
private readonly SemaphoreSlim _sendLock = new(1, 1);
1618
private CancellationTokenSource? _shutdownCts = new();
1719
private Task? _readTask;
@@ -20,12 +22,13 @@ internal class StreamClientSessionTransport : TransportBase
2022
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
2123
/// </summary>
2224
/// <param name="serverInput">
23-
/// The text writer connected to the server's input stream.
24-
/// Messages written to this writer will be sent to the server.
25+
/// The server's input stream. Messages written to this stream will be sent to the server.
2526
/// </param>
2627
/// <param name="serverOutput">
27-
/// The text reader connected to the server's output stream.
28-
/// Messages read from this reader will be received from the server.
28+
/// The server's output stream. Messages read from this stream will be received from the server.
29+
/// </param>
30+
/// <param name="encoding">
31+
/// The encoding used for reading and writing messages from the input and output streams. Defaults to UTF-8 without BOM if null.
2932
/// </param>
3033
/// <param name="endpointName">
3134
/// A name that identifies this transport endpoint in logs.
@@ -37,12 +40,18 @@ internal class StreamClientSessionTransport : TransportBase
3740
/// This constructor starts a background task to read messages from the server output stream.
3841
/// The transport will be marked as connected once initialized.
3942
/// </remarks>
40-
public StreamClientSessionTransport(
41-
TextWriter serverInput, TextReader serverOutput, string endpointName, ILoggerFactory? loggerFactory)
43+
public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory)
4244
: base(endpointName, loggerFactory)
4345
{
44-
_serverOutput = serverOutput;
45-
_serverInput = serverInput;
46+
Throw.IfNull(serverInput);
47+
Throw.IfNull(serverOutput);
48+
49+
_serverInputStream = serverInput;
50+
#if NET
51+
_serverOutput = new StreamReader(serverOutput, encoding ?? NoBomUtf8Encoding);
52+
#else
53+
_serverOutput = new CancellableStreamReader(serverOutput, encoding ?? NoBomUtf8Encoding);
54+
#endif
4655

4756
SetConnected();
4857

@@ -57,43 +66,6 @@ public StreamClientSessionTransport(
5766
readTask.Start();
5867
}
5968

60-
/// <summary>
61-
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
62-
/// </summary>
63-
/// <param name="serverInput">
64-
/// The server's input stream. Messages written to this stream will be sent to the server.
65-
/// </param>
66-
/// <param name="serverOutput">
67-
/// The server's output stream. Messages read from this stream will be received from the server.
68-
/// </param>
69-
/// <param name="encoding">
70-
/// The encoding used for reading and writing messages from the input and output streams. Defaults to UTF-8 without BOM if null.
71-
/// </param>
72-
/// <param name="endpointName">
73-
/// A name that identifies this transport endpoint in logs.
74-
/// </param>
75-
/// <param name="loggerFactory">
76-
/// Optional factory for creating loggers. If null, a NullLogger is used.
77-
/// </param>
78-
/// <remarks>
79-
/// This constructor starts a background task to read messages from the server output stream.
80-
/// The transport will be marked as connected once initialized.
81-
/// </remarks>
82-
public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory)
83-
: this(
84-
new StreamWriter(serverInput, encoding ?? NoBomUtf8Encoding),
85-
#if NET
86-
new StreamReader(serverOutput, encoding ?? NoBomUtf8Encoding),
87-
#else
88-
new CancellableStreamReader(serverOutput, encoding ?? NoBomUtf8Encoding),
89-
#endif
90-
endpointName,
91-
loggerFactory)
92-
{
93-
Throw.IfNull(serverInput);
94-
Throw.IfNull(serverOutput);
95-
}
96-
9769
/// <inheritdoc/>
9870
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
9971
{
@@ -103,16 +75,15 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
10375
id = messageWithId.Id.ToString();
10476
}
10577

106-
var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
107-
108-
LogTransportSendingMessageSensitive(Name, json);
78+
LogTransportSendingMessageSensitive(message);
10979

11080
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
11181
try
11282
{
113-
// Write the message followed by a newline using our UTF-8 writer
114-
await _serverInput.WriteLineAsync(json).ConfigureAwait(false);
115-
await _serverInput.FlushAsync(cancellationToken).ConfigureAwait(false);
83+
var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
84+
await _serverInputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false);
85+
await _serverInputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
86+
await _serverInputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
11687
}
11788
catch (Exception ex)
11889
{

src/ModelContextProtocol.Core/Server/StreamServerTransport.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
7474

7575
try
7676
{
77-
var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
78-
LogTransportSendingMessageSensitive(Name, json);
79-
await _outputStream.WriteAsync(Encoding.UTF8.GetBytes(json), cancellationToken).ConfigureAwait(false);
77+
var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
78+
LogTransportSendingMessageSensitive(message);
79+
await _outputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false);
8080
await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
8181
await _outputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
8282
}

tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using ModelContextProtocol.Client;
22
using ModelContextProtocol.Protocol;
33
using ModelContextProtocol.Tests.Utils;
4+
using System.IO.Pipelines;
45
using System.Runtime.InteropServices;
56
using System.Text;
7+
using System.Text.Json;
68

79
namespace ModelContextProtocol.Tests.Transport;
810

@@ -136,4 +138,54 @@ public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue)
136138
var content = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
137139
Assert.Equal(cliArgumentValue ?? "", content.Text);
138140
}
141+
142+
[Fact]
143+
public async Task SendMessageAsync_Should_Use_LF_Not_CRLF()
144+
{
145+
using var serverInput = new MemoryStream();
146+
Pipe serverOutputPipe = new();
147+
148+
var transport = new StreamClientTransport(serverInput, serverOutputPipe.Reader.AsStream(), LoggerFactory);
149+
await using var sessionTransport = await transport.ConnectAsync(TestContext.Current.CancellationToken);
150+
151+
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
152+
153+
await sessionTransport.SendMessageAsync(message, TestContext.Current.CancellationToken);
154+
155+
byte[] bytes = serverInput.ToArray();
156+
157+
// The output should end with exactly \n (0x0A), not \r\n (0x0D 0x0A).
158+
Assert.True(bytes.Length > 1, "Output should contain message data");
159+
Assert.Equal((byte)'\n', bytes[^1]);
160+
Assert.NotEqual((byte)'\r', bytes[^2]);
161+
162+
// Also verify the JSON content is valid
163+
var json = Encoding.UTF8.GetString(bytes).TrimEnd('\n');
164+
var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
165+
Assert.Equal(expected, json);
166+
}
167+
168+
[Fact]
169+
public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages()
170+
{
171+
Pipe serverInputPipe = new();
172+
Pipe serverOutputPipe = new();
173+
174+
var transport = new StreamClientTransport(serverInputPipe.Writer.AsStream(), serverOutputPipe.Reader.AsStream(), LoggerFactory);
175+
await using var sessionTransport = await transport.ConnectAsync(TestContext.Current.CancellationToken);
176+
177+
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
178+
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
179+
180+
// Write a \r\n-delimited message to the server's output (which the client reads)
181+
await serverOutputPipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\r\n"), TestContext.Current.CancellationToken);
182+
183+
var canRead = await sessionTransport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
184+
185+
Assert.True(canRead, "Should be able to read a \\r\\n-delimited message");
186+
Assert.True(sessionTransport.MessageReader.TryPeek(out var readMessage));
187+
Assert.NotNull(readMessage);
188+
Assert.IsType<JsonRpcRequest>(readMessage);
189+
Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
190+
}
139191
}

tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,54 @@ public async Task SendMessageAsync_Should_Log_At_Trace_Level()
227227
Assert.Contains(traceLogMessages, x => x.Message.Contains("\"method\":\"test\"") && x.Message.Contains("\"id\":44"));
228228
}
229229

230+
[Fact]
231+
public async Task SendMessageAsync_Should_Use_LF_Not_CRLF()
232+
{
233+
using var output = new MemoryStream();
234+
235+
await using var transport = new StreamServerTransport(
236+
new Pipe().Reader.AsStream(),
237+
output,
238+
loggerFactory: LoggerFactory);
239+
240+
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
241+
242+
await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
243+
244+
byte[] bytes = output.ToArray();
245+
246+
// The output should end with exactly \n (0x0A), not \r\n (0x0D 0x0A).
247+
Assert.True(bytes.Length > 1, "Output should contain message data");
248+
Assert.Equal((byte)'\n', bytes[^1]);
249+
Assert.NotEqual((byte)'\r', bytes[^2]);
250+
}
251+
252+
[Fact]
253+
public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages()
254+
{
255+
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
256+
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
257+
258+
Pipe pipe = new();
259+
using var input = pipe.Reader.AsStream();
260+
261+
await using var transport = new StreamServerTransport(
262+
input,
263+
Stream.Null,
264+
loggerFactory: LoggerFactory);
265+
266+
// Write the message with \r\n line ending
267+
await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\r\n"), TestContext.Current.CancellationToken);
268+
269+
var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
270+
271+
Assert.True(canRead, "Should be able to read a \\r\\n-delimited message");
272+
Assert.True(transport.MessageReader.TryPeek(out var readMessage));
273+
Assert.NotNull(readMessage);
274+
Assert.IsType<JsonRpcRequest>(readMessage);
275+
Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
276+
}
277+
230278
[Fact]
231279
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
232280
{

0 commit comments

Comments
 (0)