formlab-mcp 0.6.41 → 0.6.43

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
@@ -46,9 +46,9 @@ Ask Claude (or another MCP client) things like:
46
46
  | `get_sample` | Full record + canonical variant + test reports + blend lineage |
47
47
  | `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
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
- | `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
+ | `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.41",
3
+ "version": "0.6.43",
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.41",
5
+ "version": "0.6.43",
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.41",
16
+ "version": "0.6.43",
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. 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, on a tie the one declared as the default on the Test Method / Test Panel); they are different responses, never averaged together. 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, on a tie the one declared as the default on the Test Method / Test Panel); they are different responses, never averaged together. Parameter columns come in the same order as the app: grouped by measurement type (numbers, curves, distributions, ranked scales, text) and in the test panel order within a group. 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: {
@@ -340,8 +340,18 @@ const get_doe_matrix = {
340
340
  const armNames = _mcpArmNames(rows.flatMap(r => r.measurements
341
341
  .filter(m => m && m.parameter && parameters.has(m.parameter))
342
342
  .map(m => ({ parameter: m.parameter, m }))));
343
+ // Parameter columns in the app's order (13h): grouped by measurement type
344
+ // (numbers, curves, distributions, ranked scales, text), the test panel's
345
+ // order within a group — the same order as compare_test_results.
346
+ const testOf = new Map();
347
+ (db.testResults || []).forEach(t => (t.measurements || []).forEach(x => testOf.set(x, t)));
348
+ const ordered = _mcpParamGroupsOf(rows.flatMap(r => r.measurements.filter(m => m && m.parameter && parameters.has(m.parameter)).map(m => ({ m, t: testOf.get(m) || null }))), 'method', db)
349
+ .flatMap(g => g.parameters);
350
+ const byTrim = new Map([...parameters].map(k => [String(k).trim(), k]));
351
+ const paramOrder = ordered.map(p => byTrim.get(p)).filter(k => k != null);
352
+ [...parameters].forEach(k => { if (!paramOrder.includes(k)) paramOrder.push(k); });
343
353
  const paramCols = [];
344
- [...parameters].forEach(p => {
354
+ paramOrder.forEach(p => {
345
355
  const arms = armNames.get(p);
346
356
  if (!arms) { paramCols.push({ id: p, cond: null, label: p }); return; }
347
357
  arms.forEach((label, sig) => paramCols.push({ id: p, cond: sig, label }));
@@ -592,6 +602,32 @@ function _mcpArmNames(items) {
592
602
  return out;
593
603
  }
594
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
+
595
631
  function _mcpFlattenReadings(tests, indexes, paramFilter) {
596
632
  const norm = (s) => String(s || '').toLowerCase();
597
633
  const groups = new Map(); const order = [];
@@ -603,7 +639,9 @@ function _mcpFlattenReadings(tests, indexes, paramFilter) {
603
639
  const parameter = String(m.parameter || '').trim();
604
640
  if (!parameter) continue;
605
641
  if (paramFilter && !norm(parameter).includes(norm(paramFilter))) continue;
606
- 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 };
607
645
  const key = (t.id || t.uid) + '|' + parameter + '|' + _mcpCondSig(m);
608
646
  if (!groups.has(key)) { groups.set(key, []); order.push(key); }
609
647
  groups.get(key).push(row);
@@ -612,9 +650,10 @@ function _mcpFlattenReadings(tests, indexes, paramFilter) {
612
650
  return order.map(k => {
613
651
  const g = groups.get(k);
614
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 };
615
655
  const nums = g.map(r => r.numeric).filter(v => v != null && isFinite(v));
616
656
  const mean = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
617
- const pf = g.some(r => r.pf === 'fail') ? 'fail' : g.some(r => r.pf === 'pass') ? 'pass' : g[0].pf;
618
657
  return { ...g[0], numeric: mean, value: mean != null ? mean : g[0].value, pf, replicates: g.length };
619
658
  });
620
659
  }
@@ -792,7 +831,7 @@ function _evalPassFail(m, templateId, db) {
792
831
  const get_coverage_matrix = {
793
832
  definition: {
794
833
  name: 'get_coverage_matrix',
795
- 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".',
796
835
  inputSchema: {
797
836
  type: 'object',
798
837
  properties: {
@@ -823,14 +862,24 @@ const get_coverage_matrix = {
823
862
  const rows = _mcpCoverageRows(flat, grain, indexes);
824
863
  const paramSet = new Set();
825
864
  rows.forEach(r => r.cells.forEach((_, p) => paramSet.add(p)));
826
- 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); });
827
876
  const sorted = [...rows.values()].sort((a, b) => String(a.label).localeCompare(String(b.label)));
828
877
  const grainLabel = grain === 'formulation' ? 'Formulation' : grain === 'batch' ? 'Batch' : 'Sample';
829
878
  if (fmt === 'json') {
830
879
  return {
831
880
  grain, rowCount: sorted.length, parameterCount: paramCols.length, parameters: paramCols,
832
881
  rule: 'cell = latest reading by test date; same-date reports averaged (any fail → fail); one column per run condition, never averaged across',
833
- 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)])) })),
834
883
  };
835
884
  }
836
885
  const esc = (v) => /[",\n\r]/.test(String(v ?? '')) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v ?? '');
@@ -885,8 +934,10 @@ function _mcpCoverageRows(flat, grain, indexes) {
885
934
  // an agent should read the number the user sees. 12 significant digits
886
935
  // keeps every real value.
887
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;
888
938
  row.cells.set(param, {
889
- 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 } : {}),
890
941
  unit: list[0].unit, pf: (pf === 'pass' || pf === 'fail') ? pf : null,
891
942
  latestDate: date || null, reportsAveraged: list.length, nReports: row.reports.get(param).size,
892
943
  });
@@ -1394,15 +1445,21 @@ const _MCP_PARAM_GROUPS = [
1394
1445
  ];
1395
1446
  const _mcpCmp = (a, b) => String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0);
1396
1447
  function _mcpParamGroups(items, sort, db) {
1448
+ const pairs = [];
1449
+ (items || []).forEach(it => (it.tests || []).forEach(t => (t.measurements || []).forEach(m => pairs.push({ m, t }))));
1450
+ return _mcpParamGroupsOf(pairs, sort, db);
1451
+ }
1452
+ // From [{ m, t }] pairs (t may be null) — get_doe_matrix holds flattened measurements (13h).
1453
+ function _mcpParamGroupsOf(pairs, sort, db) {
1397
1454
  const sortAz = sort === 'az';
1398
1455
  const rank = new Map(_MCP_PARAM_GROUPS.map(([t], i) => [t, i]));
1399
1456
  const best = new Map(), panels = new Map(), onPanel = new Map();
1400
- (items || []).forEach(it => (it.tests || []).forEach(t => (t.measurements || []).forEach(m => {
1457
+ (pairs || []).forEach(({ m, t }) => {
1401
1458
  const p = String((m && m.parameter) || '').trim();
1402
1459
  if (!p) return;
1403
1460
  const k = rank.get(_mcpMeasType(m)) ?? 0;
1404
1461
  if (!best.has(p) || k < best.get(p)) best.set(p, k);
1405
- if (sortAz || !t.templateId) return;
1462
+ if (sortAz || !t || !t.templateId) return;
1406
1463
  if (!panels.has(t.templateId)) panels.set(t.templateId, (db.templates || []).find(x => x && x.id === t.templateId) || null);
1407
1464
  const panel = panels.get(t.templateId);
1408
1465
  const params = (panel && panel.parameters) || [];
@@ -1411,7 +1468,7 @@ function _mcpParamGroups(items, sort, db) {
1411
1468
  const at = [String(panel.name || ''), i];
1412
1469
  const cur = onPanel.get(p);
1413
1470
  if (!cur || _mcpCmp(at[0], cur[0]) < 0 || (at[0] === cur[0] && i < cur[1])) onPanel.set(p, at);
1414
- })));
1471
+ });
1415
1472
  const byMethod = (a, b) => {
1416
1473
  const x = onPanel.get(a), y = onPanel.get(b);
1417
1474
  if (x && y) return _mcpCmp(x[0], y[0]) || (x[1] - y[1]) || _mcpCmp(a, b);