Separate ASP.NET Core tests into a distinct project and make the main test project netfx compatible. (#254)

* Separate ASP.NET Core tests into a distinct project and make the main test project netfx compatible.

* Fix merge conflicts.

* Update tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj

Co-authored-by: Stephen Halter <halter73@gmail.com>

* Revert "Fix merge conflicts."

This reverts commit 852169b96786bad2144795540fa164e20b4b197e.

* Fix merge conflicts

---------

Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
Eirik Tsarpalis 2025-04-09 19:16:53 +01:00 • committed by GitHub
commit 39ab6cde0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 324 additions and 63 deletions

View file

@ -0,0 +1,13 @@
namespace ModelContextProtocol.Tests.Utils;
public class DelegatingTestOutputHelper : ITestOutputHelper
{
public ITestOutputHelper? CurrentTestOutputHelper { get; set; }
public string Output => CurrentTestOutputHelper?.Output ?? string.Empty;
public void Write(string message) => CurrentTestOutputHelper?.Write(message);
public void Write(string format, params object[] args) => CurrentTestOutputHelper?.Write(format, args);
public void WriteLine(string message) => CurrentTestOutputHelper?.WriteLine(message);
public void WriteLine(string format, params object[] args) => CurrentTestOutputHelper?.WriteLine(format, args);
}

View file

@ -0,0 +1,113 @@
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Features;
using System.IO.Pipelines;
namespace ModelContextProtocol.Tests.Utils;
public sealed class KestrelInMemoryConnection : ConnectionContext
{
private readonly Pipe _clientToServerPipe = new();
private readonly Pipe _serverToClientPipe = new();
private readonly CancellationTokenSource _connectionClosedCts = new();
private readonly FeatureCollection _features = new();
public KestrelInMemoryConnection()
{
ConnectionClosed = _connectionClosedCts.Token;
Transport = new DuplexPipe
{
Input = _clientToServerPipe.Reader,
Output = _serverToClientPipe.Writer,
};
Application = new DuplexPipe
{
Input = _serverToClientPipe.Reader,
Output = _clientToServerPipe.Writer,
};
ClientStream = new DuplexStream(Application, _connectionClosedCts);
}
public IDuplexPipe Application { get; }
public Stream ClientStream { get; }
public override IDuplexPipe Transport { get; set; }
public override string ConnectionId { get; set; } = Guid.NewGuid().ToString("N");
public override IFeatureCollection Features => _features;
public override IDictionary<object, object?> Items { get; set; } = new Dictionary<object, object?>();
public override async ValueTask DisposeAsync()
{
// This is called by Kestrel. The client should dispose the DuplexStream which
// completes the other half of these pipes.
await _serverToClientPipe.Writer.CompleteAsync();
await _serverToClientPipe.Reader.CompleteAsync();
// Don't bother disposing the _connectionClosedCts, since this is just for testing,
// and it's annoying to synchronize with DuplexStream.
await base.DisposeAsync();
}
private class DuplexPipe : IDuplexPipe
{
public required PipeReader Input { get; init; }
public required PipeWriter Output { get; init; }
}
private class DuplexStream(IDuplexPipe duplexPipe, CancellationTokenSource connectionClosedCts) : Stream
{
private readonly Stream _readStream = duplexPipe.Input.AsStream();
private readonly Stream _writeStream = duplexPipe.Output.AsStream();
public override bool CanRead => true;
public override bool CanWrite => true;
public override bool CanSeek => false;
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> _readStream.ReadAsync(buffer, offset, count, cancellationToken);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _readStream.ReadAsync(buffer, cancellationToken);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> _writeStream.WriteAsync(buffer, offset, count, cancellationToken);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
=> _writeStream.WriteAsync(buffer, cancellationToken);
public override Task FlushAsync(CancellationToken cancellationToken)
=> _writeStream.FlushAsync(cancellationToken);
protected override void Dispose(bool disposing)
{
// Signal to the server the the client has closed the connection, and dispose the client-half of the Pipes.
connectionClosedCts.Cancel();
duplexPipe.Input.Complete();
duplexPipe.Output.Complete();
}
// Unsupported stuff
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
// Don't bother supporting sync or APM methods. SocketsHttpHandler shouldn't use them.
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Flush() => throw new NotSupportedException();
}
}

View file

@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Connections;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace ModelContextProtocol.Tests.Utils;
public class KestrelInMemoryTest : LoggedTest
{
private readonly KestrelInMemoryTransport _inMemoryTransport = new();
public KestrelInMemoryTest(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
// Use SlimBuilder instead of EmptyBuilder to avoid having to call UseRouting() and UseEndpoints(_ => { })
// or a helper that does the same every test. But clear out the existing socket transport to avoid potential port conflicts.
Builder = WebApplication.CreateSlimBuilder();
Builder.Services.RemoveAll<IConnectionListenerFactory>();
Builder.Services.AddSingleton<IConnectionListenerFactory>(_inMemoryTransport);
Builder.Services.AddSingleton(LoggerProvider);
}
public WebApplicationBuilder Builder { get; }
public HttpClient CreateHttpClient()
{
var socketsHttpHandler = new SocketsHttpHandler()
{
ConnectCallback = (context, token) =>
{
var connection = _inMemoryTransport.CreateConnection();
return new(connection.ClientStream);
},
};
return new HttpClient(socketsHttpHandler);
}
}

View file

@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Connections;
using System.Net;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Utils;
public sealed class KestrelInMemoryTransport : IConnectionListenerFactory, IConnectionListener
{
private readonly Channel<ConnectionContext> _acceptQueue = Channel.CreateUnbounded<ConnectionContext>();
private EndPoint? _endPoint;
public EndPoint EndPoint => _endPoint ?? throw new InvalidOperationException("EndPoint is not set. Call BindAsync first.");
public KestrelInMemoryConnection CreateConnection()
{
var connection = new KestrelInMemoryConnection();
_acceptQueue.Writer.TryWrite(connection);
return connection;
}
public async ValueTask<ConnectionContext?> AcceptAsync(CancellationToken cancellationToken = default)
{
if (await _acceptQueue.Reader.WaitToReadAsync(cancellationToken))
{
while (_acceptQueue.Reader.TryRead(out var item))
{
return item;
}
}
return null;
}
public ValueTask<IConnectionListener> BindAsync(EndPoint endpoint, CancellationToken cancellationToken = default)
{
_endPoint = endpoint;
return new ValueTask<IConnectionListener>(this);
}
public ValueTask DisposeAsync()
{
return UnbindAsync(default);
}
public ValueTask UnbindAsync(CancellationToken cancellationToken = default)
{
_acceptQueue.Writer.TryComplete();
return default;
}
}

View file

@ -0,0 +1,31 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Test.Utils;
namespace ModelContextProtocol.Tests.Utils;
public class LoggedTest : IDisposable
{
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper;
public LoggedTest(ITestOutputHelper testOutputHelper)
{
_delegatingTestOutputHelper = new()
{
CurrentTestOutputHelper = testOutputHelper,
};
LoggerProvider = new XunitLoggerProvider(_delegatingTestOutputHelper);
LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
{
builder.AddProvider(LoggerProvider);
});
}
public ITestOutputHelper TestOutputHelper => _delegatingTestOutputHelper;
public ILoggerFactory LoggerFactory { get; }
public ILoggerProvider LoggerProvider { get; }
public virtual void Dispose()
{
_delegatingTestOutputHelper.CurrentTestOutputHelper = null;
}
}

