formlab-mcp 0.6.48 → 0.6.50

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
@@ -30,18 +30,18 @@ Ask Claude (or another MCP client) things like:
30
30
  | Tool | Purpose |
31
31
  |---|---|
32
32
  | `list_formulations` | Filtered list of recipes |
33
- | `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
+ | `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…), the declared **finishedDensity** (g/mL of the mixed product; volume-targeted batches scale by it), and its **theoretical parameters computed** the way the app's Params tab does (Energy kcal/100g, NV %, Cost/kg…: value, unit, formula, spec pass/fail, and a `warning` when ingredients lack a value or carry mixed units) — the app's own engine, vendored in `mcp/vendor/` and run in an isolated `node:vm` context |
34
34
  | `find_similar_formulations` | Find formulas using a given ingredient ≥ threshold % |
35
35
  | `compare_formulations` | Pairwise side-by-side composition diff |
36
36
  | `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`) |
37
- | `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
+ | `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`, and its **custom fields** with units (`customFields`: protein / carb / fat in g/100g, solids %… — what theoretical parameters read) |
38
38
  | `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). Each lot also reports its **Certificate of Analysis** (`coa`: linked, or matched by lot number) and `coaMissing` for an in-stock lot without one. Filters: `ingredient_id`, `expiring_within_days`, `status` (e.g. `hold` to find lots held for a recall), `missing_coa` |
39
39
  | `list_suppliers` | Supplier / manufacturer records with roll-ups — ingredients approved, lots received / on hand (+ value at paid cost), qualified / trial / disqualified source counts, last receipt; lots count by supplier **and** by manufacturer |
40
40
  | `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) |
41
41
  | `list_documents` | The document register (same as the app's Documents page): ingredient SDS / PDS / CoA / other with lot, version, source and expiry; test and notebook attachments; per-record uploads and links (formulas, batches, samples, suppliers, equipment…). File **metadata** only, never the text inside a file, with who added each file (`addedBy`) and when. Filters: `type`, `record`, `linked`, `query`, `lot`, `format`, `expiry`, `expiring_within_days`, `without_expiry`, `uploaded_within_days`, `added_by`. A `summary` block carries the page's tile counts: expired, expiring in 30 days, SDS/CoA without expiry, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, added last 7 days |
42
42
  | `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` |
43
43
  | `list_batches` | Filtered list of production / lab-prep events |
44
- | `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) |
44
+ | `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), and its **theoretical parameters computed on the batch as made** (actual composition when logged — `basis: "actual"` — else the recipe; the batch's own parameter list when it overrides the formula's) |
45
45
  | `list_samples` | Filtered list of physical specimens |
46
46
  | `get_sample` | Full record + canonical variant + test reports + blend lineage |
47
47
  | `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max` — a curve at its final point, a distribution at its spec's statistic or D50), **grade** (`min_grade`/`max_grade` on a ranked scale), **text value** (`value_equals`), **run condition** (`condition`), date, or lab) |
package/cloud.js CHANGED
@@ -48,7 +48,7 @@ async function _fetchDb({ url, anonKey, token }) {
48
48
  let body = null;
49
49
  try { body = await res.json(); } catch { /* non-JSON error page */ }
50
50
  if (res.status === 401) {
51
- throw new Error('the MCP token was rejected (missing, revoked, or malformed). Reconnect from FormLab → Settings → Account → AI tools (MCP) → Connect, and paste the new FORMLAB_MCP_TOKEN.');
51
+ throw new Error('the MCP token was rejected: it is missing, revoked or malformed, or the workspace owner has turned off AI tools for members. Reconnect from FormLab → Settings → Account → AI tools (MCP) → Connect, and paste the new FORMLAB_MCP_TOKEN.');
52
52
  }
53
53
  if (!res.ok || !body || body.ok !== true || !body.db) {
54
54
  throw new Error(`MCP endpoint error (HTTP ${res.status})${body && body.error ? ': ' + body.error : ''}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.48",
