formlab-mcp 0.6.46 → 0.6.48

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
@@ -44,8 +44,8 @@ Ask Claude (or another MCP client) things like:
44
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) |
45
45
  | `list_samples` | Filtered list of physical specimens |
46
46
  | `get_sample` | Full record + canonical variant + test reports + blend lineage |
47
- | `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
48
- | `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 |
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) |
48
+ | `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 distribution also its `bins` and, when its spec names a statistic, `judged` at it); a ranked grade carries its `scale` and `rank`; 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 |
49
49
  | `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, in the same order (grouped by measurement type, the test panel's order within a group), as the app's DOE Matrix |
50
50
  | `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 |
51
51
  | `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. A ranked scale shows its median grade (with its `rank`) when several readings share the date, a text result its most frequent value, a distribution with no D50 its bins; columns in the app's order (type groups, the test panel's order) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.46",
3
+ "version": "0.6.48",
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.46",
5
+ "version": "0.6.48",
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.46",
16
+ "version": "0.6.48",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -84,7 +84,9 @@ function _mcpTsAggregate(points) {
84
84
 
85
85
  function _trimMeasurement(m, t, db) {
86
86
  const ins = _resolveInstrument(m, t, db);
87
- const type = m.type || 'scalar';
87
+ // The app's type rule (13s): a legacy valueType 'Categorical' row is text,
88
+ // not a scalar.
89
+ const type = _mcpMeasType(m);
88
90
  // Reduce a complex type to the single representative every analysis surface
89
91
  // uses (time_series → final point, distribution → D50), and — for a series —
90
92
  // expose per-timestamp aggregation so an agent can see POINT replicates
@@ -101,6 +103,16 @@ function _trimMeasurement(m, t, db) {
101
103
  } else if (type === 'distribution' && Array.isArray(m.bins) && m.bins.length) {
102
104
  const d50 = m.bins.find(b => String(b.label || '').toUpperCase().replace(/\s/g, '') === 'D50');
103
105
  complex.representative = d50 ? { value: _round(parseFloat(d50.value), 6), basis: 'D50' } : null;
106
+ complex.bins = m.bins.map(b => ({ label: b.label, value: b.value }));
107
+ // The statistic its spec names is the one it is judged on ("D90 <=30").
108
+ const sp = _resolveSpec(m, t && t.templateId, db).spec;
109
+ if (sp && sp.stat) { const v = _mcpRepNumber(m, { dist: String(sp.stat) }); complex.judged = { value: v != null ? _round(v, 6) : null, basis: String(sp.stat) }; }
110
+ } else if (type === 'ordinal' && Array.isArray(m.scale) && m.scale.length) {
111
+ // A grade's order (13s): the scale and its place on it, so a floor like
112
+ // "at least HB" can be reasoned about.
113
+ complex.scale = m.scale.slice();
114
+ const r = _mcpRepNumber(m, { ordinal: 'rank' });
115
+ if (r != null) complex.rank = r;
104
116
  }
105
117
  return {
106
118
  parameter: m.parameter || '',
@@ -127,7 +139,7 @@ function _trimMeasurement(m, t, db) {
127
139
  const list_test_results = {
128
140
  definition: {
129
141
  name: 'list_test_results',
130
- description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). Returns metadata (incl. the run-level instrument and controlSample flag) + parameters tested; use get_test_result for per-measurement values and their resolved instruments.',
142
+ description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, grade, text value, run condition, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). The number a range filter reads: a scalar as recorded, a curve at its final point, a distribution at the statistic its spec names (D90) or else D50; a ranked scale or text result has no number — use min_grade / max_grade (positions on the reading\'s own scale, e.g. parameter="Pencil hardness", min_grade="HB") or value_equals (a text result, case-insensitive). condition narrows to readings under a run condition (substring of e.g. "Storage 40 °C"; "none" = readings with no condition). Returns metadata (incl. the run-level instrument and controlSample flag) + parameters tested; use get_test_result for per-measurement values and their resolved instruments.',
131
143
  inputSchema: {
132
144
  type: 'object',
133
145
  properties: {
@@ -135,6 +147,10 @@ const list_test_results = {
135
147
  parameter: { type: 'string', description: 'Filter to results that include this parameter (case-insensitive substring).' },
136
148
  value_min: { type: 'number', description: 'Only reports where the parameter (above) measured >= this value. Use with `parameter`; applies to any measurement if `parameter` is omitted.' },
137
149
  value_max: { type: 'number', description: 'Only reports where the parameter (above) measured <= this value. Use with `parameter`; applies to any measurement if `parameter` is omitted.' },
150
+ min_grade: { type: 'string', description: 'A ranked scale: only readings at or above this grade on the reading\'s own scale (e.g. "HB").' },
151
+ max_grade: { type: 'string', description: 'A ranked scale: only readings at or below this grade.' },
152
+ value_equals: { type: 'string', description: 'A text (or ranked) result equal to this value, case-insensitive (e.g. "pass", "slight haze").' },
153
+ condition: { type: 'string', description: 'Only readings under a run condition whose label contains this (e.g. "40 °C"); "none" = readings with no run condition.' },
138
154
  since_date: { type: 'string', description: 'ISO date — only reports on/after this date.' },
139
155
  until_date: { type: 'string', description: 'ISO date — only reports on/before this date.' },
140
156
  lab: { type: 'string', description: 'Filter by lab name (case-insensitive substring).' },
@@ -159,16 +175,32 @@ const list_test_results = {
159
175
  if (args.until_date && (t.testDate || '') > args.until_date) return false;
160
176
  if (args.lab && !norm(t.lab).includes(norm(args.lab))) return false;
161
177
  const hasRange = args.value_min != null || args.value_max != null;
162
- if (args.parameter || hasRange) {
178
+ const hasGrade = !!(args.min_grade || args.max_grade);
179
+ if (args.parameter || hasRange || hasGrade || args.value_equals || args.condition) {
163
180
  const pn = norm(args.parameter);
164
181
  const found = (t.measurements || []).some(m => {
165
182
  if (args.parameter && !norm(m.parameter).includes(pn)) return false;
183
+ if (args.condition) {
184
+ const lbl = norm(_mcpCondLabel(m));
185
+ if (norm(args.condition) === 'none' ? lbl !== '' : !lbl.includes(norm(args.condition))) return false;
186
+ }
166
187
  if (hasRange) {
167
- const v = _mcpRepNumber(m); // a curve's final value, not its point count (12v)
188
+ // The value it is judged on (13s): a distribution at its spec's statistic.
189
+ const sp = _mcpMeasType(m) === 'distribution' ? (_resolveSpec(m, t.templateId, db).spec || null) : null;
190
+ const v = sp && sp.stat ? _mcpRepNumber(m, { dist: String(sp.stat) }) : _mcpRepNumber(m); // a curve's final value, not its point count (12v)
168
191
  if (v == null) return false;
169
192
  if (args.value_min != null && v < args.value_min) return false;
170
193
  if (args.value_max != null && v > args.value_max) return false;
171
194
  }
195
+ if (hasGrade) {
196
+ if (_mcpMeasType(m) !== 'ordinal' || !Array.isArray(m.scale)) return false;
197
+ const up = m.scale.map(g => String(g).trim().toUpperCase());
198
+ const r = up.indexOf(String(m.value == null ? '' : m.value).trim().toUpperCase());
199
+ if (r < 0) return false;
200
+ if (args.min_grade) { const lo = up.indexOf(String(args.min_grade).trim().toUpperCase()); if (lo < 0 || r < lo) return false; }
201
+ if (args.max_grade) { const hi = up.indexOf(String(args.max_grade).trim().toUpperCase()); if (hi < 0 || r > hi) return false; }
202
+ }
203
+ if (args.value_equals && norm(String(m.value == null ? '' : m.value).trim()) !== norm(String(args.value_equals).trim())) return false;
172
204
  return true;
173
205
  });
174
206
  if (!found) return false;
@@ -359,13 +391,23 @@ const get_doe_matrix = {
359
391
 
360
392
  // 4) Aggregate measurements per column per row (mean of numeric scalar
361
393
  // values), reading only the column's own run condition when split.
394
+ const testOfM = new Map();
395
+ (db.testResults || []).forEach(t => (t.measurements || []).forEach(x => testOfM.set(x, t)));
362
396
  const cellParamValue = (r, col) => {
363
397
  const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
364
398
  if (!ms.length) return null;
365
399
  // The app's DOE Matrix numbers (12v): a curve's final value, a
366
- // distribution's D50, an ordinal's 1-based rank; text → its first reading.
400
+ // distribution's D50, a grade's place on its scale. 13r: a ranked scale
401
+ // is its LOWER-median grade (never an averaged rank); text its most
402
+ // frequent value, a tie to a failing one (it took the first reading); a
403
+ // distribution with no D50 its bins (it read "8 bins").
367
404
  const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
368
- if (!nums.length) return ms[0].value ?? null;
405
+ if (!nums.length) {
406
+ const rows = ms.map(m => { const t = testOfM.get(m); return { m, type: _mcpMeasType(m), value: m.value, numeric: null, rank: null, pf: _evalPassFail(m, t && t.templateId) }; });
407
+ if (rows.every(x => x.type === 'categorical')) return _mcpGradedPick(rows).value ?? null;
408
+ return _mcpCellValue(rows[0]);
409
+ }
410
+ if (ms.every(m => _mcpMeasType(m) === 'ordinal')) { const so = nums.slice().sort((a, b) => a - b); return so[Math.floor((so.length - 1) / 2)]; }
369
411
  const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
370
412
  return +mean.toFixed(4);
371
413
  };
@@ -462,7 +504,7 @@ function _mcpRepNumber(m, opts) {
462
504
  // app's Stability view draws it (FormLab 12w): one point per test date × run
463
505
  // condition — an ordinal date is the MEDIAN rank of its readings (rank =
464
506
  // 1-based position on the reading's own scale), a text date its value (or
465
- // "mixed: a / b") — failing when any reading fails; one series per condition.
507
+ // the most frequent value, a tie to a failing one) — failing when any reading fails; one series per condition.
466
508
  function _mcpGradedStability(entity, entityType, args, graded) {
467
509
  const nOrd = graded.filter(r => r.type === 'ordinal').length;
468
510
  const kind = nOrd >= graded.length - nOrd ? 'ordinal' : 'categorical';
@@ -478,8 +520,11 @@ function _mcpGradedStability(entity, entityType, args, graded) {
478
520
  rank = rs.length ? rs[Math.floor((rs.length - 1) / 2)] : null;
479
521
  label = rank != null && scale ? scale[rank - 1] : g[0].label;
480
522
  } else {
481
- const vals = [...new Set(g.map(r => r.label))];
482
- label = vals.length === 1 ? vals[0] : 'mixed: ' + vals.join(' / ');
523
+ // The most frequent value, a tie to a failing one (13r) — "mixed: a / b" said
524
+ // nothing about which reading counts. The chip's tooltip lists them all.
525
+ const k = r => String(r.label).toLowerCase(); const cnt = new Map(); g.forEach(r => cnt.set(k(r), (cnt.get(k(r)) || 0) + 1));
526
+ const top = Math.max(...cnt.values()); const tied = g.filter(r => cnt.get(k(r)) === top);
527
+ label = (tied.find(r => r.pf === 'fail') || tied[0]).label;
483
528
  }
484
529
  return { date: g[0].day, condition: split ? (g[0].cond || null) : null, label, ...(kind === 'ordinal' ? { rank } : {}),
485
530
  readings: g.length, values: g.map(r => r.label), reports: [...new Set(g.map(r => r.report))],
@@ -504,7 +549,7 @@ function _mcpGradedStability(entity, entityType, args, graded) {
504
549
  parameter: args.parameter, kind, ...(scale ? { scale } : {}), spec, byCondition: split, pointCount: pts.length, series,
505
550
  note: kind === 'ordinal'
506
551
  ? '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.'
507
- : '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.',
552
+ : 'A text (categorical) result has no number: each point is one test date per condition with its value (several readings that differ → the most frequent, a tie to a failing one; `values` lists them) and pass/fail against `spec.required`; pf "fail" when any reading failed. Narrate firstFail / latest.',
508
553
  };
509
554
  }
510
555
  function _mcpCondSig(m) {
@@ -812,7 +857,8 @@ function _evalPassFail(m, templateId, db) {
812
857
  if (type === 'ordinal') {
813
858
  if (!raw) return 'untested';
814
859
  const scale = Array.isArray(m.scale) ? m.scale : [];
815
- const idx = scale.indexOf(raw);
860
+ // Case-insensitive, as flMeasNum places a grade (13r): "hb" was judged untested but plotted at HB.
861
+ const idx = scale.map(g => String(g).trim().toUpperCase()).indexOf(raw.toUpperCase());
816
862
  if (idx < 0) return 'untested';
817
863
  const minRank = _ordinalMinRank(crit, scale);
818
864
  if (minRank == null) return 'no-criteria';
@@ -1511,8 +1557,39 @@ function _mcpCompareCell(item, parameter, agg, db) {
1511
1557
  if (!arms.has(sig)) arms.set(sig, { sig, m, t, rows: [] });
1512
1558
  arms.get(sig).rows.push({ m, t, date: _tcDate(t), value: v, pf: _evalPassFail(m, t.templateId, db) });
1513
1559
  });
1514
- // Nothing numeric (a ranked scale, a text result, a distribution with no D50):
1515
- // the latest reading, as typed.
1560
+ // A ranked scale or a text result (13r, as the app's Compare): per run
1561
+ // condition, no condition first, the latest test date — several readings on
1562
+ // it → the lower-median grade / the most frequent text (a tie to a failing
1563
+ // one), failing if any reading fails; two or more conditions → arms.
1564
+ if (!arms.size && (type === 'ordinal' || type === 'categorical')) {
1565
+ const g = new Map();
1566
+ hits.forEach(({ m, t }) => {
1567
+ const label = String(m.value == null ? '' : m.value).trim(); if (!label) return;
1568
+ const sig = _mcpCondSig(m), day = String(t.testDate || '').slice(0, 10);
1569
+ if (!g.has(sig)) g.set(sig, { sig, m, days: new Map() });
1570
+ const a = g.get(sig); if (!a.days.has(day)) a.days.set(day, []);
1571
+ a.days.get(day).push({ type: _mcpMeasType(m), value: label, rank: _mcpRepNumber(m, { ordinal: 'rank' }), pf: _evalPassFail(m, t.templateId, db), m });
1572
+ });
1573
+ const gl = [...g.values()].sort((x, y) => ((x.sig ? 1 : 0) - (y.sig ? 1 : 0)) || (y.days.size - x.days.size) || x.sig.localeCompare(y.sig));
1574
+ if (gl.length) {
1575
+ const cellOf = (a) => {
1576
+ const day = [...a.days.keys()].sort().pop(); const rs = a.days.get(day); const p = _mcpGradedPick(rs);
1577
+ const pf = rs.some(r => r.pf === 'fail') ? 'fail' : rs.some(r => r.pf === 'pass') ? 'pass' : rs[0].pf;
1578
+ const c = { type, value: p.value, pf, date: day, n: a.days.size, readings: [...a.days.values()].reduce((x, r) => x + r.length, 0),
1579
+ ...(rs.length > 1 ? { readingsOnDate: rs.map(r => r.value), basis: type === 'ordinal' ? 'middle grade of the date\'s readings' : 'most frequent of the date\'s readings' } : {}) };
1580
+ if (type === 'ordinal' && p.rank != null && Array.isArray(p.m.scale)) { c.rank = p.rank; c.scaleLength = p.m.scale.length; }
1581
+ return c;
1582
+ };
1583
+ const cells = gl.map(cellOf);
1584
+ if (agg !== 'latest' && hits.length > 1) cells.forEach(c => { c.note = 'not a number — the latest test date is shown'; });
1585
+ if (gl.length === 1) return { ...cells[0], ...(gl[0].sig ? { condition: _mcpCondLabel(gl[0].m) } : {}) };
1586
+ const tags = _mcpCondTags(gl.map(a => a.sig ? _mcpCondLabel(a.m) : ''));
1587
+ return { ...cells[0], splitByCondition: true,
1588
+ arms: gl.map((a, i) => ({ condition: a.sig ? _mcpCondLabel(a.m) : null, tag: tags[i], ...cells[i] })),
1589
+ note: 'measured under more than one run condition: one arm per condition, never mixed; the top-level value is the first arm (no run condition when there is one)' };
1590
+ }
1591
+ }
1592
+ // Nothing numeric (a distribution with no D50): the latest reading.
1516
1593
  if (!arms.size) {
1517
1594
  const { m, t } = hits[hits.length - 1];
1518
1595
  const out = { type, value: m.value ?? null, unit: m.unit || '', pf: _evalPassFail(m, t.templateId, db), date: t.testDate || '', readings: hits.length };