Skip to content

Commit f45996d

Browse files
authored
Add Conformance tests for the server (#983)
1 parent 5a65af8 commit f45996d

File tree

14 files changed

+981
-2
lines changed

14 files changed

+981
-2
lines changed

.github/workflows/ci-build-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ jobs:
6767
- name: 📦 Install dependencies for tests
6868
run: npm install @modelcontextprotocol/server-memory
6969

70+
- name: 📦 Install dependencies for tests
71+
run: npm install @modelcontextprotocol/conformance
72+
7073
- name: 🏗️ Build
7174
run: make build CONFIGURATION=${{ matrix.configuration }}
7275

ModelContextProtocol.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
<Folder Name="/tests/">
7171
<Project Path="tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj" />
7272
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
73+
<Project Path="tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj" />
7374
<Project Path="tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj" />
7475
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
7576
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public static partial class McpJsonUtilities
1616
/// </summary>
1717
/// <remarks>
1818
/// <para>
19-
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
19+
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
2020
/// includes source generated contracts for all common exchange types contained in the ModelContextProtocol library.
2121
/// </para>
2222
/// <para>
@@ -88,7 +88,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
8888
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
8989
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
9090
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
91-
91+
9292
// JSON-RPC
9393
[JsonSerializable(typeof(JsonRpcMessage))]
9494
[JsonSerializable(typeof(JsonRpcMessage[]))]
@@ -146,7 +146,10 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
146146
[JsonSerializable(typeof(AudioContentBlock))]
147147
[JsonSerializable(typeof(EmbeddedResourceBlock))]
148148
[JsonSerializable(typeof(ResourceLinkBlock))]
149+
[JsonSerializable(typeof(ContentBlock[]))]
149150
[JsonSerializable(typeof(IEnumerable<ContentBlock>))]
151+
[JsonSerializable(typeof(PromptMessage))]
152+
[JsonSerializable(typeof(IEnumerable<PromptMessage>))]
150153
[JsonSerializable(typeof(PromptReference))]
151154
[JsonSerializable(typeof(ResourceTemplateReference))]
152155
[JsonSerializable(typeof(BlobResourceContents))]

tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
5858
<ProjectReference Include="..\ModelContextProtocol.TestSseServer\ModelContextProtocol.TestSseServer.csproj" />
5959
<ProjectReference Include="..\ModelContextProtocol.TestOAuthServer\ModelContextProtocol.TestOAuthServer.csproj" />
60+
<ProjectReference Include="..\ModelContextProtocol.ConformanceServer\ModelContextProtocol.ConformanceServer.csproj" />
6061
</ItemGroup>
6162

6263
</Project>
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
using System.Diagnostics;
2+
using System.Text;
3+
using ModelContextProtocol.Tests.Utils;
4+
5+
namespace ModelContextProtocol.ConformanceTests;
6+
7+
/// <summary>
8+
/// Runs the official MCP conformance tests against the ConformanceServer.
9+
/// This test starts the ConformanceServer, runs the Node.js-based conformance test suite,
10+
/// and reports the results.
11+
/// </summary>
12+
public class ServerConformanceTests : IAsyncLifetime
13+
{
14+
// Use different ports for each target framework to allow parallel execution
15+
// net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003
16+
private static int GetPortForTargetFramework()
17+
{
18+
var testBinaryDir = AppContext.BaseDirectory;
19+
var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));
20+
21+
return targetFramework switch
22+
{
23+
"net10.0" => 3001,
24+
"net9.0" => 3002,
25+
"net8.0" => 3003,
26+
_ => 3001 // Default fallback
27+
};
28+
}
29+
30+
private readonly int _serverPort = GetPortForTargetFramework();
31+
private readonly string _serverUrl;
32+
private readonly ITestOutputHelper _output;
33+
private Task? _serverTask;
34+
private CancellationTokenSource? _serverCts;
35+
36+
public ServerConformanceTests(ITestOutputHelper output)
37+
{
38+
_output = output;
39+
_serverUrl = $"http://localhost:{_serverPort}";
40+
}
41+
42+
public async ValueTask InitializeAsync()
43+
{
44+
// Start the ConformanceServer
45+
StartConformanceServer();
46+
47+
// Wait for server to be ready (retry for up to 30 seconds)
48+
var timeout = TimeSpan.FromSeconds(30);
49+
var stopwatch = Stopwatch.StartNew();
50+
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
51+
52+
while (stopwatch.Elapsed < timeout)
53+
{
54+
try
55+
{
56+
// Try to connect to the health endpoint
57+
await httpClient.GetAsync($"{_serverUrl}/health");
58+
// Any response (even an error) means the server is up
59+
return;
60+
}
61+
catch (HttpRequestException)
62+
{
63+
// Connection refused means server not ready yet
64+
}
65+
catch (TaskCanceledException)
66+
{
67+
// Timeout means server might be processing, give it more time
68+
}
69+
70+
await Task.Delay(500);
71+
}
72+
73+
throw new InvalidOperationException("ConformanceServer failed to start within the timeout period");
74+
}
75+
76+
public async ValueTask DisposeAsync()
77+
{
78+
// Stop the server
79+
if (_serverCts != null)
80+
{
81+
_serverCts.Cancel();
82+
if (_serverTask != null)
83+
{
84+
try
85+
{
86+
await _serverTask.WaitAsync(TimeSpan.FromSeconds(5));
87+
}
88+
catch
89+
{
90+
// Ignore exceptions during shutdown
91+
}
92+
}
93+
_serverCts.Dispose();
94+
}
95+
}
96+
97+
[Fact]
98+
public async Task RunConformanceTests()
99+
{
100+
// Check if Node.js is installed
101+
Assert.SkipWhen(!IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
102+
103+
// Run the conformance test suite
104+
var result = await RunNpxConformanceTests();
105+
106+
// Report the results
107+
Assert.True(result.Success,
108+
$"Conformance tests failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
109+
}
110+
111+
private void StartConformanceServer()
112+
{
113+
// The ConformanceServer binary is in a parallel directory to the test binary
114+
// Test binary is in: artifacts/bin/ModelContextProtocol.ConformanceTests/Debug/{tfm}/
115+
// ConformanceServer binary is in: artifacts/bin/ModelContextProtocol.ConformanceServer/Debug/{tfm}/
116+
var testBinaryDir = AppContext.BaseDirectory; // e.g., .../net10.0/
117+
var configuration = Path.GetFileName(Path.GetDirectoryName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar))!);
118+
var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));
119+
var conformanceServerDir = Path.GetFullPath(
120+
Path.Combine(testBinaryDir, "..", "..", "..", "ModelContextProtocol.ConformanceServer", configuration, targetFramework));
121+
122+
if (!Directory.Exists(conformanceServerDir))
123+
{
124+
throw new DirectoryNotFoundException(
125+
$"ConformanceServer directory not found at: {conformanceServerDir}");
126+
}
127+
128+
// Start the server in a background task
129+
_serverCts = new CancellationTokenSource();
130+
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
131+
}
132+
133+
private async Task<(bool Success, string Output, string Error)> RunNpxConformanceTests()
134+
{
135+
var startInfo = new ProcessStartInfo
136+
{
137+
FileName = "npx",
138+
Arguments = $"-y @modelcontextprotocol/conformance server --url {_serverUrl}",
139+
RedirectStandardOutput = true,
140+
RedirectStandardError = true,
141+
UseShellExecute = false,
142+
CreateNoWindow = true
143+
};
144+
145+
var outputBuilder = new StringBuilder();
146+
var errorBuilder = new StringBuilder();
147+
148+
var process = new Process { StartInfo = startInfo };
149+
150+
process.OutputDataReceived += (sender, e) =>
151+
{
152+
if (e.Data != null)
153+
{
154+
_output.WriteLine(e.Data);
155+
outputBuilder.AppendLine(e.Data);
156+
}
157+
};
158+
159+
process.ErrorDataReceived += (sender, e) =>
160+
{
161+
if (e.Data != null)
162+
{
163+
_output.WriteLine(e.Data);
164+
errorBuilder.AppendLine(e.Data);
165+
}
166+
};
167+
168+
process.Start();
169+
process.BeginOutputReadLine();
170+
process.BeginErrorReadLine();
171+
172+
await process.WaitForExitAsync();
173+
174+
return (
175+
Success: process.ExitCode == 0,
176+
Output: outputBuilder.ToString(),
177+
Error: errorBuilder.ToString()
178+
);
179+
}
180+
181+
private static bool IsNodeInstalled()
182+
{
183+
try
184+
{
185+
var startInfo = new ProcessStartInfo
186+
{
187+
FileName = "npx", // Check specifically for npx because windows seems unable to find it
188+
Arguments = "--version",
189+
RedirectStandardOutput = true,
190+
RedirectStandardError = true,
191+
UseShellExecute = false,
192+
CreateNoWindow = true
193+
};
194+
195+
using var process = Process.Start(startInfo);
196+
if (process == null)
197+
{
198+
return false;
199+
}
200+
201+
process.WaitForExit(5000);
202+
return process.ExitCode == 0;
203+
}
204+
catch
205+
{
206+
return false;
207+
}
208+
}
209+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<OutputType>Exe</OutputType>
8+
<AssemblyName>ConformanceServer</AssemblyName>
9+
</PropertyGroup>
10+
11+
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
12+
<!-- For better test coverage, only disable reflection in one of the targets -->
13+
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
14+
</PropertyGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="../../src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
@HostAddress = http://localhost:3001
2+
3+
POST {{HostAddress}}/
4+
Accept: application/json, text/event-stream
5+
Content-Type: application/json
6+
7+
{
8+
"jsonrpc": "2.0",
9+
"id": 1,
10+
"method": "ping"
11+
}
12+
13+
###
14+
15+
POST {{HostAddress}}/
16+
Accept: application/json, text/event-stream
17+
Content-Type: application/json
18+
19+
{
20+
"jsonrpc": "2.0",
21+
"id": 2,
22+
"method": "initialize",
23+
"params": {
24+
"clientInfo": {
25+
"name": "RestClient",
26+
"version": "0.1.0"
27+
},
28+
"capabilities": {},
29+
"protocolVersion": "2025-06-18"
30+
}
31+
}
32+
33+
###
34+
35+
@SessionId = XxIXkrK210aKVnZxD8Iu_g
36+
37+
POST {{HostAddress}}/
38+
Accept: application/json, text/event-stream
39+
Content-Type: application/json
40+
MCP-Protocol-Version: 2025-06-18
41+
Mcp-Session-Id: {{SessionId}}
42+
43+
{
44+
"jsonrpc": "2.0",
45+
"id": 3,
46+
"method": "tools/list"
47+
}
48+
49+
###
50+
51+
POST {{HostAddress}}/
52+
Accept: application/json, text/event-stream
53+
Content-Type: application/json
54+
MCP-Protocol-Version: 2025-06-18
55+
Mcp-Session-Id: {{SessionId}}
56+
57+
{
58+
"jsonrpc": "2.0",
59+
"id": 4,
60+
"method": "resources/list"
61+
}
62+
63+
###
64+
65+
POST {{HostAddress}}/
66+
Accept: application/json, text/event-stream
67+
Content-Type: application/json
68+
MCP-Protocol-Version: 2025-06-18
69+
Mcp-Session-Id: {{SessionId}}
70+
71+
{
72+
"jsonrpc": "2.0",
73+
"id": 5,
74+
"method": "prompts/list"
75+
}

0 commit comments

Comments
 (0)