using System.ComponentModel; using ModelContextProtocol.Server; namespace CutList.Mcp; /// /// MCP tools for job management - creating jobs, managing parts/stock, and running optimization. /// All calls go through the CutList.Web REST API via ApiClient. /// [McpServerToolType] public class JobTools { private readonly ApiClient _api; public JobTools(ApiClient api) { _api = api; } #region Jobs [McpServerTool(Name = "list_jobs"), Description("Lists all jobs in the system with summary info (job number, name, customer, part/stock counts).")] public async Task ListJobs() { var jobs = await _api.GetJobsAsync(); return new JobListResult { Success = true, Jobs = jobs.Select(j => new JobSummaryDto { Id = j.Id, JobNumber = j.JobNumber, Name = j.Name, Customer = j.Customer, CuttingToolId = j.CuttingToolId, CuttingToolName = j.CuttingToolName, Notes = j.Notes, CreatedAt = j.CreatedAt, UpdatedAt = j.UpdatedAt, PartCount = j.PartCount, StockCount = j.StockCount }).ToList() }; } [McpServerTool(Name = "get_job"), Description("Gets full job details including all parts and stock assignments.")] public async Task GetJob( [Description("Job ID")] int jobId) { try { var job = await _api.GetJobAsync(jobId); if (job == null) return new JobDetailResult { Success = false, Error = $"Job {jobId} not found" }; return new JobDetailResult { Success = true, Job = MapJobDetail(job) }; } catch (HttpRequestException ex) { return new JobDetailResult { Success = false, Error = ex.Message }; } } [McpServerTool(Name = "create_job"), Description("Creates a new job. Returns the created job with its auto-generated job number.")] public async Task CreateJob( [Description("Job name/description")] string? name = null, [Description("Customer name")] string? customer = null, [Description("Cutting tool ID (use list_cutting_tools to find IDs). If not set, uses the default tool.")] int? cuttingToolId = null, [Description("Notes about the job")] string? notes = null) { try { var job = await _api.CreateJobAsync(name, customer, cuttingToolId, notes); if (job == null) return new JobDetailResult { Success = false, Error = "Failed to create job" }; return new JobDetailResult { Success = true, Job = MapJobDetail(job) }; } catch (HttpRequestException ex) { return new JobDetailResult { Success = false, Error = ex.Message }; } } [McpServerTool(Name = "update_job"), Description("Updates job details (name, customer, cutting tool, notes). Only provided fields are updated.")] public async Task UpdateJob( [Description("Job ID")] int jobId, [Description("New job name")] string? name = null, [Description("New customer name")] string? customer = null, [Description("New cutting tool ID")] int? cuttingToolId = null, [Description("New notes")] string? notes = null) { try { var job = await _api.UpdateJobAsync(jobId, name, customer, cuttingToolId, notes); if (job == null) return new JobDetailResult { Success = false, Error = $"Job {jobId} not found" }; return new JobDetailResult { Success = true, Job = MapJobDetail(job) }; } catch (HttpRequestException ex) { return new JobDetailResult { Success = false, Error = ex.Message }; } } [McpServerTool(Name = "delete_job"), Description("Deletes a job and all its parts and stock assignments.")] public async Task DeleteJob( [Description("Job ID")] int jobId) { try { await _api.DeleteJobAsync(jobId); return new SimpleResult { Success = true }; } catch (HttpRequestException ex) { return new SimpleResult { Success = false, Error = ex.Message }; } } #endregion #region Parts [McpServerTool(Name = "add_job_part"), Description("Adds a single part to a job.")] public async Task AddJobPart( [Description("Job ID")] int jobId, [Description("Material ID (use list_materials to find IDs)")] int materialId, [Description("Part name/label (e.g., 'Top Rail', 'Picket')")] string name, [Description("Part length (e.g., '36\"', '4\\' 6\"', '54.5')")] string length, [Description("Quantity needed (default 1)")] int quantity = 1) { try { var part = await _api.AddJobPartAsync(jobId, materialId, name, length, quantity); if (part == null) return new JobPartResult { Success = false, Error = "Failed to add part" }; return new JobPartResult { Success = true, Part = MapPart(part) }; } catch (HttpRequestException ex) { return new JobPartResult { Success = false, Error = ex.Message }; } } [McpServerTool(Name = "add_job_parts"), Description("Batch adds multiple parts to a job. Ideal for entering a full BOM (bill of materials). Returns the complete job state after all parts are added.")] public async Task AddJobParts( [Description("Job ID")] int jobId, [Description("Array of parts to add. Each needs: materialId (int), name (string), length (string like \"36\\\"\" or \"4' 6\\\"\"), quantity (int)")] PartEntry[] parts) { var errors = new List(); int added = 0; foreach (var part in parts) { try { await _api.AddJobPartAsync(jobId, part.MaterialId, part.Name, part.Length, part.Quantity); added++; } catch (HttpRequestException ex) { errors.Add($"Failed to add '{part.Name}': {ex.Message}"); } } // Reload the full job state try { var job = await _api.GetJobAsync(jobId); if (job == null) return new JobDetailResult { Success = false, Error = $"Job {jobId} not found after adding parts" }; var result = new JobDetailResult { Success = errors.Count == 0, Job = MapJobDetail(job) }; if (errors.Count > 0) result.Error = $"Added {added}/{parts.Length} parts. Errors: {string.Join("; ", errors)}"; return result; } catch (HttpRequestException ex) { return new JobDetailResult { Success = false, Error = ex.Message }; } } [McpServerTool(Name = "delete_job_part"), Description("Removes a part from a job.")] public async Task DeleteJobPart( [Description("Job ID")] int jobId, [Description("Part ID (from get_job results)")] int partId) { try { await _api.DeleteJobPartAsync(jobId, partId); return new SimpleResult { Success = true }; } catch (HttpRequestException ex) { return new SimpleResult { Success = false, Error = ex.Message }; } } #endregion #region Stock [McpServerTool(Name = "add_job_stock"), Description("Adds a stock material assignment to a job. Stock defines what material lengths are available for cutting.")] public async Task AddJobStock( [Description("Job ID")] int jobId, [Description("Material ID (must match the material used by parts)")] int materialId, [Description("Stock length (e.g., '20'', '240', '20 ft')")] string length, [Description("Quantity available (-1 for unlimited, default -1)")] int quantity = -1, [Description("Stock item ID from inventory (optional - links to tracked inventory)")] int? stockItemId = null, [Description("True if this is a custom length not from inventory (default false)")] bool isCustomLength = false, [Description("Priority - lower number = used first (default 10)")] int priority = 10) { try { var stock = await _api.AddJobStockAsync(jobId, materialId, stockItemId, length, quantity, isCustomLength, priority); if (stock == null) return new JobStockResult { Success = false, Error = "Failed to add stock" }; return new JobStockResult { Success = true, Stock = MapStock(stock) }; } catch (HttpRequestException ex) { return new JobStockResult { Success = false, Error = ex.Message }; } } [McpServerTool(Name = "delete_job_stock"), Description("Removes a stock assignment from a job.")] public async Task DeleteJobStock( [Description("Job ID")] int jobId, [Description("Stock ID (from get_job results)")] int stockId) { try { await _api.DeleteJobStockAsync(jobId, stockId); return new SimpleResult { Success = true }; } catch (HttpRequestException ex) { return new SimpleResult { Success = false, Error = ex.Message }; } } #endregion #region Optimization [McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined. If stock is defined, it will be used; otherwise the optimizer uses available inventory. Returns optimized cut layouts per material with efficiency stats.")] public async Task OptimizeJob( [Description("Job ID")] int jobId, [Description("Optional kerf override in inches (e.g., 0.125). If not set, uses the job's cutting tool kerf.")] double? kerfOverride = null) { try { var result = await _api.PackJobAsync(jobId, kerfOverride.HasValue ? (decimal)kerfOverride.Value : null); if (result == null) return new OptimizeJobResult { Success = false, Error = "Optimization returned no results" }; return new OptimizeJobResult { Success = true, Materials = result.Materials.Select(m => new OptMaterialResultDto { MaterialId = m.MaterialId, MaterialName = m.MaterialName, InStockBins = m.InStockBins.Select(MapBin).ToList(), ToBePurchasedBins = m.ToBePurchasedBins.Select(MapBin).ToList(), ItemsNotPlaced = m.ItemsNotPlaced.Select(i => new OptItemDto { Name = i.Name, LengthInches = i.LengthInches, LengthFormatted = i.LengthFormatted }).ToList(), Summary = MapMaterialSummary(m.Summary) }).ToList(), Summary = new OptSummaryDto { TotalInStockBins = result.Summary.TotalInStockBins, TotalToBePurchasedBins = result.Summary.TotalToBePurchasedBins, TotalPieces = result.Summary.TotalPieces, TotalMaterialFormatted = result.Summary.TotalMaterialFormatted, TotalUsedFormatted = result.Summary.TotalUsedFormatted, TotalWasteFormatted = result.Summary.TotalWasteFormatted, Efficiency = result.Summary.Efficiency, TotalItemsNotPlaced = result.Summary.TotalItemsNotPlaced } }; } catch (HttpRequestException ex) { return new OptimizeJobResult { Success = false, Error = ex.Message }; } } #endregion #region Cutting Tools [McpServerTool(Name = "list_cutting_tools"), Description("Lists all available cutting tools with their kerf (blade width) values.")] public async Task ListCuttingTools( [Description("Include inactive tools (default false)")] bool includeInactive = false) { var tools = await _api.GetCuttingToolsAsync(includeInactive); return new CuttingToolListResult { Success = true, Tools = tools.Select(t => new CuttingToolSummaryDto { Id = t.Id, Name = t.Name, KerfInches = t.KerfInches, IsDefault = t.IsDefault, IsActive = t.IsActive }).ToList() }; } #endregion #region Mapping Helpers private static JobDetailDto MapJobDetail(ApiJobDetailDto j) => new() { Id = j.Id, JobNumber = j.JobNumber, Name = j.Name, Customer = j.Customer, CuttingToolId = j.CuttingToolId, CuttingToolName = j.CuttingToolName, Notes = j.Notes, CreatedAt = j.CreatedAt, UpdatedAt = j.UpdatedAt, PartCount = j.PartCount, StockCount = j.StockCount, Parts = j.Parts.Select(MapPart).ToList(), Stock = j.Stock.Select(MapStock).ToList() }; private static JobPartSummaryDto MapPart(ApiJobPartDto p) => new() { Id = p.Id, MaterialId = p.MaterialId, MaterialName = p.MaterialName, Name = p.Name, LengthInches = p.LengthInches, LengthFormatted = p.LengthFormatted, Quantity = p.Quantity }; private static JobStockSummaryDto MapStock(ApiJobStockDto s) => new() { Id = s.Id, MaterialId = s.MaterialId, MaterialName = s.MaterialName, StockItemId = s.StockItemId, LengthInches = s.LengthInches, LengthFormatted = s.LengthFormatted, Quantity = s.Quantity, IsCustomLength = s.IsCustomLength, Priority = s.Priority }; private static OptBinDto MapBin(ApiPackedBinDto b) => new() { LengthFormatted = b.LengthFormatted, UsedFormatted = b.UsedFormatted, WasteFormatted = b.WasteFormatted, Efficiency = Math.Round(b.Efficiency, 1), Items = b.Items.Select(i => new OptItemDto { Name = i.Name, LengthInches = i.LengthInches, LengthFormatted = i.LengthFormatted }).ToList() }; private static OptMaterialSummaryDto MapMaterialSummary(ApiMaterialPackingSummaryDto s) => new() { InStockBins = s.InStockBins, ToBePurchasedBins = s.ToBePurchasedBins, TotalPieces = s.TotalPieces, Efficiency = Math.Round(s.Efficiency, 1), ItemsNotPlaced = s.ItemsNotPlaced }; #endregion } #region Job Tool DTOs public class PartEntry { public int MaterialId { get; set; } public string Name { get; set; } = string.Empty; public string Length { get; set; } = string.Empty; public int Quantity { get; set; } = 1; } public class SimpleResult { public bool Success { get; set; } public string? Error { get; set; } } public class JobSummaryDto { public int Id { get; set; } public string JobNumber { get; set; } = string.Empty; public string? Name { get; set; } public string? Customer { get; set; } public int? CuttingToolId { get; set; } public string? CuttingToolName { get; set; } public string? Notes { get; set; } public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } public int PartCount { get; set; } public int StockCount { get; set; } } public class JobDetailDto { public int Id { get; set; } public string JobNumber { get; set; } = string.Empty; public string? Name { get; set; } public string? Customer { get; set; } public int? CuttingToolId { get; set; } public string? CuttingToolName { get; set; } public string? Notes { get; set; } public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } public int PartCount { get; set; } public int StockCount { get; set; } public List Parts { get; set; } = new(); public List Stock { get; set; } = new(); } public class JobPartSummaryDto { public int Id { get; set; } public int MaterialId { get; set; } public string MaterialName { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public decimal LengthInches { get; set; } public string LengthFormatted { get; set; } = string.Empty; public int Quantity { get; set; } } public class JobStockSummaryDto { public int Id { get; set; } public int MaterialId { get; set; } public string MaterialName { get; set; } = string.Empty; public int? StockItemId { get; set; } public decimal LengthInches { get; set; } public string LengthFormatted { get; set; } = string.Empty; public int Quantity { get; set; } public bool IsCustomLength { get; set; } public int Priority { get; set; } } public class JobListResult { public bool Success { get; set; } public string? Error { get; set; } public List Jobs { get; set; } = new(); } public class JobDetailResult { public bool Success { get; set; } public string? Error { get; set; } public JobDetailDto? Job { get; set; } } public class JobPartResult { public bool Success { get; set; } public string? Error { get; set; } public JobPartSummaryDto? Part { get; set; } } public class JobStockResult { public bool Success { get; set; } public string? Error { get; set; } public JobStockSummaryDto? Stock { get; set; } } public class CuttingToolSummaryDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal KerfInches { get; set; } public bool IsDefault { get; set; } public bool IsActive { get; set; } } public class CuttingToolListResult { public bool Success { get; set; } public string? Error { get; set; } public List Tools { get; set; } = new(); } // Optimization result DTOs — streamlined for LLM consumption public class OptimizeJobResult { public bool Success { get; set; } public string? Error { get; set; } public List Materials { get; set; } = new(); public OptSummaryDto? Summary { get; set; } } public class OptMaterialResultDto { public int MaterialId { get; set; } public string MaterialName { get; set; } = string.Empty; public List InStockBins { get; set; } = new(); public List ToBePurchasedBins { get; set; } = new(); public List ItemsNotPlaced { get; set; } = new(); public OptMaterialSummaryDto Summary { get; set; } = new(); } public class OptBinDto { public string LengthFormatted { get; set; } = string.Empty; public string UsedFormatted { get; set; } = string.Empty; public string WasteFormatted { get; set; } = string.Empty; public double Efficiency { get; set; } public List Items { get; set; } = new(); } public class OptItemDto { public string Name { get; set; } = string.Empty; public double LengthInches { get; set; } public string LengthFormatted { get; set; } = string.Empty; } public class OptSummaryDto { public int TotalInStockBins { get; set; } public int TotalToBePurchasedBins { get; set; } public int TotalPieces { get; set; } public string TotalMaterialFormatted { get; set; } = string.Empty; public string TotalUsedFormatted { get; set; } = string.Empty; public string TotalWasteFormatted { get; set; } = string.Empty; public double Efficiency { get; set; } public int TotalItemsNotPlaced { get; set; } } public class OptMaterialSummaryDto { public int InStockBins { get; set; } public int ToBePurchasedBins { get; set; } public int TotalPieces { get; set; } public double Efficiency { get; set; } public int ItemsNotPlaced { get; set; } } #endregion