View file

@ -0,0 +1,20 @@
namespace ModelContextProtocol.Tests.Utils;
public class MockHttpHandler : HttpMessageHandler
{
public Func<HttpRequestMessage, Task<HttpResponseMessage>>? RequestHandler { get; set; }
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (RequestHandler == null)
throw new InvalidOperationException($"No {nameof(RequestHandler)} was set! Please set handler first and make request afterwards.");
cancellationToken.ThrowIfCancellationRequested();
var result = await RequestHandler.Invoke(request);
cancellationToken.ThrowIfCancellationRequested();
return result;
}
}

View file

@ -0,0 +1,83 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using System.Text.Json;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Utils;
public class TestServerTransport : ITransport
{
private readonly Channel<IJsonRpcMessage> _messageChannel;
public bool IsConnected { get; set; }
public ChannelReader<IJsonRpcMessage> MessageReader => _messageChannel;
public List<IJsonRpcMessage> SentMessages { get; } = [];
public Action<IJsonRpcMessage>? OnMessageSent { get; set; }
public TestServerTransport()
{
_messageChannel = Channel.CreateUnbounded<IJsonRpcMessage>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = true,
});
IsConnected = true;
}
public ValueTask DisposeAsync()
{
_messageChannel.Writer.TryComplete();
IsConnected = false;
return ValueTask.CompletedTask;
}
public async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
{
SentMessages.Add(message);
if (message is JsonRpcRequest request)
{
if (request.Method == RequestMethods.RootsList)
await ListRoots(request, cancellationToken);
else if (request.Method == RequestMethods.SamplingCreateMessage)
await Sampling(request, cancellationToken);
else
await WriteMessageAsync(request, cancellationToken);
}
else if (message is JsonRpcNotification notification)
{
await WriteMessageAsync(notification, cancellationToken);
}
OnMessageSent?.Invoke(message);
}
private async Task ListRoots(JsonRpcRequest request, CancellationToken cancellationToken)
{
await WriteMessageAsync(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new ListRootsResult
{
Roots = []
}),
}, cancellationToken);
}
private async Task Sampling(JsonRpcRequest request, CancellationToken cancellationToken)
{
await WriteMessageAsync(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Content = new(), Model = "model", Role = "role" }),
}, cancellationToken);
}
private async Task WriteMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
{
await _messageChannel.Writer.WriteAsync(message, cancellationToken);
}
}

View file

@ -0,0 +1,52 @@
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ModelContextProtocol.Test.Utils;
public class XunitLoggerProvider(ITestOutputHelper output) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName)
{
return new XunitLogger(output, categoryName);
}
public void Dispose()
{
}
private class XunitLogger(ITestOutputHelper output, string category) : ILogger
{
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var sb = new StringBuilder();
var timestamp = DateTimeOffset.UtcNow.ToString("s", CultureInfo.InvariantCulture);
var prefix = $"| [{timestamp}] {category} {logLevel}: ";
var lines = formatter(state, exception);
sb.Append(prefix);
sb.Append(lines);
if (exception is not null)
{
sb.AppendLine();
sb.Append(exception.ToString());
}
output.WriteLine(sb.ToString());
}
public bool IsEnabled(LogLevel logLevel) => true;
public IDisposable BeginScope<TState>(TState state) where TState : notnull
=> new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose()
{
}
}
}
}