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 +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +68 -11
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.
|
|
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.
|
|
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.
|
|
16
|
+
"version": "0.6.43",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
(
|
|
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);
|