diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 72ecc778d..74dd56ab2 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -70,6 +70,7 @@ + diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs new file mode 100644 index 000000000..70e02d6c2 --- /dev/null +++ b/tests/Common/Utils/NodeHelpers.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace ModelContextProtocol.Tests.Utils; + +/// +/// Helper utilities for Node.js and npm operations. +/// +public static class NodeHelpers +{ + /// + /// Creates a ProcessStartInfo configured to run npx with the specified arguments. + /// + /// The arguments to pass to npx. + /// A configured ProcessStartInfo for running npx. + public static ProcessStartInfo NpxStartInfo(string arguments) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On Windows, npx is a PowerShell script, so we need to use cmd.exe to invoke it + return new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c npx {arguments}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + } + else + { + // On Unix-like systems, npx is typically a shell script that can be executed directly + return new ProcessStartInfo + { + FileName = "npx", + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + } + } + + /// + /// Checks if Node.js and npx are installed and available on the system. + /// + /// True if npx is available, false otherwise. + public static bool IsNpxInstalled() + { + try + { + var startInfo = NpxStartInfo("--version"); + + using var process = Process.Start(startInfo); + if (process == null) + { + return false; + } + + process.WaitForExit(5000); + return process.ExitCode == 0; + } + catch + { + return false; + } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs new file mode 100644 index 000000000..7a92cd67c --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using System.Text; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.ConformanceTests; + +/// +/// Runs the official MCP conformance tests against the ConformanceClient. +/// This test runs the Node.js-based conformance test suite for the client +/// and reports the results. +/// +public class ClientConformanceTests //: IAsyncLifetime +{ + private readonly ITestOutputHelper _output; + + // Public static property required for SkipUnless attribute + public static bool IsNpxInstalled => NodeHelpers.IsNpxInstalled(); + + public ClientConformanceTests(ITestOutputHelper output) + { + _output = output; + } + + [Theory(Skip = "npx is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNpxInstalled))] + [InlineData("initialize")] + [InlineData("tools_call")] + [InlineData("auth/metadata-default")] + [InlineData("auth/metadata-var1")] + [InlineData("auth/metadata-var2")] + [InlineData("auth/metadata-var3")] + [InlineData("auth/basic-cimd")] + // [InlineData("auth/2025-03-26-oauth-metadata-backcompat")] + // [InlineData("auth/2025-03-26-oauth-endpoint-fallback")] + [InlineData("auth/scope-from-www-authenticate")] + [InlineData("auth/scope-from-scopes-supported")] + [InlineData("auth/scope-omitted-when-undefined")] + [InlineData("auth/scope-step-up")] + public async Task RunConformanceTest(string scenario) + { + // Run the conformance test suite + var result = await RunClientConformanceScenario(scenario); + + // Report the results + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + + private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario) + { + // Construct an absolute path to the conformance client executable + var exeSuffix = OperatingSystem.IsWindows() ? ".exe" : ""; + var conformanceClientPath = Path.GetFullPath($"./ModelContextProtocol.ConformanceClient{exeSuffix}"); + // Replace AspNetCore.Tests with ConformanceClient in the path + conformanceClientPath = conformanceClientPath.Replace("AspNetCore.Tests", "ConformanceClient"); + + if (!File.Exists(conformanceClientPath)) + { + throw new FileNotFoundException( + $"ConformanceClient executable not found at: {conformanceClientPath}"); + } + + var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance client --scenario {scenario} --command \"{conformanceClientPath} {scenario}\""); + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + var process = new Process { StartInfo = startInfo }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + _output.WriteLine(e.Data); + outputBuilder.AppendLine(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + _output.WriteLine(e.Data); + errorBuilder.AppendLine(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(); + + return ( + Success: process.ExitCode == 0, + Output: outputBuilder.ToString(), + Error: errorBuilder.ToString() + ); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 128b5158c..095560a22 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -57,6 +57,7 @@ + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 8c0055fe8..312f32457 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -98,7 +98,7 @@ public async ValueTask DisposeAsync() public async Task RunConformanceTests() { // Check if Node.js is installed - Assert.SkipWhen(!IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen(!NodeHelpers.IsNpxInstalled(), "Node.js is not installed. Skipping conformance tests."); // Run the conformance test suite var result = await RunNpxConformanceTests(); @@ -117,15 +117,7 @@ private void StartConformanceServer() private async Task<(bool Success, string Output, string Error)> RunNpxConformanceTests() { - var startInfo = new ProcessStartInfo - { - FileName = "npx", - Arguments = $"-y @modelcontextprotocol/conformance server --url {_serverUrl}", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; + var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance server --url {_serverUrl}"); var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); @@ -162,33 +154,4 @@ private void StartConformanceServer() Error: errorBuilder.ToString() ); } - - private static bool IsNodeInstalled() - { - try - { - var startInfo = new ProcessStartInfo - { - FileName = "npx", // Check specifically for npx because windows seems unable to find it - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(startInfo); - if (process == null) - { - return false; - } - - process.WaitForExit(5000); - return process.ExitCode == 0; - } - catch - { - return false; - } - } } diff --git a/tests/ModelContextProtocol.ConformanceClient/ModelContextProtocol.ConformanceClient.csproj b/tests/ModelContextProtocol.ConformanceClient/ModelContextProtocol.ConformanceClient.csproj new file mode 100644 index 000000000..e6cfad564 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceClient/ModelContextProtocol.ConformanceClient.csproj @@ -0,0 +1,23 @@ + + + + net10.0;net9.0;net8.0 + enable + enable + Exe + + + + + false + + + + + + + + + + + diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs new file mode 100644 index 000000000..e2f09e88f --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Web; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +// This program expects the following command-line arguments: +// 1. The client conformance test scenario to run (e.g., "tools_call") +// 2. The endpoint URL (e.g., "http://localhost:3001") + +if (args.Length < 2) +{ + Console.WriteLine("Usage: dotnet run --project ModelContextProtocol.ConformanceClient.csproj [endpoint]"); + return 1; +} + +var scenario = args[0]; +var endpoint = args[1]; + +McpClientOptions options = new() +{ + ClientInfo = new() + { + Name = "ConformanceClient", + Version = "1.0.0" + } +}; + +var consoleLoggerFactory = LoggerFactory.Create(builder => +{ + builder.AddConsole(); +}); + +// Configure OAuth callback port via environment or pick an ephemeral port. +var callbackPortEnv = Environment.GetEnvironmentVariable("OAUTH_CALLBACK_PORT"); +int callbackPort = 0; +if (!string.IsNullOrEmpty(callbackPortEnv) && int.TryParse(callbackPortEnv, out var parsedPort)) +{ + callbackPort = parsedPort; +} + +if (callbackPort == 0) +{ + var tcp = new TcpListener(IPAddress.Loopback, 0); + tcp.Start(); + callbackPort = ((IPEndPoint)tcp.LocalEndpoint).Port; + tcp.Stop(); +} + +var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback"); + +var clientTransport = new HttpClientTransport(new() +{ + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.StreamableHttp, + OAuth = new() + { + RedirectUri = clientRedirectUri, + // Configure the metadata document URI for CIMD. + ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"), + AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct), + DynamicClientRegistration = new() + { + ClientName = "ProtectedMcpClient", + }, + } +}, loggerFactory: consoleLoggerFactory); + +await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory); + +bool success = true; + +switch (scenario) +{ + case "tools_call": + { + var tools = await mcpClient.ListToolsAsync(); + Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}"); + + // Call the "add_numbers" tool + var toolName = "add_numbers"; + Console.WriteLine($"Calling tool: {toolName}"); + var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary + { + { "a", 5 }, + { "b", 10 } + }); + success &= !(result.IsError == true); + break; + } + case "auth/scope-step-up": + { + // Just testing that we can authenticate and list tools + var tools = await mcpClient.ListToolsAsync(); + Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}"); + + // Call the "test_tool" tool + var toolName = tools.FirstOrDefault()?.Name ?? "test-tool"; + Console.WriteLine($"Calling tool: {toolName}"); + var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary + { + { "foo", "bar" }, + }); + success &= !(result.IsError == true); + break; + } + default: + // No extra processing for other scenarios + break; +} + +// Exit code 0 on success, 1 on failure +return success ? 0 : 1; + +// Copied from ProtectedMcpClient sample +// Simulate a user opening the browser and logging in +// Copied from OAuthTestBase +static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) +{ + Console.WriteLine("Starting OAuth authorization flow..."); + Console.WriteLine($"Simulating opening browser to: {authorizationUrl}"); + + using var handler = new HttpClientHandler() + { + AllowAutoRedirect = false, + }; + using var httpClient = new HttpClient(handler); + using var redirectResponse = await httpClient.GetAsync(authorizationUrl, cancellationToken); + var location = redirectResponse.Headers.Location; + + if (location is not null && !string.IsNullOrEmpty(location.Query)) + { + // Parse query string to extract "code" parameter + var query = location.Query.TrimStart('?'); + foreach (var pair in query.Split('&')) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2 && parts[0] == "code") + { + return HttpUtility.UrlDecode(parts[1]); + } + } + } + + return null; +} diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index 73f4f89bc..15b2c87f2 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -5,7 +5,6 @@ enable enable Exe - ConformanceServer