Add client conformance tests (#1102)

Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
Mike Kistler 2026-01-09 07:26:50 -06:00 • committed by GitHub
commit 14f9735c19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 343 additions and 40 deletions

View file

@ -70,6 +70,7 @@
<Folder Name="/tests/">
<Project Path="tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.ConformanceClient/ModelContextProtocol.ConformanceClient.csproj" />
<Project Path="tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj" />
<Project Path="tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj" />
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />

View file

@ -0,0 +1,70 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace ModelContextProtocol.Tests.Utils;
/// <summary>
/// Helper utilities for Node.js and npm operations.
/// </summary>
public static class NodeHelpers
{
/// <summary>
/// Creates a ProcessStartInfo configured to run npx with the specified arguments.
/// </summary>
/// <param name="arguments">The arguments to pass to npx.</param>
/// <returns>A configured ProcessStartInfo for running npx.</returns>
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
};
}
}
/// <summary>
/// Checks if Node.js and npx are installed and available on the system.
/// </summary>
/// <returns>True if npx is available, false otherwise.</returns>
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;
}
}
}

View file

@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Text;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
/// <summary>
/// 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.
/// </summary>
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()
);
}
}

View file

@ -57,6 +57,7 @@
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
<ProjectReference Include="..\ModelContextProtocol.TestSseServer\ModelContextProtocol.TestSseServer.csproj" />
<ProjectReference Include="..\ModelContextProtocol.TestOAuthServer\ModelContextProtocol.TestOAuthServer.csproj" />
<ProjectReference Include="..\ModelContextProtocol.ConformanceClient\ModelContextProtocol.ConformanceClient.csproj" />
<ProjectReference Include="..\ModelContextProtocol.ConformanceServer\ModelContextProtocol.ConformanceServer.csproj" />
</ItemGroup>

View file

@ -98,7 +98,7 @@ public class ServerConformanceTests : IAsyncLifetime
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 @@ public class ServerConformanceTests : IAsyncLifetime
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 @@ public class ServerConformanceTests : IAsyncLifetime
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;
}
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
<!-- For better test coverage, only disable reflection in one of the targets -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>
</Project>

View file

@ -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 <scenario> [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<string, object?>
{
{ "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<string, object?>
{
{ "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<string?> 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;
}

View file

@ -5,7 +5,6 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<AssemblyName>ConformanceServer</AssemblyName>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">