Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ jobs:
- name: 📦 Install dependencies for tests
run: npm install @modelcontextprotocol/server-memory

- name: 📦 Install dependencies for tests
run: npm install @modelcontextprotocol/conformance

- name: 🏗️ Build
run: make build CONFIGURATION=${{ matrix.configuration }}

Expand Down
1 change: 1 addition & 0 deletions ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -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.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj" />
<Project Path="tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj" />
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
Expand Down
7 changes: 5 additions & 2 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static partial class McpJsonUtilities
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in the ModelContextProtocol library.
/// </para>
/// <para>
Expand Down Expand Up @@ -88,7 +88,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]

// JSON-RPC
[JsonSerializable(typeof(JsonRpcMessage))]
[JsonSerializable(typeof(JsonRpcMessage[]))]
Expand Down Expand Up @@ -146,7 +146,10 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSerializable(typeof(AudioContentBlock))]
[JsonSerializable(typeof(EmbeddedResourceBlock))]
[JsonSerializable(typeof(ResourceLinkBlock))]
[JsonSerializable(typeof(ContentBlock[]))]
[JsonSerializable(typeof(IEnumerable<ContentBlock>))]
[JsonSerializable(typeof(PromptMessage))]
[JsonSerializable(typeof(IEnumerable<PromptMessage>))]
[JsonSerializable(typeof(PromptReference))]
[JsonSerializable(typeof(ResourceTemplateReference))]
[JsonSerializable(typeof(BlobResourceContents))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.ConformanceServer\ModelContextProtocol.ConformanceServer.csproj" />
</ItemGroup>

</Project>
209 changes: 209 additions & 0 deletions tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
using System.Diagnostics;
using System.Text;
using ModelContextProtocol.Tests.Utils;

namespace ModelContextProtocol.ConformanceTests;

/// <summary>
/// Runs the official MCP conformance tests against the ConformanceServer.
/// This test starts the ConformanceServer, runs the Node.js-based conformance test suite,
/// and reports the results.
/// </summary>
public class ServerConformanceTests : IAsyncLifetime
{
// Use different ports for each target framework to allow parallel execution
// net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003
private static int GetPortForTargetFramework()
{
var testBinaryDir = AppContext.BaseDirectory;
var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));

return targetFramework switch
{
"net10.0" => 3001,
"net9.0" => 3002,
"net8.0" => 3003,
_ => 3001 // Default fallback
};
}

private readonly int _serverPort = GetPortForTargetFramework();
private readonly string _serverUrl;
private readonly ITestOutputHelper _output;
private Task? _serverTask;
private CancellationTokenSource? _serverCts;

public ServerConformanceTests(ITestOutputHelper output)
{
_output = output;
_serverUrl = $"http://localhost:{_serverPort}";
}

public async ValueTask InitializeAsync()
{
// Start the ConformanceServer
StartConformanceServer();

// Wait for server to be ready (retry for up to 30 seconds)
var timeout = TimeSpan.FromSeconds(30);
var stopwatch = Stopwatch.StartNew();
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };

while (stopwatch.Elapsed < timeout)
{
try
{
// Try to connect to the health endpoint
await httpClient.GetAsync($"{_serverUrl}/health");
// Any response (even an error) means the server is up
return;
}
catch (HttpRequestException)
{
// Connection refused means server not ready yet
}
catch (TaskCanceledException)
{
// Timeout means server might be processing, give it more time
}

await Task.Delay(500);
}

throw new InvalidOperationException("ConformanceServer failed to start within the timeout period");
}

public async ValueTask DisposeAsync()
{
// Stop the server
if (_serverCts != null)
{
_serverCts.Cancel();
if (_serverTask != null)
{
try
{
await _serverTask.WaitAsync(TimeSpan.FromSeconds(5));
}
catch
{
// Ignore exceptions during shutdown
}
}
_serverCts.Dispose();
}
}

[Fact]
public async Task RunConformanceTests()
{
// Check if Node.js is installed
Assert.SkipWhen(!IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");

// Run the conformance test suite
var result = await RunNpxConformanceTests();

// Report the results
Assert.True(result.Success,
$"Conformance tests failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}

private void StartConformanceServer()
{
// The ConformanceServer binary is in a parallel directory to the test binary
// Test binary is in: artifacts/bin/ModelContextProtocol.ConformanceTests/Debug/{tfm}/
// ConformanceServer binary is in: artifacts/bin/ModelContextProtocol.ConformanceServer/Debug/{tfm}/
var testBinaryDir = AppContext.BaseDirectory; // e.g., .../net10.0/
var configuration = Path.GetFileName(Path.GetDirectoryName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar))!);
var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));
var conformanceServerDir = Path.GetFullPath(
Path.Combine(testBinaryDir, "..", "..", "..", "ModelContextProtocol.ConformanceServer", configuration, targetFramework));

if (!Directory.Exists(conformanceServerDir))
{
throw new DirectoryNotFoundException(
$"ConformanceServer directory not found at: {conformanceServerDir}");
}

// Start the server in a background task
_serverCts = new CancellationTokenSource();
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
}

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 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()
);
}

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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<AssemblyName>ConformanceServer</AssemblyName>
</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.AspNetCore/ModelContextProtocol.AspNetCore.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
@HostAddress = http://localhost:3001

POST {{HostAddress}}/
Accept: application/json, text/event-stream
Content-Type: application/json

{
"jsonrpc": "2.0",
"id": 1,
"method": "ping"
}

###

POST {{HostAddress}}/
Accept: application/json, text/event-stream
Content-Type: application/json

{
"jsonrpc": "2.0",
"id": 2,
"method": "initialize",
"params": {
"clientInfo": {
"name": "RestClient",
"version": "0.1.0"
},
"capabilities": {},
"protocolVersion": "2025-06-18"
}
}

###

@SessionId = XxIXkrK210aKVnZxD8Iu_g

POST {{HostAddress}}/
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2025-06-18
Mcp-Session-Id: {{SessionId}}

{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/list"
}

###

POST {{HostAddress}}/
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2025-06-18
Mcp-Session-Id: {{SessionId}}

{
"jsonrpc": "2.0",
"id": 4,
"method": "resources/list"
}

###

POST {{HostAddress}}/
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2025-06-18
Mcp-Session-Id: {{SessionId}}

{
"jsonrpc": "2.0",
"id": 5,
"method": "prompts/list"
}
Loading