diff --git a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs
index 37b8f3b..fd0f033 100644
--- a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs
+++ b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs
@@ -38,6 +38,54 @@ public class McpWholeJobTests : IDisposable
string engine, bool noNew = false, CancellationToken token = default) =>
tools.AutoNestJob(plateIndex, names, quantities, engine, noNew, token);
+ [Fact]
+ public void AcceptedPricesAndRequirementsSurviveSaveWithoutLosingMetadata()
+ {
+ var session = Session();
+ session.Nest.Customer = "Cost customer";
+ session.Nest.Notes = "Keep these notes";
+ session.Nest.Units = Units.Inches;
+ session.Nest.SalvageRate = 0.25;
+ var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
+ [new SheetStockInput { Width = 10, Length = 10, Quantity = 2, Cost = 50 },
+ new SheetStockInput { Width = 12, Length = 12, Quantity = 2, Cost = 7 }]);
+ Assert.Contains("Whole job complete", output);
+ Assert.Contains("gross total=7", output);
+ Assert.Contains("net score=7", output);
+ Assert.Contains("supplied-cost", output);
+ Assert.All(session.AllDrawings(), d => Assert.Equal(1, d.Quantity.Required));
+ var path = Path.Combine(directory, "priced.nest");
+ new InputTools(session).SaveNest(path);
+ var reopened = new NestReader(path).Read();
+ Assert.Equal("Cost customer", reopened.Customer);
+ Assert.Equal("Keep these notes", reopened.Notes);
+ Assert.Equal(Units.Inches, reopened.Units);
+ Assert.Equal(0.25, reopened.SalvageRate);
+ Assert.Equal(new[] { 50d, 7d }, reopened.PlateOptions.Select(o => o.Cost));
+ Assert.All(reopened.Drawings, d => Assert.Equal(1, d.Quantity.Required));
+ Assert.Equal(2, reopened.Plates.Sum(p => p.Parts.Count));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(0d)]
+ [InlineData(-1d)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ public void InvalidCostsDoNotMutateSession(double? cost)
+ {
+ var session = Session();
+ var quantities = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
+ var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
+ [new SheetStockInput { Width = 10, Length = 10, Quantity = 1, Cost = 5 },
+ new SheetStockInput { Width = 12, Length = 12, Quantity = 1, Cost = cost }]);
+ Assert.Contains("Error:", output);
+ Assert.Contains("sheets[1]", output);
+ Assert.Empty(session.GetPlate(0).Parts);
+ Assert.Empty(session.Nest.PlateOptions);
+ Assert.Equal(quantities, session.AllDrawings().Select(d => d.Quantity.Required));
+ }
+
[Fact]
public void FiniteSheetInputsReachEngineWithTemplateSettings()
{
@@ -398,6 +446,7 @@ public class McpWholeJobTests : IDisposable
public void CancellationAfterPluginIgnoresTokenDoesNotCommit()
{
var session = Session();
+ var required = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
using var cts = new CancellationTokenSource();
var engine = "JobCancel-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "cancels after solving", () => new CancelStub(cts));
@@ -406,6 +455,7 @@ public class McpWholeJobTests : IDisposable
Assert.Single(session.AllPlates());
Assert.Empty(session.GetPlate(0).Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
+ Assert.Equal(required, session.AllDrawings().Select(d => d.Quantity.Required));
}
private sealed class CancelStub(CancellationTokenSource source) : INestingEngine
diff --git a/OpenNest.Mcp/SheetStockInput.cs b/OpenNest.Mcp/SheetStockInput.cs
index 2ccf5cf..c7e9a70 100644
--- a/OpenNest.Mcp/SheetStockInput.cs
+++ b/OpenNest.Mcp/SheetStockInput.cs
@@ -3,6 +3,9 @@ namespace OpenNest.Mcp;
/// Request-local physical inventory for one whole-job solve.
public sealed class SheetStockInput
{
+ /// Positive per-sheet cost in common units; omit on every row for area scoring.
+ public double? Cost { get; set; }
+
public double Width { get; set; }
public double Length { get; set; }
public int Quantity { get; set; }
diff --git a/OpenNest.Mcp/Tools/InputTools.cs b/OpenNest.Mcp/Tools/InputTools.cs
index 096705b..3225d5f 100644
--- a/OpenNest.Mcp/Tools/InputTools.cs
+++ b/OpenNest.Mcp/Tools/InputTools.cs
@@ -69,8 +69,24 @@ namespace OpenNest.Mcp.Tools
[Description("Name for the nest (optional)")] string name = null
)
{
- var nest = new Nest();
- nest.Name = name ?? Path.GetFileNameWithoutExtension(path);
+ var source = _session.Nest;
+ var nest = new Nest
+ {
+ Name = name ?? (string.IsNullOrWhiteSpace(source?.Name) ? Path.GetFileNameWithoutExtension(path) : source.Name),
+ Customer = source?.Customer ?? "",
+ Notes = source?.Notes ?? "",
+ AssistGas = source?.AssistGas ?? "",
+ Status = source?.Status ?? NestStatus.Quote,
+ MadeBy = source?.MadeBy ?? "",
+ Thickness = source?.Thickness ?? 0,
+ Material = source?.Material ?? new Material(),
+ Units = source?.Units ?? default,
+ DateCreated = source?.DateCreated ?? default,
+ DateLastModified = source?.DateLastModified ?? default,
+ PlateDefaults = source?.PlateDefaults ?? new Nest.PlateSettings(),
+ PlateOptions = source?.PlateOptions ?? new(),
+ SalvageRate = source?.SalvageRate ?? 0.5,
+ };
foreach (var drawing in _session.AllDrawings())
nest.Drawings.Add(drawing);
diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs
index 6ecfb12..39bf840 100644
--- a/OpenNest.Mcp/Tools/NestingTools.cs
+++ b/OpenNest.Mcp/Tools/NestingTools.cs
@@ -344,7 +344,7 @@ namespace OpenNest.Mcp.Tools
[Description(JobEngines)] string engine = null,
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
CancellationToken cancellationToken = default,
- [Description("Optional finite physical sheet stock: width, length and available quantity per size. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
+ [Description("Optional finite physical sheet stock: width, length, available quantity and optional positive per-sheet cost in common units. Specify costs for every row or omit all. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
)
{
var template = _session.GetPlate(plateIndex);
@@ -429,6 +429,8 @@ namespace OpenNest.Mcp.Tools
if (result.Plates.Count > limit)
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
+ var costSummary = NestCostSummary.FromAccepted(result);
+
// Build the entire target map before attaching a single part. Indexes in a mixed
// stock proposal are solver ordinals, not indexes into the session's empty plates.
var usedTargets = new HashSet(ReferenceEqualityComparer.Instance);
@@ -462,7 +464,18 @@ namespace OpenNest.Mcp.Tools
_session.Plates.Add(target);
target.Parts.AddRange(proposed.Parts);
}
+ foreach (var item in parsed.items)
+ item.Drawing.Quantity.Required = item.Quantity;
+ if (sheets != null)
+ {
+ _session.Nest ??= new Nest();
+ _session.Nest.PlateOptions = offeredStock.stock.Select(s => new PlateOption
+ { Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
+ }
var lines = new StringBuilder();
+ lines.AppendLine($"Cost basis={costSummary.Basis}, gross total={costSummary.GrossTotal}, estimated salvage credit={costSummary.SalvageCredit}, net score={costSummary.NetScore}");
+ foreach (var usage in costSummary.Stock)
+ lines.AppendLine($" {usage.StockId}: used={usage.Used}, supplied cost={usage.Cost?.ToString() ?? "unspecified"}, gross subtotal={usage.GrossSubtotal}");
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {targets.Count(t => t.created)} new sheet(s) created.");
if (sheets != null)
{
@@ -486,15 +499,20 @@ namespace OpenNest.Mcp.Tools
if (sheets.Length == 0)
return (null, 0, "Error: sheets must contain at least one stock size. Nothing committed.");
var stock = new List(sheets.Length);
+ var priced = sheets.Any(s => s?.Cost != null);
long total = 0;
for (var i = 0; i < sheets.Length; i++)
{
var row = sheets[i];
if (row == null || !double.IsFinite(row.Width) || !double.IsFinite(row.Length)
+ || !double.IsFinite(row.Width * row.Length)
|| row.Width <= 0 || row.Length <= 0 || row.Quantity <= 0
|| row.Length <= template.EdgeSpacing.Left + template.EdgeSpacing.Right
|| row.Width <= template.EdgeSpacing.Top + template.EdgeSpacing.Bottom)
return (null, 0, $"Error: sheets[{i}] requires finite positive usable dimensions and a positive physical quantity. Nothing committed.");
+ if (row.Cost is double cost && (!double.IsFinite(cost) || cost <= 0)
+ || priced && row.Cost == null)
+ return (null, 0, $"Error: sheets[{i}] requires a finite positive cost; specify costs on every row or omit all. Nothing committed.");
var duplicate = stock.FindIndex(s => s.Size.Width == row.Width && s.Size.Length == row.Length);
if (duplicate >= 0)
return (null, 0, $"Error: sheets[{i}] duplicates sheets[{duplicate}] size. Nothing committed.");
@@ -502,7 +520,7 @@ namespace OpenNest.Mcp.Tools
if (total > int.MaxValue)
return (null, 0, "Error: total sheet inventory exceeds the supported sheet limit. Nothing committed.");
stock.Add(new NestPlateStock($"stock-{i}", new Size(row.Width, row.Length), row.Quantity,
- template.PartSpacing, template.EdgeSpacing, template.Quadrant));
+ template.PartSpacing, template.EdgeSpacing, template.Quadrant, row.Cost));
}
return (stock, total, null);
}
diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md
index dd69713..6cd8664 100644
--- a/docs/automatic-nesting.md
+++ b/docs/automatic-nesting.md
@@ -115,3 +115,15 @@ overflow fails instead of becoming a score.
Returned nests preserve offered stock prices. These are cost-aware heuristics,
not global cost optimizers; validation and fulfillment remain required.
+
+
+MCP `autonest_job` accepts optional `cost` on each `sheets` row, alongside `width`,
+`length` and positive finite `quantity`. Supply positive finite costs on every row
+or omit all costs. MCP continues to reject duplicate sizes and disables salvage.
+Its result reports the cost basis, used physical counts, supplied costs, gross
+subtotals, gross total, estimated salvage credit and net score for the proposal.
+
+An accepted MCP solve stores requested drawing quantities and offered stock prices;
+save/reopen preserves them and existing nest metadata, plate defaults and salvage
+settings. Rejected/cancelled proposals change neither requirements nor options.
+Inventory remains request-local; saved options are offers, not a purchase ledger.
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index 45c6f71..e6032eb 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -92,7 +92,10 @@ format version is required. Legacy zero cost means unspecified, not a free sheet
Positive costs are generic per-sheet values in caller-consistent units. Solving
requires all available options to be priced or all to be unspecified.
-API returned nests retain offered options, excluding rows with zero available
-quantity because `.nest` options do not retain inventory. Options are offers, not
-a per-sheet purchase ledger. API quote archives additionally retain request costs
-and validated proposal cost summaries; old archives load with `Costs == null`.
+Desktop and accepted MCP/API jobs retain offered options (API rows with zero available
+quantity are excluded because `.nest` options do not retain inventory). MCP save also preserves
+loaded job metadata, plate defaults and salvage settings, and accepted whole-job
+requirements are stored on drawings. Options do not persist finite inventory or
+associate a historical purchase price with each physical sheet. API quote archives
+additionally retain request costs and validated proposal cost summaries; old archives
+without summaries load with `Costs == null`.