csharp-sdk/tests/Common/Utils/NodeHelpers.cs

464 lines
19 KiB
C#

using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Tests.Utils;
/// <summary>
/// Helper utilities for Node.js and npm operations.
/// </summary>
public static class NodeHelpers
{
private static readonly object _npmInstallLock = new();
private static bool _npmInstallCompleted;
/// <summary>
/// Finds the repository root by searching for package-lock.json in ancestor directories.
/// </summary>
private static string FindRepoRoot()
{
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");
var lockFilePath = Path.Combine(repoRoot, "package-lock.json");
// Run 'npm ci' if node_modules doesn't exist or is outdated
// (package-lock.json is newer than node_modules).
if (!Directory.Exists(nodeModulesPath) ||
File.GetLastWriteTimeUtc(lockFilePath) > Directory.GetLastWriteTimeUtc(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>
/// <param name="appendProtocolVersionFromEnv">
/// When <see langword="true"/> (the default) and the MCP_CONFORMANCE_PROTOCOL_VERSION
/// environment variable is set, a "--spec-version &lt;value&gt;" argument is appended.
/// Pass <see langword="false"/> for scenarios that pin their own spec version (e.g. the
/// draft-only caching scenario) to avoid a conflicting duplicate flag.
/// </param>
/// <returns>A configured ProcessStartInfo for running the binary.</returns>
public static ProcessStartInfo ConformanceTestStartInfo(string arguments, bool appendProtocolVersionFromEnv = true)
{
EnsureNpmDependenciesInstalled();
// If MCP_CONFORMANCE_PROTOCOL_VERSION is set, pass it as --spec-version to the runner.
if (appendProtocolVersionFromEnv)
{
var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION");
if (!string.IsNullOrEmpty(protocolVersion))
{
arguments += $" --spec-version {protocolVersion}";
}
}
var repoRoot = FindRepoRoot();
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
ProcessStartInfo startInfo;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// On Windows, node_modules/.bin contains .cmd shims that can be executed directly
startInfo = new ProcessStartInfo
{
FileName = $"{binPath}.cmd",
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
else
{
startInfo = new ProcessStartInfo
{
FileName = binPath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
// On macOS, disable .NET mini-dump file generation for child processes. When
// dotnet test runs with --blame-crash, it sets DOTNET_DbgEnableMiniDump=1 in the
// environment. This is inherited by grandchild .NET processes (e.g. ConformanceClient
// launched via node). On macOS, the createdump tool can hang indefinitely due to
// ptrace/SIP restrictions, causing the entire test run to hang. Disabling mini-dumps
// only suppresses the dump file creation; the runtime still prints crash diagnostics
// (stack traces, signal info, etc.) to stderr, which the test captures.
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
startInfo.Environment["DOTNET_DbgEnableMiniDump"] = "0";
startInfo.Environment["COMPlus_DbgEnableMiniDump"] = "0";
}
return startInfo;
}
/// <summary>
/// Checks if Node.js is installed and available on the system.
/// </summary>
public static bool IsNodeInstalled()
{
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "node",
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
});
if (process == null)
{
return false;
}
process.WaitForExit(5000);
return process.ExitCode == 0;
}
catch
{
return false;
}
}
/// <summary>
/// Checks whether the SEP-2243 conformance scenarios are available, by reading the
/// <em>installed</em> conformance package version from node_modules.
/// The http-standard-headers, http-custom-headers, http-invalid-tool-headers,
/// http-header-validation, and http-custom-header-server-validation scenarios were
/// introduced in conformance package 0.2.0. Reading the installed version (rather than
/// the pinned version in package.json) means this also returns <see langword="true"/>
/// when a newer private build has been installed locally via
/// <c>npm install --no-save &lt;path-to-conformance&gt;</c>.
/// Additionally requires that the installed conformance package emits the draft wire
/// version this SDK speaks — see <see cref="HasMatchingDraftWireVersion"/>.
/// </summary>
public static bool HasSep2243Scenarios()
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0))
&& HasMatchingDraftWireVersion();
/// <summary>
/// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance
/// PR #275) is available, by reading the <em>installed</em> conformance package version
/// from node_modules. The caching scenario was introduced in conformance package 0.2.0.
/// Reading the installed version (rather than the pinned version in package.json) means
/// this also returns <see langword="true"/> when a newer private build has been installed
/// locally via <c>npm install --no-save &lt;path-to-conformance&gt;</c>.
/// Additionally requires that the installed conformance package emits the draft wire
/// version this SDK speaks — see <see cref="HasMatchingDraftWireVersion"/>.
/// </summary>
public static bool HasCachingScenario()
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0))
&& HasMatchingDraftWireVersion();
/// <summary>
/// Returns <see langword="true"/> when the installed conformance package's bundled
/// dist emits the same draft protocol version string as this SDK
/// (<see cref="McpHttpHeaders.DraftProtocolVersion"/>). Used to suppress draft-only
/// conformance scenarios when the published conformance binary is still pinned to a
/// stale wire string (for example, conformance 0.2.0-alpha.2 ships
/// <c>"DRAFT-2026-v1"</c> while this SDK speaks <c>"2026-07-28"</c>).
/// </summary>
/// <remarks>
/// This check is a pragmatic alternative to inspecting the conformance package's
/// internal constants: the bundled <c>dist/index.js</c> is minified so we can't grep
/// the constant name, but the literal version string survives bundling and is unique
/// enough to be a reliable signal.
/// </remarks>
public static bool HasMatchingDraftWireVersion()
{
try
{
var repoRoot = FindRepoRoot();
var distPath = Path.Combine(
repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js");
if (!File.Exists(distPath))
{
return false;
}
var bundled = File.ReadAllText(distPath);
return bundled.Contains(McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal);
}
catch
{
return false;
}
}
/// <summary>
/// Returns <see langword="true"/> when the conformance package installed in node_modules
/// has a version greater than or equal to <paramref name="minimumVersion"/>.
/// </summary>
private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion)
{
var version = GetInstalledConformanceVersion();
return version is not null && version >= minimumVersion;
}
/// <summary>
/// Reads the version of the conformance package actually installed in node_modules,
/// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be
/// parsed as a <see cref="Version"/>. Returns <see langword="null"/> if it cannot be
/// determined.
/// </summary>
private static Version? GetInstalledConformanceVersion()
{
try
{
var repoRoot = FindRepoRoot();
var packageJsonPath = Path.Combine(
repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json");
// This is a skip gate for version-conditional conformance scenarios, so it must stay
// side-effect-free. If the conformance package isn't installed, report no version (the
// scenario is simply gated off); the actual scenario run path restores npm dependencies
// separately via ConformanceTestStartInfo.
if (!File.Exists(packageJsonPath))
{
return null;
}
using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
if (json.RootElement.TryGetProperty("version", out var versionElement) &&
versionElement.GetString() is { } versionStr)
{
// Strip any prerelease/build suffix so System.Version can parse it.
var core = versionStr.Split('-', '+')[0];
if (Version.TryParse(core, out var version))
{
return version;
}
}
return null;
}
catch
{
return null;
}
}
/// <summary>
/// Runs the conformance runner ("conformance &lt;arguments&gt;") in server mode and returns
/// whether it succeeded along with the captured stdout/stderr. Centralizes the process
/// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback)
/// shared by the server-side conformance tests.
/// </summary>
/// <param name="arguments">Arguments to pass to the conformance runner.</param>
/// <param name="onLine">Optional callback invoked for each captured stdout/stderr line.</param>
/// <param name="appendProtocolVersionFromEnv">
/// Forwarded to <see cref="ConformanceTestStartInfo(string, bool)"/>.
/// </param>
/// <param name="cancellationToken">Token used to cancel the run.</param>
public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync(
string arguments,
Action<string>? onLine = null,
bool appendProtocolVersionFromEnv = true,
CancellationToken cancellationToken = default)
{
var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv);
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
using var process = new Process { StartInfo = startInfo };
// Protect callbacks with try/catch so a callback that throws on a background thread
// (e.g. ITestOutputHelper after the test completes) does not crash the test host.
DataReceivedEventHandler outputHandler = (sender, e) =>
{
if (e.Data != null)
{
try { onLine?.Invoke(e.Data); } catch { }
outputBuilder.AppendLine(e.Data);
}
};
DataReceivedEventHandler errorHandler = (sender, e) =>
{
if (e.Data != null)
{
try { onLine?.Invoke(e.Data); } catch { }
errorBuilder.AppendLine(e.Data);
}
};
process.OutputDataReceived += outputHandler;
process.ErrorDataReceived += errorHandler;
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromMinutes(5));
try
{
#if NET
await process.WaitForExitAsync(cts.Token);
#else
// net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill
// extension and surface a timeout the same way the modern path does.
await process.WaitForExitAsync(TimeSpan.FromMinutes(5));
if (!process.HasExited)
{
throw new OperationCanceledException();
}
#endif
}
catch (OperationCanceledException)
{
#if NET
process.Kill(entireProcessTree: true);
#else
process.Kill();
#endif
process.OutputDataReceived -= outputHandler;
process.ErrorDataReceived -= errorHandler;
return (
false,
outputBuilder.ToString(),
errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed.");
}
process.OutputDataReceived -= outputHandler;
process.ErrorDataReceived -= errorHandler;
var stdoutText = outputBuilder.ToString();
var stderrText = errorBuilder.ToString();
// The Node.js conformance runner can crash during cleanup on Windows with a libuv
// assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit
// code even though every conformance check passed. When that happens, fall back to
// parsing the "Test Results:" summary in stdout to decide success.
bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText);
return (success, stdoutText, stderrText);
}
/// <summary>
/// Parses the conformance runner output for a "Test Results:" line such as
/// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed
/// and none failed.
/// </summary>
private static bool ConformanceOutputIndicatesSuccess(string output)
{
// Match lines like "Passed: 3/3, 0 failed, 0 warnings"
var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed");
if (!match.Success)
{
return false;
}
int passed = int.Parse(match.Groups[1].Value);
int total = int.Parse(match.Groups[2].Value);
int failed = int.Parse(match.Groups[3].Value);
return passed == total && failed == 0 && total > 0;
}
/// <summary>
/// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult)
/// conformance scenarios are available, by reading the <em>installed</em> conformance
/// package version from node_modules. The <c>incomplete-result-*</c> scenarios were
/// introduced in conformance package 0.2.0 (see
/// https://github.com/modelcontextprotocol/conformance/pull/188).
/// Reading the installed version (rather than the pinned version in package.json) means
/// this also returns <see langword="true"/> when a newer private build has been installed
/// locally via <c>npm install --no-save &lt;path-to-conformance&gt;</c>.
/// Additionally requires that the installed conformance package emits the draft wire
/// version this SDK speaks — see <see cref="HasMatchingDraftWireVersion"/>.
/// </summary>
public static bool HasMrtrScenarios()
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0))
&& HasMatchingDraftWireVersion();
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
};
}
}
}