formlab-mcp 0.6.46 → 0.6.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.46",
3
+ "version": "0.6.47",
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.47",
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.47",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -359,13 +359,23 @@ const get_doe_matrix = {
359
359
 
360
360
  // 4) Aggregate measurements per column per row (mean of numeric scalar
361
361
  // values), reading only the column's own run condition when split.
362
+ const testOfM = new Map();
363
+ (db.testResults || []).forEach(t => (t.measurements || []).forEach(x => testOfM.set(x, t)));
362
364
  const cellParamValue = (r, col) => {
363
365
  const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
364
366
  if (!ms.length) return null;
365
367
  // 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.
368
+ // distribution's D50, a grade's place on its scale. 13r: a ranked scale
369
+ // is its LOWER-median grade (never an averaged rank); text its most
370
+ // frequent value, a tie to a failing one (it took the first reading); a
371
+ // distribution with no D50 its bins (it read "8 bins").
367
372
  const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
368
- if (!nums.length) return ms[0].value ?? null;
373
+ if (!nums.length) {
374
+ 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) }; });
375
+ if (rows.every(x => x.type === 'categorical')) return _mcpGradedPick(rows).value ?? null;
376
+ return _mcpCellValue(rows[0]);
377
+ }
378
+ 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
379
  const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
370
380
  return +mean.toFixed(4);
371
381
  };
