Skip to content

Commit 39ab6cd

Browse files
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 <[email protected]> * Revert "Fix merge conflicts." This reverts commit 852169b. * Fix merge conflicts --------- Co-authored-by: Stephen Halter <[email protected]>
1 parent 08f9cdb commit 39ab6cd

32 files changed

+324
-63
lines changed

Directory.Packages.props

+1
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,6 @@
6666
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="$(System10Version)" />
6767
<PackageVersion Include="xunit.v3" Version="2.0.1" />
6868
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
69+
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
6970
</ItemGroup>
7071
</Project>

ModelContextProtocol.sln

+7
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EverythingServer", "samples
5454
EndProject
5555
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore", "src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj", "{37B6A5E0-9995-497D-8B43-3BC6870CC716}"
5656
EndProject
57+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore.Tests", "tests\ModelContextProtocol.AspNetCore.Tests\ModelContextProtocol.AspNetCore.Tests.csproj", "{85557BA6-3D29-4C95-A646-2A972B1C2F25}"
58+
EndProject
5759
Global
5860
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5961
Debug|Any CPU = Debug|Any CPU
@@ -104,6 +106,10 @@ Global
104106
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Debug|Any CPU.Build.0 = Debug|Any CPU
105107
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.ActiveCfg = Release|Any CPU
106108
{37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.Build.0 = Release|Any CPU
109+
{85557BA6-3D29-4C95-A646-2A972B1C2F25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
110+
{85557BA6-3D29-4C95-A646-2A972B1C2F25}.Debug|Any CPU.Build.0 = Debug|Any CPU
111+
{85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.ActiveCfg = Release|Any CPU
112+
{85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.Build.0 = Release|Any CPU
107113
EndGlobalSection
108114
GlobalSection(SolutionProperties) = preSolution
109115
HideSolutionNode = FALSE
@@ -121,6 +127,7 @@ Global
121127
{0D1552DC-E6ED-4AAC-5562-12F8352F46AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
122128
{17B8453F-AB72-99C5-E5EA-D0B065A6AE65} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
123129
{37B6A5E0-9995-497D-8B43-3BC6870CC716} = {A2F1F52A-9107-4BF8-8C3F-2F6670E7D0AD}
130+
{85557BA6-3D29-4C95-A646-2A972B1C2F25} = {2A77AF5C-138A-4EBB-9A13-9205DCD67928}
124131
EndGlobalSection
125132
GlobalSection(ExtensibilityGlobals) = postSolution
126133
SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89}

samples/TestServerWithHosting/Program.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@
3535
}
3636
finally
3737
{
38-
await Log.CloseAndFlushAsync();
38+
Log.CloseAndFlush();
3939
}

samples/TestServerWithHosting/TestServerWithHosting.csproj

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
5+
<TargetFrameworks>net9.0;net8.0;net472</TargetFrameworks>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
88
<!--

src/Common/Polyfills/System/Diagnostics/ProcessExtensions.cs

+24
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,28 @@ public static void Kill(this Process process, bool entireProcessTree)
1010
_ = entireProcessTree;
1111
process.Kill();
1212
}
13+
14+
public static async Task WaitForExitAsync(this Process process, CancellationToken cancellationToken = default)
15+
{
16+
if (process.HasExited)
17+
{
18+
return;
19+
}
20+
21+
var tcs = new TaskCompletionSource<bool>();
22+
void ProcessExitedHandler(object? sender, EventArgs e) => tcs.TrySetResult(true);
23+
24+
try
25+
{
26+
process.EnableRaisingEvents = true;
27+
process.Exited += ProcessExitedHandler;
28+
29+
using var _ = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
30+
await tcs.Task.ConfigureAwait(false);
31+
}
32+
finally
33+
{
34+
process.Exited -= ProcessExitedHandler;
35+
}
36+
}
1337
}

src/Common/Polyfills/System/IO/TextReaderExtensions.cs

+1-15
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,7 @@ internal static class TextReaderExtensions
44
{
55
public static Task<string> ReadLineAsync(this TextReader reader, CancellationToken cancellationToken)
66
{
7-
if (cancellationToken.IsCancellationRequested)
8-
{
9-
return Task.FromCanceled<string>(cancellationToken);
10-
}
11-
7+
cancellationToken.ThrowIfCancellationRequested();
128
return reader.ReadLineAsync();
139
}
14-
15-
public static Task<string> ReadToEndAsync(this TextReader reader, CancellationToken cancellationToken)
16-
{
17-
if (cancellationToken.IsCancellationRequested)
18-
{
19-
return Task.FromCanceled<string>(cancellationToken);
20-
}
21-
22-
return reader.ReadToEndAsync();
23-
}
2410
}

src/Common/Polyfills/System/IO/TextWriterExtensions.cs

-27
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,6 @@ namespace System.IO;
55

66
internal static class TextWriterExtensions
77
{
8-
public static async Task WriteLineAsync(this TextWriter writer, ReadOnlyMemory<char> value, CancellationToken cancellationToken)
9-
{
10-
Throw.IfNull(writer);
11-
12-
if (value.IsEmpty)
13-
{
14-
return;
15-
}
16-
17-
cancellationToken.ThrowIfCancellationRequested();
18-
19-
if (MemoryMarshal.TryGetString(value, out string str, out int start, out int length) &&
20-
start == 0 && length == str.Length)
21-
{
22-
await writer.WriteLineAsync(str).ConfigureAwait(false);
23-
}
24-
else if (MemoryMarshal.TryGetArray(value, out ArraySegment<char> array) &&
25-
array.Array is not null && array.Offset == 0 && array.Count == array.Array.Length)
26-
{
27-
await writer.WriteLineAsync(array.Array).ConfigureAwait(false);
28-
}
29-
else
30-
{
31-
await writer.WriteLineAsync(value.ToArray()).ConfigureAwait(false);
32-
}
33-
}
34-
358
public static async Task FlushAsync(this TextWriter writer, CancellationToken cancellationToken)
369
{
3710
cancellationToken.ThrowIfCancellationRequested();

src/Common/Polyfills/System/Threading/Tasks/TaskExtensions.cs

+8-3
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ public static Task WaitAsync(this Task task, CancellationToken cancellationToken
99
return WaitAsync(task, Timeout.InfiniteTimeSpan, cancellationToken);
1010
}
1111

12-
public static async Task<T> WaitAsync<T>(this Task<T> task, CancellationToken cancellationToken)
12+
public static Task<T> WaitAsync<T>(this Task<T> task, CancellationToken cancellationToken)
1313
{
14-
await WaitAsync(task, Timeout.InfiniteTimeSpan, cancellationToken);
14+
return WaitAsync(task, Timeout.InfiniteTimeSpan, cancellationToken);
15+
}
16+
17+
public static async Task<T> WaitAsync<T>(this Task<T> task, TimeSpan timeout, CancellationToken cancellationToken = default)
18+
{
19+
await WaitAsync((Task)task, timeout, cancellationToken).ConfigureAwait(false);
1520
return task.Result;
1621
}
1722

18-
public static async Task WaitAsync(this Task task, TimeSpan timeout, CancellationToken cancellationToken)
23+
public static async Task WaitAsync(this Task task, TimeSpan timeout, CancellationToken cancellationToken = default)
1924
{
2025
Throw.IfNull(task);
2126

File renamed without changes.

src/ModelContextProtocol/ModelContextProtocol.csproj

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
<IsAotCompatible>true</IsAotCompatible>
1515
</PropertyGroup>
1616

17+
<ItemGroup>
18+
<Compile Include="..\Common\Throw.cs" Link="Utils\Throw.cs" />
19+
</ItemGroup>
20+
1721
<!-- Dependencies only needed by netstandard2.0 -->
1822
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
1923
<Compile Include="..\Common\Polyfills\**\*.cs" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
global using Xunit;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<LangVersion>Latest</LangVersion>
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
<RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace>
11+
</PropertyGroup>
12+
13+
<PropertyGroup>
14+
<!-- Without this, tests are currently not showing results until all tests complete
15+
https://xunit.net/docs/getting-started/v3/microsoft-testing-platform
16+
-->
17+
<DisableTestingPlatformServerCapability>true</DisableTestingPlatformServerCapability>
18+
</PropertyGroup>
19+
20+
<ItemGroup>
21+
<PackageReference Include="coverlet.collector">
22+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
23+
<PrivateAssets>all</PrivateAssets>
24+
</PackageReference>
25+
<PackageReference Include="GitHubActionsTestLogger">
26+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
27+
<PrivateAssets>all</PrivateAssets>
28+
</PackageReference>
29+
<PackageReference Include="Microsoft.Extensions.AI" />
30+
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
31+
<PackageReference Include="Microsoft.Extensions.Logging" />
32+
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
33+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
34+
<PackageReference Include="Moq" />
35+
<PackageReference Include="OpenTelemetry" />
36+
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
37+
<PackageReference Include="System.Linq.AsyncEnumerable" />
38+
<PackageReference Include="xunit.v3" />
39+
<PackageReference Include="xunit.runner.visualstudio">
40+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
41+
<PrivateAssets>all</PrivateAssets>
42+
</PackageReference>
43+
</ItemGroup>
44+
45+
<ItemGroup>
46+
<ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" />
47+
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
48+
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
49+
<ProjectReference Include="..\ModelContextProtocol.TestSseServer\ModelContextProtocol.TestSseServer.csproj" />
50+
</ItemGroup>
51+
52+
</Project>

tests/ModelContextProtocol.Tests/Server/MapMcpTests.cs renamed to tests/ModelContextProtocol.AspNetCore.Tests/Server/MapMcpTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using Microsoft.AspNetCore.Builder;
22
using ModelContextProtocol.Tests.Utils;
33

4-
namespace ModelContextProtocol.Tests.Server;
4+
namespace ModelContextProtocol.AspNetCore.Tests.Server;
55

66
public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
77
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace ModelContextProtocol.Tests.Utils;
2+
3+
public class DelegatingTestOutputHelper : ITestOutputHelper
4+
{
5+
public ITestOutputHelper? CurrentTestOutputHelper { get; set; }
6+
7+
public string Output => CurrentTestOutputHelper?.Output ?? string.Empty;
8+
9+
public void Write(string message) => CurrentTestOutputHelper?.Write(message);
10+
public void Write(string format, params object[] args) => CurrentTestOutputHelper?.Write(format, args);
11+
public void WriteLine(string message) => CurrentTestOutputHelper?.WriteLine(message);
12+
public void WriteLine(string format, params object[] args) => CurrentTestOutputHelper?.WriteLine(format, args);
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using Microsoft.Extensions.Logging;
2+
using ModelContextProtocol.Test.Utils;
3+
4+
namespace ModelContextProtocol.Tests.Utils;
5+
6+
public class LoggedTest : IDisposable
7+
{
8+
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper;
9+
10+
public LoggedTest(ITestOutputHelper testOutputHelper)
11+
{
12+
_delegatingTestOutputHelper = new()
13+
{
14+
CurrentTestOutputHelper = testOutputHelper,
15+
};
16+
LoggerProvider = new XunitLoggerProvider(_delegatingTestOutputHelper);
17+
LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
18+
{
19+
builder.AddProvider(LoggerProvider);
20+
});
21+
}
22+
23+
public ITestOutputHelper TestOutputHelper => _delegatingTestOutputHelper;
24+
public ILoggerFactory LoggerFactory { get; }
25+
public ILoggerProvider LoggerProvider { get; }
26+
27+
public virtual void Dispose()
28+
{
29+
_delegatingTestOutputHelper.CurrentTestOutputHelper = null;
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace ModelContextProtocol.Tests.Utils;
2+
3+
public class MockHttpHandler : HttpMessageHandler
4+
{
5+
public Func<HttpRequestMessage, Task<HttpResponseMessage>>? RequestHandler { get; set; }
6+
7+
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
8+
{
9+
if (RequestHandler == null)
10+
throw new InvalidOperationException($"No {nameof(RequestHandler)} was set! Please set handler first and make request afterwards.");
11+
12+
cancellationToken.ThrowIfCancellationRequested();
13+
14+
var result = await RequestHandler.Invoke(request);
15+
16+
cancellationToken.ThrowIfCancellationRequested();
17+
18+
return result;
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using ModelContextProtocol.Protocol.Messages;
2+
using ModelContextProtocol.Protocol.Transport;
3+
using ModelContextProtocol.Protocol.Types;
4+
using System.Text.Json;
5+
using System.Threading.Channels;
6+
7+
namespace ModelContextProtocol.Tests.Utils;
8+
9+
public class TestServerTransport : ITransport
10+
{
11+
private readonly Channel<IJsonRpcMessage> _messageChannel;
12+
13+
public bool IsConnected { get; set; }
14+
15+
public ChannelReader<IJsonRpcMessage> MessageReader => _messageChannel;
16+
17+
public List<IJsonRpcMessage> SentMessages { get; } = [];
18+
19+
public Action<IJsonRpcMessage>? OnMessageSent { get; set; }
20+
21+
public TestServerTransport()
22+
{
23+
_messageChannel = Channel.CreateUnbounded<IJsonRpcMessage>(new UnboundedChannelOptions
24+
{
25+
SingleReader = true,
26+
SingleWriter = true,
27+
});
28+
IsConnected = true;
29+
}
30+
31+
public ValueTask DisposeAsync()
32+
{
33+
_messageChannel.Writer.TryComplete();
34+
IsConnected = false;
35+
return ValueTask.CompletedTask;
36+
}
37+
38+
public async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
39+
{
40+
SentMessages.Add(message);
41+
if (message is JsonRpcRequest request)
42+
{
43+
if (request.Method == RequestMethods.RootsList)
44+
await ListRoots(request, cancellationToken);
45+
else if (request.Method == RequestMethods.SamplingCreateMessage)
46+
await Sampling(request, cancellationToken);
47+
else
48+
await WriteMessageAsync(request, cancellationToken);
49+
}
50+
else if (message is JsonRpcNotification notification)
51+
{
52+
await WriteMessageAsync(notification, cancellationToken);
53+
}
54+
55+
OnMessageSent?.Invoke(message);
56+
}
57+
58+
private async Task ListRoots(JsonRpcRequest request, CancellationToken cancellationToken)
59+
{
60+
await WriteMessageAsync(new JsonRpcResponse
61+
{
62+
Id = request.Id,
63+
Result = JsonSerializer.SerializeToNode(new ListRootsResult
64+
{
65+
Roots = []
66+
}),
67+
}, cancellationToken);
68+
}
69+
70+
private async Task Sampling(JsonRpcRequest request, CancellationToken cancellationToken)
71+
{
72+
await WriteMessageAsync(new JsonRpcResponse
73+
{
74+
Id = request.Id,
75+
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Content = new(), Model = "model", Role = "role" }),
76+
}, cancellationToken);
77+
}
78+
79+
private async Task WriteMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
80+
{
81+
await _messageChannel.Writer.WriteAsync(message, cancellationToken);
82+
}
83+
}

0 commit comments

Comments
 (0)