Files
OpenNest/OpenNest.Mcp/Tools/TestTools.cs
T
ajandClaude Opus 5.5 4d55e11318 style: use one spelling across the codebase
The repo mixed variants such as center/centre and color/colour, so a
name could be spelled either way and searches missed half the hits.
Standardize on the US spelling, matching .NET's own APIs (Color, Center).

Wire values owned by other systems keep their spelling: the material
ledger's "Cancelled" hold status and GitHub's "cancelled" job status.

No behavior change beyond the spelling of a few status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:30 -04:00

164 lines
7.3 KiB
C#

using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using ModelContextProtocol.Server;
namespace OpenNest.Mcp.Tools
{
[McpServerToolType]
public class TestTools
{
private readonly IConfiguration configuration;
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
public TestTools(IConfiguration configuration)
{
this.configuration = configuration;
}
[McpServerTool(Name = "test_engine")]
[Description(
"Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server."
)]
public async Task<string> TestEngine(
[Description("Path to the nest .nest file")] string nestFile,
[Description("Drawing name to fill with (default: first drawing)")] string drawingName = null,
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
[Description("Output nest file path (default: <input>-result.nest)")] string outputFile = null,
CancellationToken cancellationToken = default
)
{
if (!File.Exists(nestFile))
return $"Error: nest file not found: {nestFile}";
using var process = new Process();
var started = false;
Task completion = null;
try
{
var sourceRoot = configuration["EngineHarness:SourceRoot"];
if (string.IsNullOrWhiteSpace(sourceRoot) || !Path.IsPathFullyQualified(sourceRoot))
return "Error: EngineHarness:SourceRoot must be an absolute OpenNest checkout path.";
var harnessProject = Path.Combine(sourceRoot, "OpenNest.Console", "OpenNest.Console.csproj");
if (!File.Exists(harnessProject))
return $"Error: harness project not found: {harnessProject}";
var dotnetPath = configuration["EngineHarness:DotnetPath"] ?? Path.GetFullPath(Path.Combine(
RuntimeEnvironment.GetRuntimeDirectory(), "..", "..", "..",
OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"));
if (!Path.IsPathFullyQualified(dotnetPath) || !File.Exists(dotnetPath))
return "Error: EngineHarness:DotnetPath must identify an existing absolute dotnet executable.";
if (!int.TryParse(configuration["EngineHarness:TimeoutSeconds"] ?? "120",
NumberStyles.None, CultureInfo.InvariantCulture, out var timeoutSeconds)
|| timeoutSeconds <= 0 || timeoutSeconds > int.MaxValue / 1000)
return "Error: EngineHarness:TimeoutSeconds must be a positive finite deadline (at most 2147483 seconds).";
var psi = new ProcessStartInfo(dotnetPath)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = sourceRoot,
};
foreach (var argument in new[] { "run", "--project", harnessProject, "--", Path.GetFullPath(nestFile) })
psi.ArgumentList.Add(argument);
if (!string.IsNullOrEmpty(drawingName))
{
psi.ArgumentList.Add("--drawing");
psi.ArgumentList.Add(drawingName);
}
psi.ArgumentList.Add("--plate");
psi.ArgumentList.Add(plateIndex.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(outputFile))
{
psi.ArgumentList.Add("--output");
psi.ArgumentList.Add(Path.GetFullPath(outputFile));
}
process.StartInfo = psi;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
deadline.Token.ThrowIfCancellationRequested();
started = process.Start();
if (!started)
return "Error running test harness: process did not start.";
var stdoutTask = process.StandardOutput.ReadToEndAsync(deadline.Token);
var stderrTask = process.StandardError.ReadToEndAsync(deadline.Token);
completion = Task.WhenAll(process.WaitForExitAsync(deadline.Token), stdoutTask, stderrTask);
// Pipe EOF can outlive the direct process, so bound the aggregate as well.
await completion.WaitAsync(deadline.Token);
var stdout = await stdoutTask;
var stderr = await stderrTask;
var result = new StringBuilder();
if (!string.IsNullOrWhiteSpace(stdout))
result.Append(stdout.TrimEnd());
if (!string.IsNullOrWhiteSpace(stderr))
{
result.AppendLine();
result.AppendLine();
result.AppendLine("=== Errors ===");
result.Append(stderr.TrimEnd());
}
if (process.ExitCode != 0)
{
result.AppendLine();
result.AppendLine($"Process exited with code {process.ExitCode}");
}
return result.ToString();
}
catch (Exception ex)
{
var error = $"Error running test harness: {ex.Message}";
if (ex is OperationCanceledException)
{
var reason = cancellationToken.IsCancellationRequested ? "canceled" : "timed out";
error = $"Error running test harness: {reason}.";
}
if (started)
error += await StopProcessAsync(process);
return error;
}
finally
{
if (started)
{
// Close our pipe ends even if a detached descendant still owns writers.
process.StandardOutput.Dispose();
process.StandardError.Dispose();
}
if (completion != null)
_ = completion.ContinueWith(task => _ = task.Exception, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
}
private static async Task<string> StopProcessAsync(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync().WaitAsync(CleanupTimeout);
return string.Empty;
}
catch (Exception ex)
{
return $" Cleanup failed: {ex.Message}";
}
}
}
}