formlab-mcp 0.6.23 → 0.6.24
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 +2 -2
- package/package.json +1 -1
- package/tools/formulations.js +5 -0
- package/tools/lab.js +88 -16
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
29
29
|
| Tool | Purpose |
|
|
30
30
|
|---|---|
|
|
31
31
|
| `list_formulations` | Filtered list of recipes |
|
|
32
|
-
| `get_formulation` | Full record + flattened wt-% composition (sub-formulas expanded), the ordered **step procedure**, user **custom fields**,
|
|
32
|
+
| `get_formulation` | Full record + flattened wt-% composition (sub-formulas expanded), the ordered **step procedure**, user **custom fields**, any **process-factor values** (DOE inputs — cure temp, mix time, RPM, pH…), and the declared **finishedDensity** (g/mL of the mixed product; volume-targeted batches scale by it) |
|
|
33
33
|
| `find_similar_formulations` | Find formulas using a given ingredient ≥ threshold % |
|
|
34
34
|
| `compare_formulations` | Pairwise side-by-side composition diff |
|
|
35
35
|
| `list_ingredients` | Filtered list of raw materials. Filters: `family`, `supplier`, `name_contains`, `in_stock_only`, `ingredient_class` (small-molecule / surfactant / polymer / extract / fragrance / pigment / sequence / mixture), `sequence_contains` (e.g. `KTTKS` → Matrixyl), `taxon_contains` (e.g. `Centella`) |
|
|
@@ -39,7 +39,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
39
39
|
| `get_supplier` | One supplier in full: record, every ingredient it's an approved source for, every lot from it, and the **recall trace** (batches that consumed those lots → products, shipments, samples, sub-batches; lots on hold flagged) |
|
|
40
40
|
| `list_inventory` | Portfolio stock rollup — one row per ingredient with on-hand qty, summed lot balance, nearest expiry, and a low-stock flag (on-hand ≤ `reorderThreshold`, or zero). Filters: `low_stock_only`, `expiring_within_days`, `family` |
|
|
41
41
|
| `list_batches` | Filtered list of production / lab-prep events |
|
|
42
|
-
| `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes) and **actual mass** (yield, else summed inputs
|
|
42
|
+
| `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes) and **actual mass** (yield, else summed as-prepared inputs — mL rows through that ingredient's density — else measured volume × density), **estimatedVolume** (mass ÷ the formula's finished density when no actual exists, flagged `estimate: true`), **plannedMass** for volume-target batches (target × finished density) and **formulaFinishedDensity**, the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage, and — as a finished-goods lot — its storage **`location`** + finished-goods **`stockLedger`** (shipments out + adjustments; `shippedQty` total) |
|
|
43
43
|
| `list_samples` | Filtered list of physical specimens |
|
|
44
44
|
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
45
45
|
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.24",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab \u2014 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/tools/formulations.js
CHANGED
|
@@ -23,6 +23,11 @@ function _trimFormulation(f) {
|
|
|
23
23
|
owner: f.owner || '',
|
|
24
24
|
regulatoryCategory: f.regulatoryCategory || '',
|
|
25
25
|
shelfLifeMonths: f.shelfLifeMonths ?? null,
|
|
26
|
+
// Density of the MIXED product (g/mL), when declared — volume-targeted
|
|
27
|
+
// batches scale by it instead of the ideal-mix estimate. null = not set.
|
|
28
|
+
finishedDensity: (f.finishedDensity && isFinite(parseFloat(f.finishedDensity.value)))
|
|
29
|
+
? { value: parseFloat(f.finishedDensity.value), temp: f.finishedDensity.temp ?? null, source: f.finishedDensity.source || 'manual', batchId: f.finishedDensity.batchId || null, date: f.finishedDensity.date || null }
|
|
30
|
+
: null,
|
|
26
31
|
tags: f.tags || [],
|
|
27
32
|
projectId: f.projectId || null,
|
|
28
33
|
compositionRowCount: (f.composition || []).length,
|
package/tools/lab.js
CHANGED
|
@@ -25,16 +25,57 @@ function _batchUnitMassKg(b) {
|
|
|
25
25
|
const v = parseFloat(b.unitMass.value);
|
|
26
26
|
return (isFinite(v) && v > 0) ? _massToKg(v, b.unitMass.unit) : null;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
// Ingredient density → kg/L (=== g/mL). Mirrors the app's _densityToKgPerL for
|
|
29
|
+
// the units it accepts; unknown unit → treated as g/mL (the app's default).
|
|
30
|
+
const _DENS_TO_KGL = { 'g/ml': 1, 'g/cm3': 1, 'g/cm³': 1, 'g/cc': 1, 'kg/l': 1, 'kg/m3': 0.001, 'kg/m³': 0.001, 'g/l': 0.001, 'lb/gal': 0.119826, 'lb/ft3': 0.0160185, 'lb/ft³': 0.0160185 };
|
|
31
|
+
function _densKgPerL(v, u) { const d = parseFloat(v); if (!isFinite(d) || d <= 0) return null; const f = _DENS_TO_KGL[String(u || 'g/mL').trim().toLowerCase()]; return d * (f == null ? 1 : f); }
|
|
32
|
+
// Density of ONE as-prepared row: the ingredient's own density, or a sub-
|
|
33
|
+
// formula's declared finished density. null when unknown.
|
|
34
|
+
function _rowDensityKgPerL(c, ingredientsById, formulationsById) {
|
|
35
|
+
if (c.ingredientId && ingredientsById) { const ing = ingredientsById.get(c.ingredientId); return ing ? _densKgPerL(ing.density, ing.densityUnit) : null; }
|
|
36
|
+
if (c.formulationId && formulationsById) { const sf = formulationsById.get(c.formulationId); return sf ? _formulaFinishedDensity(sf) : null; }
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// The formula's DECLARED finished density (g/mL) — adopted from a measured
|
|
40
|
+
// batch or entered from a spec. The app also has an ideal-mix estimate from
|
|
41
|
+
// ingredient densities; MCP does not reproduce that (estimate) and returns
|
|
42
|
+
// null instead, so a null here means "the app plans with an estimate".
|
|
43
|
+
function _formulaFinishedDensity(f) {
|
|
44
|
+
const v = f && f.finishedDensity ? parseFloat(f.finishedDensity.value) : NaN;
|
|
45
|
+
return (isFinite(v) && v > 0) ? v : null;
|
|
46
|
+
}
|
|
47
|
+
function _finishedDensityInfo(f, batchesById) {
|
|
48
|
+
const v = _formulaFinishedDensity(f);
|
|
49
|
+
if (v == null) return null;
|
|
50
|
+
const fd = f.finishedDensity;
|
|
51
|
+
const src = (fd.batchId && batchesById) ? batchesById.get(fd.batchId) : null;
|
|
52
|
+
return { value: v, unit: 'g/mL', temp: fd.temp ?? null, source: fd.source || 'manual', fromBatch: src ? src.uid : null, date: fd.date || null };
|
|
53
|
+
}
|
|
54
|
+
// Σ as-prepared row masses in kg, each row through ITS OWN unit: g rows
|
|
55
|
+
// directly, mL rows × that ingredient's density. Mirrors sumActualRowsMassKg
|
|
56
|
+
// in the app (cost.js). Any volume row that can't resolve → null (never a
|
|
57
|
+
// partial sum that silently drops the water).
|
|
58
|
+
function _sumActualRowsKg(rows, ingredientsById, formulationsById) {
|
|
59
|
+
if (!Array.isArray(rows)) return null;
|
|
60
|
+
let kg = 0, n = 0;
|
|
61
|
+
for (const c of rows) {
|
|
62
|
+
if (!c || !(c.ingredientId || c.formulationId)) continue;
|
|
63
|
+
const a = parseFloat(c.amount); if (!isFinite(a)) continue;
|
|
64
|
+
const m = _massToKg(a, c.unit); if (m != null) { kg += m; n++; continue; }
|
|
65
|
+
const L = _volToL(a, c.unit);
|
|
66
|
+
if (L != null) { const d = _rowDensityKgPerL(c, ingredientsById, formulationsById); if (d == null) return null; kg += L * d; n++; }
|
|
67
|
+
}
|
|
68
|
+
return n ? kg : null;
|
|
69
|
+
}
|
|
70
|
+
function _batchActualMassKg(b, ingredientsById, formulationsById) {
|
|
29
71
|
const y = parseFloat(b.actualYield);
|
|
30
72
|
if (isFinite(y)) {
|
|
31
73
|
const kg = _massToKg(y, b.unit); if (kg != null) return kg;
|
|
32
74
|
const umk = _batchUnitMassKg(b); if (umk != null) return y * umk; // count unit × per-unit mass
|
|
33
75
|
}
|
|
34
76
|
if (Array.isArray(b.actualComposition)) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (any) return t;
|
|
77
|
+
const t = _sumActualRowsKg(b.actualComposition, ingredientsById, formulationsById);
|
|
78
|
+
if (t != null) return t;
|
|
38
79
|
}
|
|
39
80
|
// Volume-anchored batch (no weighed mass above): mass = measured volume ×
|
|
40
81
|
// measured density (V·ρ — always valid; summing per-ingredient volumes is
|
|
@@ -49,25 +90,47 @@ function _batchActualMassKg(b) {
|
|
|
49
90
|
}
|
|
50
91
|
return null;
|
|
51
92
|
}
|
|
52
|
-
function _batchActualVolume(b) {
|
|
93
|
+
function _batchActualVolume(b, ingredientsById, formulationsById) {
|
|
53
94
|
const mv = parseFloat(b.measuredVolume);
|
|
54
95
|
if (isFinite(mv) && mv > 0) { const L = _volToL(mv, b.measuredVolumeUnit || 'L'); if (L != null) return { liters: L, source: 'measured' }; }
|
|
55
|
-
const d = parseFloat(b.measuredDensity), m = _batchActualMassKg(b);
|
|
96
|
+
const d = parseFloat(b.measuredDensity), m = _batchActualMassKg(b, ingredientsById, formulationsById);
|
|
56
97
|
if (isFinite(d) && d > 0 && m != null) return { liters: m / d, source: 'derived' };
|
|
57
98
|
return null;
|
|
58
99
|
}
|
|
59
100
|
// Public shape for a batch's actual volume (or null). Includes the density used
|
|
60
101
|
// (derived case) or implied by the measurement (measured case) for QC.
|
|
61
|
-
function _actualVolumeInfo(b) {
|
|
62
|
-
const av = _batchActualVolume(b);
|
|
102
|
+
function _actualVolumeInfo(b, ingredientsById, formulationsById) {
|
|
103
|
+
const av = _batchActualVolume(b, ingredientsById, formulationsById);
|
|
63
104
|
if (!av) return null;
|
|
64
105
|
const out = { liters: +av.liters.toFixed(4), unit: 'L', source: av.source };
|
|
65
106
|
if (av.source === 'derived') { const d = parseFloat(b.measuredDensity); if (isFinite(d)) out.densityUsed = d; }
|
|
66
|
-
else { const m = _batchActualMassKg(b); if (m != null && av.liters > 0) out.impliedDensity = +(m / av.liters).toFixed(4); }
|
|
107
|
+
else { const m = _batchActualMassKg(b, ingredientsById, formulationsById); if (m != null && av.liters > 0) out.impliedDensity = +(m / av.liters).toFixed(4); }
|
|
67
108
|
return out;
|
|
68
109
|
}
|
|
110
|
+
// ESTIMATED volume when a batch has no actual: mass (actual, else target) ÷ the
|
|
111
|
+
// formula's declared finished density. Never from an ideal-mix estimate, never
|
|
112
|
+
// an actual. Mirrors the app's _batchEstimatedVolume.
|
|
113
|
+
function _estimatedVolumeInfo(b, f, ingredientsById, formulationsById, batchesById) {
|
|
114
|
+
if (!f || _batchActualVolume(b, ingredientsById, formulationsById)) return null;
|
|
115
|
+
const fd = _finishedDensityInfo(f, batchesById);
|
|
116
|
+
if (!fd) return null;
|
|
117
|
+
let kg = _batchActualMassKg(b, ingredientsById, formulationsById), basis = 'actual-mass';
|
|
118
|
+
if (kg == null) { const t = _massToKg(parseFloat(b.targetQuantity), b.unit); if (t != null) { kg = t; basis = 'target-mass'; } }
|
|
119
|
+
if (kg == null || !(kg > 0)) return null;
|
|
120
|
+
return { liters: +(kg / fd.value).toFixed(4), unit: 'L', basis, densityUsed: fd.value, densityFromBatch: fd.fromBatch, estimate: true };
|
|
121
|
+
}
|
|
122
|
+
// A VOLUME-target batch's planned mass = target volume × the formula's finished
|
|
123
|
+
// density (what the weigh-out scales by). null density → the app used its
|
|
124
|
+
// ideal-mix estimate; say so rather than reproduce it.
|
|
125
|
+
function _plannedMassInfo(b, f, batchesById) {
|
|
126
|
+
const L = _volToL(parseFloat(b.targetQuantity), b.unit);
|
|
127
|
+
if (L == null || !(L > 0)) return null;
|
|
128
|
+
const fd = _finishedDensityInfo(f, batchesById);
|
|
129
|
+
if (!fd) return { kg: null, note: 'No finished density declared on the formula — the app scaled this volume target with an ideal-mix estimate from ingredient densities (volumes assumed additive).' };
|
|
130
|
+
return { kg: +(L * fd.value).toFixed(4), unit: 'kg', densityUsed: fd.value, densitySource: fd.source, densityFromBatch: fd.fromBatch, note: 'Each recipe row weighs out to its wt% of this mass; the balance (q.s.) row is filled to the volume mark, so pure-ingredient volumes do not sum to the target.' };
|
|
131
|
+
}
|
|
69
132
|
|
|
70
|
-
function _trimBatch(b, formulationsById) {
|
|
133
|
+
function _trimBatch(b, formulationsById, ingredientsById, batchesById) {
|
|
71
134
|
if (!b) return null;
|
|
72
135
|
const f = b.formulationId ? formulationsById.get(b.formulationId) : null;
|
|
73
136
|
return {
|
|
@@ -89,11 +152,20 @@ function _trimBatch(b, formulationsById) {
|
|
|
89
152
|
densityTempC: (b.densityTemp != null && b.densityTemp !== '') ? b.densityTemp : null,
|
|
90
153
|
measuredVolume: b.measuredVolume ?? null,
|
|
91
154
|
measuredVolumeUnit: b.measuredVolumeUnit || '',
|
|
92
|
-
actualVolume: _actualVolumeInfo(b),
|
|
155
|
+
actualVolume: _actualVolumeInfo(b, ingredientsById, formulationsById),
|
|
156
|
+
// No actual volume → an ESTIMATE at the formula's declared finished density
|
|
157
|
+
// (actual mass, else target mass). `estimate: true`; never an actual.
|
|
158
|
+
estimatedVolume: _estimatedVolumeInfo(b, f, ingredientsById, formulationsById, batchesById),
|
|
93
159
|
// Actual MASS in kg (the conserved, additive quantity). From actual yield
|
|
94
|
-
// (mass-unit batch), else summed
|
|
95
|
-
//
|
|
96
|
-
|
|
160
|
+
// (mass-unit batch), else summed as-prepared inputs — each row through its
|
|
161
|
+
// OWN unit (g directly, mL × that ingredient's density) — else measured
|
|
162
|
+
// volume × measured density. null when none is determinable.
|
|
163
|
+
actualMass: (function () { const m = _batchActualMassKg(b, ingredientsById, formulationsById); return (m != null && isFinite(m)) ? { kg: +m.toFixed(4), unit: 'kg' } : null; })(),
|
|
164
|
+
// Volume-target batches only: the planned mass the weigh-out scaled to.
|
|
165
|
+
plannedMass: _plannedMassInfo(b, f, batchesById),
|
|
166
|
+
// The formula's declared finished density (g/mL of the mixed product) that
|
|
167
|
+
// volume targets and estimates use; null = the app is on its ideal-mix estimate.
|
|
168
|
+
formulaFinishedDensity: _finishedDensityInfo(f, batchesById),
|
|
97
169
|
preparedDate: b.preparedDate || '',
|
|
98
170
|
preparedBy: b.preparedBy || '',
|
|
99
171
|
// Finished-goods storage location for this batch's produced stock (Production
|
|
@@ -167,7 +239,7 @@ const list_batches = {
|
|
|
167
239
|
return {
|
|
168
240
|
totalMatching: filtered.length,
|
|
169
241
|
returned: Math.min(filtered.length, limit),
|
|
170
|
-
batches: filtered.slice(0, limit).map(b => _trimBatch(b, indexes.formulationsById)),
|
|
242
|
+
batches: filtered.slice(0, limit).map(b => _trimBatch(b, indexes.formulationsById, indexes.ingredientsById, indexes.batchesById)),
|
|
171
243
|
};
|
|
172
244
|
},
|
|
173
245
|
};
|
|
@@ -202,7 +274,7 @@ const get_batch = {
|
|
|
202
274
|
})
|
|
203
275
|
: null;
|
|
204
276
|
return {
|
|
205
|
-
..._trimBatch(b, indexes.formulationsById),
|
|
277
|
+
..._trimBatch(b, indexes.formulationsById, indexes.ingredientsById, indexes.batchesById),
|
|
206
278
|
notes: b.notes || '',
|
|
207
279
|
equipmentUsed: b.equipmentUsed || '',
|
|
208
280
|
// Multi-select equipment: full list [{name, equipmentId}]; falls back to
|