@@ -462,7 +472,7 @@ function _mcpRepNumber(m, opts) {
462
472
  // app's Stability view draws it (FormLab 12w): one point per test date × run
463
473
  // condition — an ordinal date is the MEDIAN rank of its readings (rank =
464
474
  // 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.
475
+ // the most frequent value, a tie to a failing one) — failing when any reading fails; one series per condition.
466
476
  function _mcpGradedStability(entity, entityType, args, graded) {
467
477
  const nOrd = graded.filter(r => r.type === 'ordinal').length;
468
478
  const kind = nOrd >= graded.length - nOrd ? 'ordinal' : 'categorical';
@@ -478,8 +488,11 @@ function _mcpGradedStability(entity, entityType, args, graded) {
478
488
  rank = rs.length ? rs[Math.floor((rs.length - 1) / 2)] : null;
479
489
  label = rank != null && scale ? scale[rank - 1] : g[0].label;
480
490
  } else {
481
- const vals = [...new Set(g.map(r => r.label))];
482
- label = vals.length === 1 ? vals[0] : 'mixed: ' + vals.join(' / ');
491
+ // The most frequent value, a tie to a failing one (13r) — "mixed: a / b" said
492
+ // nothing about which reading counts. The chip's tooltip lists them all.
493
+ const k = r => String(r.label).toLowerCase(); const cnt = new Map(); g.forEach(r => cnt.set(k(r), (cnt.get(k(r)) || 0) + 1));
494
+ const top = Math.max(...cnt.values()); const tied = g.filter(r => cnt.get(k(r)) === top);
495
+ label = (tied.find(r => r.pf === 'fail') || tied[0]).label;
483
496
  }
484
497
  return { date: g[0].day, condition: split ? (g[0].cond || null) : null, label, ...(kind === 'ordinal' ? { rank } : {}),
485
498
  readings: g.length, values: g.map(r => r.label), reports: [...new Set(g.map(r => r.report))],
@@ -504,7 +517,7 @@ function _mcpGradedStability(entity, entityType, args, graded) {
504
517
  parameter: args.parameter, kind, ...(scale ? { scale } : {}), spec, byCondition: split, pointCount: pts.length, series,
505
518
  note: kind === 'ordinal'
506
519
  ? '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.',
520
+ : '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
521
  };
509
522
  }
510
523
  function _mcpCondSig(m) {
@@ -812,7 +825,8 @@ function _evalPassFail(m, templateId, db) {
812
825
  if (type === 'ordinal') {
813
826
  if (!raw) return 'untested';
814
827
  const scale = Array.isArray(m.scale) ? m.scale : [];
815
- const idx = scale.indexOf(raw);
828
+ // Case-insensitive, as flMeasNum places a grade (13r): "hb" was judged untested but plotted at HB.
829
+ const idx = scale.map(g => String(g).trim().toUpperCase()).indexOf(raw.toUpperCase());
816
830
  if (idx < 0) return 'untested';
817
831
  const minRank = _ordinalMinRank(crit, scale);
818
832
  if (minRank == null) return 'no-criteria';
@@ -1511,8 +1525,39 @@ function _mcpCompareCell(item, parameter, agg, db) {
1511
1525
  if (!arms.has(sig)) arms.set(sig, { sig, m, t, rows: [] });
1512
1526
  arms.get(sig).rows.push({ m, t, date: _tcDate(t), value: v, pf: _evalPassFail(m, t.templateId, db) });
1513
1527
  });
1514
- // Nothing numeric (a ranked scale, a text result, a distribution with no D50):
1515
- // the latest reading, as typed.
1528
+ // A ranked scale or a text result (13r, as the app's Compare): per run
1529
+ // condition, no condition first, the latest test date — several readings on
1530
+ // it → the lower-median grade / the most frequent text (a tie to a failing
1531
+ // one), failing if any reading fails; two or more conditions → arms.
1532
+ if (!arms.size && (type === 'ordinal' || type === 'categorical')) {
1533
+ const g = new Map();
1534
+ hits.forEach(({ m, t }) => {
1535
+ const label = String(m.value == null ? '' : m.value).trim(); if (!label) return;
1536
+ const sig = _mcpCondSig(m), day = String(t.testDate || '').slice(0, 10);
1537
+ if (!g.has(sig)) g.set(sig, { sig, m, days: new Map() });
1538
+ const a = g.get(sig); if (!a.days.has(day)) a.days.set(day, []);
1539
+ a.days.get(day).push({ type: _mcpMeasType(m), value: label, rank: _mcpRepNumber(m, { ordinal: 'rank' }), pf: _evalPassFail(m, t.templateId, db), m });
1540
+ });
1541
+ 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));
1542
+ if (gl.length) {
1543
+ const cellOf = (a) => {
1544
+ const day = [...a.days.keys()].sort().pop(); const rs = a.days.get(day); const p = _mcpGradedPick(rs);
1545
+ const pf = rs.some(r => r.pf === 'fail') ? 'fail' : rs.some(r => r.pf === 'pass') ? 'pass' : rs[0].pf;
1546
+ const c = { type, value: p.value, pf, date: day, n: a.days.size, readings: [...a.days.values()].reduce((x, r) => x + r.length, 0),
1547
+ ...(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' } : {}) };
1548
+ if (type === 'ordinal' && p.rank != null && Array.isArray(p.m.scale)) { c.rank = p.rank; c.scaleLength = p.m.scale.length; }
1549
+ return c;
1550
+ };
1551
+ const cells = gl.map(cellOf);
1552
+ if (agg !== 'latest' && hits.length > 1) cells.forEach(c => { c.note = 'not a number — the latest test date is shown'; });
1553
+ if (gl.length === 1) return { ...cells[0], ...(gl[0].sig ? { condition: _mcpCondLabel(gl[0].m) } : {}) };
1554
+ const tags = _mcpCondTags(gl.map(a => a.sig ? _mcpCondLabel(a.m) : ''));
1555
+ return { ...cells[0], splitByCondition: true,
1556
+ arms: gl.map((a, i) => ({ condition: a.sig ? _mcpCondLabel(a.m) : null, tag: tags[i], ...cells[i] })),
1557
+ 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)' };
1558
+ }
1559
+ }
1560
+ // Nothing numeric (a distribution with no D50): the latest reading.
1516
1561
  if (!arms.size) {
1517
1562
  const { m, t } = hits[hits.length - 1];
1518
1563
  const out = { type, value: m.value ?? null, unit: m.unit || '', pf: _evalPassFail(m, t.templateId, db), date: t.testDate || '', readings: hits.length };