diff --git a/Directory.Packages.props b/Directory.Packages.props index dbdf26dcc..acdc0ee88 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -66,5 +66,6 @@ + \ No newline at end of file diff --git a/ModelContextProtocol.sln b/ModelContextProtocol.sln index 1ceb3a230..0e4fd7214 100644 --- a/ModelContextProtocol.sln +++ b/ModelContextProtocol.sln @@ -54,6 +54,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EverythingServer", "samples EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore", "src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj", "{37B6A5E0-9995-497D-8B43-3BC6870CC716}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore.Tests", "tests\ModelContextProtocol.AspNetCore.Tests\ModelContextProtocol.AspNetCore.Tests.csproj", "{85557BA6-3D29-4C95-A646-2A972B1C2F25}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -104,6 +106,10 @@ Global {37B6A5E0-9995-497D-8B43-3BC6870CC716}.Debug|Any CPU.Build.0 = Debug|Any CPU {37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.ActiveCfg = Release|Any CPU {37B6A5E0-9995-497D-8B43-3BC6870CC716}.Release|Any CPU.Build.0 = Release|Any CPU + {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -121,6 +127,7 @@ Global {0D1552DC-E6ED-4AAC-5562-12F8352F46AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {17B8453F-AB72-99C5-E5EA-D0B065A6AE65} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {37B6A5E0-9995-497D-8B43-3BC6870CC716} = {A2F1F52A-9107-4BF8-8C3F-2F6670E7D0AD} + {85557BA6-3D29-4C95-A646-2A972B1C2F25} = {2A77AF5C-138A-4EBB-9A13-9205DCD67928} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89} diff --git a/samples/TestServerWithHosting/Program.cs b/samples/TestServerWithHosting/Program.cs index 1ab6fc7a2..33d540311 100644 --- a/samples/TestServerWithHosting/Program.cs +++ b/samples/TestServerWithHosting/Program.cs @@ -35,5 +35,5 @@ } finally { - await Log.CloseAndFlushAsync(); + Log.CloseAndFlush(); } \ No newline at end of file diff --git a/samples/TestServerWithHosting/TestServerWithHosting.csproj b/samples/TestServerWithHosting/TestServerWithHosting.csproj index d12277e25..9ddb6190d 100644 --- a/samples/TestServerWithHosting/TestServerWithHosting.csproj +++ b/samples/TestServerWithHosting/TestServerWithHosting.csproj @@ -2,7 +2,7 @@ Exe - net9.0;net8.0 + net9.0;net8.0;net472 enable enable diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/GlobalUsings.cs b/tests/ModelContextProtocol.AspNetCore.Tests/GlobalUsings.cs new file mode 100644 index 000000000..8c927eb74 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj new file mode 100644 index 000000000..5e0e028b3 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -0,0 +1,52 @@ + + + + net9.0;net8.0 + enable + enable + Latest + false + true + ModelContextProtocol.AspNetCore.Tests + + + + + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/tests/ModelContextProtocol.Tests/Server/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Server/MapMcpTests.cs similarity index 92% rename from tests/ModelContextProtocol.Tests/Server/MapMcpTests.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/Server/MapMcpTests.cs index f8a80d665..5a3c4181f 100644 --- a/tests/ModelContextProtocol.Tests/Server/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Server/MapMcpTests.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Builder; using ModelContextProtocol.Tests.Utils; -namespace ModelContextProtocol.Tests.Server; +namespace ModelContextProtocol.AspNetCore.Tests.Server; public class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper) { diff --git a/tests/ModelContextProtocol.Tests/SseIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs similarity index 100% rename from tests/ModelContextProtocol.Tests/SseIntegrationTests.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs diff --git a/tests/ModelContextProtocol.Tests/SseServerIntegrationTestFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTestFixture.cs similarity index 100% rename from tests/ModelContextProtocol.Tests/SseServerIntegrationTestFixture.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTestFixture.cs diff --git a/tests/ModelContextProtocol.Tests/SseServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTests.cs similarity index 100% rename from tests/ModelContextProtocol.Tests/SseServerIntegrationTests.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/SseServerIntegrationTests.cs diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/DelegatingTestOutputHelper.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/DelegatingTestOutputHelper.cs new file mode 100644 index 000000000..ef452fcba --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/DelegatingTestOutputHelper.cs @@ -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); +} diff --git a/tests/ModelContextProtocol.Tests/Utils/KestrelInMemoryConnection.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs similarity index 100% rename from tests/ModelContextProtocol.Tests/Utils/KestrelInMemoryConnection.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs diff --git a/tests/ModelContextProtocol.Tests/Utils/KestrelInMemoryTest.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTest.cs similarity index 100% rename from tests/ModelContextProtocol.Tests/Utils/KestrelInMemoryTest.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTest.cs diff --git a/tests/ModelContextProtocol.Tests/Utils/KestrelInMemoryTransport.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTransport.cs similarity index 100% rename from tests/ModelContextProtocol.Tests/Utils/KestrelInMemoryTransport.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryTransport.cs diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/LoggedTest.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/LoggedTest.cs new file mode 100644 index 000000000..aa1ecbc27 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/LoggedTest.cs @@ -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; + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/MockHttpHandler.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/MockHttpHandler.cs new file mode 100644 index 000000000..5e58a6cd5 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/MockHttpHandler.cs @@ -0,0 +1,20 @@ +namespace ModelContextProtocol.Tests.Utils; + +public class MockHttpHandler : HttpMessageHandler +{ + public Func>? RequestHandler { get; set; } + + protected async override Task 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; + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/TestServerTransport.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/TestServerTransport.cs new file mode 100644 index 000000000..f21660143 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/TestServerTransport.cs @@ -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 _messageChannel; + + public bool IsConnected { get; set; } + + public ChannelReader MessageReader => _messageChannel; + + public List SentMessages { get; } = []; + + public Action? OnMessageSent { get; set; } + + public TestServerTransport() + { + _messageChannel = Channel.CreateUnbounded(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); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/XunitLoggerProvider.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/XunitLoggerProvider.cs new file mode 100644 index 000000000..c76d2649a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/XunitLoggerProvider.cs @@ -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( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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 state) where TState : notnull + => new NoopDisposable(); + + private sealed class NoopDisposable : IDisposable + { + public void Dispose() + { + } + } + } +} diff --git a/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj b/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj index fb6320a07..060732bfc 100644 --- a/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj +++ b/tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj @@ -2,7 +2,7 @@ Exe - net9.0;net8.0 + net9.0;net8.0;net472 enable enable TestServer diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index f650e23c7..ee97f7bb7 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -64,7 +64,8 @@ private static async Task Main(string[] args) private static async Task RunBackgroundLoop(IMcpServer server, CancellationToken cancellationToken = default) { - var loggingLevels = Enum.GetValues(); + var loggingLevels = (LoggingLevel[])Enum.GetValues(typeof(LoggingLevel)); + var random = new Random(); while (true) { @@ -74,7 +75,7 @@ private static async Task RunBackgroundLoop(IMcpServer server, CancellationToken // Send random log messages every few seconds if (_minimumLoggingLevel is not null) { - var logLevel = loggingLevels[Random.Shared.Next(loggingLevels.Length)]; + var logLevel = loggingLevels[random.Next(loggingLevels.Length)]; await server.SendMessageAsync(new JsonRpcNotification() { Method = NotificationMethods.LoggingMessageNotification, @@ -468,7 +469,7 @@ private static ResourcesCapability ConfigureResources() throw new McpException("Invalid resource URI"); } - _subscribedResources.Remove(request.Params.Uri, out _); + _subscribedResources.TryRemove(request.Params.Uri, out _); return Task.FromResult(new EmptyResult()); }, diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs index 5cd78da97..341203304 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs @@ -1,6 +1,7 @@ -using ModelContextProtocol.Client; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; -using Microsoft.Extensions.Logging; +using System.Runtime.InteropServices; namespace ModelContextProtocol.Tests; @@ -25,11 +26,11 @@ public ClientIntegrationTestFixture() TestServerTransportOptions = new() { - Command = OperatingSystem.IsWindows() ? "TestServer.exe" : "dotnet", + Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "TestServer.exe" : "dotnet", Name = "TestServer", }; - if (!OperatingSystem.IsWindows()) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Change to Arguments to "mcp-server-everything" if you want to run the server locally after creating a symlink TestServerTransportOptions.Arguments = ["TestServer.dll"]; diff --git a/tests/ModelContextProtocol.Tests/GlobalUsings.cs b/tests/ModelContextProtocol.Tests/GlobalUsings.cs index 8c927eb74..6d129626d 100644 --- a/tests/ModelContextProtocol.Tests/GlobalUsings.cs +++ b/tests/ModelContextProtocol.Tests/GlobalUsings.cs @@ -1 +1,2 @@ -global using Xunit; \ No newline at end of file +global using Xunit; +global using System.Net.Http; \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index a72caf55d..e3b4345ca 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -1,6 +1,7 @@  + Exe net9.0;net8.0 enable enable @@ -35,7 +36,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -46,10 +47,16 @@ - + + + + + + + PreserveNewest diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index a30247028..054a47985 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs index 7e79356e4..e6164786f 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs @@ -187,12 +187,12 @@ public async Task SendMessageAsync_Should_Preserve_Unicode_Characters() // Magnifying glass emoji: 🔍 (U+1F50D) bool magnifyingGlassFound = emojiResult.Contains("🔍") || - emojiResult.Contains("\\ud83d\\udd0d", StringComparison.OrdinalIgnoreCase); + emojiResult.IndexOf("\\ud83d\\udd0d", StringComparison.OrdinalIgnoreCase) >= 0; // Rocket emoji: 🚀 (U+1F680) bool rocketFound = emojiResult.Contains("🚀") || - emojiResult.Contains("\\ud83d\\ude80", StringComparison.OrdinalIgnoreCase); + 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"); diff --git a/tests/ModelContextProtocol.Tests/Utils/TestServerTransport.cs b/tests/ModelContextProtocol.Tests/Utils/TestServerTransport.cs index f21660143..ed9b2e04d 100644 --- a/tests/ModelContextProtocol.Tests/Utils/TestServerTransport.cs +++ b/tests/ModelContextProtocol.Tests/Utils/TestServerTransport.cs @@ -32,7 +32,7 @@ public ValueTask DisposeAsync() { _messageChannel.Writer.TryComplete(); IsConnected = false; - return ValueTask.CompletedTask; + return default; } public async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)