formlab-mcp 0.6.18 → 0.6.20

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 CHANGED
@@ -33,11 +33,11 @@ Ask Claude (or another MCP client) things like:
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`) |
36
- | `get_ingredient` | Full record + supplier / **cost ($/kg)** / stock / chemical name / molecular weight / storage / formulations using it, plus **GHS safety** (pictograms, H/P codes, signal word), **per-jurisdiction regulatory status**, and **inventory lots** (balances + expiry). Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
37
- | `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry). Filters: `ingredient_id`, `expiring_within_days`, `status` |
36
+ | `get_ingredient` | Full record + supplier / **cost ($/kg)** / stock / chemical name / molecular weight / storage / formulations using it, plus **GHS safety** (pictograms, H/P codes, signal word), **per-jurisdiction regulatory status**, **approved sources** (the suppliers / manufacturers qualified to supply this material — ASL/AML), and **inventory lots** (balances + expiry + the source each was received from). Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
37
+ | `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry, **unit cost**, **status** — `active` / `hold` / `quarantine` / `rejected`; held lots are blocked from consumption, with a `holdReason` — and the **approved source** it was received from: `source.supplier` + `source.manufacturer`, where recalls hinge on the manufacturer). Filters: `ingredient_id`, `expiring_within_days`, `status` (e.g. `hold` to find lots held for a recall) |
38
38
  | `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` |
39
39
  | `list_batches` | Filtered list of production / lab-prep events |
40
- | `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, else measured volume × density), the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage |
40
+ | `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, else measured volume × density), 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) |
41
41
  | `list_samples` | Filtered list of physical specimens |
42
42
  | `get_sample` | Full record + canonical variant + test reports + blend lineage |
43
43
  | `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.18",
3
+ "version": "0.6.20",
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.18",
5
+ "version": "0.6.20",
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.18",
16
+ "version": "0.6.20",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -74,9 +74,27 @@ function _trimIngredient(i) {
74
74
  ...(i.surfactantDetails ? { surfactantDetails: i.surfactantDetails } : {}),
75
75
  ...(i.pigmentDetails ? { pigmentDetails: i.pigmentDetails } : {}),
76
76
  ...(i.fragranceDetails ? { fragranceDetails: i.fragranceDetails } : {}),
77
+ // Approved sources (ASL/AML): the suppliers/manufacturers qualified to supply
78
+ // this material. A received lot names one via lot.sourceId. Manufacturer is
79
+ // the recall/equivalence-relevant party; supplier is who you buy from.
80
+ ...(Array.isArray(i.sources) && i.sources.length ? { sources: i.sources.map(s => ({
81
+ id: s.id, supplier: s.supplier || '', manufacturer: s.manufacturer || '',
82
+ partNo: s.partNo || '', grade: s.grade || '', status: s.status || 'qualified',
83
+ primary: !!s.primary,
84
+ })) } : {}),
77
85
  };
78
86
  }
79
87
 
