formlab-mcp 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +171 -1
- package/tools/lab.js +47 -1
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ Or for local-dev (from the repo):
|
|
|
145
145
|
}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
148
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 34 tools become callable in any conversation.
|
|
149
149
|
|
|
150
150
|
## Wire it up to Claude Code
|
|
151
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab — lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.juliu1980/formlab-mcp",
|
|
4
4
|
"description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
|
|
5
|
-
"version": "0.6.
|
|
5
|
+
"version": "0.6.7",
|
|
6
6
|
"websiteUrl": "https://formvix.com/mcp",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/juliu1980/FormLab",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "formlab-mcp",
|
|
16
|
-
"version": "0.6.
|
|
16
|
+
"version": "0.6.7",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// - get_coverage_matrix (which items × parameters have been tested)
|
|
8
8
|
// - list_doe_designs (saved design records — the intent behind the runs)
|
|
9
9
|
// - get_doe_design (one design in full, incl. its run matrix)
|
|
10
|
+
// - get_stability (drift + I-chart + shelf-life, split by condition)
|
|
10
11
|
// ============================================================
|
|
11
12
|
|
|
12
13
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
@@ -611,7 +612,7 @@ const list_doe_designs = {
|
|
|
611
612
|
const get_doe_design = {
|
|
612
613
|
definition: {
|
|
613
614
|
name: 'get_doe_design',
|
|
614
|
-
description: 'Get one saved DOE design in full: factors and ranges, constraints, the model it was
|
|
615
|
+
description: 'Get one saved DOE design in full: factors and ranges, constraints, the model it was optimized for, generation settings (run budget, replicates, center points, seed), D-efficiency, and the complete run matrix with the formulation each run became. Everything needed to reproduce or audit the design.',
|
|
615
616
|
inputSchema: {
|
|
616
617
|
type: 'object',
|
|
617
618
|
properties: {
|
|
@@ -686,6 +687,174 @@ const get_doe_design = {
|
|
|
686
687
|
},
|
|
687
688
|
};
|
|
688
689
|
|
|
690
|
+
// ============================================================
|
|
691
|
+
// get_stability — grounded stability / shelf-life analysis
|
|
692
|
+
// ------------------------------------------------------------
|
|
693
|
+
// The MCP analog of the in-app Stability & Trends view: for one entity
|
|
694
|
+
// (formulation / sample / batch) × parameter, it returns the DETERMINISTIC
|
|
695
|
+
// numbers — drift, I-chart, spec status, and an ICH-flavored shelf-life
|
|
696
|
+
// projection — split by storage condition when captured. Deterministic
|
|
697
|
+
// facts here, so the model narrates rather than re-derives the regression.
|
|
698
|
+
// ============================================================
|
|
699
|
+
const _MS_PER_MONTH = 30.4375 * 86400000;
|
|
700
|
+
const _round = (v, d = 4) => (v == null || !Number.isFinite(v)) ? null : +v.toFixed(d);
|
|
701
|
+
|
|
702
|
+
function _ols(xs, ys) {
|
|
703
|
+
const n = xs.length;
|
|
704
|
+
if (n < 2 || ys.length !== n) return null;
|
|
705
|
+
const mx = xs.reduce((a, b) => a + b, 0) / n, my = ys.reduce((a, b) => a + b, 0) / n;
|
|
706
|
+
let num = 0, den = 0, ssTot = 0;
|
|
707
|
+
for (let i = 0; i < n; i++) { num += (xs[i] - mx) * (ys[i] - my); den += (xs[i] - mx) ** 2; ssTot += (ys[i] - my) ** 2; }
|
|
708
|
+
if (den === 0) return null;
|
|
709
|
+
const slope = num / den, intercept = my - slope * mx;
|
|
710
|
+
let ssRes = 0;
|
|
711
|
+
for (let i = 0; i < n; i++) { const pr = intercept + slope * xs[i]; ssRes += (ys[i] - pr) ** 2; }
|
|
712
|
+
return { slope, intercept, r2: ssTot === 0 ? 1 : 1 - ssRes / ssTot };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Individuals (I) control chart: mean ± 3σ, σ from the average moving range.
|
|
716
|
+
function _iChart(ys) {
|
|
717
|
+
const n = ys.length;
|
|
718
|
+
if (n < 2) return null;
|
|
719
|
+
const mean = ys.reduce((a, b) => a + b, 0) / n;
|
|
720
|
+
let mrSum = 0;
|
|
721
|
+
for (let i = 1; i < n; i++) mrSum += Math.abs(ys[i] - ys[i - 1]);
|
|
722
|
+
const sigma = (mrSum / (n - 1)) / 1.128; // d2 for n=2
|
|
723
|
+
const ucl = mean + 3 * sigma, lcl = mean - 3 * sigma;
|
|
724
|
+
const ooc = [];
|
|
725
|
+
ys.forEach((y, i) => { if (sigma > 0 && (y > ucl || y < lcl)) ooc.push(i + 1); });
|
|
726
|
+
return { mean, sigma, ucl, lcl, ooc };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Shelf life: fit vs elapsed months, extrapolate to the spec limit the trend
|
|
730
|
+
// heads toward. Point-estimate crossing + the conservative 95% CI-bound crossing.
|
|
731
|
+
function _shelfLife(points, spec) {
|
|
732
|
+
if (!points || points.length < 3 || (spec.min == null && spec.max == null)) return null;
|
|
733
|
+
const t0 = Math.min(...points.map(p => p.date.getTime()));
|
|
734
|
+
const xs = points.map(p => (p.date.getTime() - t0) / _MS_PER_MONTH);
|
|
735
|
+
const ys = points.map(p => p.value);
|
|
736
|
+
const fit = _ols(xs, ys);
|
|
737
|
+
if (!fit || Math.abs(fit.slope) < 1e-9) return null;
|
|
738
|
+
const limit = fit.slope < 0 ? spec.min : spec.max;
|
|
739
|
+
if (limit == null) return null;
|
|
740
|
+
const edge = fit.slope < 0 ? 'lower' : 'upper';
|
|
741
|
+
const lastX = Math.max(...xs);
|
|
742
|
+
const remaining = (limit - fit.intercept) / fit.slope - lastX;
|
|
743
|
+
const n = xs.length, mx = xs.reduce((a, b) => a + b, 0) / n;
|
|
744
|
+
const ssX = xs.reduce((a, b) => a + (b - mx) ** 2, 0);
|
|
745
|
+
let ssR = 0; xs.forEach((x, i) => { const pr = fit.intercept + fit.slope * x; ssR += (ys[i] - pr) ** 2; });
|
|
746
|
+
const sResid = Math.sqrt(ssR / Math.max(1, n - 2));
|
|
747
|
+
const tcrit = n > 30 ? 1.96 : n > 10 ? 2.1 : 2.5;
|
|
748
|
+
const sign = fit.slope < 0 ? -1 : 1;
|
|
749
|
+
const bound = (tt) => fit.intercept + fit.slope * tt + sign * tcrit * sResid * Math.sqrt(1 / n + ((tt - mx) ** 2) / Math.max(1e-9, ssX));
|
|
750
|
+
let ciRemaining = null;
|
|
751
|
+
for (let tt = lastX; tt <= lastX + 120; tt += 0.25) {
|
|
752
|
+
if (fit.slope < 0 ? bound(tt) <= limit : bound(tt) >= limit) { ciRemaining = tt - lastX; break; }
|
|
753
|
+
}
|
|
754
|
+
return { edge, limit, remainingMonths: remaining, ciRemainingMonths: ciRemaining, r2: fit.r2 };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const get_stability = {
|
|
758
|
+
definition: {
|
|
759
|
+
name: 'get_stability',
|
|
760
|
+
description: 'Grounded stability / shelf-life analysis for one entity × parameter — the deterministic numbers behind the Stability & Trends view. Pass a formulation (pools all its samples), a sample (that sample over time), or a batch (pools its samples) by id or UID, plus a parameter name. Returns the time series, drift (per-month slope + R²), an individuals control chart (mean ± 3σ + out-of-control points), spec status, and an ICH-flavored projected shelf life (linear extrapolation to the spec crossing: point estimate + the conservative 95%-confidence-bound crossing). When measurements carry run conditions (e.g. Storage 25 °C vs 40 °C) it splits into one series per condition — a true accelerated-vs-ambient comparison. Use these figures directly; do not re-derive the regression.',
|
|
761
|
+
inputSchema: {
|
|
762
|
+
type: 'object',
|
|
763
|
+
properties: {
|
|
764
|
+
entity_id: { type: 'string', description: 'A formulation, sample, or batch — id or UID (e.g. FRM-002, SMP-007, BTC-001). The type is auto-detected and sets the pooling level.' },
|
|
765
|
+
parameter: { type: 'string', description: 'The measured parameter to analyze (case-insensitive, e.g. "Niacinamide assay").' },
|
|
766
|
+
by_condition: { type: 'boolean', description: 'Split the series by captured storage condition (default true). Set false to pool all conditions into one trend.' },
|
|
767
|
+
},
|
|
768
|
+
required: ['entity_id', 'parameter'],
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
handler: async (args) => {
|
|
772
|
+
const db = getStore().db;
|
|
773
|
+
let entity = null, entityType = null;
|
|
774
|
+
for (const [type, coll] of [['formulation', 'formulations'], ['sample', 'samples'], ['batch', 'batches']]) {
|
|
775
|
+
const e = (db[coll] || []).find(x => x.id === args.entity_id || x.uid === args.entity_id);
|
|
776
|
+
if (e) { entity = e; entityType = type; break; }
|
|
777
|
+
}
|
|
778
|
+
if (!entity) return { error: `No formulation, sample, or batch found for "${args.entity_id}".` };
|
|
779
|
+
const sampleIds = entityType === 'sample'
|
|
780
|
+
? new Set([entity.id])
|
|
781
|
+
: new Set((db.samples || []).filter(s => (entityType === 'batch' ? s.batchId : s.formulationId) === entity.id).map(s => s.id));
|
|
782
|
+
|
|
783
|
+
const paramLc = (args.parameter || '').trim().toLowerCase();
|
|
784
|
+
if (!paramLc) return { error: 'parameter is required.' };
|
|
785
|
+
const points = [];
|
|
786
|
+
let specMin = null, specMax = null, unit = '';
|
|
787
|
+
(db.testResults || []).filter(t => sampleIds.has(t.sampleId)).forEach(t => {
|
|
788
|
+
const dStr = t.testDate || (t.createdAt ? String(t.createdAt).slice(0, 10) : null);
|
|
789
|
+
if (!dStr) return;
|
|
790
|
+
const date = new Date(dStr + (t.testTime && /^\d{4}-\d{2}-\d{2}$/.test(dStr) ? 'T' + t.testTime : ''));
|
|
791
|
+
if (!Number.isFinite(date.getTime())) return;
|
|
792
|
+
(t.measurements || []).forEach(m => {
|
|
793
|
+
if ((m.parameter || '').trim().toLowerCase() !== paramLc) return;
|
|
794
|
+
let value = parseFloat(m.value);
|
|
795
|
+
if (!Number.isFinite(value) && Array.isArray(m.points) && m.points.length) {
|
|
796
|
+
const last = m.points[m.points.length - 1]; value = parseFloat(last && (last.value ?? last.y)); // time-series → final
|
|
797
|
+
}
|
|
798
|
+
if (!Number.isFinite(value)) return;
|
|
799
|
+
if (m.unit && !unit) unit = m.unit;
|
|
800
|
+
const ac = m.acceptanceCriteria;
|
|
801
|
+
if (ac) { if (Number.isFinite(ac.min)) specMin = specMin == null ? ac.min : Math.max(specMin, ac.min); if (Number.isFinite(ac.max)) specMax = specMax == null ? ac.max : Math.min(specMax, ac.max); }
|
|
802
|
+
const cond = (m.conditions && Object.keys(m.conditions).length)
|
|
803
|
+
? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '';
|
|
804
|
+
points.push({ date, value, cond });
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
// Spec fallback to the Test Method library entry when no measurement carried one.
|
|
808
|
+
if (specMin == null && specMax == null) {
|
|
809
|
+
const pdef = (db.parameters || []).find(p => (p.name || '').trim().toLowerCase() === paramLc);
|
|
810
|
+
if (pdef && pdef.acceptanceCriteria) { specMin = pdef.acceptanceCriteria.min ?? null; specMax = pdef.acceptanceCriteria.max ?? null; }
|
|
811
|
+
}
|
|
812
|
+
points.sort((a, b) => a.date - b.date);
|
|
813
|
+
if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
|
|
814
|
+
|
|
815
|
+
const spec = { min: specMin, max: specMax };
|
|
816
|
+
const analyze = (pts) => {
|
|
817
|
+
const ys = pts.map(p => p.value);
|
|
818
|
+
const n = ys.length;
|
|
819
|
+
const out = { points: n, from: pts[0].date.toISOString().slice(0, 10), to: pts[n - 1].date.toISOString().slice(0, 10), values: ys.map(v => _round(v, 6)), latest: _round(ys[n - 1], 6) };
|
|
820
|
+
if (n < 2) { out.note = 'Fewer than 2 points — no trend/shelf-life.'; return out; }
|
|
821
|
+
const fit = _ols(pts.map((_, i) => i), ys);
|
|
822
|
+
if (fit) {
|
|
823
|
+
const days = (pts[n - 1].date - pts[0].date) / 86400000;
|
|
824
|
+
const perMonth = days > 0 ? (fit.slope * (n - 1) / days) * 30 : 0;
|
|
825
|
+
out.drift = { perMonth: _round(perMonth, 4), r2: _round(fit.r2, 3), direction: perMonth > 0 ? 'increasing' : perMonth < 0 ? 'decreasing' : 'flat' };
|
|
826
|
+
}
|
|
827
|
+
const ic = _iChart(ys);
|
|
828
|
+
if (ic) out.controlChart = { mean: _round(ic.mean), ucl: _round(ic.ucl), lcl: _round(ic.lcl), sigma: _round(ic.sigma), outOfControlPoints: ic.ooc };
|
|
829
|
+
if (spec.min != null || spec.max != null) out.spec = { ...spec, latestInSpec: (spec.min == null || ys[n - 1] >= spec.min) && (spec.max == null || ys[n - 1] <= spec.max) };
|
|
830
|
+
const sl = _shelfLife(pts, spec);
|
|
831
|
+
if (sl) out.shelfLife = {
|
|
832
|
+
headingToward: `${sl.edge} spec (${_round(sl.limit)})`,
|
|
833
|
+
remainingMonths: _round(sl.remainingMonths, 1),
|
|
834
|
+
ci95Months: sl.ciRemainingMonths != null ? _round(sl.ciRemainingMonths, 1) : null,
|
|
835
|
+
r2: _round(sl.r2, 2), basis: 'linear extrapolation to spec crossing',
|
|
836
|
+
};
|
|
837
|
+
return out;
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const split = args.by_condition !== false && points.some(p => p.cond);
|
|
841
|
+
let series;
|
|
842
|
+
if (split) {
|
|
843
|
+
const byC = new Map();
|
|
844
|
+
points.forEach(p => { const k = p.cond || '(no condition)'; if (!byC.has(k)) byC.set(k, []); byC.get(k).push(p); });
|
|
845
|
+
series = [...byC.keys()].sort().map(k => ({ condition: k, ...analyze(byC.get(k)) }));
|
|
846
|
+
} else {
|
|
847
|
+
series = [{ condition: null, ...analyze(points) }];
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
|
|
852
|
+
parameter: args.parameter, unit, spec, pointCount: points.length, byCondition: split, series,
|
|
853
|
+
note: 'shelfLife.remainingMonths is the point-estimate months from the last test to the spec crossing; ci95Months is the conservative (ICH-style) estimate where the 95% confidence bound crosses spec. Numbers are computed here — narrate them, do not recompute.',
|
|
854
|
+
};
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
|
|
689
858
|
export const tools = {
|
|
690
859
|
list_test_results,
|
|
691
860
|
get_test_result,
|
|
@@ -694,4 +863,5 @@ export const tools = {
|
|
|
694
863
|
get_coverage_matrix,
|
|
695
864
|
list_doe_designs,
|
|
696
865
|
get_doe_design,
|
|
866
|
+
get_stability,
|
|
697
867
|
};
|
package/tools/lab.js
CHANGED
|
@@ -8,6 +8,44 @@
|
|
|
8
8
|
|
|
9
9
|
import { getStore, resolveById } from '../data.js';
|
|
10
10
|
|
|
11
|
+
// --- Self-contained unit conversion + actual-volume derivation ---------------
|
|
12
|
+
// The MCP server has no access to the app's cost.js helpers, so a small local
|
|
13
|
+
// factor table covers the common units. Mirrors js/views/batches.js:
|
|
14
|
+
// _batchActualMassKg / _batchActualVolume — actual volume is measured directly
|
|
15
|
+
// when logged, else actual mass ÷ measured finished-product density. Never a
|
|
16
|
+
// sum of per-ingredient volumes (volume isn't additive; mass is).
|
|
17
|
+
const _MASS_TO_KG = { mg: 1e-6, cg: 1e-5, dg: 1e-4, g: 1e-3, hg: 0.1, kg: 1, oz: 0.0283495, lb: 0.453592, stone: 6.35029, t: 1000, ton: 1000, tonne: 1000, grain: 6.47989e-5, 'µg': 1e-9, ug: 1e-9, mcg: 1e-9 };
|
|
18
|
+
const _VOL_TO_L = { 'µl': 1e-6, ul: 1e-6, ml: 1e-3, cl: 1e-2, dl: 0.1, l: 1, gal: 3.78541, fl_oz: 0.0295735, floz: 0.0295735, pt: 0.473176, qt: 0.946353, tsp: 0.00492892, tbsp: 0.0147868, cup: 0.236588 };
|
|
19
|
+
function _massToKg(v, u) { const f = _MASS_TO_KG[String(u || '').trim().toLowerCase()]; return (f != null && isFinite(v)) ? v * f : null; }
|
|
20
|
+
function _volToL(v, u) { const f = _VOL_TO_L[String(u || '').trim().toLowerCase()]; return (f != null && isFinite(v)) ? v * f : null; }
|
|
21
|
+
function _batchActualMassKg(b) {
|
|
22
|
+
const y = parseFloat(b.actualYield);
|
|
23
|
+
if (isFinite(y)) { const kg = _massToKg(y, b.unit); if (kg != null) return kg; }
|
|
24
|
+
if (Array.isArray(b.actualComposition)) {
|
|
25
|
+
let t = 0, any = false;
|
|
26
|
+
for (const c of b.actualComposition) { const kg = _massToKg(parseFloat(c.amount), c.unit); if (kg != null) { t += kg; any = true; } }
|
|
27
|
+
if (any) return t;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function _batchActualVolume(b) {
|
|
32
|
+
const mv = parseFloat(b.measuredVolume);
|
|
33
|
+
if (isFinite(mv) && mv > 0) { const L = _volToL(mv, b.measuredVolumeUnit || 'L'); if (L != null) return { liters: L, source: 'measured' }; }
|
|
34
|
+
const d = parseFloat(b.measuredDensity), m = _batchActualMassKg(b);
|
|
35
|
+
if (isFinite(d) && d > 0 && m != null) return { liters: m / d, source: 'derived' };
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// Public shape for a batch's actual volume (or null). Includes the density used
|
|
39
|
+
// (derived case) or implied by the measurement (measured case) for QC.
|
|
40
|
+
function _actualVolumeInfo(b) {
|
|
41
|
+
const av = _batchActualVolume(b);
|
|
42
|
+
if (!av) return null;
|
|
43
|
+
const out = { liters: +av.liters.toFixed(4), unit: 'L', source: av.source };
|
|
44
|
+
if (av.source === 'derived') { const d = parseFloat(b.measuredDensity); if (isFinite(d)) out.densityUsed = d; }
|
|
45
|
+
else { const m = _batchActualMassKg(b); if (m != null && av.liters > 0) out.impliedDensity = +(m / av.liters).toFixed(4); }
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
11
49
|
function _trimBatch(b, formulationsById) {
|
|
12
50
|
if (!b) return null;
|
|
13
51
|
const f = b.formulationId ? formulationsById.get(b.formulationId) : null;
|
|
@@ -20,6 +58,14 @@ function _trimBatch(b, formulationsById) {
|
|
|
20
58
|
targetQuantity: b.targetQuantity ?? null,
|
|
21
59
|
unit: b.unit || '',
|
|
22
60
|
actualYield: b.actualYield ?? null,
|
|
61
|
+
// Finished-product density (g/mL === kg/L), measured on the blend, + the
|
|
62
|
+
// derived/measured actual VOLUME (mass is the additive quantity; volume is
|
|
63
|
+
// measured or derived from density, never summed across ingredients).
|
|
64
|
+
measuredDensity: b.measuredDensity ?? null,
|
|
65
|
+
densityTempC: (b.densityTemp != null && b.densityTemp !== '') ? b.densityTemp : null,
|
|
66
|
+
measuredVolume: b.measuredVolume ?? null,
|
|
67
|
+
measuredVolumeUnit: b.measuredVolumeUnit || '',
|
|
68
|
+
actualVolume: _actualVolumeInfo(b),
|
|
23
69
|
preparedDate: b.preparedDate || '',
|
|
24
70
|
preparedBy: b.preparedBy || '',
|
|
25
71
|
process: b.process || '',
|
|
@@ -95,7 +141,7 @@ const list_batches = {
|
|
|
95
141
|
const get_batch = {
|
|
96
142
|
definition: {
|
|
97
143
|
name: 'get_batch',
|
|
98
|
-
description: 'Get full details for a single batch including its actual composition (as-prepared, when logged), child samples, lineage (parent / sub-blend parents),
|
|
144
|
+
description: 'Get full details for a single batch including its actual composition (as-prepared, when logged), child samples, lineage (parent / sub-blend parents), inventory consumption note, and finished-product density + actual volume (measured directly or derived as actual mass ÷ measured density — never summed from per-ingredient volumes). Accepts internal id or UID.',
|
|
99
145
|
inputSchema: {
|
|
100
146
|
type: 'object',
|
|
101
147
|
properties: {
|