Add client conformance tests (#1102)
Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
parent
f3fb617ffb
commit
14f9735c19
8 changed files with 343 additions and 40 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
70
tests/Common/Utils/NodeHelpers.cs
Normal file
70
tests/Common/Utils/NodeHelpers.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
147
tests/ModelContextProtocol.ConformanceClient/Program.cs
Normal file
147
tests/ModelContextProtocol.ConformanceClient/Program.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>ConformanceServer</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||
|
|
|
|||
Loading…
Reference in a new issue