Improve testing for StreamableHttpHandler and IdleTrackingBackgroundService (#345)
* Add idle session pruning tests
* Remove redundant CTS from StreamableHttpHandler
* StreamableHttpServerTransport.HandlePostRequest already creates the equivelant CTS
which DeleteRequest_CompletesSession_WhichCancelsLongRunningToolCalls verifies
* Fix accept header validation for GET
* Use InvalidOperationException for transport-not-connected errors in doc comments
* Fix flaky Cancellation_ThrowsCancellationException test
This commit is contained in:
parent
4fd3ebf3fe
commit
e3ff552824
8 changed files with 315 additions and 291 deletions
|
|
@ -45,7 +45,10 @@ public class McpClientFactoryTests
|
|||
Task t = McpClientFactory.CreateAsync(
|
||||
new StreamClientTransport(new Pipe().Writer.AsStream(), new Pipe().Reader.AsStream()),
|
||||
cancellationToken: cts.Token);
|
||||
Assert.False(t.IsCompleted);
|
||||
if (!preCanceled)
|
||||
{
|
||||
Assert.False(t.IsCompleted);
|
||||
}
|
||||
|
||||
if (!preCanceled)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ public abstract class ClientServerTestBase : LoggedTest, IAsyncDisposable
|
|||
Dispose();
|
||||
}
|
||||
|
||||
protected async Task<IMcpClient> CreateMcpClientForServer(McpClientOptions? options = null)
|
||||
protected async Task<IMcpClient> CreateMcpClientForServer()
|
||||
{
|
||||
return await McpClientFactory.CreateAsync(
|
||||
new StreamClientTransport(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
<DisableTestingPlatformServerCapability>true</DisableTestingPlatformServerCapability>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Common\**\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
|
@ -55,13 +59,6 @@
|
|||
<ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Dependencies only needed by net472 -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
|
||||
<Compile Include="..\..\src\Common\Throw.cs" Link="Utils\Throw.cs" />
|
||||
<Compile Include="..\..\src\Common\Polyfills\**\*.cs" />
|
||||
<PackageReference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Condition="$([MSBuild]::IsOSPlatform('Windows'))" Include="$([System.IO.Path]::GetFullPath('$(ArtifactsBinDir)ModelContextProtocol.TestServer\$(Configuration)'))\$(TargetFramework)\TestServer.exe">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Utils;
|
||||
|
||||
public class TestServerTransport : ITransport
|
||||
{
|
||||
private readonly Channel<JsonRpcMessage> _messageChannel;
|
||||
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
public ChannelReader<JsonRpcMessage> MessageReader => _messageChannel;
|
||||
|
||||
public List<JsonRpcMessage> SentMessages { get; } = [];
|
||||
|
||||
public Action<JsonRpcMessage>? OnMessageSent { get; set; }
|
||||
|
||||
public TestServerTransport()
|
||||
{
|
||||
_messageChannel = Channel.CreateUnbounded<JsonRpcMessage>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
});
|
||||
IsConnected = true;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_messageChannel.Writer.TryComplete();
|
||||
IsConnected = false;
|
||||
return default;
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(JsonRpcMessage 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 = []
|
||||
}, McpJsonUtilities.DefaultOptions),
|
||||
}, 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.User }, McpJsonUtilities.DefaultOptions),
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task WriteMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _messageChannel.Writer.WriteAsync(message, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ModelContextProtocol.Tests.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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue