formlab-mcp 0.6.32 → 0.6.33
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 +86 -11
package/README.md
CHANGED
|
@@ -45,9 +45,9 @@ Ask Claude (or another MCP client) things like:
|
|
|
45
45
|
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
46
46
|
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
|
|
47
47
|
| `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 |
|
|
48
|
-
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
48
|
+
| `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
49
|
| `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 |
|
|
50
|
-
| `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 — same rollup as the app's Test Analytics → Coverage |
|
|
50
|
+
| `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
51
|
| `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` |
|
|
52
52
|
| `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 |
|
|
53
53
|
| `get_project_pivot` | Portfolio analytics — aggregate projects by `group_by` (status / phase / priority / business_unit / site / customer / lead, or `cf:<custom field>`) × `metric` (count, total_formulas / batches / samples, total_cost, avg_formulas / batches / cost_per_batch), rolling child activity + cost up by any project attribute. Optional `project_uids` scope |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.33",
|
|
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.33",
|
|
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.33",
|
|
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. 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); they are different responses, never averaged together. 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: {
|
|
@@ -335,11 +335,22 @@ const get_doe_matrix = {
|
|
|
335
335
|
const ing = indexes.ingredientsById.get(id);
|
|
336
336
|
return { id, label: (ing?.name || id) + ' (%)' };
|
|
337
337
|
});
|
|
338
|
-
|
|
338
|
+
// One column per parameter, or per (parameter, run condition) when it was
|
|
339
|
+
// measured under two or more conditions — see _mcpArmNames.
|
|
340
|
+
const armNames = _mcpArmNames(rows.flatMap(r => r.measurements
|
|
341
|
+
.filter(m => m && m.parameter && parameters.has(m.parameter))
|
|
342
|
+
.map(m => ({ parameter: m.parameter, m }))));
|
|
343
|
+
const paramCols = [];
|
|
344
|
+
[...parameters].forEach(p => {
|
|
345
|
+
const arms = armNames.get(p);
|
|
346
|
+
if (!arms) { paramCols.push({ id: p, cond: null, label: p }); return; }
|
|
347
|
+
arms.forEach((label, sig) => paramCols.push({ id: p, cond: sig, label }));
|
|
348
|
+
});
|
|
339
349
|
|
|
340
|
-
// 4) Aggregate measurements per
|
|
341
|
-
|
|
342
|
-
|
|
350
|
+
// 4) Aggregate measurements per column per row (mean of numeric scalar
|
|
351
|
+
// values), reading only the column's own run condition when split.
|
|
352
|
+
const cellParamValue = (r, col) => {
|
|
353
|
+
const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
|
|
343
354
|
if (!ms.length) return null;
|
|
344
355
|
const nums = ms.map(m => parseFloat(m.value)).filter(v => isFinite(v));
|
|
345
356
|
if (!nums.length) return ms[0].value ?? null;
|
|
@@ -360,7 +371,7 @@ const get_doe_matrix = {
|
|
|
360
371
|
rows: limitedRows.map(r => ({
|
|
361
372
|
label: r.label,
|
|
362
373
|
ingredientPct: Object.fromEntries(ingCols.map(c => [c.label, r.composition.get(c.id) ?? null])),
|
|
363
|
-
parameterMean: Object.fromEntries(paramCols.map(c => [c.label, cellParamValue(r, c
|
|
374
|
+
parameterMean: Object.fromEntries(paramCols.map(c => [c.label, cellParamValue(r, c)])),
|
|
364
375
|
})),
|
|
365
376
|
};
|
|
366
377
|
}
|
|
@@ -377,7 +388,7 @@ const get_doe_matrix = {
|
|
|
377
388
|
const cells = [
|
|
378
389
|
r.label,
|
|
379
390
|
...ingCols.map(c => r.composition.get(c.id) ?? ''),
|
|
380
|
-
...paramCols.map(c => cellParamValue(r, c
|
|
391
|
+
...paramCols.map(c => cellParamValue(r, c) ?? ''),
|
|
381
392
|
];
|
|
382
393
|
lines.push(cells.map(escCSV).join(','));
|
|
383
394
|
});
|
|
@@ -414,6 +425,57 @@ function _mcpCondSig(m) {
|
|
|
414
425
|
if (!c || typeof c !== 'object') return '';
|
|
415
426
|
return Object.keys(c).filter(k => c[k] != null && c[k] !== '').sort().map(k => `${k}=${c[k]}`).join('|');
|
|
416
427
|
}
|
|
428
|
+
// ---- Run-condition ARMS (mirror the app, FormLab 1.16.18 build 24h–24i) ----
|
|
429
|
+
// A parameter measured under two or more run conditions (storage 25 °C vs
|
|
430
|
+
// 40 °C, first vs second heat) is one column per condition in the DOE Matrix
|
|
431
|
+
// and the Coverage matrix: an ambient arm and an accelerated one are different
|
|
432
|
+
// responses, never averaged. The bare parameter name keeps the BASE arm — the
|
|
433
|
+
// readings with no condition when there are any, otherwise the arm with the
|
|
434
|
+
// most readings (the arm a bare `pH` means in the app's search) — and the
|
|
435
|
+
// others read "pH · 40 °C". Same rule and the same tag shortening as the app
|
|
436
|
+
// (js/utils.js flCondTags), so column names match what the user sees.
|
|
437
|
+
function _mcpCondLabel(m) {
|
|
438
|
+
const c = m && m.conditions;
|
|
439
|
+
if (!c || typeof c !== 'object') return '';
|
|
440
|
+
return Object.entries(c).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k} ${v}`).join(' · ');
|
|
441
|
+
}
|
|
442
|
+
function _mcpCondTags(conds) {
|
|
443
|
+
const named = conds.filter(Boolean);
|
|
444
|
+
let pre = '';
|
|
445
|
+
if (named.length > 1) {
|
|
446
|
+
const first = named[0];
|
|
447
|
+
let i = 0;
|
|
448
|
+
while (i < first.length && named.every(c => c[i] === first[i])) i++;
|
|
449
|
+
pre = first.slice(0, i);
|
|
450
|
+
pre = pre.slice(0, pre.lastIndexOf(' ') + 1);
|
|
451
|
+
}
|
|
452
|
+
const tags = conds.map(c => c ? (c.slice(pre.length) || c) : 'no condition');
|
|
453
|
+
const lead = tags.map(t => t.split(/\s+[\/·]\s+/)[0]);
|
|
454
|
+
return (new Set(lead).size === lead.length) ? lead : tags;
|
|
455
|
+
}
|
|
456
|
+
// items: [{ parameter, m }] → Map(parameter → Map(condSig → column name)),
|
|
457
|
+
// only for parameters with two or more arms.
|
|
458
|
+
function _mcpArmNames(items) {
|
|
459
|
+
const arms = new Map();
|
|
460
|
+
for (const it of items) {
|
|
461
|
+
const sig = _mcpCondSig(it.m);
|
|
462
|
+
if (!arms.has(it.parameter)) arms.set(it.parameter, new Map());
|
|
463
|
+
const a = arms.get(it.parameter);
|
|
464
|
+
const cur = a.get(sig) || { n: 0, m: it.m };
|
|
465
|
+
cur.n++; a.set(sig, cur);
|
|
466
|
+
}
|
|
467
|
+
const out = new Map();
|
|
468
|
+
arms.forEach((a, param) => {
|
|
469
|
+
if (a.size < 2) return;
|
|
470
|
+
const keys = [...a.keys()];
|
|
471
|
+
const base = a.has('') ? '' : keys.slice().sort((x, y) => (a.get(y).n - a.get(x).n) || x.localeCompare(y))[0];
|
|
472
|
+
const ordered = [base, ...keys.filter(k => k !== base).sort((x, y) => x.localeCompare(y))];
|
|
473
|
+
const tags = _mcpCondTags(ordered.map(k => k ? _mcpCondLabel(a.get(k).m) : ''));
|
|
474
|
+
out.set(param, new Map(ordered.map((k, i) => [k, i === 0 ? param : `${param} · ${tags[i]}`])));
|
|
475
|
+
});
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
417
479
|
function _mcpFlattenReadings(tests, indexes, paramFilter) {
|
|
418
480
|
const norm = (s) => String(s || '').toLowerCase();
|
|
419
481
|
const groups = new Map(); const order = [];
|
|
@@ -613,7 +675,7 @@ function _evalPassFail(m, templateId, db) {
|
|
|
613
675
|
const get_coverage_matrix = {
|
|
614
676
|
definition: {
|
|
615
677
|
name: 'get_coverage_matrix',
|
|
616
|
-
description: 'Coverage matrix matching the app\'s Test Analytics → Coverage: rows = sample, batch or formulation (grain), columns = parameters. 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".',
|
|
678
|
+
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".',
|
|
617
679
|
inputSchema: {
|
|
618
680
|
type: 'object',
|
|
619
681
|
properties: {
|
|
@@ -650,7 +712,7 @@ const get_coverage_matrix = {
|
|
|
650
712
|
if (fmt === 'json') {
|
|
651
713
|
return {
|
|
652
714
|
grain, rowCount: sorted.length, parameterCount: paramCols.length, parameters: paramCols,
|
|
653
|
-
rule: 'cell = latest reading by test date; same-date reports averaged (any fail → fail)',
|
|
715
|
+
rule: 'cell = latest reading by test date; same-date reports averaged (any fail → fail); one column per run condition, never averaged across',
|
|
654
716
|
rows: sorted.map(r => ({ [grain]: r.label, id: r.uid, cells: Object.fromEntries([...r.cells.entries()].sort()) })),
|
|
655
717
|
};
|
|
656
718
|
}
|
|
@@ -671,6 +733,15 @@ const get_coverage_matrix = {
|
|
|
671
733
|
|
|
672
734
|
// Latest-date rollup (mirrors the app's _taCoverageRows).
|
|
673
735
|
function _mcpCoverageRows(flat, grain, indexes) {
|
|
736
|
+
// One column per run condition first (see _mcpArmNames), so "latest" never
|
|
737
|
+
// means whichever storage arm was pulled last and a shared date never
|
|
738
|
+
// averages ambient with accelerated.
|
|
739
|
+
const armNames = _mcpArmNames(flat);
|
|
740
|
+
if (armNames.size) flat = flat.map(r => {
|
|
741
|
+
const map = armNames.get(r.parameter);
|
|
742
|
+
const nm = map && map.get(_mcpCondSig(r.m));
|
|
743
|
+
return (!nm || nm === r.parameter) ? r : { ...r, parameter: nm };
|
|
744
|
+
});
|
|
674
745
|
const rows = new Map();
|
|
675
746
|
for (const r of flat) {
|
|
676
747
|
let rec = null, label = '';
|
|
@@ -693,8 +764,12 @@ function _mcpCoverageRows(flat, grain, indexes) {
|
|
|
693
764
|
const nums = list.map(x => x.numeric).filter(v => v != null && isFinite(v));
|
|
694
765
|
const mean = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
|
|
695
766
|
const pf = list.some(x => x.pf === 'fail') ? 'fail' : list.some(x => x.pf === 'pass') ? 'pass' : list[0].pf;
|
|
767
|
+
// Replicate means carry float dust ((5.9 + 5.9 + 5.9) / 3 = 5.900000000000001);
|
|
768
|
+
// an agent should read the number the user sees. 12 significant digits
|
|
769
|
+
// keeps every real value.
|
|
770
|
+
const tidy = (v) => (typeof v === 'number' && isFinite(v)) ? Number(v.toPrecision(12)) : v;
|
|
696
771
|
row.cells.set(param, {
|
|
697
|
-
value: list.length === 1 ? (list[0].numeric != null ? list[0].numeric : list[0].value) : (mean != null ? mean : list[0].value),
|
|
772
|
+
value: tidy(list.length === 1 ? (list[0].numeric != null ? list[0].numeric : list[0].value) : (mean != null ? mean : list[0].value)),
|
|
698
773
|
unit: list[0].unit, pf: (pf === 'pass' || pf === 'fail') ? pf : null,
|
|
699
774
|
latestDate: date || null, reportsAveraged: list.length, nReports: row.reports.get(param).size,
|
|
700
775
|
});
|
|
@@ -1154,7 +1229,7 @@ const get_stability = {
|
|
|
1154
1229
|
},
|
|
1155
1230
|
};
|
|
1156
1231
|
|
|
1157
|
-
export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess };
|
|
1232
|
+
export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames };
|
|
1158
1233
|
export const tools = {
|
|
1159
1234
|
list_test_results,
|
|
1160
1235
|
get_test_result,
|