formlab-mcp 0.6.32 → 0.6.34

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
@@ -45,13 +45,13 @@ Ask Claude (or another MCP client) things like:
45
45
  | `get_sample` | Full record + canonical variant + test reports + blend lineage |
46
46
  | `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
47
47
  | `get_test_result` | Full report: every measurement's value, spec, any per-measurement run **conditions**, and the **resolved instrument** (`instrumentSource`: row / method / run). Complex types carry a `representative` (time-series → final, distribution → D50); a time series adds `aggregated` (per-timestamp mean ± sd) and `pointReplicates` (repeat readings at one `t`). Report-level `seriesReplicates` lists parameters measured 2+ times as separate rows — point vs series replicates are distinct. Each measurement also gives its **effective spec** (`effectiveSpec`, `specSource`: override / template / parameter / none) and `passFail` — panel and Test Method specs apply exactly as in the app, and every pass/fail count across the tools uses the same rule |
48
- | `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
48
+ | `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters. A parameter measured under two or more **run conditions** (storage 25 °C vs 40 °C) is one column per condition (`pH`, `pH · 40 °C`; the bare name is the base arm), never averaged together — same columns as the app's DOE Matrix |
49
49
  | `find_failures` | Parameters ranked by failure **rate** (failing ÷ all readings; replicates in one report count once), `lowN` flag under 5 readings, plus formulations ranked by share of failing reports — same numbers as the app's Test Analytics → Failures |
50
- | `get_coverage_matrix` | TEST coverage by formulation, batch or sample (`grain`) × parameter. Each cell is the **latest** reading by test date (same-day reports averaged, any fail → fail) with its date and how many reports measured it — same rollup as the app's Test Analytics → Coverage |
50
+ | `get_coverage_matrix` | TEST coverage by formulation, batch or sample (`grain`) × parameter. Each cell is the **latest** reading by test date (same-day reports averaged, any fail → fail) with its date and how many reports measured it; one column per **run condition** when a parameter was measured under two or more — same rollup as the app's Test Analytics → Coverage |
51
51
  | `compare_batches` | Reproducibility of 2+ runs (ideally of one formula): each run's **yield %**, actual produced mass, cost/kg; per-ingredient **drift** across the runs vs the formula's proposed wt-%; and the biggest **outlier** run. `basis`: `wt_percent` (default) or `amount` |
52
52
  | `get_batch_pivot` | Production analytics — aggregate batches by `group_by` (formula / status / month / prepared_by / project) × `metric` (count, avg_yield_pct, total_produced_kg, avg_cost_per_batch, total_samples, total_tests), with share + total for additive metrics. Optional `batch_uids` scope + `status` filter |
53
53
  | `get_project_pivot` | Portfolio analytics — aggregate projects by `group_by` (status / phase / priority / business_unit / site / customer / lead, or `cf:<custom field>`) × `metric` (count, total_formulas / batches / samples, total_cost, avg_formulas / batches / cost_per_batch), rolling child activity + cost up by any project attribute. Optional `project_uids` scope |
54
- | `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), split by storage condition |
54
+ | `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), all on **one point per test date per run condition** (replicates averaged, never separate time points), split by storage condition |
55
55
  | `get_design_space` | COMPOSITION design space for one project: which ingredients vary and over what observed wt-% ranges (ranked), an occupied-region summary ("where you've been"), the biggest untested **interior gap** as ready-to-seed DOE ranges (a combination you could have made but skipped), and an optional standardised **2-component PCA** of all varying ingredients — loadings, variance-explained, scree, and a full-dimensional gap. Matches the in-app Design Space Viewer exactly. Answers *"where haven't I explored?"* / *"what should I formulate next?"* |
56
56
  | `list_doe_designs` | Saved DOE designs — the *recipe* behind each batch of runs: design type, factors + ranges, constraints, run count, D-efficiency. Filters: `project`, `design_type`, `constrained_only`, `name_contains` |
57
57
  | `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
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.32",
5
+ "version": "0.6.34",
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.32",
16
+ "version": "0.6.34",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -226,7 +226,7 @@ const get_test_result = {
226
226
  const get_doe_matrix = {
227
227
  definition: {
228
228
  name: 'get_doe_matrix',
229
- description: 'Build a Design-of-Experiments style pivot matrix: rows = formulations (or samples / batches / tests), columns = composition wt-% per ingredient + aggregated test-parameter values. Same shape as the FormLab DOE Matrix view. Returns CSV when format=csv (default) for compact LLM consumption, or structured JSON when format=json.',
229
+ description: 'Build a Design-of-Experiments style pivot matrix: rows = formulations (or samples / batches / tests), columns = composition wt-% per ingredient + aggregated test-parameter values. Same shape as the FormLab DOE Matrix view. A parameter measured under two or more RUN CONDITIONS (e.g. storage 25 °C vs 40 °C) is one column per condition — "pH · 40 °C" — and the bare parameter column is the base arm (readings with no condition, else the condition with the most readings); they are different responses, never averaged together. Returns CSV when format=csv (default) for compact LLM consumption, or structured JSON when format=json.',
230
230
  inputSchema: {
231
231
  type: 'object',
232
232
  properties: {
@@ -335,11 +335,22 @@ const get_doe_matrix = {
335
335
  const ing = indexes.ingredientsById.get(id);
336
336
  return { id, label: (ing?.name || id) + ' (%)' };
337
337
  });
338
- const paramCols = [...parameters].map(p => ({ id: p, label: p }));
338
+ // One column per parameter, or per (parameter, run condition) when it was
339
+ // measured under two or more conditions — see _mcpArmNames.
340
+ const armNames = _mcpArmNames(rows.flatMap(r => r.measurements
341
+ .filter(m => m && m.parameter && parameters.has(m.parameter))
342
+ .map(m => ({ parameter: m.parameter, m }))));
343
+ const paramCols = [];
344
+ [...parameters].forEach(p => {
345
+ const arms = armNames.get(p);
346
+ if (!arms) { paramCols.push({ id: p, cond: null, label: p }); return; }
347
+ arms.forEach((label, sig) => paramCols.push({ id: p, cond: sig, label }));
348
+ });
339
349
 
340
- // 4) Aggregate measurements per param per row (mean of numeric scalar values)
341
- const cellParamValue = (r, param) => {
342
- const ms = r.measurements.filter(m => m.parameter === param);
350
+ // 4) Aggregate measurements per column per row (mean of numeric scalar
351
+ // values), reading only the column's own run condition when split.
352
+ const cellParamValue = (r, col) => {
353
+ const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
343
354
  if (!ms.length) return null;
344
355
  const nums = ms.map(m => parseFloat(m.value)).filter(v => isFinite(v));
345
356
  if (!nums.length) return ms[0].value ?? null;
@@ -360,7 +371,7 @@ const get_doe_matrix = {
360
371
  rows: limitedRows.map(r => ({
361
372
  label: r.label,
362
373
  ingredientPct: Object.fromEntries(ingCols.map(c => [c.label, r.composition.get(c.id) ?? null])),
363
- parameterMean: Object.fromEntries(paramCols.map(c => [c.label, cellParamValue(r, c.id)])),
374
+ parameterMean: Object.fromEntries(paramCols.map(c => [c.label, cellParamValue(r, c)])),
364
375
  })),
365
376
  };
366
377
  }
@@ -377,7 +388,7 @@ const get_doe_matrix = {
377
388
  const cells = [
378
389
  r.label,
379
390
  ...ingCols.map(c => r.composition.get(c.id) ?? ''),
380
- ...paramCols.map(c => cellParamValue(r, c.id) ?? ''),
391
+ ...paramCols.map(c => cellParamValue(r, c) ?? ''),
381
392
  ];
382
393
  lines.push(cells.map(escCSV).join(','));
383
394
  });
@@ -414,6 +425,57 @@ function _mcpCondSig(m) {
414
425
  if (!c || typeof c !== 'object') return '';
415
426
  return Object.keys(c).filter(k => c[k] != null && c[k] !== '').sort().map(k => `${k}=${c[k]}`).join('|');
416
427
  }
428
+ // ---- Run-condition ARMS (mirror the app, FormLab 1.16.18 build 24h–24i) ----
429
+ // A parameter measured under two or more run conditions (storage 25 °C vs
430
+ // 40 °C, first vs second heat) is one column per condition in the DOE Matrix
431
+ // and the Coverage matrix: an ambient arm and an accelerated one are different
432
+ // responses, never averaged. The bare parameter name keeps the BASE arm — the
433
+ // readings with no condition when there are any, otherwise the arm with the
434
+ // most readings (the arm a bare `pH` means in the app's search) — and the
435
+ // others read "pH · 40 °C". Same rule and the same tag shortening as the app
436
+ // (js/utils.js flCondTags), so column names match what the user sees.
437
+ function _mcpCondLabel(m) {
438
+ const c = m && m.conditions;
439
+ if (!c || typeof c !== 'object') return '';
440
+ return Object.entries(c).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k} ${v}`).join(' · ');
441
+ }
442
+ function _mcpCondTags(conds) {
443
+ const named = conds.filter(Boolean);
444
+ let pre = '';
445
+ if (named.length > 1) {
446
+ const first = named[0];
447
+ let i = 0;
448
+ while (i < first.length && named.every(c => c[i] === first[i])) i++;
449
+ pre = first.slice(0, i);
450
+ pre = pre.slice(0, pre.lastIndexOf(' ') + 1);
451
+ }
452
+ const tags = conds.map(c => c ? (c.slice(pre.length) || c) : 'no condition');
453
+ const lead = tags.map(t => t.split(/\s+[\/·]\s+/)[0]);
454
+ return (new Set(lead).size === lead.length) ? lead : tags;
455
+ }
456
+ // items: [{ parameter, m }] → Map(parameter → Map(condSig → column name)),
457
+ // only for parameters with two or more arms.
458
+ function _mcpArmNames(items) {
459
+ const arms = new Map();
460
+ for (const it of items) {
461
+ const sig = _mcpCondSig(it.m);
462
+ if (!arms.has(it.parameter)) arms.set(it.parameter, new Map());
463
+ const a = arms.get(it.parameter);
464
+ const cur = a.get(sig) || { n: 0, m: it.m };
465
+ cur.n++; a.set(sig, cur);
466
+ }
467
+ const out = new Map();
468
+ arms.forEach((a, param) => {
469
+ if (a.size < 2) return;
470
+ const keys = [...a.keys()];
471
+ const base = a.has('') ? '' : keys.slice().sort((x, y) => (a.get(y).n - a.get(x).n) || x.localeCompare(y))[0];
472
+ const ordered = [base, ...keys.filter(k => k !== base).sort((x, y) => x.localeCompare(y))];
473
+ const tags = _mcpCondTags(ordered.map(k => k ? _mcpCondLabel(a.get(k).m) : ''));
474
+ out.set(param, new Map(ordered.map((k, i) => [k, i === 0 ? param : `${param} · ${tags[i]}`])));
475
+ });
476
+ return out;
477
+ }
478
+
417
479
  function _mcpFlattenReadings(tests, indexes, paramFilter) {
418
480
  const norm = (s) => String(s || '').toLowerCase();
419
481
  const groups = new Map(); const order = [];
@@ -613,7 +675,7 @@ function _evalPassFail(m, templateId, db) {
613
675
  const get_coverage_matrix = {
614
676
  definition: {
615
677
  name: 'get_coverage_matrix',
616
- description: 'Coverage matrix matching the app\'s Test Analytics → Coverage: rows = sample, batch or formulation (grain), columns = parameters. Each cell is the LATEST reading by test date; readings from several reports on that same date are averaged and fail if any of them fails. Each cell also gives latestDate, reportsAveraged and nReports (how many reports measured it). Empty = never tested. Use for "what\'s untested", "what\'s the current value" and "what\'s been measured many times".',
678
+ description: 'Coverage matrix matching the app\'s Test Analytics → Coverage: rows = sample, batch or formulation (grain), columns = parameters — one column per RUN CONDITION when a parameter was measured under two or more (e.g. "pH" and "pH · 40 °C"; the bare name is the base arm), never averaged together. Each cell is the LATEST reading by test date; readings from several reports on that same date are averaged and fail if any of them fails. Each cell also gives latestDate, reportsAveraged and nReports (how many reports measured it). Empty = never tested. Use for "what\'s untested", "what\'s the current value" and "what\'s been measured many times".',
617
679
  inputSchema: {
618
680
  type: 'object',
619
681
  properties: {
@@ -650,7 +712,7 @@ const get_coverage_matrix = {
650
712
  if (fmt === 'json') {
651
713
  return {
652
714
  grain, rowCount: sorted.length, parameterCount: paramCols.length, parameters: paramCols,
653
- rule: 'cell = latest reading by test date; same-date reports averaged (any fail → fail)',
715
+ rule: 'cell = latest reading by test date; same-date reports averaged (any fail → fail); one column per run condition, never averaged across',
654
716
  rows: sorted.map(r => ({ [grain]: r.label, id: r.uid, cells: Object.fromEntries([...r.cells.entries()].sort()) })),
655
717
  };
656
718
  }
@@ -671,6 +733,15 @@ const get_coverage_matrix = {
671
733
 
672
734
  // Latest-date rollup (mirrors the app's _taCoverageRows).
673
735
  function _mcpCoverageRows(flat, grain, indexes) {
736
+ // One column per run condition first (see _mcpArmNames), so "latest" never
737
+ // means whichever storage arm was pulled last and a shared date never
738
+ // averages ambient with accelerated.
739
+ const armNames = _mcpArmNames(flat);
740
+ if (armNames.size) flat = flat.map(r => {
741
+ const map = armNames.get(r.parameter);
742
+ const nm = map && map.get(_mcpCondSig(r.m));
743
+ return (!nm || nm === r.parameter) ? r : { ...r, parameter: nm };
744
+ });
674
745
  const rows = new Map();
675
746
  for (const r of flat) {
676
747
  let rec = null, label = '';
@@ -693,8 +764,12 @@ function _mcpCoverageRows(flat, grain, indexes) {
693
764
  const nums = list.map(x => x.numeric).filter(v => v != null && isFinite(v));
694
765
  const mean = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
695
766
  const pf = list.some(x => x.pf === 'fail') ? 'fail' : list.some(x => x.pf === 'pass') ? 'pass' : list[0].pf;
767
+ // Replicate means carry float dust ((5.9 + 5.9 + 5.9) / 3 = 5.900000000000001);
768
+ // an agent should read the number the user sees. 12 significant digits
769
+ // keeps every real value.
770
+ const tidy = (v) => (typeof v === 'number' && isFinite(v)) ? Number(v.toPrecision(12)) : v;
696
771
  row.cells.set(param, {
697
- value: list.length === 1 ? (list[0].numeric != null ? list[0].numeric : list[0].value) : (mean != null ? mean : list[0].value),
772
+ value: tidy(list.length === 1 ? (list[0].numeric != null ? list[0].numeric : list[0].value) : (mean != null ? mean : list[0].value)),
698
773
  unit: list[0].unit, pf: (pf === 'pass' || pf === 'fail') ? pf : null,
699
774
  latestDate: date || null, reportsAveraged: list.length, nReports: row.reports.get(param).size,
700
775
  });
@@ -1013,7 +1088,7 @@ function _shelfLifeAssess(sl, targetMonths, now) {
1013
1088
  const get_stability = {
1014
1089
  definition: {
1015
1090
  name: 'get_stability',
1016
- 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.',
1091
+ 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. A point is one TEST DATE: every reading taken that day under that condition is averaged (points = test dates, readings = the raw count), so replicates never count as separate time points in the drift, control chart or shelf-life fit — the same points as the app. Use these figures directly; do not re-derive the regression.',
1017
1092
  inputSchema: {
1018
1093
  type: 'object',
1019
1094
  properties: {
@@ -1068,7 +1143,7 @@ const get_stability = {
1068
1143
  }
1069
1144
  const cond = (m.conditions && Object.keys(m.conditions).length)
1070
1145
  ? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '';
1071
- points.push({ date, value, cond, spec: sp });
1146
+ points.push({ date, day: dStr, value, cond, spec: sp });
1072
1147
  });
1073
1148
  });
1074
1149
  const specList = [...specByKey.values()].map(e => ({ min: e.min, max: e.max, readings: e.readings, sources: [...e.sources] }));
@@ -1079,7 +1154,21 @@ const get_stability = {
1079
1154
  const pdef = (db.parameters || []).find(p => (p.name || '').trim().toLowerCase() === paramLc);
1080
1155
  if (pdef && pdef.acceptanceCriteria) { specMin = pdef.acceptanceCriteria.min ?? null; specMax = pdef.acceptanceCriteria.max ?? null; }
1081
1156
  }
1082
- points.sort((a, b) => a.date - b.date);
1157
+ // ONE POINT PER TEST DATE PER RUN CONDITION — the mean of every reading
1158
+ // taken that day under that condition — exactly as the app's Stability view
1159
+ // (FormLab build 24k). Replicates are not separate time points: counting a
1160
+ // triplicate as three consecutive observations took the control chart's
1161
+ // sigma from within-day differences (Clarifying Shampoo's ambient pH: 10
1162
+ // out-of-control points on 53 readings, 1 on its 18 test dates) and gave
1163
+ // the drift and shelf-life fits degrees of freedom the study does not have.
1164
+ const byDay = new Map();
1165
+ points.forEach(p => { const k = p.day + '\u0000' + p.cond; if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(p); });
1166
+ points.length = 0;
1167
+ byDay.forEach(g => {
1168
+ const value = g.reduce((a, r) => a + r.value, 0) / g.length;
1169
+ points.push({ date: new Date(g[0].day), value, cond: g[0].cond, spec: (g.find(r => r.spec) || {}).spec || null, readings: g.length });
1170
+ });
1171
+ points.sort((a, b) => (a.date - b.date) || a.cond.localeCompare(b.cond));
1083
1172
  if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
1084
1173
 
1085
1174
  const spec = { min: specMin, max: specMax };
@@ -1094,7 +1183,7 @@ const get_stability = {
1094
1183
  const analyze = (pts) => {
1095
1184
  const ys = pts.map(p => p.value);
1096
1185
  const n = ys.length;
1097
- 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) };
1186
+ const out = { points: n, readings: pts.reduce((a, p) => a + (p.readings || 1), 0), 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) };
1098
1187
  if (n < 2) { out.note = 'Fewer than 2 points — no trend/shelf-life.'; return out; }
1099
1188
  // Drift on real elapsed months (not point order), like the shelf-life
1100
1189
  // projection and the app's Stability view: uneven ICH schedules
@@ -1154,7 +1243,7 @@ const get_stability = {
1154
1243
  },
1155
1244
  };
1156
1245
 
1157
- export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess };
1246
+ export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames };
1158
1247
  export const tools = {
1159
1248
  list_test_results,
1160
1249
  get_test_result,