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>
This commit is contained in:
Copilot 2026-02-18 13:01:56 -05:00 • committed by GitHub
commit f3da4729f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 136 additions and 63 deletions

View file

@ -1,8 +1,10 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Transport;
@ -136,4 +138,54 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
var content = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
Assert.Equal(cliArgumentValue ?? "", content.Text);
}
[Fact]
public async Task SendMessageAsync_Should_Use_LF_Not_CRLF()
{
using var serverInput = new MemoryStream();
Pipe serverOutputPipe = new();
var transport = new StreamClientTransport(serverInput, serverOutputPipe.Reader.AsStream(), LoggerFactory);
await using var sessionTransport = await transport.ConnectAsync(TestContext.Current.CancellationToken);
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
await sessionTransport.SendMessageAsync(message, TestContext.Current.CancellationToken);
byte[] bytes = serverInput.ToArray();
// The output should end with exactly \n (0x0A), not \r\n (0x0D 0x0A).
Assert.True(bytes.Length > 1, "Output should contain message data");
Assert.Equal((byte)'\n', bytes[^1]);
Assert.NotEqual((byte)'\r', bytes[^2]);
// Also verify the JSON content is valid
var json = Encoding.UTF8.GetString(bytes).TrimEnd('\n');
var expected = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
Assert.Equal(expected, json);
}
[Fact]
public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages()
{
Pipe serverInputPipe = new();
Pipe serverOutputPipe = new();
var transport = new StreamClientTransport(serverInputPipe.Writer.AsStream(), serverOutputPipe.Reader.AsStream(), LoggerFactory);
await using var sessionTransport = await transport.ConnectAsync(TestContext.Current.CancellationToken);
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
// Write a \r\n-delimited message to the server's output (which the client reads)
await serverOutputPipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\r\n"), TestContext.Current.CancellationToken);
var canRead = await sessionTransport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
Assert.True(canRead, "Should be able to read a \\r\\n-delimited message");
Assert.True(sessionTransport.MessageReader.TryPeek(out var readMessage));
Assert.NotNull(readMessage);
Assert.IsType<JsonRpcRequest>(readMessage);
Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
}
}

View file

@ -227,6 +227,54 @@ public class StdioServerTransportTests : LoggedTest
Assert.Contains(traceLogMessages, x => x.Message.Contains("\"method\":\"test\"") && x.Message.Contains("\"id\":44"));
}
[Fact]
public async Task SendMessageAsync_Should_Use_LF_Not_CRLF()
{
using var output = new MemoryStream();
await using var transport = new StreamServerTransport(
new Pipe().Reader.AsStream(),
output,
loggerFactory: LoggerFactory);
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
byte[] bytes = output.ToArray();
// The output should end with exactly \n (0x0A), not \r\n (0x0D 0x0A).
Assert.True(bytes.Length > 1, "Output should contain message data");
Assert.Equal((byte)'\n', bytes[^1]);
Assert.NotEqual((byte)'\r', bytes[^2]);
}
[Fact]
public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages()
{
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
Pipe pipe = new();
using var input = pipe.Reader.AsStream();
await using var transport = new StreamServerTransport(
input,
Stream.Null,
loggerFactory: LoggerFactory);
// Write the message with \r\n line ending
await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\r\n"), TestContext.Current.CancellationToken);
var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
Assert.True(canRead, "Should be able to read a \\r\\n-delimited message");
Assert.True(transport.MessageReader.TryPeek(out var readMessage));
Assert.NotNull(readMessage);
Assert.IsType<JsonRpcRequest>(readMessage);
Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
}
[Fact]
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
{