3
+ "version": "0.6.50",
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/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.48",
5
+ "version": "0.6.50",
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.48",
16
+ "version": "0.6.50",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
package/theoretical.js ADDED
@@ -0,0 +1,81 @@
1
+ // =====================================================================
2
+ // Theoretical parameters for the MCP server — the app's own engine, not a
3
+ // port. mcp/vendor/ holds exact copies of js/cost.js, js/nesting.js and
4
+ // js/views/theoretical-params.js (test/mcp-theoretical.test.js keeps them
5
+ // identical), so get_formulation reports the same Energy / NV / Cost per kg
6
+ // the Params tab shows, with the same ⚠ notes.
7
+ //
8
+ // They run in a node:vm context that holds ONLY a JSON copy of the data: no
9
+ // host objects (whose prototype chain would lead back to this process), no
10
+ // require, no process. Formulas are shared workspace data and the engine
11
+ // compiles them with new Function; since app build 17m the compiler allows
12
+ // only the formula language's own names (B21). The vm is a second wall, so a
13
+ // formula from someone else's workspace can't reach this machine either way.
14
+ // =====================================================================
15
+ import { readFileSync } from 'node:fs';
16
+ import vm from 'node:vm';
17
+
18
+ const VENDOR = new URL('./vendor/', import.meta.url);
19
+ const FILES = ['cost.js', 'nesting.js', 'theoretical-params.js'];
20
+ let _source = null;
21
+ function source() {
22
+ if (!_source) _source = FILES.map(f => readFileSync(new URL(f, VENDOR), 'utf8')).join('\n;\n');
23
+ return _source;
24
+ }
25
+
26
+ // Runs inside the vm: evaluates one formulation's (or batch's) parameters and
27
+ // returns a JSON string, so only plain text crosses back.
28
+ const EPILOGUE = `
29
+ ;function __theo(kind, id) {
30
+ var coll = kind === 'batch' ? db.batches : db.formulations;
31
+ var e = (coll || []).find(function (x) { return x && x.id === id; });
32
+ if (!e) return '[]';
33
+ var params = _tpEffectiveParams(kind === 'batch' ? 'batch' : 'formulation', e).filter(function (p) { return p && p.id; });
34
+ if (!params.length) return '[]';
35
+ var comp = _tpEffectiveComposition(kind === 'batch' ? 'batch' : 'formulation', e);
36
+ var rows = _tpBuildRows(comp.rows);
37
+ var res = _tpEvaluateAll(params, rows);
38
+ return JSON.stringify(params.map(function (p) {
39
+ var r = res[p.id] || {};
40
+ var v = (!r.error && Number.isFinite(r.value)) ? r.value : null;
41
+ var warning = null;
42
+ try { var m = _tpMissingNote(_tpMissingInputs(p.formula, rows)); if (m) warning = m.text; } catch (_) {}
43
+ var spec = (p.spec && (p.spec.min != null || p.spec.max != null)) ? { min: p.spec.min != null ? p.spec.min : null, max: p.spec.max != null ? p.spec.max : null } : null;
44
+ var pass = (v != null && spec) ? ((spec.min == null || v >= spec.min) && (spec.max == null || v <= spec.max)) : null;
45
+ return { name: p.name || '', value: v, unit: p.unit || '', formula: p.formula || '', error: r.error || null,
46
+ warning: warning, spec: spec, pass: pass, pinned: !!p.pinned, basis: comp.source };
47
+ }));
48
+ }`;
49
+
50
+ const _contexts = new WeakMap(); // store db object → its vm context
51
+ function contextFor(db) {
52
+ let ctx = _contexts.get(db);
53
+ if (ctx) return ctx;
54
+ const data = JSON.stringify({
55
+ ingredients: db.ingredients || [], formulations: db.formulations || [], batches: db.batches || [],
56
+ settings: db.settings || {},
57
+ });
58
+ // A prototype-less sandbox: the classic vm escape (this.constructor.constructor
59
+ // → the host's Function) has no constructor to walk to.
60
+ const sandbox = Object.create(null);
61
+ sandbox.__dbJson = data;
62
+ ctx = vm.createContext(sandbox);
63
+ vm.runInContext('var console = { log: function () {}, warn: function () {}, error: function () {}, info: function () {} };'
64
+ + 'var db = JSON.parse(__dbJson); delete globalThis.__dbJson;\n' + source() + EPILOGUE, ctx, { filename: 'formlab-theoretical.js', timeout: 10000 });
65
+ _contexts.set(db, ctx);
66
+ return ctx;
67
+ }
68
+
69
+ // [{ name, value, unit, formula, error, warning, spec, pass, pinned, basis }] —
70
+ // value null when it can't be computed (error says why); warning is the Params
71
+ // tab's ⚠ note ("2 ingredients missing carb", "sugar in mixed units (…)");
72
+ // basis is 'proposal' (the formula's recipe) or 'actual' (a batch as made).
73
+ export function theoreticalParams(db, kind, id) {
74
+ try {
75
+ const ctx = contextFor(db);
76
+ vm.runInContext('var __id = ' + JSON.stringify(String(id)) + ', __kind = ' + JSON.stringify(kind === 'batch' ? 'batch' : 'formulation') + ';', ctx);
77
+ return JSON.parse(vm.runInContext('__theo(__kind, __id)', ctx, { timeout: 2000 }));
78
+ } catch (e) {
79
+ return [{ name: '(theoretical parameters)', value: null, unit: '', formula: '', error: 'could not compute: ' + ((e && e.message) || e), warning: null, spec: null, pass: null, pinned: false, basis: null }];
80
+ }
81
+ }
@@ -7,6 +7,7 @@
7
7
  // ============================================================
