Use package-lock.json to pin conformance test dependencies (#1247)

This commit is contained in:
Stephen Halter 2026-02-05 17:42:41 -08:00 • committed by GitHub
commit e55ae7b7bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1416 additions and 54 deletions

View file

@ -16,6 +16,8 @@ on:
- "*.props" - "*.props"
- "Makefile" - "Makefile"
- "global.json" - "global.json"
- "package.json"
- "package-lock.json"
- "src/**" - "src/**"
- "tests/**" - "tests/**"
- "samples/**" - "samples/**"
@ -52,15 +54,8 @@ jobs:
with: with:
node-version: '20' node-version: '20'
- name: 📦 Install dependencies for tests - name: 📦 Install pinned npm dependencies for tests
run: npm install @modelcontextprotocol/server-everything@2025.12.18 run: npm ci
- name: 📦 Install dependencies for tests
run: npm install @modelcontextprotocol/server-memory
# Keep version in sync with McpConformanceVersion in Directory.Packages.props
- name: 📦 Install conformance test runner
run: npm install @modelcontextprotocol/conformance@0.1.10
- name: 🏗️ Build - name: 🏗️ Build
run: make build CONFIGURATION=${{ matrix.configuration }} run: make build CONFIGURATION=${{ matrix.configuration }}

3
.gitignore vendored
View file

@ -34,6 +34,9 @@ _ReSharper*/
**/packages/* **/packages/*
!**/packages/build/ !**/packages/build/
# npm
node_modules/
# MSTest test Results # MSTest test Results
[Tt]est[Rr]esult*/ [Tt]est[Rr]esult*/
[Bb]uild[Ll]og.* [Bb]uild[Ll]og.*

View file

@ -5,9 +5,6 @@
<System9Version>9.0.11</System9Version> <System9Version>9.0.11</System9Version>
<System10Version>10.0.2</System10Version> <System10Version>10.0.2</System10Version>
<MicrosoftExtensionsVersion>10.2.0</MicrosoftExtensionsVersion> <MicrosoftExtensionsVersion>10.2.0</MicrosoftExtensionsVersion>
<!-- Pin the conformance tester Node package version for CI stability.
Keep in sync npm install step at ci-build-test.yml -->
<McpConformanceVersion>0.1.10</McpConformanceVersion>
</PropertyGroup> </PropertyGroup>
<!-- Product dependencies shared --> <!-- Product dependencies shared -->

1274
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

10
package.json Normal file
View file

@ -0,0 +1,10 @@
{
"private": true,
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
"dependencies": {
"@modelcontextprotocol/conformance": "0.1.10",
"@modelcontextprotocol/sdk": "1.25.3",
"@modelcontextprotocol/server-everything": "2025.12.18",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
}

View file

@ -8,20 +8,88 @@ namespace ModelContextProtocol.Tests.Utils;
/// </summary> /// </summary>
public static class NodeHelpers public static class NodeHelpers
{ {
private static readonly object _npmInstallLock = new();
private static bool _npmInstallCompleted;
/// <summary> /// <summary>
/// Creates a ProcessStartInfo configured to run npx with the specified arguments. /// Finds the repository root by searching for package-lock.json in ancestor directories.
/// </summary> /// </summary>
/// <param name="arguments">The arguments to pass to npx.</param> private static string FindRepoRoot()
/// <returns>A configured ProcessStartInfo for running npx.</returns>
public static ProcessStartInfo NpxStartInfo(string arguments)
{ {
var dir = AppContext.BaseDirectory;
while (dir is not null)
{
if (File.Exists(Path.Combine(dir, "package-lock.json")))
{
return dir;
}
dir = Path.GetDirectoryName(dir);
}
throw new InvalidOperationException("Could not find repository root (no package-lock.json found in ancestor directories).");
}
/// <summary>
/// Ensures npm dependencies are installed by running 'npm ci' from the repo root.
/// This is safe to call multiple times; it only runs once per test process.
/// </summary>
public static void EnsureNpmDependenciesInstalled()
{
if (_npmInstallCompleted)
{
return;
}
lock (_npmInstallLock)
{
if (_npmInstallCompleted)
{
return;
}
var repoRoot = FindRepoRoot();
var nodeModulesPath = Path.Combine(repoRoot, "node_modules");
// Use 'npm ci' if node_modules doesn't exist, otherwise assume it's up to date.
if (!Directory.Exists(nodeModulesPath))
{
var startInfo = NpmStartInfo("ci", repoRoot);
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start 'npm ci'.");
process.WaitForExit(120_000);
if (process.ExitCode != 0)
{
var error = process.StandardError.ReadToEnd();
throw new InvalidOperationException($"'npm ci' failed with exit code {process.ExitCode}: {error}");
}
}
_npmInstallCompleted = true;
}
}
/// <summary>
/// Creates a ProcessStartInfo configured to run a binary from node_modules/.bin/conformance.
/// Calls <see cref="EnsureNpmDependenciesInstalled"/> first.
/// </summary>
/// <param name="binaryName">The name of the binary in node_modules/.bin (e.g. "conformance").</param>
/// <param name="arguments">The arguments to pass to the binary.</param>
/// <returns>A configured ProcessStartInfo for running the binary.</returns>
public static ProcessStartInfo ConformanceTestStartInfo(string arguments)
{
EnsureNpmDependenciesInstalled();
var repoRoot = FindRepoRoot();
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
// On Windows, npx is a PowerShell script, so we need to use cmd.exe to invoke it // On Windows, node_modules/.bin contains .cmd shims that can be executed directly
return new ProcessStartInfo return new ProcessStartInfo
{ {
FileName = "cmd.exe", FileName = $"{binPath}.cmd",
Arguments = $"/c npx {arguments}", Arguments = arguments,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
@ -30,10 +98,9 @@ public static class NodeHelpers
} }
else else
{ {
// On Unix-like systems, npx is typically a shell script that can be executed directly
return new ProcessStartInfo return new ProcessStartInfo
{ {
FileName = "npx", FileName = binPath,
Arguments = arguments, Arguments = arguments,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
@ -44,16 +111,22 @@ public static class NodeHelpers
} }
/// <summary> /// <summary>
/// Checks if Node.js and npx are installed and available on the system. /// Checks if Node.js is installed and available on the system.
/// </summary> /// </summary>
/// <returns>True if npx is available, false otherwise.</returns> public static bool IsNodeInstalled()
public static bool IsNpxInstalled()
{ {
try try
{ {
var startInfo = NpxStartInfo("--version"); using var process = Process.Start(new ProcessStartInfo
{
FileName = "node",
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
});
using var process = Process.Start(startInfo);
if (process == null) if (process == null)
{ {
return false; return false;
@ -67,4 +140,34 @@ public static class NodeHelpers
return false; return false;
} }
} }
private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c npm {arguments}",
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
else
{
return new ProcessStartInfo
{
FileName = "npm",
Arguments = arguments,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
}
} }

View file

@ -9,19 +9,19 @@ namespace ModelContextProtocol.ConformanceTests;
/// This test runs the Node.js-based conformance test suite for the client /// This test runs the Node.js-based conformance test suite for the client
/// and reports the results. /// and reports the results.
/// </summary> /// </summary>
public class ClientConformanceTests //: IAsyncLifetime public class ClientConformanceTests
{ {
private readonly ITestOutputHelper _output; private readonly ITestOutputHelper _output;
// Public static property required for SkipUnless attribute // Public static property required for SkipUnless attribute
public static bool IsNpxInstalled => NodeHelpers.IsNpxInstalled(); public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled();
public ClientConformanceTests(ITestOutputHelper output) public ClientConformanceTests(ITestOutputHelper output)
{ {
_output = output; _output = output;
} }
[Theory(Skip = "npx is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNpxInstalled))] [Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
[InlineData("initialize")] [InlineData("initialize")]
[InlineData("tools_call")] [InlineData("tools_call")]
[InlineData("auth/metadata-default")] [InlineData("auth/metadata-default")]
@ -59,7 +59,7 @@ public class ClientConformanceTests //: IAsyncLifetime
$"ConformanceClient executable not found at: {conformanceClientPath}"); $"ConformanceClient executable not found at: {conformanceClientPath}");
} }
var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance client --scenario {scenario} --command \"{conformanceClientPath} {scenario}\""); var startInfo = NodeHelpers.ConformanceTestStartInfo($"client --scenario {scenario} --command \"{conformanceClientPath} {scenario}\"");
var outputBuilder = new StringBuilder(); var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder(); var errorBuilder = new StringBuilder();

View file

@ -52,13 +52,6 @@
<PackageReference Include="System.Linq.AsyncEnumerable" /> <PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>McpConformanceVersion</_Parameter1>
<_Parameter2>$(McpConformanceVersion)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" /> <ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" /> <ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />

View file

@ -97,11 +97,10 @@ public class ServerConformanceTests : IAsyncLifetime
[Fact] [Fact]
public async Task RunConformanceTests() public async Task RunConformanceTests()
{ {
// Check if Node.js is installed Assert.SkipWhen(!NodeHelpers.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 // Run the conformance test suite
var result = await RunNpxConformanceTests(); var result = await RunConformanceTestsAsync();
// Report the results // Report the results
Assert.True(result.Success, Assert.True(result.Success,
@ -115,21 +114,9 @@ public class ServerConformanceTests : IAsyncLifetime
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token)); _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
} }
private static string GetConformanceVersion() private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync()
{ {
var assembly = typeof(ServerConformanceTests).Assembly; var startInfo = NodeHelpers.ConformanceTestStartInfo($"server --url {_serverUrl}");
var attribute = assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyMetadataAttribute), false)
.Cast<System.Reflection.AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "McpConformanceVersion");
return attribute?.Value ?? throw new InvalidOperationException("McpConformanceVersion not found in assembly metadata");
}
private async Task<(bool Success, string Output, string Error)> RunNpxConformanceTests()
{
// Version is configured in Directory.Packages.props for central management
var version = GetConformanceVersion();
var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance@{version} server --url {_serverUrl}");
var outputBuilder = new StringBuilder(); var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder(); var errorBuilder = new StringBuilder();