From 4d208f64117e4453c63cb08cc72133f52a8f516b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 31 Jan 2026 23:11:22 -0500 Subject: [PATCH] feat: Add CutList.Mcp project for MCP server integration Add new MCP (Model Context Protocol) server project that exposes cut list optimization tools for AI assistants. Implements tools for: - create_cutlist: Optimized bin packing with parts and stock bins - parse_length: Parse architectural format to decimal inches - format_length: Format inches to feet/inches/fractions - create_cutlist_report: Generate formatted printable text report Co-Authored-By: Claude Opus 4.5 --- CutList.Mcp/CutList.Mcp.csproj | 19 ++ CutList.Mcp/CutListTools.cs | 388 +++++++++++++++++++++++++++++++++ CutList.Mcp/Program.cs | 13 ++ CutList.sln | 42 +++- 4 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 CutList.Mcp/CutList.Mcp.csproj create mode 100644 CutList.Mcp/CutListTools.cs create mode 100644 CutList.Mcp/Program.cs diff --git a/CutList.Mcp/CutList.Mcp.csproj b/CutList.Mcp/CutList.Mcp.csproj new file mode 100644 index 0000000..87ab5d8 --- /dev/null +++ b/CutList.Mcp/CutList.Mcp.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/CutList.Mcp/CutListTools.cs b/CutList.Mcp/CutListTools.cs new file mode 100644 index 0000000..285e176 --- /dev/null +++ b/CutList.Mcp/CutListTools.cs @@ -0,0 +1,388 @@ +using System.ComponentModel; +using CutList.Core; +using CutList.Core.Formatting; +using CutList.Core.Nesting; +using ModelContextProtocol.Server; + +namespace CutList.Mcp; + +/// +/// MCP tools for cut list optimization. +/// +[McpServerToolType] +public static class CutListTools +{ + /// + /// Creates an optimized cut list by packing parts into stock bins using a first-fit decreasing algorithm. + /// Returns the optimal arrangement of cuts to minimize waste. + /// + /// List of parts to cut. Each part has a name, length (in inches or architectural format like 12' 6"), and quantity. + /// List of available stock material. Each has a length, quantity (-1 for unlimited), and priority (lower = used first). + /// Blade/cutting width in inches. Default is 0.125 (1/8"). This accounts for material lost to the cut itself. + /// Optimized cut list showing which parts go in which bins, utilization percentages, and any parts that couldn't fit. + [McpServerTool(Name = "create_cutlist"), Description("Creates an optimized cut list by packing parts into stock bins to minimize waste.")] + public static CutListResult CreateCutList( + [Description("Parts to cut. Each object needs: name (string), length (string like \"12'\" or \"36\\\"\" or \"12.5\"), quantity (int)")] + PartInput[] parts, + [Description("Stock bins available. Each needs: length (string), quantity (int, use -1 for unlimited), priority (int, lower = used first, default 25)")] + StockBinInput[] stockBins, + [Description("Blade kerf/width in inches (default 0.125)")] + double kerf = 0.125) + { + try + { + // Convert parts to BinItems + var binItems = new List(); + foreach (var part in parts) + { + double length = ParseLength(part.Length); + if (length <= 0) + { + return new CutListResult + { + Success = false, + Error = $"Invalid part length: {part.Length} for part '{part.Name}'" + }; + } + + for (int i = 0; i < part.Quantity; i++) + { + binItems.Add(new BinItem(part.Name, length)); + } + } + + // Convert stock bins to MultiBins + var multiBins = new List(); + foreach (var bin in stockBins) + { + double length = ParseLength(bin.Length); + if (length <= 0) + { + return new CutListResult + { + Success = false, + Error = $"Invalid bin length: {bin.Length}" + }; + } + + multiBins.Add(new MultiBin(length, bin.Quantity, bin.Priority)); + } + + // Run the packing algorithm + var engine = new MultiBinEngine(); + engine.SetBins(multiBins); + engine.Spacing = kerf; + + var packResult = engine.Pack(binItems); + + // Convert results + var resultBins = new List(); + foreach (var bin in packResult.Bins) + { + var resultBin = new ResultBin + { + Length = FormatLength(bin.Length), + LengthInches = bin.Length, + UsedLength = FormatLength(bin.UsedLength), + UsedLengthInches = bin.UsedLength, + RemainingLength = FormatLength(bin.RemainingLength), + RemainingLengthInches = bin.RemainingLength, + Utilization = Math.Round(bin.Utilization * 100, 2), + Items = bin.Items.Select(item => new ResultItem + { + Name = item.Name, + Length = FormatLength(item.Length), + LengthInches = item.Length + }).ToList() + }; + resultBins.Add(resultBin); + } + + var unusedItems = packResult.ItemsNotUsed.Select(item => new ResultItem + { + Name = item.Name, + Length = FormatLength(item.Length), + LengthInches = item.Length + }).ToList(); + + // Calculate summary statistics + double totalStockUsed = packResult.Bins.Sum(b => b.Length); + double totalMaterialUsed = packResult.Bins.Sum(b => b.UsedLength); + double totalWaste = packResult.Bins.Sum(b => b.RemainingLength); + double overallUtilization = totalStockUsed > 0 ? (totalMaterialUsed / totalStockUsed) * 100 : 0; + + return new CutListResult + { + Success = true, + Bins = resultBins, + UnusedItems = unusedItems, + Summary = new CutListSummary + { + TotalBinsUsed = packResult.Bins.Count, + TotalPartsPlaced = binItems.Count - packResult.ItemsNotUsed.Count, + TotalPartsNotPlaced = packResult.ItemsNotUsed.Count, + TotalStockLength = FormatLength(totalStockUsed), + TotalStockLengthInches = totalStockUsed, + TotalWaste = FormatLength(totalWaste), + TotalWasteInches = totalWaste, + OverallUtilization = Math.Round(overallUtilization, 2) + } + }; + } + catch (Exception ex) + { + return new CutListResult + { + Success = false, + Error = ex.Message + }; + } + } + + /// + /// Parses a length string in architectural format (feet/inches/fractions) to inches. + /// + /// Length string like "12'", "6\"", "12' 6\"", "12.5", "6 1/2\"", etc. + /// The length in inches, or an error message if parsing fails. + [McpServerTool(Name = "parse_length"), Description("Parses an architectural length string (feet/inches/fractions) to decimal inches.")] + public static ParseLengthResult ParseLengthString( + [Description("Length string like \"12'\", \"6\\\"\", \"12' 6\\\"\", \"12.5\", \"6 1/2\\\"\"")] + string input) + { + try + { + double inches = ParseLength(input); + return new ParseLengthResult + { + Success = true, + Inches = inches, + Formatted = FormatLength(inches) + }; + } + catch (Exception ex) + { + return new ParseLengthResult + { + Success = false, + Error = ex.Message + }; + } + } + + /// + /// Formats a length in inches to a human-readable string with feet and fractional inches. + /// + /// Length in inches. + /// Formatted string like "12' 6 1/2\"". + [McpServerTool(Name = "format_length"), Description("Formats a length in inches to feet and fractional inches.")] + public static FormatLengthResult FormatLengthString( + [Description("Length in inches")] + double inches) + { + try + { + return new FormatLengthResult + { + Success = true, + Formatted = FormatLength(inches), + Inches = inches + }; + } + catch (Exception ex) + { + return new FormatLengthResult + { + Success = false, + Error = ex.Message + }; + } + } + + /// + /// Creates an optimized cut list and saves a formatted text report to a file. + /// + [McpServerTool(Name = "create_cutlist_report"), Description("Creates an optimized cut list and saves a formatted printable text report to a file. Returns the file path.")] + public static CutListReportResult CreateCutListReport( + [Description("Parts to cut. Each object needs: name (string), length (string like \"12'\" or \"36\\\"\" or \"12.5\"), quantity (int)")] + PartInput[] parts, + [Description("Stock bins available. Each needs: length (string), quantity (int, use -1 for unlimited), priority (int, lower = used first, default 25)")] + StockBinInput[] stockBins, + [Description("Blade kerf/width in inches (default 0.125)")] + double kerf = 0.125, + [Description("File path to save the report. If not provided, saves to a temp file.")] + string? filePath = null) + { + try + { + // Convert parts to BinItems + var binItems = new List(); + foreach (var part in parts) + { + double length = ParseLength(part.Length); + if (length <= 0) + { + return new CutListReportResult + { + Success = false, + Error = $"Invalid part length: {part.Length} for part '{part.Name}'" + }; + } + + for (int i = 0; i < part.Quantity; i++) + { + binItems.Add(new BinItem(part.Name, length)); + } + } + + // Convert stock bins to MultiBins + var multiBins = new List(); + foreach (var bin in stockBins) + { + double length = ParseLength(bin.Length); + if (length <= 0) + { + return new CutListReportResult + { + Success = false, + Error = $"Invalid bin length: {bin.Length}" + }; + } + + multiBins.Add(new MultiBin(length, bin.Quantity, bin.Priority)); + } + + // Run the packing algorithm + var engine = new MultiBinEngine(); + engine.SetBins(multiBins); + engine.Spacing = kerf; + + var packResult = engine.Pack(binItems); + + // Determine file path + var outputPath = string.IsNullOrWhiteSpace(filePath) + ? Path.Combine(Path.GetTempPath(), $"cutlist_{DateTime.Now:yyyyMMdd_HHmmss}.txt") + : filePath; + + // Save using BinFileSaver + var saver = new BinFileSaver(packResult.Bins); + saver.SaveBinsToFile(outputPath); + + return new CutListReportResult + { + Success = true, + FilePath = Path.GetFullPath(outputPath), + TotalBins = packResult.Bins.Count, + TotalParts = binItems.Count - packResult.ItemsNotUsed.Count, + PartsNotPlaced = packResult.ItemsNotUsed.Count + }; + } + catch (Exception ex) + { + return new CutListReportResult + { + Success = false, + Error = ex.Message + }; + } + } + + private static double ParseLength(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return 0; + + // Try parsing as a plain number first + if (double.TryParse(input.Trim(), out double plainNumber)) + return plainNumber; + + // Try architectural format + return ArchUnits.ParseToInches(input); + } + + private static string FormatLength(double inches) + { + return ArchUnits.FormatFromInches(inches); + } +} + +// Input models +public class PartInput +{ + public string Name { get; set; } = string.Empty; + public string Length { get; set; } = string.Empty; + public int Quantity { get; set; } = 1; +} + +public class StockBinInput +{ + public string Length { get; set; } = string.Empty; + public int Quantity { get; set; } = -1; // -1 = unlimited + public int Priority { get; set; } = 25; // Lower = used first +} + +// Output models +public class CutListResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public List Bins { get; set; } = new(); + public List UnusedItems { get; set; } = new(); + public CutListSummary? Summary { get; set; } +} + +public class ResultBin +{ + public string Length { get; set; } = string.Empty; + public double LengthInches { get; set; } + public string UsedLength { get; set; } = string.Empty; + public double UsedLengthInches { get; set; } + public string RemainingLength { get; set; } = string.Empty; + public double RemainingLengthInches { get; set; } + public double Utilization { get; set; } + public List Items { get; set; } = new(); +} + +public class ResultItem +{ + public string Name { get; set; } = string.Empty; + public string Length { get; set; } = string.Empty; + public double LengthInches { get; set; } +} + +public class CutListSummary +{ + public int TotalBinsUsed { get; set; } + public int TotalPartsPlaced { get; set; } + public int TotalPartsNotPlaced { get; set; } + public string TotalStockLength { get; set; } = string.Empty; + public double TotalStockLengthInches { get; set; } + public string TotalWaste { get; set; } = string.Empty; + public double TotalWasteInches { get; set; } + public double OverallUtilization { get; set; } +} + +public class ParseLengthResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public double Inches { get; set; } + public string? Formatted { get; set; } +} + +public class FormatLengthResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public string? Formatted { get; set; } + public double Inches { get; set; } +} + +public class CutListReportResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public string? FilePath { get; set; } + public int TotalBins { get; set; } + public int TotalParts { get; set; } + public int PartsNotPlaced { get; set; } +} diff --git a/CutList.Mcp/Program.cs b/CutList.Mcp/Program.cs new file mode 100644 index 0000000..458c10b --- /dev/null +++ b/CutList.Mcp/Program.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Server; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(typeof(Program).Assembly); + +var app = builder.Build(); +await app.RunAsync(); diff --git a/CutList.sln b/CutList.sln index adf2323..3a9379e 100644 --- a/CutList.sln +++ b/CutList.sln @@ -7,20 +7,54 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList", "CutList\CutList. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core", "CutList.Core\CutList.Core.csproj", "{3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\CutList.Mcp.csproj", "{3B53377F-E012-42BA-82C8-322815D661B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|Any CPU.Build.0 = Release|Any CPU {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Debug|x64.ActiveCfg = Debug|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Debug|x64.Build.0 = Debug|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Debug|x86.ActiveCfg = Debug|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Debug|x86.Build.0 = Debug|Any CPU {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Release|Any CPU.ActiveCfg = Release|Any CPU {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Release|Any CPU.Build.0 = Release|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Release|x64.ActiveCfg = Release|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Release|x64.Build.0 = Release|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Release|x86.ActiveCfg = Release|Any CPU + {3E82A1E3-07A8-40C4-ABC4-DF24C5120073}.Release|x86.Build.0 = Release|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|x64.Build.0 = Debug|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Debug|x86.Build.0 = Debug|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|Any CPU.Build.0 = Release|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|x64.ActiveCfg = Release|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|x64.Build.0 = Release|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|x86.ActiveCfg = Release|Any CPU + {3D873FF0-6930-4BCE-A5A9-DA5C20354DEE}.Release|x86.Build.0 = Release|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Debug|x64.Build.0 = Debug|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Debug|x86.Build.0 = Debug|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Release|Any CPU.Build.0 = Release|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Release|x64.ActiveCfg = Release|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Release|x64.Build.0 = Release|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Release|x86.ActiveCfg = Release|Any CPU + {3B53377F-E012-42BA-82C8-322815D661B3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE