formlab-mcp 0.6.42 → 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 +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +47 -6
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.
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
});
|