formlab-mcp 0.6.33 → 0.6.35
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 +62 -9
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
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 |
|
|
54
|
-
| `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), split by storage condition |
|
|
54
|
+
| `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), all on **one point per test date per run condition** (replicates averaged, never separate time points), split by storage condition |
|
|
55
55
|
| `get_design_space` | COMPOSITION design space for one project: which ingredients vary and over what observed wt-% ranges (ranked), an occupied-region summary ("where you've been"), the biggest untested **interior gap** as ready-to-seed DOE ranges (a combination you could have made but skipped), and an optional standardised **2-component PCA** of all varying ingredients — loadings, variance-explained, scree, and a full-dimensional gap. Matches the in-app Design Space Viewer exactly. Answers *"where haven't I explored?"* / *"what should I formulate next?"* |
|
|
56
56
|
| `list_doe_designs` | Saved DOE designs — the *recipe* behind each batch of runs: design type, factors + ranges, constraints, run count, D-efficiency. Filters: `project`, `design_type`, `constrained_only`, `name_contains` |
|
|
57
57
|
| `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.35",
|
|
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.35",
|
|
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.35",
|
|
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); 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. 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: {
|
|
@@ -453,7 +453,34 @@ function _mcpCondTags(conds) {
|
|
|
453
453
|
const lead = tags.map(t => t.split(/\s+[\/·]\s+/)[0]);
|
|
454
454
|
return (new Set(lead).size === lead.length) ? lead : tags;
|
|
455
455
|
}
|
|
456
|
-
//
|
|
456
|
+
// Is this reading taken under the DEFAULT run condition its Test Method or
|
|
457
|
+
// Test Panel declares? Port of the app's flCondDefaultScore (js/utils.js):
|
|
458
|
+
// the tiebreak for the primary arm when two arms have equally long records,
|
|
459
|
+
// instead of the condition's name ("25 °C" sorting before "40 °C").
|
|
460
|
+
function _mcpCondDefaultScore(m, t, db) {
|
|
461
|
+
const c = m && m.conditions;
|
|
462
|
+
if (!c || typeof c !== 'object') return 0;
|
|
463
|
+
const decls = [];
|
|
464
|
+
const ps = db.parameters || [];
|
|
465
|
+
const nm = String(m.parameter || '').trim().toLowerCase();
|
|
466
|
+
const method = (m.parameterId && ps.find(p => p && p.id === m.parameterId)) || (nm ? ps.find(p => p && String(p.name || '').trim().toLowerCase() === nm) : null);
|
|
467
|
+
if (method && Array.isArray(method.conditions)) decls.push(...method.conditions);
|
|
468
|
+
const tmpl = t && t.templateId ? (db.templates || []).find(x => x && x.id === t.templateId) : null;
|
|
469
|
+
if (tmpl && Array.isArray(tmpl.conditions)) decls.push(...tmpl.conditions);
|
|
470
|
+
const lc = {};
|
|
471
|
+
Object.keys(c).forEach(k => { lc[k.toLowerCase()] = c[k]; });
|
|
472
|
+
let matched = 0;
|
|
473
|
+
for (const d of decls) {
|
|
474
|
+
if (!d || !d.key || d.default == null || d.default === '') continue;
|
|
475
|
+
const v = lc[String(d.key).toLowerCase()];
|
|
476
|
+
if (v == null || v === '') continue;
|
|
477
|
+
if (String(v).trim() !== String(d.default).trim()) return 0;
|
|
478
|
+
matched++;
|
|
479
|
+
}
|
|
480
|
+
return matched ? 1 : 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// items: [{ parameter, m, test? }] → Map(parameter → Map(condSig → column name)),
|
|
457
484
|
// only for parameters with two or more arms.
|
|
458
485
|
function _mcpArmNames(items) {
|
|
459
486
|
const arms = new Map();
|
|
@@ -461,14 +488,26 @@ function _mcpArmNames(items) {
|
|
|
461
488
|
const sig = _mcpCondSig(it.m);
|
|
462
489
|
if (!arms.has(it.parameter)) arms.set(it.parameter, new Map());
|
|
463
490
|
const a = arms.get(it.parameter);
|
|
464
|
-
const cur = a.get(sig) || { n: 0, m: it.m };
|
|
491
|
+
const cur = a.get(sig) || { n: 0, m: it.m, t: it.test || null };
|
|
465
492
|
cur.n++; a.set(sig, cur);
|
|
466
493
|
}
|
|
467
494
|
const out = new Map();
|
|
495
|
+
let testOf = null; // measurement → its report, built only if a tie needs a panel default
|
|
496
|
+
const score = (arm) => {
|
|
497
|
+
const { db } = getStore();
|
|
498
|
+
let t = arm.t;
|
|
499
|
+
if (!t) {
|
|
500
|
+
if (!testOf) { testOf = new Map(); (db.testResults || []).forEach(r => (r.measurements || []).forEach(x => testOf.set(x, r))); }
|
|
501
|
+
t = testOf.get(arm.m) || null;
|
|
502
|
+
}
|
|
503
|
+
return _mcpCondDefaultScore(arm.m, t, db);
|
|
504
|
+
};
|
|
468
505
|
arms.forEach((a, param) => {
|
|
469
506
|
if (a.size < 2) return;
|
|
470
507
|
const keys = [...a.keys()];
|
|
471
|
-
|
|
508
|
+
// No condition wins outright; among conditioned arms, most readings, then
|
|
509
|
+
// the declared default condition, then by name — as in the app.
|
|
510
|
+
const base = a.has('') ? '' : keys.slice().sort((x, y) => (a.get(y).n - a.get(x).n) || (score(a.get(y)) - score(a.get(x))) || x.localeCompare(y))[0];
|
|
472
511
|
const ordered = [base, ...keys.filter(k => k !== base).sort((x, y) => x.localeCompare(y))];
|
|
473
512
|
const tags = _mcpCondTags(ordered.map(k => k ? _mcpCondLabel(a.get(k).m) : ''));
|
|
474
513
|
out.set(param, new Map(ordered.map((k, i) => [k, i === 0 ? param : `${param} · ${tags[i]}`])));
|
|
@@ -1088,7 +1127,7 @@ function _shelfLifeAssess(sl, targetMonths, now) {
|
|
|
1088
1127
|
const get_stability = {
|
|
1089
1128
|
definition: {
|
|
1090
1129
|
name: 'get_stability',
|
|
1091
|
-
description: 'Grounded stability / shelf-life analysis for one entity × parameter — the deterministic numbers behind the Stability & Trends view. Pass a formulation (pools all its samples), a sample (that sample over time), or a batch (pools its samples) by id or UID, plus a parameter name. Returns the time series, drift (per-month slope + R²), an individuals control chart (mean ± 3σ + out-of-control points), spec status, and an ICH-flavored projected shelf life (linear extrapolation to the spec crossing: point estimate + the conservative 95%-confidence-bound crossing). When measurements carry run conditions (e.g. Storage 25 °C vs 40 °C) it splits into one series per condition — a true accelerated-vs-ambient comparison. Use these figures directly; do not re-derive the regression.',
|
|
1130
|
+
description: 'Grounded stability / shelf-life analysis for one entity × parameter — the deterministic numbers behind the Stability & Trends view. Pass a formulation (pools all its samples), a sample (that sample over time), or a batch (pools its samples) by id or UID, plus a parameter name. Returns the time series, drift (per-month slope + R²), an individuals control chart (mean ± 3σ + out-of-control points), spec status, and an ICH-flavored projected shelf life (linear extrapolation to the spec crossing: point estimate + the conservative 95%-confidence-bound crossing). When measurements carry run conditions (e.g. Storage 25 °C vs 40 °C) it splits into one series per condition — a true accelerated-vs-ambient comparison. A point is one TEST DATE: every reading taken that day under that condition is averaged (points = test dates, readings = the raw count), so replicates never count as separate time points in the drift, control chart or shelf-life fit — the same points as the app. Use these figures directly; do not re-derive the regression.',
|
|
1092
1131
|
inputSchema: {
|
|
1093
1132
|
type: 'object',
|
|
1094
1133
|
properties: {
|
|
@@ -1143,7 +1182,7 @@ const get_stability = {
|
|
|
1143
1182
|
}
|
|
1144
1183
|
const cond = (m.conditions && Object.keys(m.conditions).length)
|
|
1145
1184
|
? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '';
|
|
1146
|
-
points.push({ date, value, cond, spec: sp });
|
|
1185
|
+
points.push({ date, day: dStr, value, cond, spec: sp });
|
|
1147
1186
|
});
|
|
1148
1187
|
});
|
|
1149
1188
|
const specList = [...specByKey.values()].map(e => ({ min: e.min, max: e.max, readings: e.readings, sources: [...e.sources] }));
|
|
@@ -1154,7 +1193,21 @@ const get_stability = {
|
|
|
1154
1193
|
const pdef = (db.parameters || []).find(p => (p.name || '').trim().toLowerCase() === paramLc);
|
|
1155
1194
|
if (pdef && pdef.acceptanceCriteria) { specMin = pdef.acceptanceCriteria.min ?? null; specMax = pdef.acceptanceCriteria.max ?? null; }
|
|
1156
1195
|
}
|
|
1157
|
-
|
|
1196
|
+
// ONE POINT PER TEST DATE PER RUN CONDITION — the mean of every reading
|
|
1197
|
+
// taken that day under that condition — exactly as the app's Stability view
|
|
1198
|
+
// (FormLab build 24k). Replicates are not separate time points: counting a
|
|
1199
|
+
// triplicate as three consecutive observations took the control chart's
|
|
1200
|
+
// sigma from within-day differences (Clarifying Shampoo's ambient pH: 10
|
|
1201
|
+
// out-of-control points on 53 readings, 1 on its 18 test dates) and gave
|
|
1202
|
+
// the drift and shelf-life fits degrees of freedom the study does not have.
|
|
1203
|
+
const byDay = new Map();
|
|
1204
|
+
points.forEach(p => { const k = p.day + '\u0000' + p.cond; if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(p); });
|
|
1205
|
+
points.length = 0;
|
|
1206
|
+
byDay.forEach(g => {
|
|
1207
|
+
const value = g.reduce((a, r) => a + r.value, 0) / g.length;
|
|
1208
|
+
points.push({ date: new Date(g[0].day), value, cond: g[0].cond, spec: (g.find(r => r.spec) || {}).spec || null, readings: g.length });
|
|
1209
|
+
});
|
|
1210
|
+
points.sort((a, b) => (a.date - b.date) || a.cond.localeCompare(b.cond));
|
|
1158
1211
|
if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
|
|
1159
1212
|
|
|
1160
1213
|
const spec = { min: specMin, max: specMax };
|
|
@@ -1169,7 +1222,7 @@ const get_stability = {
|
|
|
1169
1222
|
const analyze = (pts) => {
|
|
1170
1223
|
const ys = pts.map(p => p.value);
|
|
1171
1224
|
const n = ys.length;
|
|
1172
|
-
const out = { points: n, from: pts[0].date.toISOString().slice(0, 10), to: pts[n - 1].date.toISOString().slice(0, 10), values: ys.map(v => _round(v, 6)), latest: _round(ys[n - 1], 6) };
|
|
1225
|
+
const out = { points: n, readings: pts.reduce((a, p) => a + (p.readings || 1), 0), from: pts[0].date.toISOString().slice(0, 10), to: pts[n - 1].date.toISOString().slice(0, 10), values: ys.map(v => _round(v, 6)), latest: _round(ys[n - 1], 6) };
|
|
1173
1226
|
if (n < 2) { out.note = 'Fewer than 2 points — no trend/shelf-life.'; return out; }
|
|
1174
1227
|
// Drift on real elapsed months (not point order), like the shelf-life
|
|
1175
1228
|
// projection and the app's Stability view: uneven ICH schedules
|
|
@@ -1229,7 +1282,7 @@ const get_stability = {
|
|
|
1229
1282
|
},
|
|
1230
1283
|
};
|
|
1231
1284
|
|
|
1232
|
-
export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames };
|
|
1285
|
+
export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
|
|
1233
1286
|
export const tools = {
|
|
1234
1287
|
list_test_results,
|
|
1235
1288
|
get_test_result,
|