8
8
 
9
9
  import { getStore, resolveById, flattenComposition } from '../data.js';
10
+ import { theoreticalParams } from '../theoretical.js';
10
11
 
11
12
  // Strip db-internal noise (createdAt, updatedAt) for cleaner LLM output
12
13
  // while keeping the human-facing fields the user cares about.
@@ -82,7 +83,7 @@ const list_formulations = {
82
83
  const get_formulation = {
83
84
  definition: {
84
85
  name: 'get_formulation',
85
- description: 'Get full details for a single formulation including its composition (raw rows AND flattened wt-% leaves with sub-formulas expanded), step procedure, any recorded process-factor values (cure temp, mix time, RPM, pH — DOE inputs), and recent batches. Accepts either internal id or UID (e.g. FORM-001).',
86
+ description: 'Get full details for a single formulation including its composition (raw rows AND flattened wt-% leaves with sub-formulas expanded), step procedure, any recorded process-factor values (cure temp, mix time, RPM, pH — DOE inputs), and recent batches. Accepts either internal id or UID (e.g. FORM-001). Also returns `theoreticalParameters`: values computed from the composition the way the app Params tab does (e.g. Energy kcal/100g, NV %, Cost/kg) — each with name, value, unit, formula, spec + pass, and `warning` when ingredients lack a value the formula reads or carry mixed units (those rows are not counted). Quote these; do not recompute nutrition or NV yourself.',
86
87
  inputSchema: {
87
88
  type: 'object',
88
89
  properties: {
@@ -131,6 +132,8 @@ const get_formulation = {
131
132
  // Process-factor values (DOE Matrix "+ Factor" columns) — recorded process
132
133
  // conditions (cure temp, mix time, RPM, pH, …) treated as DOE INPUTS.
133
134
  processFactors: (f.factorValues || []).filter(x => x && x.name).map(x => ({ name: x.name, value: x.value != null ? x.value : '', unit: x.unit || '' })),
135
+ // Computed by the app's own engine (mcp/theoretical.js → mcp/vendor/).
136
+ theoreticalParameters: theoreticalParams(db, 'formulation', f.id),
134
137
  batchCount: batches.length,
135
138
  sampleCount: samples.length,
136
139
  };
@@ -170,7 +170,7 @@ const list_ingredients = {
170
170
  const get_ingredient = {
171
171
  definition: {
172
172
  name: 'get_ingredient',
173
- 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).',
173
+ 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, plus its `customFields` (name, value, unit — e.g. protein / carb / fat in g/100g, solids %; what theoretical parameters read). Accepts internal id or UID (e.g. ING-001).',
174
174
  inputSchema: {
175
175
  type: 'object',
176
176
  properties: {
@@ -224,6 +224,10 @@ const get_ingredient = {
224
224
  description: ing.description || '',
225
225
  notes: ing.notes || '',
226
226
  attributes: ing.attributes || {},
227
+ // User-defined fields: what theoretical parameters read as ing.<name>
228
+ // (protein / carb / fat in g/100g for nutrition, solids % for NV, …).
229
+ // The unit is a label; a 0 is a value.
230
+ customFields: (ing.customFields || []).filter(c => c && c.name).map(c => ({ name: c.name, value: c.value != null ? c.value : '', unit: c.unit || '', ...(c.info ? { info: c.info } : {}) })),
227
231
  safety,
228
232
  regulatory,
229
233
  lots,
package/tools/lab.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // ============================================================
8
8
 
9
9
  import { getStore, resolveById } from '../data.js';
10
+ import { theoreticalParams } from '../theoretical.js';
10
11
 
11
12
  // --- Self-contained unit conversion + actual-volume derivation ---------------
12
13
  // The MCP server has no access to the app's cost.js helpers, so a small local
@@ -247,7 +248,7 @@ const list_batches = {
247
248
  const get_batch = {
248
249
  definition: {
249
250
  name: 'get_batch',
250
- 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.',
251
+ 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. Also returns `theoreticalParameters` computed on the batch as made (its actual composition when logged — `basis: "actual"` — else the recipe of its formula), with the batch parameter list when it overrides the one on the formula; same fields as get_formulation.',
251
252
  inputSchema: {
252
253
  type: 'object',
253
254
  properties: {
@@ -295,6 +296,7 @@ const get_batch = {
295
296
  date: e.ts || null, delta: e.delta ?? null, unit: e.unit || b.unit || '', kind: e.kind || '', note: e.note || '',
296
297
  })),
297
298
  shippedQty: (Array.isArray(b.stockLedger) ? b.stockLedger : []).reduce((s, e) => e.kind === 'shipment' ? s + Math.abs(parseFloat(e.delta) || 0) : s, 0),
299
+ theoreticalParameters: theoreticalParams(getStore().db, 'batch', b.id),
298
300
  };
299
301
  },
300
302
  };
@@ -0,0 +1,18 @@
1
+ # Vendored app files — do not edit here
2
+
3
+ Exact copies of the FormLab app's theoretical-parameter engine, so the MCP
4
+ server computes the same numbers the Params tab shows (the npm package can't
5
+ reach files outside `mcp/`):
6
+
7
+ | Copy | Source |
8
+ |---|---|
9
+ | `cost.js` | `js/cost.js` |
10
+ | `nesting.js` | `js/nesting.js` |
11
+ | `theoretical-params.js` | `js/views/theoretical-params.js` |
12
+
13
+ Edit the source, then copy it over:
14
+
15
+ cp js/cost.js js/nesting.js js/views/theoretical-params.js mcp/vendor/
16
+
17
+ The pre-commit hook (`scripts/hooks/pre-commit`) refreshes them when a commit touches a source; `test/mcp-theoretical.test.js` fails while a copy differs. `mcp/theoretical.js`
18
+ runs them in a `node:vm` context holding only a JSON copy of the data.