formlab-mcp 0.6.38 → 0.6.40

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
@@ -51,7 +51,7 @@ Ask Claude (or another MCP client) things like:
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), all on **one point per test date per run condition** (replicates averaged, never separate time points), 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. A ranked-scale (ordinal) or text parameter returns its step / pass-fail series instead: per-condition points, firstFail, latest, scaleShift |
55
55
  | `get_design_space` | COMPOSITION design space for one project, several (`projects`) or any formula list (`formulas`, across projects — like the app's Scope → DOE Matrix): 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. Optional `axes` (choose the 2–3 axes), `by_role` (map ingredients summed by primary Function, e.g. Pigment / Solvent / Film Former — no gap, roles aren't DOE factors) and `property` (highest / lowest formula plus the ingredients whose level tracks it, Spearman ρ). Reports formulas that aren't on the map and the spread on it. 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.38",
3
+ "version": "0.6.40",
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.38",
5
+ "version": "0.6.40",
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.38",
16
+ "version": "0.6.40",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -448,6 +448,55 @@ function _mcpRepNumber(m, opts) {
448
448
  const v = parseFloat(m.value);
449
449
  return isFinite(v) ? v : null;
450
450
  }
451
+ // A ranked-scale (ordinal) or text (categorical) parameter's trend, as the
452
+ // app's Stability view draws it (FormLab 13b): one point per test date × run
453
+ // condition — an ordinal date is the MEDIAN rank of its readings (rank =
454
+ // 1-based position on the reading's own scale), a text date its value (or
455
+ // "mixed: a / b") — failing when any reading fails; one series per condition.
456
+ function _mcpGradedStability(entity, entityType, args, graded) {
457
+ const nOrd = graded.filter(r => r.type === 'ordinal').length;
458
+ const kind = nOrd >= graded.length - nOrd ? 'ordinal' : 'categorical';
459
+ const rows = graded.filter(r => r.type === kind);
460
+ const scale = kind === 'ordinal' ? ((rows.find(r => r.scale) || {}).scale || null) : null;
461
+ const split = args.by_condition !== false;
462
+ const byKey = new Map();
463
+ rows.forEach(r => { const k = r.day + '\u0000' + (split ? r.cond : ''); if (!byKey.has(k)) byKey.set(k, []); byKey.get(k).push(r); });
464
+ const pts = [...byKey.values()].map(g => {
465
+ let label, rank = null;
466
+ if (kind === 'ordinal') {
467
+ const rs = g.map(r => r.rank).filter(v => v != null).sort((a, b) => a - b);
468
+ rank = rs.length ? rs[Math.floor((rs.length - 1) / 2)] : null;
469
+ label = rank != null && scale ? scale[rank - 1] : g[0].label;
470
+ } else {
471
+ const vals = [...new Set(g.map(r => r.label))];
472
+ label = vals.length === 1 ? vals[0] : 'mixed: ' + vals.join(' / ');
473
+ }
474
+ return { date: g[0].day, condition: split ? (g[0].cond || null) : null, label, ...(kind === 'ordinal' ? { rank } : {}),
475
+ readings: g.length, values: g.map(r => r.label), reports: [...new Set(g.map(r => r.report))],
476
+ pf: g.some(r => r.pf === 'fail') ? 'fail' : g.some(r => r.pf === 'pass') ? 'pass' : null };
477
+ }).sort((a, b) => a.date.localeCompare(b.date) || String(a.condition).localeCompare(String(b.condition)));
478
+ const sp = (rows.find(r => r.spec) || {}).spec || null;
479
+ const floorLabel = sp ? (sp.minValue != null ? sp.minValue : (sp.minRank != null && scale ? scale[sp.minRank] : null)) : null;
480
+ const spec = sp ? { ...(floorLabel != null ? { atLeast: floorLabel, atLeastRank: scale ? scale.indexOf(floorLabel) + 1 : null } : {}),
481
+ ...(sp.requiredValue != null ? { required: sp.requiredValue } : {}) } : null;
482
+ const byCond = new Map();
483
+ pts.forEach(p => { const k = p.condition || ''; if (!byCond.has(k)) byCond.set(k, []); byCond.get(k).push(p); });
484
+ const series = [...byCond.keys()].sort().map(k => {
485
+ const ps = byCond.get(k), first = ps[0], last = ps[ps.length - 1];
486
+ const firstFail = ps.find(p => p.pf === 'fail');
487
+ const out = { condition: k || null, points: ps, latest: { date: last.date, label: last.label, pf: last.pf },
488
+ firstFail: firstFail ? { date: firstFail.date, label: firstFail.label } : null, inSpecNow: last.pf == null ? null : last.pf === 'pass' };
489
+ if (kind === 'ordinal' && first.rank != null && last.rank != null) out.scaleShift = { from: first.label, to: last.label, steps: last.rank - first.rank };
490
+ return out;
491
+ });
492
+ return {
493
+ entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
494
+ parameter: args.parameter, kind, ...(scale ? { scale } : {}), spec, byCondition: split, pointCount: pts.length, series,
495
+ note: kind === 'ordinal'
496
+ ? 'A ranked scale (ordinal) has no number, so there is no regression, control chart or shelf-life projection: each point is one test date per condition, its value the MEDIAN rank of that date\'s readings (rank = position on `scale`, 1-based; higher = later on the scale). pf "fail" when any reading that date failed. scaleShift.steps is the rank change first → latest (negative = moved down the scale). Narrate firstFail / latest; do not average ranks into a number.'
497
+ : 'A text (categorical) result has no number: each point is one test date per condition with its value (or "mixed: a / b" when readings differ) and pass/fail against `spec.required`; pf "fail" when any reading failed. Narrate firstFail / latest.',
498
+ };
499
+ }
451
500
  function _mcpCondSig(m) {
452
501
  const c = m && m.conditions;
453
502
  if (!c || typeof c !== 'object') return '';
@@ -725,15 +774,16 @@ function _evalPassFail(m, templateId, db) {
725
774
  if (!crit.requiredValue) return 'no-criteria';
726
775
  return raw.toLowerCase() === String(crit.requiredValue).toLowerCase() ? 'pass' : 'fail';
727
776
  }
777
+ // As the app (13d): one representative value against the range — a curve's
778
+ // FINAL point, a distribution's statistic named by the spec (crit.stat:
779
+ // "D90"), D50 when unnamed. It used to require every point / bin in range.
728
780
  if (type === 'time_series' || type === 'distribution') {
729
781
  const list = type === 'time_series' ? (Array.isArray(m.points) ? m.points : []) : (Array.isArray(m.bins) ? m.bins : []);
730
782
  if (!list.length) return 'untested';
731
- for (const p of list) {
732
- const v = parseFloat(p.value);
733
- if (!isFinite(v)) return 'untested';
734
- if (crit.min != null && v < crit.min) return 'fail';
735
- if (crit.max != null && v > crit.max) return 'fail';
736
- }
783
+ const v = type === 'time_series' ? _mcpRepNumber(m, { ts: 'final' }) : _mcpRepNumber(m, { dist: crit.stat || 'D50' });
784
+ if (v == null || !isFinite(v)) return 'untested';
785
+ if (crit.min != null && v < crit.min) return 'fail';
786
+ if (crit.max != null && v > crit.max) return 'fail';
737
787
  return 'pass';
738
788
  }
739
789
  return 'no-criteria';
@@ -1155,7 +1205,7 @@ function _shelfLifeAssess(sl, targetMonths, now) {
1155
1205
  const get_stability = {
1156
1206
  definition: {
1157
1207
  name: 'get_stability',
1158
- 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.',
1208
+ 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. A ranked-scale (ordinal, e.g. pencil hardness) or text (e.g. appearance) parameter returns kind "ordinal" / "categorical" instead: per-condition points with the label (median rank for an ordinal), pass/fail, firstFail, latest and scaleShift — the app\'s step / pass-fail-strip trend, with no regression. Use these figures directly; do not re-derive the regression.',
1159
1209
  inputSchema: {
1160
1210
  type: 'object',
1161
1211
  properties: {
@@ -1182,6 +1232,7 @@ const get_stability = {
1182
1232
  if (!paramLc) return { error: 'parameter is required.' };
1183
1233
  const points = [];
1184
1234
  const skipped = { ordinal: 0, categorical: 0 };
1235
+ const graded = []; // ordinal / text readings, for the step / strip trend (13b)
1185
1236
  let specMin = null, specMax = null, unit = '';
1186
1237
  // Each reading keeps the spec it was judged by (reading → panel row →
1187
1238
  // method). Mirrors the app: one band only when they all agree; 2+ distinct
@@ -1197,7 +1248,17 @@ const get_stability = {
1197
1248
  // As the app's Stability chart (13a): a curve → its final value (not
1198
1249
  // "12 points" → 12), and ordinal / text readings left out and counted.
1199
1250
  const _ty = _mcpMeasType(m);
1200
- if (_ty === 'ordinal' || _ty === 'categorical') { skipped[_ty]++; return; }
1251
+ if (_ty === 'ordinal' || _ty === 'categorical') {
1252
+ skipped[_ty]++;
1253
+ const label = String(m.value == null ? '' : m.value).trim();
1254
+ if (label) {
1255
+ const rs0 = _resolveSpec(m, t.templateId);
1256
+ graded.push({ day: dStr, type: _ty, label, rank: _mcpRepNumber(m, { ordinal: 'rank' }), scale: Array.isArray(m.scale) ? m.scale : null,
1257
+ spec: rs0.spec || null, pf: rs0.spec ? _evalPassFail(m, t.templateId) : null, report: t.uid || t.id,
1258
+ cond: (m.conditions && Object.keys(m.conditions).length) ? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '' });
1259
+ }
1260
+ return;
1261
+ }
1201
1262
  const value = _mcpRepNumber(m);
1202
1263
  if (value == null) return;
1203
1264
  if (m.unit && !unit) unit = m.unit;
@@ -1238,7 +1299,8 @@ const get_stability = {
1238
1299
  points.push({ date: new Date(g[0].day), value, cond: g[0].cond, spec: (g.find(r => r.spec) || {}).spec || null, readings: g.length });
1239
1300
  });
1240
1301
  points.sort((a, b) => (a.date - b.date) || a.cond.localeCompare(b.cond));
1241
- if (!points.length) return { error: `No dated numeric "${args.parameter}" measurements on ${entity.uid || entity.id}.${skipped.ordinal || skipped.categorical ? ` ${skipped.ordinal} ordinal and ${skipped.categorical} text reading(s) exist but have no number to trend — use list_test_results / find_failures for their pass/fail.` : ''}` };
1302
+ if (!points.length && graded.length) return _mcpGradedStability(entity, entityType, args, graded);
1303
+ if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
1242
1304
 
1243
1305
  const spec = { min: specMin, max: specMax };
1244
1306
  // Target shelf life: the formula's own Shelf life, else the workspace