diff --git a/CutList.Mcp/JobTools.cs b/CutList.Mcp/JobTools.cs
new file mode 100644
index 0000000..1894867
--- /dev/null
+++ b/CutList.Mcp/JobTools.cs
@@ -0,0 +1,619 @@
+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