Add more tests (#51)

* Add more tests

* Fix some code smells

* Remove more code smells

* Combine "ServerIntegrationTests" and "ClientIntegrationTests" because both are client integration tests just using different servers

* Clean up resources after test

* Improve kill logic for spawned child processes

* Improved testability for Stdio

* Fix SseIntegration tests
This commit is contained in:
Toni Wenzel 2025-03-12 18:55:52 +01:00 • committed by GitHub
commit b5076263d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 883 additions and 751 deletions

View file

@ -0,0 +1,26 @@
namespace McpDotNet.Protocol.Transport;
/// <summary>
/// Container for the input and output streams.
/// </summary>
public class InputOutputStreams
{
/// <summary>
/// Gets the default input and output streams.
/// </summary>
public static readonly InputOutputStreams Default = new()
{
Output = Console.Out,
Input = Console.In
};
/// <summary>
/// Gets or sets the output stream.
/// </summary>
public required TextWriter Output { get; init; }
/// <summary>
/// Gets or sets the input stream.
/// </summary>
public required TextReader Input { get; init; }
}

View file

@ -119,7 +119,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
if (_messageEndpoint == null)
throw new InvalidOperationException("Transport not connected");
var content = new StringContent(
using var content = new StringContent(
JsonSerializer.Serialize(message, _jsonOptions),
Encoding.UTF8,
"application/json"
@ -217,7 +217,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var response = await _httpClient.SendAsync(
@ -328,7 +328,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
if (hostUrl.EndsWith("/sse", StringComparison.Ordinal))
hostUrl = hostUrl[..^4];
var endpointUri = hostUrl + data;
var endpointUri = $"{hostUrl.TrimEnd('/')}/{data.TrimStart('/')}";
_messageEndpoint = new Uri(endpointUri);
}

View file

@ -5,6 +5,7 @@ using System.Text.Json;
using McpDotNet.Configuration;
using McpDotNet.Logging;
using McpDotNet.Protocol.Messages;
using McpDotNet.Utils;
using McpDotNet.Utils.Json;
using Microsoft.Extensions.Logging;
@ -245,12 +246,10 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
// Wait for the process to exit
_logger.TransportWaitingForShutdown(EndpointName);
if (!_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
{
// If it doesn't exit gracefully, terminate it
_logger.TransportKillingProcess(EndpointName);
_process.Kill(true);
}
// Kill the while process tree because the process may spawn child processes
// and Node.js does not kill its children when it exits properly
_process.KillTree(_options.ShutdownTimeout);
}
catch (Exception ex)
{
@ -262,9 +261,11 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
}
if (_shutdownCts != null)
{
await _shutdownCts.CancelAsync().ConfigureAwait(false);
_shutdownCts?.Dispose();
_shutdownCts = null;
_shutdownCts.Dispose();
_shutdownCts = null;
}
if (_readTask != null)
{

View file

@ -6,6 +6,11 @@ namespace McpDotNet.Protocol.Transport;
/// </summary>
public record StdioClientTransportOptions
{
/// <summary>
/// The default timeout to wait for the server to shut down gracefully.
/// </summary>
public static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// The command to execute to start the server process.
/// </summary>
@ -29,5 +34,5 @@ public record StdioClientTransportOptions
/// <summary>
/// The timeout to wait for the server to shut down gracefully.
/// </summary>
public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan ShutdownTimeout { get; init; } = DefaultShutdownTimeout;
}

View file

@ -18,6 +18,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
private readonly JsonSerializerOptions _jsonOptions;
private Task? _readTask;
private CancellationTokenSource? _shutdownCts;
private readonly TextReader _input;
private readonly TextWriter _output;
private string EndpointName => $"Server (stdio) ({_serverName})";
@ -26,8 +28,14 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
/// </summary>
/// <param name="serverOptions">The server options.</param>
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
public StdioServerTransport(McpServerOptions serverOptions, ILoggerFactory? loggerFactory)
: this(serverOptions is not null ? serverOptions.ServerInfo.Name : throw new ArgumentNullException(nameof(serverOptions)), loggerFactory)
/// <param name="inputOutputStreams">Container for the input and output streams</param>
public StdioServerTransport(McpServerOptions serverOptions, ILoggerFactory? loggerFactory, InputOutputStreams inputOutputStreams)
: this(
serverOptions is not null ? serverOptions.ServerInfo.Name : throw new ArgumentNullException(nameof(serverOptions)),
loggerFactory,
inputOutputStreams is not null ? inputOutputStreams.Output : throw new ArgumentNullException(nameof(inputOutputStreams)),
inputOutputStreams.Input
)
{
}
@ -37,11 +45,26 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
/// <param name="serverName">The name of the server.</param>
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory)
: this(serverName, loggerFactory, Console.Out, Console.In)
{
}
/// <summary>
/// Initializes a new instance of the StdioServerTransport class.
/// </summary>
/// <param name="serverName">The name of the server.</param>
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
/// <param name="output">The output stream writer</param>
/// <param name="input">The input stream reader</param>
public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory, TextWriter output, TextReader input)
: base(loggerFactory)
{
_serverName = serverName;
_logger = loggerFactory is not null ? loggerFactory.CreateLogger<StdioClientTransport>() : NullLogger.Instance;
_jsonOptions = JsonSerializerOptionsExtensions.DefaultOptions;
_input = input ?? throw new ArgumentNullException(nameof(input));
_output = output ?? throw new ArgumentNullException(nameof(output));
}
/// <inheritdoc/>
@ -56,7 +79,6 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
return Task.CompletedTask;
}
/// <inheritdoc/>
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
{
@ -77,8 +99,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
var json = JsonSerializer.Serialize(message, _jsonOptions);
_logger.TransportSendingMessage(EndpointName, id, json);
await Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
await Console.Out.FlushAsync(cancellationToken).ConfigureAwait(false);
await _output.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
await _output.FlushAsync(cancellationToken).ConfigureAwait(false);
_logger.TransportSentMessage(EndpointName, id);
}
@ -106,7 +128,7 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
{
_logger.TransportWaitingForMessage(EndpointName);
var reader = Console.In;
var reader = _input;
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line == null)
{