csharp-sdk/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs
Aditya Singh 990bd76b53
Reply with JSON-RPC parse error for over-nested stdio requests (#1629)
Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
2026-07-22 15:30:52 -07:00

368 lines
14 KiB
C#

using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Transport;
public class StdioServerTransportTests : LoggedTest
{
private readonly McpServerOptions _serverOptions;
public StdioServerTransportTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
_serverOptions = new McpServerOptions
{
ProtocolVersion = "2.0",
InitializationTimeout = TimeSpan.FromSeconds(10),
ServerInstructions = "Test Instructions"
};
}
[Fact]
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
{
// Use StreamServerTransport with Stream.Null rather than StdioServerTransport.
// StdioServerTransport opens Console.OpenStandardInput() which permanently
// blocks a thread pool thread on the test host's stdin. StdioServerTransport
// should only be instantiated in a dedicated child process.
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null, _serverOptions.ServerInfo?.Name);
// 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!));
}
[Fact]
public async Task Should_Start_In_Connected_State()
{
await using var transport = new StreamServerTransport(new Pipe().Reader.AsStream(), Stream.Null, loggerFactory: LoggerFactory);
Assert.True(transport.IsConnected);
}
[Fact]
public async Task SendMessageAsync_Should_Send_Message()
{
using var output = new MemoryStream();
await using var transport = new StreamServerTransport(
new Pipe().Reader.AsStream(),
output,
loggerFactory: LoggerFactory);
// Verify transport is connected
Assert.True(transport.IsConnected, "Transport should be connected after StartListeningAsync");
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(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 DisposeAsync_Should_Dispose_Resources()
{
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null, loggerFactory: LoggerFactory);
await transport.DisposeAsync();
Assert.False(transport.IsConnected);
}
[Fact]
public async Task ReadMessagesAsync_Should_Read_Messages()
{
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(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 StreamServerTransport(
input,
Stream.Null,
loggerFactory: LoggerFactory);
// 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.ToString());
}
[Fact]
public async Task CleanupAsync_Should_Cleanup_Resources()
{
var transport = new StreamServerTransport(Stream.Null, Stream.Null, loggerFactory: LoggerFactory);
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 StreamServerTransport(
new Pipe().Reader.AsStream(),
output,
loggerFactory: LoggerFactory);
// 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 = new RequestId(44),
Params = new JsonObject
{
["text"] = 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, McpJsonUtilities.DefaultOptions), chineseResult);
// Test 2: Emoji (non-BMP Unicode using surrogate pairs)
var emojiText = "🔍 🚀 👍"; // Magnifying glass, rocket, thumbs up
var emojiMessage = new JsonRpcRequest
{
Method = "test",
Id = new RequestId(45),
Params = new JsonObject
{
["text"] = 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.IndexOf("\\ud83d\\udd0d", StringComparison.OrdinalIgnoreCase) >= 0;
// Rocket emoji: 🚀 (U+1F680)
bool rocketFound =
emojiResult.Contains("🚀") ||
emojiResult.IndexOf("\\ud83d\\ude80", StringComparison.OrdinalIgnoreCase) >= 0;
Assert.True(magnifyingGlassFound, "Magnifying glass emoji not found in result");
Assert.True(rocketFound, "Rocket emoji not found in result");
}
[Fact]
public async Task SendMessageAsync_Should_Log_At_Trace_Level()
{
// Arrange
var mockLoggerProvider = new MockLoggerProvider();
using var traceLoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
{
builder.AddProvider(XunitLoggerProvider);
builder.AddProvider(mockLoggerProvider);
builder.SetMinimumLevel(LogLevel.Trace);
});
using var output = new MemoryStream();
await using var transport = new StreamServerTransport(
new Pipe().Reader.AsStream(),
output,
loggerFactory: traceLoggerFactory);
// Act
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(44) };
await transport.SendMessageAsync(message, TestContext.Current.CancellationToken);
// Assert
var traceLogMessages = mockLoggerProvider.LogMessages
.Where(x => x.LogLevel == LogLevel.Trace && x.Message.Contains("transport sending message"))
.ToList();
Assert.NotEmpty(traceLogMessages);
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_Respond_With_ParseError_For_Request_Exceeding_MaxDepth()
{
// Build a ping request whose params nest more deeply than System.Text.Json's default
// reader MaxDepth of 64, which is what makes full deserialization throw. The request still
// carries an id, so the transport should reply with a JSON-RPC parse error for that id rather
// than dropping the request and leaving the caller pending.
var nested = new StringBuilder();
const int depth = 100;
for (int i = 0; i < depth; i++)
{
nested.Append("{\"p").Append(i).Append("\":");
}
nested.Append("{\"leaf\":true}");
nested.Append('}', depth);
var requestLine = $"{{\"jsonrpc\":\"2.0\",\"id\":900100,\"method\":\"ping\",\"params\":{nested}}}";
Pipe inputPipe = new();
Pipe outputPipe = new();
using var input = inputPipe.Reader.AsStream();
using var output = outputPipe.Writer.AsStream();
await using var transport = new StreamServerTransport(
input,
output,
loggerFactory: LoggerFactory);
await inputPipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{requestLine}\n"), TestContext.Current.CancellationToken);
// Read the single response line the transport writes back to the output stream.
using var responseReader = new StreamReader(outputPipe.Reader.AsStream(), Encoding.UTF8);
var responseLine = await responseReader.ReadLineAsync(
#if NET
TestContext.Current.CancellationToken
#endif
);
Assert.NotNull(responseLine);
var response = JsonSerializer.Deserialize<JsonRpcMessage>(responseLine!, McpJsonUtilities.DefaultOptions);
var error = Assert.IsType<JsonRpcError>(response);
Assert.Equal("900100", error.Id.ToString());
Assert.Equal((int)McpErrorCode.ParseError, error.Error.Code);
// The transport should still be reading further messages after recovering from the bad one.
Assert.True(transport.IsConnected);
}
[Fact]
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
{
// Arrange
var mockLoggerProvider = new MockLoggerProvider();
using var traceLoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
{
builder.AddProvider(XunitLoggerProvider);
builder.AddProvider(mockLoggerProvider);
builder.SetMinimumLevel(LogLevel.Trace);
});
var message = new JsonRpcRequest { Method = "test", Id = new RequestId(99) };
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: traceLoggerFactory);
// Act
await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{json}\n"), TestContext.Current.CancellationToken);
// Wait for the message to be processed
var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken);
Assert.True(canRead, "Nothing to read here from transport message reader");
// Assert
var traceLogMessages = mockLoggerProvider.LogMessages
.Where(x => x.LogLevel == LogLevel.Trace && x.Message.Contains("transport received message"))
.ToList();
Assert.NotEmpty(traceLogMessages);
Assert.Contains(traceLogMessages, x => x.Message.Contains("\"method\":\"test\"") && x.Message.Contains("\"id\":99"));
}
}