formlab-mcp 0.6.42 → 0.6.44

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
@@ -48,7 +48,7 @@ Ask Claude (or another MCP client) things like:
48
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 |
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
- | `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
+ | `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) |
52
52
  | `compare_test_results` | **Test results** side by side across 2+ formulas, batches, samples or reports — the app's Compare Results table. Parameters grouped by measurement type (Numbers, Curves, Distributions, Ranked scales, Text results) and, within a group, in the test panel's order (`order: method`, default) or `az`. One cell per item × parameter: `aggregation` `latest` (default; one value per test date, replicates averaged ± sd), `mean` (± sd between dates), `median` (min–max) or `trend` (change from the first date). Curves compare at their final point, distributions at D50; ranked scales and text show the latest reading (with its rank); a parameter measured under 2+ run conditions is split into `arms`, never averaged. `differs` flags a >5% spread across items, as the app highlights |
53
53
  | `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` |
54
54
  | `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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.42",
3
+ "version": "0.6.44",
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.42",
5
+ "version": "0.6.44",
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.42",
16
+ "version": "0.6.44",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -602,6 +602,32 @@ function _mcpArmNames(items) {
602
602
  return out;
603
603
  }
604
604
 
605
+ // Ranked scales and text results (13i, mirrors the app's _taGradedPick): the
606
+ // reading that stands for several — a ranked scale's LOWER median by position
607
+ // on the scale (never an average of ranks), a text result's most frequent
608
+ // value with a tie going to a failing one. It used to be the first one read.
609
+ const _mcpGraded = (r) => !!r && (r.type === 'ordinal' || r.type === 'categorical');
610
+ function _mcpGradedPick(list) {
611
+ if (list[0].type === 'ordinal') {
612
+ const ranked = list.filter(r => r.rank != null).sort((a, b) => a.rank - b.rank);
613
+ return ranked.length ? ranked[Math.floor((ranked.length - 1) / 2)] : list[list.length - 1];
614
+ }
615
+ const key = (r) => String(r.value == null ? '' : r.value).trim().toLowerCase();
616
+ const cnt = new Map(); list.forEach(r => cnt.set(key(r), (cnt.get(key(r)) || 0) + 1));
617
+ const top = Math.max(...cnt.values());
618
+ const tied = list.filter(r => cnt.get(key(r)) === top);
619
+ return tied.find(r => r.pf === 'fail') || tied[0];
620
+ }
621
+ // A cell's value when it has no number: a distribution with no D50 shows its
622
+ // bins (it showed "3 bins", the count); otherwise the reading as recorded.
623
+ function _mcpCellValue(r) {
624
+ if (r.numeric != null) return r.numeric;
625
+ const m = r.m;
626
+ if (r.type === 'distribution' && m && Array.isArray(m.bins) && m.bins.length)
627
+ return m.bins.map(b => `${b.label} ${b.value}`).join(' · ');
628
+ return r.value;
629
+ }
630
+
605
631
  function _mcpFlattenReadings(tests, indexes, paramFilter) {
606
632
  const norm = (s) => String(s || '').toLowerCase();
607
633
  const groups = new Map(); const order = [];
@@ -613,7 +639,9 @@ function _mcpFlattenReadings(tests, indexes, paramFilter) {
613
639
  const parameter = String(m.parameter || '').trim();
614
640
  if (!parameter) continue;
615
641
  if (paramFilter && !norm(parameter).includes(norm(paramFilter))) continue;
616
- const row = { test: t, sample: samp, formulation, parameter, numeric: _mcpRepNumber(m), value: m.value, unit: m.unit || '', pf: _evalPassFail(m, t.templateId), m };
642
+ const type = _mcpMeasType(m);
643
+ const row = { test: t, sample: samp, formulation, parameter, numeric: _mcpRepNumber(m), value: m.value, unit: m.unit || '', pf: _evalPassFail(m, t.templateId), m,
644
+ type, rank: type === 'ordinal' ? _mcpRepNumber(m, { ordinal: 'rank' }) : null };
617
645
  const key = (t.id || t.uid) + '|' + parameter + '|' + _mcpCondSig(m);
618
646
  if (!groups.has(key)) { groups.set(key, []); order.push(key); }
619
647
  groups.get(key).push(row);
@@ -622,9 +650,10 @@ function _mcpFlattenReadings(tests, indexes, paramFilter) {
622
650
  return order.map(k => {
623
651
  const g = groups.get(k);
624
652
  if (g.length === 1) return g[0];
653
+ const pf = g.some(r => r.pf === 'fail') ? 'fail' : g.some(r => r.pf === 'pass') ? 'pass' : g[0].pf;
654
+ if (_mcpGraded(g[0])) return { ..._mcpGradedPick(g), pf, replicates: g.length };
625
655
  const nums = g.map(r => r.numeric).filter(v => v != null && isFinite(v));
626
656
  const mean = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
627
- const pf = g.some(r => r.pf === 'fail') ? 'fail' : g.some(r => r.pf === 'pass') ? 'pass' : g[0].pf;
628
657
  return { ...g[0], numeric: mean, value: mean != null ? mean : g[0].value, pf, replicates: g.length };
629
658
  });
630
659
  }
@@ -802,7 +831,7 @@ function _evalPassFail(m, templateId, db) {
802
831
  const get_coverage_matrix = {
803
832
  definition: {
804
833
  name: 'get_coverage_matrix',
805
- 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".',
834
+ 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). A ranked scale shows its median grade when several readings share the date (with its rank on the scale), a text result its most frequent value, a distribution with no D50 its bins. Columns come in the same order as the app: grouped by measurement type, the test panel order within a group. Empty = never tested. Use for "what\'s untested", "what\'s the current value" and "what\'s been measured many times".',
806
835
  inputSchema: {
807
836
  type: 'object',
808
837
  properties: {
@@ -833,14 +862,24 @@ const get_coverage_matrix = {
833
862
  const rows = _mcpCoverageRows(flat, grain, indexes);
834
863
  const paramSet = new Set();
835
864
  rows.forEach(r => r.cells.forEach((_, p) => paramSet.add(p)));
836
- const paramCols = [...paramSet].sort();
865
+ // The app's column order (13i): grouped by measurement type, the test
866
+ // panel's order within a group, each run condition right after its
867
+ // parameter. It was A–Z, case-sensitive (pH after Viscosity).
868
+ const armOf = new Map();
869
+ _mcpArmNames(flat).forEach((names, param) => names.forEach(nm => { if (nm !== param) armOf.set(nm, param); }));
870
+ const paramCols = [];
871
+ _mcpParamGroupsOf(flat.map(r => ({ m: r.m, t: r.test })), 'method', db).flatMap(g => g.parameters).forEach(b => {
872
+ if (paramSet.has(b)) paramCols.push(b);
873
+ [...paramSet].filter(p => armOf.get(p) === b).sort((x, y) => x.localeCompare(y)).forEach(p => paramCols.push(p));
874
+ });
875
+ [...paramSet].forEach(p => { if (!paramCols.includes(p)) paramCols.push(p); });
837
876
  const sorted = [...rows.values()].sort((a, b) => String(a.label).localeCompare(String(b.label)));
838
877
  const grainLabel = grain === 'formulation' ? 'Formulation' : grain === 'batch' ? 'Batch' : 'Sample';
839
878
  if (fmt === 'json') {
840
879
  return {
841
880
  grain, rowCount: sorted.length, parameterCount: paramCols.length, parameters: paramCols,
842
881
  rule: 'cell = latest reading by test date; same-date reports averaged (any fail → fail); one column per run condition, never averaged across',
843
- rows: sorted.map(r => ({ [grain]: r.label, id: r.uid, cells: Object.fromEntries([...r.cells.entries()].sort()) })),
882
+ rows: sorted.map(r => ({ [grain]: r.label, id: r.uid, cells: Object.fromEntries(paramCols.filter(p => r.cells.has(p)).map(p => [p, r.cells.get(p)])) })),
844
883
  };
845
884
  }
846
885
  const esc = (v) => /[",\n\r]/.test(String(v ?? '')) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v ?? '');
@@ -895,8 +934,10 @@ function _mcpCoverageRows(flat, grain, indexes) {
895
934
  // an agent should read the number the user sees. 12 significant digits
896
935
  // keeps every real value.
897
936
  const tidy = (v) => (typeof v === 'number' && isFinite(v)) ? Number(v.toPrecision(12)) : v;
937
+ const pick = list.length === 1 ? list[0] : _mcpGraded(list[0]) ? _mcpGradedPick(list) : null;
898
938
  row.cells.set(param, {
899
- value: tidy(list.length === 1 ? (list[0].numeric != null ? list[0].numeric : list[0].value) : (mean != null ? mean : list[0].value)),
939
+ value: tidy(pick ? _mcpCellValue(pick) : (mean != null ? mean : _mcpCellValue(list[0]))),
940
+ ...(pick && pick.type === 'ordinal' && pick.rank != null ? { rank: pick.rank } : {}),
900
941
  unit: list[0].unit, pf: (pf === 'pass' || pf === 'fail') ? pf : null,
901
942
  latestDate: date || null, reportsAveraged: list.length, nReports: row.reports.get(param).size,
902
943
  });
@@ -1480,8 +1521,8 @@ function _mcpCompareCell(item, parameter, agg, db) {
1480
1521
  });
1481
1522
  return { sig: a.sig, m: a.m, t: points[points.length - 1].t, latestM: points[points.length - 1].m, points, readings: a.rows.length };
1482
1523
  });
1483
- // Primary first: the longest record, then the declared default condition, then the most recent, then by name.
1484
- series.sort((a, b) => (b.points.length - a.points.length)
1524
+ // Primary first: no run condition when there is one (13n), then the longest record, the declared default condition, the most recent, then by name.
1525
+ series.sort((a, b) => ((a.sig ? 1 : 0) - (b.sig ? 1 : 0)) || (b.points.length - a.points.length)
1485
1526
  || (_mcpCondDefaultScore(b.latestM, b.t, db) - _mcpCondDefaultScore(a.latestM, a.t, db))
1486
1527
  || (b.points[b.points.length - 1].date - a.points[a.points.length - 1].date) || _mcpCmp(a.sig, b.sig));
1487
1528
  const basis = type === 'time_series' ? 'final' : type === 'distribution' ? 'D50' : undefined;
@@ -1509,7 +1550,7 @@ function _mcpCompareCell(item, parameter, agg, db) {
1509
1550
  const tags = _mcpCondTags(series.map(s => s.sig ? _mcpCondLabel(s.m) : ''));
1510
1551
  return { type, ...cells[0], splitByCondition: true,
1511
1552
  arms: series.map((s, i) => ({ condition: s.sig ? _mcpCondLabel(s.m) : null, tag: tags[i], ...cells[i] })),
1512
- note: 'measured under more than one run condition: one arm per condition, never averaged; the top-level value is the first arm (the longest record)' };
1553
+ note: 'measured under more than one run condition: one arm per condition, never averaged; the top-level value is the first arm (no run condition when there is one, else the longest record)' };
1513
1554
  }
1514
1555
 
1515
1556
  // The app's difference highlight (_tcKeysDiffer): numbers differ when their