From c9a2583f2641dd11bf18e7d989a7a53a69b5d0de Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 4 Feb 2026 23:38:27 -0500 Subject: [PATCH] feat: Add MCP inventory management tools Add comprehensive MCP tools for inventory management: - list_suppliers, add_supplier - list_materials, add_material, search_materials - list_stock_items, add_stock_item - list_supplier_offerings, add_supplier_offering - add_stock_with_offering (convenience method) Features: - Dimension-based material search with tolerance - Auto-generate size strings from dimensions - Parse size strings to typed dimensions - Type/Grade support for material categorization Co-Authored-By: Claude Opus 4.5 --- CutList.Mcp/CutList.Mcp.csproj | 1 + CutList.Mcp/InventoryTools.cs | 1099 ++++++++++++++++++++++++++++++++ CutList.Mcp/Program.cs | 6 + 3 files changed, 1106 insertions(+) create mode 100644 CutList.Mcp/InventoryTools.cs diff --git a/CutList.Mcp/CutList.Mcp.csproj b/CutList.Mcp/CutList.Mcp.csproj index 87ab5d8..de6e390 100644 --- a/CutList.Mcp/CutList.Mcp.csproj +++ b/CutList.Mcp/CutList.Mcp.csproj @@ -2,6 +2,7 @@ + diff --git a/CutList.Mcp/InventoryTools.cs b/CutList.Mcp/InventoryTools.cs new file mode 100644 index 0000000..f568258 --- /dev/null +++ b/CutList.Mcp/InventoryTools.cs @@ -0,0 +1,1099 @@ +using System.ComponentModel; +using CutList.Core.Formatting; +using CutList.Web.Data; +using CutList.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; + +namespace CutList.Mcp; + +/// +/// MCP tools for inventory management - suppliers, materials, stock items, and offerings. +/// +[McpServerToolType] +public class InventoryTools +{ + private readonly ApplicationDbContext _context; + + public InventoryTools(ApplicationDbContext context) + { + _context = context; + } + + #region Suppliers + + [McpServerTool(Name = "list_suppliers"), Description("Lists all suppliers in the system.")] + public async Task ListSuppliers( + [Description("Include inactive suppliers (default false)")] + bool includeInactive = false) + { + var query = _context.Suppliers.AsQueryable(); + if (!includeInactive) + query = query.Where(s => s.IsActive); + + var suppliers = await query.OrderBy(s => s.Name).ToListAsync(); + + return new SupplierListResult + { + Success = true, + Suppliers = suppliers.Select(s => new SupplierDto + { + Id = s.Id, + Name = s.Name, + ContactInfo = s.ContactInfo, + Notes = s.Notes, + IsActive = s.IsActive + }).ToList() + }; + } + + [McpServerTool(Name = "add_supplier"), Description("Adds a new supplier to the system.")] + public async Task AddSupplier( + [Description("Supplier name (e.g., 'O'Neal Steel')")] + string name, + [Description("Contact info - website, phone, email, etc.")] + string? contactInfo = null, + [Description("Notes about the supplier")] + string? notes = null) + { + var supplier = new Supplier + { + Name = name, + ContactInfo = contactInfo, + Notes = notes, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + _context.Suppliers.Add(supplier); + await _context.SaveChangesAsync(); + + return new SupplierResult + { + Success = true, + Supplier = new SupplierDto + { + Id = supplier.Id, + Name = supplier.Name, + ContactInfo = supplier.ContactInfo, + Notes = supplier.Notes, + IsActive = supplier.IsActive + } + }; + } + + #endregion + + #region Materials + + [McpServerTool(Name = "list_materials"), Description("Lists all materials (shape/size combinations) in the system.")] + public async Task ListMaterials( + [Description("Filter by shape (e.g., 'Angle', 'FlatBar', 'RoundTube')")] + string? shape = null, + [Description("Include inactive materials (default false)")] + bool includeInactive = false) + { + var query = _context.Materials + .Include(m => m.Dimensions) + .AsQueryable(); + + if (!includeInactive) + query = query.Where(m => m.IsActive); + + if (!string.IsNullOrEmpty(shape)) + { + var parsedShape = MaterialShapeExtensions.ParseShape(shape); + if (parsedShape.HasValue) + { + query = query.Where(m => m.Shape == parsedShape.Value); + } + else + { + // Fallback to string-based display name search + var shapeLower = shape.ToLower(); + query = query.Where(m => m.Shape.ToString().ToLower().Contains(shapeLower)); + } + } + + var materials = await query + .OrderBy(m => m.Shape) + .ThenBy(m => m.Size) + .ToListAsync(); + + return new MaterialListResult + { + Success = true, + Materials = materials.Select(m => new MaterialDto + { + Id = m.Id, + Shape = m.Shape.GetDisplayName(), + Size = m.Size, + Description = m.Description, + DisplayName = m.DisplayName, + IsActive = m.IsActive, + Dimensions = m.Dimensions != null ? MapDimensions(m.Dimensions) : null + }).ToList() + }; + } + + [McpServerTool(Name = "add_material"), Description("Adds a new material (shape/size combination) to the system with optional parsed dimensions.")] + public async Task AddMaterial( + [Description("Material shape (e.g., 'Angle', 'FlatBar', 'RoundTube', 'SquareTube', 'Channel', 'IBeam', 'Pipe')")] + string shape, + [Description("Material size string (e.g., '2 x 2 x 1/4'). If not provided, will be auto-generated from dimensions.")] + string? size = null, + [Description("Optional description")] + string? description = null, + [Description("Diameter in inches (for Round Bar)")] + double? diameter = null, + [Description("Outer diameter in inches (for Round Tube)")] + double? outerDiameter = null, + [Description("Width in inches (for Flat Bar, Rectangular Tube)")] + double? width = null, + [Description("Height in inches (for Rectangular Tube, Channel, I-Beam)")] + double? height = null, + [Description("Size in inches (for Square Bar, Square Tube - the side length)")] + double? squareSize = null, + [Description("Thickness in inches (for Flat Bar, Angle)")] + double? thickness = null, + [Description("Wall thickness in inches (for tubes, pipe)")] + double? wall = null, + [Description("Leg 1 length in inches (for Angle)")] + double? leg1 = null, + [Description("Leg 2 length in inches (for Angle)")] + double? leg2 = null, + [Description("Flange width in inches (for Channel)")] + double? flange = null, + [Description("Web thickness in inches (for Channel)")] + double? web = null, + [Description("Weight per foot in lbs (for I-Beam)")] + double? weightPerFoot = null, + [Description("Nominal pipe size in inches (for Pipe)")] + double? nominalSize = null, + [Description("Schedule (for Pipe, e.g., '40', '80', 'STD')")] + string? schedule = null) + { + var parsedShape = MaterialShapeExtensions.ParseShape(shape); + if (!parsedShape.HasValue) + { + return new MaterialResult + { + Success = false, + Error = $"Unknown shape: {shape}. Valid shapes are: RoundBar, RoundTube, FlatBar, SquareBar, SquareTube, RectangularTube, Angle, Channel, IBeam, Pipe" + }; + } + + // Create typed dimensions object based on shape + MaterialDimensions? dimensions = CreateDimensionsForShape( + parsedShape.Value, diameter, outerDiameter, width, height, squareSize, + thickness, wall, leg1, leg2, flange, web, weightPerFoot, nominalSize, schedule); + + // Auto-generate size string if not provided + var finalSize = size ?? dimensions?.GenerateSizeString(); + if (string.IsNullOrWhiteSpace(finalSize)) + { + return new MaterialResult + { + Success = false, + Error = "Size string is required. Either provide a size parameter or dimension values for auto-generation." + }; + } + + // Check if material already exists + var existing = await _context.Materials + .FirstOrDefaultAsync(m => m.Shape == parsedShape.Value && m.Size.ToLower() == finalSize.ToLower()); + + if (existing != null) + { + return new MaterialResult + { + Success = false, + Error = $"Material '{parsedShape.Value.GetDisplayName()} - {finalSize}' already exists with ID {existing.Id}" + }; + } + + var material = new Material + { + Shape = parsedShape.Value, + Size = finalSize, + Description = description, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + _context.Materials.Add(material); + await _context.SaveChangesAsync(); + + // Add dimensions + dimensions.MaterialId = material.Id; + _context.MaterialDimensions.Add(dimensions); + await _context.SaveChangesAsync(); + + return new MaterialResult + { + Success = true, + Material = new MaterialDto + { + Id = material.Id, + Shape = material.Shape.GetDisplayName(), + Size = material.Size, + Description = material.Description, + DisplayName = material.DisplayName, + IsActive = material.IsActive, + Dimensions = MapDimensions(dimensions) + } + }; + } + + [McpServerTool(Name = "search_materials"), Description("Search for materials by dimension value with tolerance. Returns materials where the specified dimension is within +/- tolerance of the target value.")] + public async Task SearchMaterials( + [Description("Which dimension to search: diameter, outerDiameter, width, height, size, thickness, wall, leg1, leg2, flange, web, weightPerFoot, nominalSize")] + string dimensionType, + [Description("Target dimension value in inches (or lbs for weightPerFoot)")] + double targetValue, + [Description("Tolerance value - returns results within +/- this amount (default 0.01)")] + double tolerance = 0.01, + [Description("Optional shape filter (e.g., 'RoundBar', 'Angle')")] + string? shape = null) + { + var minValue = (decimal)(targetValue - tolerance); + var maxValue = (decimal)(targetValue + tolerance); + + // Search the appropriate typed dimension table based on dimension type + List materials = dimensionType.ToLowerInvariant() switch + { + "diameter" => await SearchByDimension(d => d.Diameter >= minValue && d.Diameter <= maxValue, shape), + "outerdiameter" or "outer_diameter" or "od" => await SearchByDimension(d => d.OuterDiameter >= minValue && d.OuterDiameter <= maxValue, shape), + "width" => await SearchWidthDimensions(minValue, maxValue, shape), + "height" => await SearchHeightDimensions(minValue, maxValue, shape), + "size" or "squaresize" => await SearchSizeDimensions(minValue, maxValue, shape), + "thickness" => await SearchThicknessDimensions(minValue, maxValue, shape), + "wall" => await SearchWallDimensions(minValue, maxValue, shape), + "leg1" => await SearchByDimension(d => d.Leg1 >= minValue && d.Leg1 <= maxValue, shape), + "leg2" => await SearchByDimension(d => d.Leg2 >= minValue && d.Leg2 <= maxValue, shape), + "flange" => await SearchByDimension(d => d.Flange >= minValue && d.Flange <= maxValue, shape), + "web" => await SearchByDimension(d => d.Web >= minValue && d.Web <= maxValue, shape), + "weightperfoot" or "weight" => await SearchByDimension(d => d.WeightPerFoot >= minValue && d.WeightPerFoot <= maxValue, shape), + "nominalsize" or "nominal_size" or "nps" => await SearchByDimension(d => d.NominalSize >= minValue && d.NominalSize <= maxValue, shape), + _ => throw new ArgumentException($"Unknown dimension type: {dimensionType}") + }; + + return new MaterialListResult + { + Success = true, + Materials = materials.Select(m => new MaterialDto + { + Id = m.Id, + Shape = m.Shape.GetDisplayName(), + Size = m.Size, + Description = m.Description, + DisplayName = m.DisplayName, + IsActive = m.IsActive, + Dimensions = m.Dimensions != null ? MapDimensions(m.Dimensions) : null + }).ToList() + }; + } + + private async Task> SearchByDimension(System.Linq.Expressions.Expression> predicate, string? shape) where T : MaterialDimensions + { + var query = _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(predicate); + + if (!string.IsNullOrEmpty(shape)) + { + var parsedShape = MaterialShapeExtensions.ParseShape(shape); + if (parsedShape.HasValue) + { + query = query.Where(d => d.Material.Shape == parsedShape.Value); + } + } + + return await query + .Select(d => d.Material) + .OrderBy(m => m.Shape) + .ThenBy(m => m.Size) + .ToListAsync(); + } + + // Width is used by FlatBar, RectangularTube + private async Task> SearchWidthDimensions(decimal minValue, decimal maxValue, string? shape) + { + var flatBars = await SearchByDimension(d => d.Width >= minValue && d.Width <= maxValue, shape); + var rectTubes = await SearchByDimension(d => d.Width >= minValue && d.Width <= maxValue, shape); + return flatBars.Concat(rectTubes).OrderBy(m => m.Shape).ThenBy(m => m.Size).ToList(); + } + + // Height is used by RectangularTube, Channel, IBeam + private async Task> SearchHeightDimensions(decimal minValue, decimal maxValue, string? shape) + { + var rectTubes = await SearchByDimension(d => d.Height >= minValue && d.Height <= maxValue, shape); + var channels = await SearchByDimension(d => d.Height >= minValue && d.Height <= maxValue, shape); + var ibeams = await SearchByDimension(d => d.Height >= minValue && d.Height <= maxValue, shape); + return rectTubes.Concat(channels).Concat(ibeams).OrderBy(m => m.Shape).ThenBy(m => m.Size).ToList(); + } + + // Size is used by SquareBar, SquareTube + private async Task> SearchSizeDimensions(decimal minValue, decimal maxValue, string? shape) + { + var squareBars = await SearchByDimension(d => d.Size >= minValue && d.Size <= maxValue, shape); + var squareTubes = await SearchByDimension(d => d.Size >= minValue && d.Size <= maxValue, shape); + return squareBars.Concat(squareTubes).OrderBy(m => m.Shape).ThenBy(m => m.Size).ToList(); + } + + // Thickness is used by FlatBar, Angle + private async Task> SearchThicknessDimensions(decimal minValue, decimal maxValue, string? shape) + { + var flatBars = await SearchByDimension(d => d.Thickness >= minValue && d.Thickness <= maxValue, shape); + var angles = await SearchByDimension(d => d.Thickness >= minValue && d.Thickness <= maxValue, shape); + return flatBars.Concat(angles).OrderBy(m => m.Shape).ThenBy(m => m.Size).ToList(); + } + + // Wall is used by RoundTube, SquareTube, RectangularTube, Pipe + private async Task> SearchWallDimensions(decimal minValue, decimal maxValue, string? shape) + { + var roundTubes = await SearchByDimension(d => d.Wall >= minValue && d.Wall <= maxValue, shape); + var squareTubes = await SearchByDimension(d => d.Wall >= minValue && d.Wall <= maxValue, shape); + var rectTubes = await SearchByDimension(d => d.Wall >= minValue && d.Wall <= maxValue, shape); + var pipes = await SearchByDimension(d => d.Wall != null && d.Wall >= minValue && d.Wall <= maxValue, shape); + return roundTubes.Concat(squareTubes).Concat(rectTubes).Concat(pipes).OrderBy(m => m.Shape).ThenBy(m => m.Size).ToList(); + } + + private static MaterialDimensions? CreateDimensionsForShape( + MaterialShape shape, double? diameter, double? outerDiameter, double? width, double? height, + double? squareSize, double? thickness, double? wall, double? leg1, double? leg2, + double? flange, double? web, double? weightPerFoot, double? nominalSize, string? schedule) + { + return shape switch + { + MaterialShape.RoundBar when diameter.HasValue => new RoundBarDimensions { Diameter = (decimal)diameter.Value }, + MaterialShape.RoundTube when outerDiameter.HasValue && wall.HasValue => new RoundTubeDimensions { OuterDiameter = (decimal)outerDiameter.Value, Wall = (decimal)wall.Value }, + MaterialShape.FlatBar when width.HasValue && thickness.HasValue => new FlatBarDimensions { Width = (decimal)width.Value, Thickness = (decimal)thickness.Value }, + MaterialShape.SquareBar when squareSize.HasValue => new SquareBarDimensions { Size = (decimal)squareSize.Value }, + MaterialShape.SquareTube when squareSize.HasValue && wall.HasValue => new SquareTubeDimensions { Size = (decimal)squareSize.Value, Wall = (decimal)wall.Value }, + MaterialShape.RectangularTube when width.HasValue && height.HasValue && wall.HasValue => new RectangularTubeDimensions { Width = (decimal)width.Value, Height = (decimal)height.Value, Wall = (decimal)wall.Value }, + MaterialShape.Angle when leg1.HasValue && leg2.HasValue && thickness.HasValue => new AngleDimensions { Leg1 = (decimal)leg1.Value, Leg2 = (decimal)leg2.Value, Thickness = (decimal)thickness.Value }, + MaterialShape.Channel when height.HasValue && flange.HasValue && web.HasValue => new ChannelDimensions { Height = (decimal)height.Value, Flange = (decimal)flange.Value, Web = (decimal)web.Value }, + MaterialShape.IBeam when height.HasValue && weightPerFoot.HasValue => new IBeamDimensions { Height = (decimal)height.Value, WeightPerFoot = (decimal)weightPerFoot.Value }, + MaterialShape.Pipe when nominalSize.HasValue => new PipeDimensions { NominalSize = (decimal)nominalSize.Value, Wall = wall.HasValue ? (decimal)wall.Value : null, Schedule = schedule }, + _ => null + }; + } + + /// + /// Parses a size string into MaterialDimensions based on shape. + /// Format: values separated by 'x' (e.g., "1 1/2 x 1/8", "2 x 2 x 1/4") + /// + private static (MaterialDimensions? dimensions, int sortOrder) ParseSizeStringToDimensions(MaterialShape shape, string sizeString) + { + var p = sizeString.Split('x', StringSplitOptions.TrimEntries) + .Select(ParseDimension) + .ToArray(); + + decimal D(int i) => (decimal)p[i]!.Value; + int Sort(double v) => (int)(v * 1000); + + return shape switch + { + MaterialShape.RoundBar when p.Length >= 1 && p[0].HasValue + => (new RoundBarDimensions { Diameter = D(0) }, Sort(p[0]!.Value)), + + MaterialShape.RoundTube when p.Length >= 2 && p[0].HasValue && p[1].HasValue + => (new RoundTubeDimensions { OuterDiameter = D(0), Wall = D(1) }, Sort(p[0]!.Value)), + + MaterialShape.FlatBar when p.Length >= 2 && p[0].HasValue && p[1].HasValue + => (new FlatBarDimensions { Width = D(0), Thickness = D(1) }, Sort(p[0]!.Value)), + + MaterialShape.SquareBar when p.Length >= 1 && p[0].HasValue + => (new SquareBarDimensions { Size = D(0) }, Sort(p[0]!.Value)), + + MaterialShape.SquareTube when p.Length >= 2 && p[0].HasValue && p[1].HasValue + => (new SquareTubeDimensions { Size = D(0), Wall = D(1) }, Sort(p[0]!.Value)), + + MaterialShape.RectangularTube when p.Length >= 3 && p[0].HasValue && p[1].HasValue && p[2].HasValue + => (new RectangularTubeDimensions { Width = D(0), Height = D(1), Wall = D(2) }, Sort(Math.Max(p[0]!.Value, p[1]!.Value))), + + MaterialShape.Angle when p.Length >= 3 && p[0].HasValue && p[1].HasValue && p[2].HasValue + => (new AngleDimensions { Leg1 = D(0), Leg2 = D(1), Thickness = D(2) }, Sort(Math.Max(p[0]!.Value, p[1]!.Value))), + + MaterialShape.Channel when p.Length >= 3 && p[0].HasValue && p[1].HasValue && p[2].HasValue + => (new ChannelDimensions { Height = D(0), Flange = D(1), Web = D(2) }, Sort(p[0]!.Value)), + + MaterialShape.IBeam when p.Length >= 2 && p[0].HasValue && p[1].HasValue + => (new IBeamDimensions { Height = D(0), WeightPerFoot = D(1) }, Sort(p[0]!.Value)), + + MaterialShape.Pipe when p.Length >= 1 && p[0].HasValue + => (new PipeDimensions { NominalSize = D(0), Schedule = ParsePipeSchedule(sizeString) }, Sort(p[0]!.Value)), + + _ => (null, 0) + }; + + static double? ParseDimension(string value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var processed = Fraction.ReplaceFractionsWithDecimals(value.Trim()); + return processed.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Sum(part => double.TryParse(part, out var d) ? d : 0) is > 0 and var total ? total : null; + } + + static string? ParsePipeSchedule(string s) + { + var idx = s.IndexOf("SCH", StringComparison.OrdinalIgnoreCase); + if (idx < 0) return null; + var rest = s[(idx + 3)..].Trim(); + var end = rest.IndexOfAny([' ', 'x', 'X']); + return end > 0 ? rest[..end] : rest; + } + } + + private static MaterialDimensionsDto MapDimensions(MaterialDimensions d) + { + var dto = new MaterialDimensionsDto(); + + switch (d) + { + case RoundBarDimensions rb: + dto.Diameter = (double)rb.Diameter; + break; + case RoundTubeDimensions rt: + dto.OuterDiameter = (double)rt.OuterDiameter; + dto.Wall = (double)rt.Wall; + break; + case FlatBarDimensions fb: + dto.Width = (double)fb.Width; + dto.Thickness = (double)fb.Thickness; + break; + case SquareBarDimensions sb: + dto.Size = (double)sb.Size; + break; + case SquareTubeDimensions st: + dto.Size = (double)st.Size; + dto.Wall = (double)st.Wall; + break; + case RectangularTubeDimensions rect: + dto.Width = (double)rect.Width; + dto.Height = (double)rect.Height; + dto.Wall = (double)rect.Wall; + break; + case AngleDimensions a: + dto.Leg1 = (double)a.Leg1; + dto.Leg2 = (double)a.Leg2; + dto.Thickness = (double)a.Thickness; + break; + case ChannelDimensions c: + dto.Height = (double)c.Height; + dto.Flange = (double)c.Flange; + dto.Web = (double)c.Web; + break; + case IBeamDimensions i: + dto.Height = (double)i.Height; + dto.WeightPerFoot = (double)i.WeightPerFoot; + break; + case PipeDimensions p: + dto.NominalSize = (double)p.NominalSize; + dto.Wall = p.Wall.HasValue ? (double)p.Wall.Value : null; + dto.Schedule = p.Schedule; + break; + } + + return dto; + } + + #endregion + + #region Stock Items + + [McpServerTool(Name = "list_stock_items"), Description("Lists stock items (material lengths available in inventory).")] + public async Task ListStockItems( + [Description("Filter by material ID")] + int? materialId = null, + [Description("Filter by shape (e.g., 'Angle')")] + string? shape = null, + [Description("Include inactive stock items (default false)")] + bool includeInactive = false) + { + var query = _context.StockItems + .Include(s => s.Material) + .AsQueryable(); + + if (!includeInactive) + query = query.Where(s => s.IsActive); + + if (materialId.HasValue) + query = query.Where(s => s.MaterialId == materialId.Value); + + if (!string.IsNullOrEmpty(shape)) + { + var parsedShape = MaterialShapeExtensions.ParseShape(shape); + if (parsedShape.HasValue) + { + query = query.Where(s => s.Material.Shape == parsedShape.Value); + } + else + { + var shapeLower = shape.ToLower(); + query = query.Where(s => s.Material.Shape.ToString().ToLower().Contains(shapeLower)); + } + } + + var items = await query + .OrderBy(s => s.Material.Shape) + .ThenBy(s => s.Material.Size) + .ThenBy(s => s.LengthInches) + .ToListAsync(); + + return new StockItemListResult + { + Success = true, + StockItems = items.Select(s => new StockItemDto + { + Id = s.Id, + MaterialId = s.MaterialId, + MaterialName = s.Material.DisplayName, + LengthInches = s.LengthInches, + LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches), + Name = s.Name, + QuantityOnHand = s.QuantityOnHand, + Notes = s.Notes, + IsActive = s.IsActive + }).ToList() + }; + } + + [McpServerTool(Name = "add_stock_item"), Description("Adds a new stock item (a specific length of material that can be stocked).")] + public async Task AddStockItem( + [Description("Material ID (use list_materials to find IDs)")] + int materialId, + [Description("Stock length (e.g., '20'', '240', '20 ft')")] + string length, + [Description("Optional name/label for this stock item")] + string? name = null, + [Description("Initial quantity on hand (default 0)")] + int quantityOnHand = 0, + [Description("Notes")] + string? notes = null) + { + var material = await _context.Materials.FindAsync(materialId); + if (material == null) + { + return new StockItemResult + { + Success = false, + Error = $"Material with ID {materialId} not found" + }; + } + + // Parse length + double lengthInches; + try + { + lengthInches = double.TryParse(length.Trim(), out var plain) + ? plain + : ArchUnits.ParseToInches(length); + } + catch + { + return new StockItemResult + { + Success = false, + Error = $"Could not parse length: {length}" + }; + } + + // Check for duplicate + var existing = await _context.StockItems + .FirstOrDefaultAsync(s => s.MaterialId == materialId && s.LengthInches == (decimal)lengthInches && s.IsActive); + + if (existing != null) + { + return new StockItemResult + { + Success = false, + Error = $"Stock item for {material.DisplayName} at {ArchUnits.FormatFromInches(lengthInches)} already exists with ID {existing.Id}" + }; + } + + var stockItem = new StockItem + { + MaterialId = materialId, + LengthInches = (decimal)lengthInches, + Name = name, + QuantityOnHand = quantityOnHand, + Notes = notes, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + _context.StockItems.Add(stockItem); + await _context.SaveChangesAsync(); + + return new StockItemResult + { + Success = true, + StockItem = new StockItemDto + { + Id = stockItem.Id, + MaterialId = stockItem.MaterialId, + MaterialName = material.DisplayName, + LengthInches = stockItem.LengthInches, + LengthFormatted = ArchUnits.FormatFromInches((double)stockItem.LengthInches), + Name = stockItem.Name, + QuantityOnHand = stockItem.QuantityOnHand, + Notes = stockItem.Notes, + IsActive = stockItem.IsActive + } + }; + } + + #endregion + + #region Supplier Offerings + + [McpServerTool(Name = "list_supplier_offerings"), Description("Lists supplier offerings (what suppliers sell for each stock item).")] + public async Task ListSupplierOfferings( + [Description("Filter by supplier ID")] + int? supplierId = null, + [Description("Filter by stock item ID")] + int? stockItemId = null, + [Description("Filter by material ID")] + int? materialId = null) + { + var query = _context.SupplierOfferings + .Include(o => o.Supplier) + .Include(o => o.StockItem) + .ThenInclude(s => s.Material) + .AsQueryable(); + + if (supplierId.HasValue) + query = query.Where(o => o.SupplierId == supplierId.Value); + + if (stockItemId.HasValue) + query = query.Where(o => o.StockItemId == stockItemId.Value); + + if (materialId.HasValue) + query = query.Where(o => o.StockItem.MaterialId == materialId.Value); + + var offerings = await query + .OrderBy(o => o.Supplier.Name) + .ThenBy(o => o.StockItem.Material.Shape) + .ThenBy(o => o.StockItem.Material.Size) + .ThenBy(o => o.StockItem.LengthInches) + .ToListAsync(); + + return new SupplierOfferingListResult + { + Success = true, + Offerings = offerings.Select(o => new SupplierOfferingDto + { + Id = o.Id, + SupplierId = o.SupplierId, + SupplierName = o.Supplier.Name, + StockItemId = o.StockItemId, + MaterialName = o.StockItem.Material.DisplayName, + LengthFormatted = ArchUnits.FormatFromInches((double)o.StockItem.LengthInches), + PartNumber = o.PartNumber, + SupplierDescription = o.SupplierDescription, + Price = o.Price, + Notes = o.Notes + }).ToList() + }; + } + + [McpServerTool(Name = "add_supplier_offering"), Description("Adds a supplier offering - links a supplier to a stock item with their part number and pricing.")] + public async Task AddSupplierOffering( + [Description("Supplier ID (use list_suppliers to find)")] + int supplierId, + [Description("Stock item ID (use list_stock_items to find)")] + int stockItemId, + [Description("Supplier's part number")] + string? partNumber = null, + [Description("Supplier's description of the item")] + string? supplierDescription = null, + [Description("Price per unit")] + decimal? price = null, + [Description("Notes")] + string? notes = null) + { + var supplier = await _context.Suppliers.FindAsync(supplierId); + if (supplier == null) + { + return new SupplierOfferingResult + { + Success = false, + Error = $"Supplier with ID {supplierId} not found" + }; + } + + var stockItem = await _context.StockItems + .Include(s => s.Material) + .FirstOrDefaultAsync(s => s.Id == stockItemId); + + if (stockItem == null) + { + return new SupplierOfferingResult + { + Success = false, + Error = $"Stock item with ID {stockItemId} not found" + }; + } + + // Check for duplicate + var existing = await _context.SupplierOfferings + .FirstOrDefaultAsync(o => o.SupplierId == supplierId && o.StockItemId == stockItemId); + + if (existing != null) + { + return new SupplierOfferingResult + { + Success = false, + Error = $"Offering from {supplier.Name} for this stock item already exists with ID {existing.Id}" + }; + } + + var offering = new SupplierOffering + { + SupplierId = supplierId, + StockItemId = stockItemId, + PartNumber = partNumber, + SupplierDescription = supplierDescription, + Price = price, + Notes = notes + }; + + _context.SupplierOfferings.Add(offering); + await _context.SaveChangesAsync(); + + return new SupplierOfferingResult + { + Success = true, + Offering = new SupplierOfferingDto + { + Id = offering.Id, + SupplierId = offering.SupplierId, + SupplierName = supplier.Name, + StockItemId = offering.StockItemId, + MaterialName = stockItem.Material.DisplayName, + LengthFormatted = ArchUnits.FormatFromInches((double)stockItem.LengthInches), + PartNumber = offering.PartNumber, + SupplierDescription = offering.SupplierDescription, + Price = offering.Price, + Notes = offering.Notes + } + }; + } + + [McpServerTool(Name = "add_stock_with_offering"), Description("Convenience method: adds a material (if needed), stock item (if needed), and supplier offering all at once.")] + public async Task AddStockWithOffering( + [Description("Supplier ID (use list_suppliers or add_supplier first)")] + int supplierId, + [Description("Material shape (e.g., 'Angle', 'FlatBar')")] + string shape, + [Description("Material size (e.g., '2 x 2 x 1/4')")] + string size, + [Description("Stock length (e.g., '20'', '240')")] + string length, + [Description("Material type: Steel, Aluminum, Stainless, Brass, Copper (default: Steel)")] + string type = "Steel", + [Description("Grade or specification (e.g., 'A36', 'Hot Roll', '304', '6061-T6')")] + string? grade = null, + [Description("Supplier's part number")] + string? partNumber = null, + [Description("Supplier's description")] + string? supplierDescription = null, + [Description("Price per unit")] + decimal? price = null) + { + var supplier = await _context.Suppliers.FindAsync(supplierId); + if (supplier == null) + { + return new AddStockWithOfferingResult + { + Success = false, + Error = $"Supplier with ID {supplierId} not found" + }; + } + + // Parse shape + var parsedShape = MaterialShapeExtensions.ParseShape(shape); + if (!parsedShape.HasValue) + { + return new AddStockWithOfferingResult + { + Success = false, + Error = $"Unknown shape: {shape}. Valid shapes are: RoundBar, RoundTube, FlatBar, SquareBar, SquareTube, RectangularTube, Angle, Channel, IBeam, Pipe" + }; + } + + // Parse material type + if (!Enum.TryParse(type, ignoreCase: true, out var parsedType)) + { + return new AddStockWithOfferingResult + { + Success = false, + Error = $"Unknown material type: {type}. Valid types are: Steel, Aluminum, Stainless, Brass, Copper" + }; + } + + // Parse length + double lengthInches; + try + { + lengthInches = double.TryParse(length.Trim(), out var plain) + ? plain + : ArchUnits.ParseToInches(length); + } + catch + { + return new AddStockWithOfferingResult + { + Success = false, + Error = $"Could not parse length: {length}" + }; + } + + // Find or create material (match on shape, type, grade, and size) + var material = await _context.Materials + .Include(m => m.Dimensions) + .FirstOrDefaultAsync(m => m.Shape == parsedShape.Value + && m.Type == parsedType + && m.Grade == grade + && m.Size.ToLower() == size.ToLower()); + + bool materialCreated = false; + if (material == null) + { + // Parse dimensions from size string + var (dimensions, sortOrder) = ParseSizeStringToDimensions(parsedShape.Value, size); + + material = new Material + { + Shape = parsedShape.Value, + Type = parsedType, + Grade = grade, + Size = size, + SortOrder = sortOrder, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + _context.Materials.Add(material); + await _context.SaveChangesAsync(); + + // Add dimensions if parsed successfully + if (dimensions != null) + { + dimensions.MaterialId = material.Id; + _context.MaterialDimensions.Add(dimensions); + await _context.SaveChangesAsync(); + } + + materialCreated = true; + } + + // Find or create stock item + var stockItem = await _context.StockItems + .FirstOrDefaultAsync(s => s.MaterialId == material.Id && s.LengthInches == (decimal)lengthInches && s.IsActive); + + bool stockItemCreated = false; + if (stockItem == null) + { + stockItem = new StockItem + { + MaterialId = material.Id, + LengthInches = (decimal)lengthInches, + QuantityOnHand = 0, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + _context.StockItems.Add(stockItem); + await _context.SaveChangesAsync(); + stockItemCreated = true; + } + + // Check if offering already exists + var existingOffering = await _context.SupplierOfferings + .FirstOrDefaultAsync(o => o.SupplierId == supplierId && o.StockItemId == stockItem.Id); + + if (existingOffering != null) + { + return new AddStockWithOfferingResult + { + Success = false, + Error = $"Offering from {supplier.Name} for {material.DisplayName} at {ArchUnits.FormatFromInches(lengthInches)} already exists", + MaterialCreated = materialCreated, + StockItemCreated = stockItemCreated + }; + } + + // Create offering + var offering = new SupplierOffering + { + SupplierId = supplierId, + StockItemId = stockItem.Id, + PartNumber = partNumber, + SupplierDescription = supplierDescription, + Price = price + }; + + _context.SupplierOfferings.Add(offering); + await _context.SaveChangesAsync(); + + return new AddStockWithOfferingResult + { + Success = true, + MaterialId = material.Id, + MaterialName = material.DisplayName, + MaterialCreated = materialCreated, + StockItemId = stockItem.Id, + StockItemCreated = stockItemCreated, + LengthFormatted = ArchUnits.FormatFromInches(lengthInches), + OfferingId = offering.Id, + PartNumber = partNumber, + SupplierDescription = supplierDescription, + Price = price + }; + } + + #endregion +} + +#region DTOs + +public class SupplierDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? ContactInfo { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } +} + +public class SupplierListResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public List Suppliers { get; set; } = new(); +} + +public class SupplierResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public SupplierDto? Supplier { get; set; } +} + +public class MaterialDimensionsDto +{ + public double? Diameter { get; set; } + public double? OuterDiameter { get; set; } + public double? Width { get; set; } + public double? Height { get; set; } + public double? Size { get; set; } + public double? Thickness { get; set; } + public double? Wall { get; set; } + public double? Leg1 { get; set; } + public double? Leg2 { get; set; } + public double? Flange { get; set; } + public double? Web { get; set; } + public double? WeightPerFoot { get; set; } + public double? NominalSize { get; set; } + public string? Schedule { get; set; } +} + +public class MaterialDto +{ + public int Id { get; set; } + public string Shape { get; set; } = string.Empty; + public string Size { get; set; } = string.Empty; + public string? Description { get; set; } + public string DisplayName { get; set; } = string.Empty; + public bool IsActive { get; set; } + public MaterialDimensionsDto? Dimensions { get; set; } +} + +public class MaterialListResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public List Materials { get; set; } = new(); +} + +public class MaterialResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public MaterialDto? Material { get; set; } +} + +public class StockItemDto +{ + public int Id { get; set; } + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public decimal LengthInches { get; set; } + public string LengthFormatted { get; set; } = string.Empty; + public string? Name { get; set; } + public int QuantityOnHand { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } +} + +public class StockItemListResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public List StockItems { get; set; } = new(); +} + +public class StockItemResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public StockItemDto? StockItem { get; set; } +} + +public class SupplierOfferingDto +{ + public int Id { get; set; } + public int SupplierId { get; set; } + public string SupplierName { get; set; } = string.Empty; + public int StockItemId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public string LengthFormatted { get; set; } = string.Empty; + public string? PartNumber { get; set; } + public string? SupplierDescription { get; set; } + public decimal? Price { get; set; } + public string? Notes { get; set; } +} + +public class SupplierOfferingListResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public List Offerings { get; set; } = new(); +} + +public class SupplierOfferingResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public SupplierOfferingDto? Offering { get; set; } +} + +public class AddStockWithOfferingResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public bool MaterialCreated { get; set; } + public int StockItemId { get; set; } + public bool StockItemCreated { get; set; } + public string LengthFormatted { get; set; } = string.Empty; + public int OfferingId { get; set; } + public string? PartNumber { get; set; } + public string? SupplierDescription { get; set; } + public decimal? Price { get; set; } +} + +#endregion diff --git a/CutList.Mcp/Program.cs b/CutList.Mcp/Program.cs index 458c10b..ed8d318 100644 --- a/CutList.Mcp/Program.cs +++ b/CutList.Mcp/Program.cs @@ -1,9 +1,15 @@ +using CutList.Web.Data; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ModelContextProtocol.Server; var builder = Host.CreateApplicationBuilder(args); +// Add DbContext for inventory tools +builder.Services.AddDbContext(options => + options.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true")); + builder.Services .AddMcpServer() .WithStdioServerTransport()