88
+ // Resolve the approved source a lot was received from (lot.sourceId → the
89
+ // ingredient's ing.sources[]). Null when the lot predates sources or is unlinked.
90
+ function _lotSourceInfo(l) {
91
+ if (!l || !l.sourceId) return null;
92
+ const ing = (db.ingredients || []).find(i => i && i.id === l.ingredientId);
93
+ const s = (ing && Array.isArray(ing.sources)) ? ing.sources.find(x => x.id === l.sourceId) : null;
94
+ if (!s) return null;
95
+ return { sourceId: s.id, supplier: s.supplier || '', manufacturer: s.manufacturer || '', status: s.status || 'qualified' };
96
+ }
97
+
80
98
  const list_ingredients = {
81
99
  definition: {
82
100
  name: 'list_ingredients',
@@ -130,7 +148,7 @@ const list_ingredients = {
130
148
  const get_ingredient = {
131
149
  definition: {
132
150
  name: 'get_ingredient',
133
- description: 'Get full details for a single ingredient: cost ($/kg), GHS safety (pictograms / H- / P-codes / signal word), per-jurisdiction regulatory status, inventory lots (balances + expiry), recent stock movements, and the formulations using it. Accepts internal id or UID (e.g. ING-001).',
151
+ description: 'Get full details for a single ingredient: cost ($/kg), GHS safety (pictograms / H- / P-codes / signal word), per-jurisdiction regulatory status, approved sources (the suppliers/manufacturers qualified to supply this material — ASL/AML), inventory lots (balances + expiry + the source each was received from), recent stock movements, and the formulations using it. Accepts internal id or UID (e.g. ING-001).',
134
152
  inputSchema: {
135
153
  type: 'object',
136
154
  properties: {
@@ -176,6 +194,7 @@ const get_ingredient = {
176
194
  qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
177
195
  receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
178
196
  supplier: l.supplier || '', status: l.status || 'active',
197
+ sourceId: l.sourceId || null, source: _lotSourceInfo(l),
179
198
  }));
180
199
  return {
181
200
  ..._trimIngredient(ing),
@@ -295,13 +314,13 @@ const find_by_smarts = {
295
314
  const list_lots = {
296
315
  definition: {
297
316
  name: 'list_lots',
298
- description: 'List inventory lots (received batches of an ingredient, each with its own remaining balance, supplier, and expiry). Optionally filter by ingredient, expiring-within-days, or status. For one ingredient\'s full lots + traceability, use get_ingredient.',
317
+ description: 'List inventory lots (received batches of an ingredient, each with its own remaining balance, supplier, expiry, and unit cost). Each lot also carries the approved source it was received from (source.supplier + source.manufacturer; recalls hinge on the manufacturer), or null for lots predating sources. Optionally filter by ingredient, expiring-within-days, or status. A lot\'s status can be "active", "hold" (a manual QC / recall hold — see holdReason), "quarantine", or "rejected"; the last three are blocked from auto-consumption. For one ingredient\'s full lots + traceability, use get_ingredient.',
299
318
  inputSchema: {
300
319
  type: 'object',
301
320
  properties: {
302
321
  ingredient_id: { type: 'string', description: 'Only lots for this ingredient (internal id or UID, e.g. ING-001).' },
303
322
  expiring_within_days: { type: 'number', description: 'Only lots whose expiryDate is within this many days from now (negative days = already expired are always included).' },
304
- status: { type: 'string', description: 'Filter by lot status (e.g. "active").' },
323
+ status: { type: 'string', description: 'Filter by lot status: "active", "hold", "quarantine", or "rejected". Held/quarantined/rejected lots are blocked from auto-consumption (use "hold" to find lots put on hold for a QC / recall).' },
305
324
  limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
306
325
  },
307
326
  },
@@ -332,6 +351,15 @@ const list_lots = {
332
351
  qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
333
352
  receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
334
353
  supplier: l.supplier || '', status: l.status || 'active',
354
+ // Approved source this lot was received from (supplier + manufacturer);
355
+ // null for lots received before sources existed. Recalls hinge on the
356
+ // manufacturer, so it is surfaced explicitly here.
357
+ sourceId: l.sourceId || null, source: _lotSourceInfo(l),
358
+ // 'hold' / 'quarantine' / 'rejected' lots are blocked from auto-consumption;
359
+ // holdReason explains a manual hold (QC / recall).
360
+ holdReason: l.holdReason || '',
361
+ unitCost: (l.unitCost != null && l.unitCost !== '') ? l.unitCost : null,
362
+ unitCostCcy: l.unitCostCcy || null,
335
363
  }));
336
364
  return { totalMatching: rows.length, returned: Math.min(rows.length, limit), lots: rows.slice(0, limit) };
337
365
  },
package/tools/lab.js CHANGED
@@ -96,6 +96,9 @@ function _trimBatch(b, formulationsById) {
96
96
  actualMass: (function () { const m = _batchActualMassKg(b); return (m != null && isFinite(m)) ? { kg: +m.toFixed(4), unit: 'kg' } : null; })(),
97
97
  preparedDate: b.preparedDate || '',
98
98
  preparedBy: b.preparedBy || '',
99
+ // Finished-goods storage location for this batch's produced stock (Production
100
+ // Inventory: a batch is a finished-goods lot).
101
+ location: b.location || '',
99
102
  process: b.process || '',
100
103
  // Per-batch process-factor values (DOE Matrix "+ Factor" at batch grain) —
101
104
  // recorded process conditions (cure temp, mix time, RPM, pH, …) as DOE INPUTS.
@@ -172,7 +175,7 @@ const list_batches = {
172
175
  const get_batch = {
173
176
  definition: {
174
177
  name: 'get_batch',
175
- 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.',
178
+ description: 'Get full details for a single batch including its actual composition (as-prepared, when logged), child samples, lineage (parent / sub-blend parents), and — treating the batch as a finished-goods lot — its storage `location` and finished-goods `stockLedger` (shipments out + adjustments; on-hand = produced − sample pulls − sub-batch draws + Σ ledger). Plus 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.',
176
179
  inputSchema: {
177
180
  type: 'object',
178
181
  properties: {
@@ -212,6 +215,14 @@ const get_batch = {
212
215
  sourceBlendParents: parents,
213
216
  samples: samples.map(s => ({ id: s.id, uid: s.uid, name: s.name || '', status: s.status })),
214
217
  sampleCount: samples.length,
218
+ // Finished-goods drawdown ledger (a batch is a finished-goods lot): shipments
219
+ // out + manual adjustments, in the batch unit. On-hand = produced − sample
220
+ // pulls − sub-batch draws + Σ(ledger deltas). Sub-batches are batches whose
221
+ // parentBatchId === this id (see list_batches).
222
+ stockLedger: (Array.isArray(b.stockLedger) ? b.stockLedger : []).map(e => ({
223
+ date: e.ts || null, delta: e.delta ?? null, unit: e.unit || b.unit || '', kind: e.kind || '', note: e.note || '',
224
+ })),
225
+ shippedQty: (Array.isArray(b.stockLedger) ? b.stockLedger : []).reduce((s, e) => e.kind === 'shipment' ? s + Math.abs(parseFloat(e.delta) || 0) : s, 0),
215
226
  };
216
227
  },
217
228
  };