formlab-mcp 0.6.45 → 0.6.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.45",
3
+ "version": "0.6.46",
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.45",
5
+ "version": "0.6.46",
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.45",
16
+ "version": "0.6.46",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -1437,7 +1437,7 @@ const get_stability = {
1437
1437
  },
1438
1438
  };
1439
1439
 
1440
- export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
1440
+ export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore, _mcpCondSig, _mcpCondLabel, _mcpParamGroupsOf };
1441
1441
  // ============================================================
1442
1442
  // compare_test_results — the app's Compare Results table (js/views/testing-
1443
1443
  // compare.js), 13g. Items are formulas, batches, samples or test reports; each
@@ -27,7 +27,7 @@
27
27
  // ============================================================
28
28
 
29
29
  import { getStore, resolveById, flattenComposition } from '../data.js';
30
- import { _mcpRepNumber } from './analytics.js';
30
+ import { _mcpRepNumber, _mcpMeasType } from './analytics.js';
31
31
 
32
32
  // ---- composition helper -----------------------------------------------------
33
33
  // Ingredient wt-% map for one formula, mirroring the app's _asmIngredientMap:
@@ -346,20 +346,24 @@ function _roleCandidates(forms, roleMaps) {
346
346
  return out.sort((a, b) => b.score - a.score);
347
347
  }
348
348
  // A formula's value for a measured parameter, as the app's
349
- // _asmFormulaValueForCol (12v): readings of the primary run condition (the one
350
- // with the most readings), each as the DOE Matrix reads it (a curve's final
351
- // value, a distribution's D50, an ordinal's rank), then their mean.
349
+ // _asmFormulaValueForCol: the plain-name readings — no run condition when
350
+ // there are any (13q; it took the condition with the most readings), else the
351
+ // most readings — each read as the DOE Matrix reads it (a curve's final value,
352
+ // a distribution's D50, a grade's place on its scale), then their mean; a
353
+ // ranked scale's middle grade (lower median), never an averaged rank.
352
354
  function _paramValue(f, param, db) {
353
355
  const sids = new Set((db.samples || []).filter(s => s.formulationId === f.id).map(s => s.id));
354
356
  const ms = [];
355
357
  (db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param) ms.push(m); }); });
356
358
  const sig = (m) => { const c = m && m.conditions; return c && typeof c === 'object' ? Object.keys(c).filter(k => c[k] != null && c[k] !== '').sort().map(k => k + '=' + c[k]).join('; ') : ''; };
357
359
  const byCond = new Map();
358
- ms.forEach(m => { if (_mcpRepNumber(m) == null) return; const k = sig(m); byCond.set(k, (byCond.get(k) || 0) + 1); });
359
- const primary = [...byCond.entries()].sort((a, b) => b[1] - a[1])[0];
360
+ ms.forEach(m => { if (_mcpRepNumber(m, { ordinal: 'rank' }) == null) return; const k = sig(m); byCond.set(k, (byCond.get(k) || 0) + 1); });
361
+ const primary = [...byCond.entries()].sort((a, b) => ((a[0] ? 1 : 0) - (b[0] ? 1 : 0)) || (b[1] - a[1]) || a[0].localeCompare(b[0]))[0];
360
362
  const pool = primary ? ms.filter(m => sig(m) === primary[0]) : ms;
361
363
  const vals = pool.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
362
- return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
364
+ if (!vals.length) return null;
365
+ if (pool.some(m => _mcpMeasType(m) === 'ordinal')) { const so = vals.slice().sort((a, b) => a - b); return so[Math.floor((so.length - 1) / 2)]; }
366
+ return vals.reduce((a, b) => a + b, 0) / vals.length;
363
367
  }
364
368
  function _spearman(xs, ys) {
365
369
  const rank = (a) => { const idx = a.map((v, i) => [v, i]).sort((p, q) => p[0] - q[0]); const r = new Array(a.length); let i = 0; while (i < idx.length) { let j = i; while (j + 1 < idx.length && idx[j + 1][0] === idx[i][0]) j++; const avg = (i + j) / 2 + 1; for (let k = i; k <= j; k++) r[idx[k][1]] = avg; i = j + 1; } return r; };
@@ -503,7 +507,7 @@ const get_design_space = {
503
507
  if (vals.length < 2) out.notes.push(`"${args.property}" is measured on ${vals.length} of the formulas on the map — need at least 2 for insights (check the exact parameter name).`);
504
508
  else {
505
509
  const hi = vals.reduce((a, b) => b.v > a.v ? b : a), lo = vals.reduce((a, b) => b.v < a.v ? b : a);
506
- const pi = { property: args.property, basis: 'mean of every reading per formula', formulasMeasured: vals.length,
510
+ const pi = { property: args.property, basis: 'per formula: the readings with no run condition (else the condition tested most) — the mean, a curve at its final point, a distribution at its D50; a ranked scale as its middle grade\'s place on the scale', formulasMeasured: vals.length,
507
511
  highest: { formula: hi.f.name, uid: hi.f.uid || null, value: +hi.v.toFixed(4) },
508
512
  lowest: { formula: lo.f.name, uid: lo.f.uid || null, value: +lo.v.toFixed(4) } };
509
513
  if (vals.length >= 4) {
package/tools/library.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // ============================================================
13
13
 
14
14
  import { getStore, resolveById } from '../data.js';
15
- import { _evalPassFail } from './analytics.js';
15
+ import { _evalPassFail, _mcpRepNumber, _mcpMeasType, _resolveSpec, _mcpCondSig, _mcpCondLabel, _mcpCondTags, _mcpParamGroupsOf } from './analytics.js';
16
16
 
17
17
  // ── Usage roll-ups (mirror the Usage tabs on the Panel / Method / Equipment
18
18
  // detail pages) ────────────────────────────────────────────────────────
@@ -26,7 +26,23 @@ function _resolveInstrument(m, t, db) {
26
26
  if (t && (t.equipmentId || t.instrument)) return { equipmentId: t.equipmentId || '', name: t.instrument || '', source: 'run' };
27
27
  return { equipmentId: '', name: '', source: '' };
28
28
  }
29
- function _num(m) { const v = parseFloat(m && m.value); return Number.isFinite(v) ? v : null; }
29
+ // A reading's number, the app's rule (13q): a curve's final point, a
30
+ // distribution at the statistic its spec names (D90) or D50, nothing for a
31
+ // grade or text. It was parseFloat(m.value): a curve's point count, "6B" → 6.
32
+ function _num(m, t) {
33
+ if (!m) return null;
34
+ const sp = _mcpMeasType(m) === 'distribution' ? (_resolveSpec(m, t && t.templateId).spec || null) : null;
35
+ const v = sp && sp.stat ? _mcpRepNumber(m, { dist: String(sp.stat) }) : _mcpRepNumber(m);
36
+ return v != null && Number.isFinite(v) ? v : null;
37
+ }
38
+ // Condition tags for one parameter's arms ('' first): { key → "40 °C" }, only
39
+ // when there are two or more.
40
+ function _condTagMap(keys) {
41
+ const ks = [...new Set(keys)].sort((x, y) => ((x ? 1 : 0) - (y ? 1 : 0)) || x.localeCompare(y));
42
+ if (ks.length < 2) return new Map();
43
+ const tags = _mcpCondTags(ks.map(k => k ? k.split('|').map(p => p.replace('=', ' ')).join(' · ') : ''));
44
+ return new Map(ks.map((k, i) => [k, tags[i]]));
45
+ }
30
46
  function _mean(a) { return a.length ? a.reduce((x, y) => x + y, 0) / a.length : null; }
31
47
  function _sd(a) { if (a.length < 2) return null; const mu = _mean(a); return Math.sqrt(a.reduce((acc, x) => acc + (x - mu) ** 2, 0) / (a.length - 1)); }
32
48
  function _r(v, d = 3) { return v == null ? null : +v.toFixed(d); }
@@ -163,18 +179,32 @@ const get_equipment = {
163
179
  const nm = _norm(e.name);
164
180
  const mine = (m, t) => { const r = _resolveInstrument(m, t, db); return r.equipmentId ? r.equipmentId === e.id : (!!nm && _norm(r.name) === nm); };
165
181
  const tests = (db.testResults || []).filter(t => (t.measurements || []).some(m => mine(m, t)));
166
- const here = new Map(), fleet = new Map();
182
+ // Per parameter × RUN CONDITION (13q, as the app): a unit that ran the
183
+ // 40 °C pulls was compared with everyone's ambient readings.
184
+ const here = new Map(), fleet = new Map(), pairs = [];
167
185
  (db.testResults || []).forEach(t => (t.measurements || []).forEach(m => {
168
186
  const pn = (m.parameter || '').trim(); if (!pn) return;
169
- const v = _num(m); const isMine = mine(m, t); const bucket = isMine ? here : fleet;
170
- if (!bucket.has(pn)) bucket.set(pn, { parameter: pn, readings: 0, vals: [], fails: 0 });
171
- const g = bucket.get(pn); g.readings++; if (v != null) g.vals.push(v); if (isMine && _evalPassFail(m, t.templateId) === 'fail') g.fails++;
187
+ const cond = _mcpCondSig(m);
188
+ const v = _num(m, t); const isMine = mine(m, t); const bucket = isMine ? here : fleet;
189
+ if (isMine) pairs.push({ m, t });
190
+ const k = pn + '\u0000' + cond;
191
+ if (!bucket.has(k)) bucket.set(k, { k, parameter: pn, cond, m0: m, readings: 0, vals: [], fails: 0 });
192
+ const g = bucket.get(k); g.readings++; if (v != null) g.vals.push(v); if (isMine && _evalPassFail(m, t.templateId) === 'fail') g.fails++;
172
193
  }));
173
- const measured = [...here.values()].map(g => {
174
- const f = fleet.get(g.parameter); const mh = _mean(g.vals), me = f ? _mean(f.vals) : null;
175
- return { parameter: g.parameter, readings: g.readings, mean: _r(mh), fails: g.fails, readingsElsewhere: f ? f.readings : 0, meanElsewhere: _r(me),
176
- biasPct: (mh != null && me != null && me !== 0) ? _r((mh - me) / Math.abs(me) * 100, 1) : null };
177
- }).sort((a, b) => b.readings - a.readings);
194
+ // The app's order: type groups, the test panel's order; no condition first.
195
+ const order = _mcpParamGroupsOf(pairs, 'method', db).flatMap(g => g.parameters);
196
+ const oi = (pn) => { const i = order.indexOf(pn); return i < 0 ? 1e6 : i; };
197
+ const tagsBy = new Map();
198
+ here.forEach(g => { if (!tagsBy.has(g.parameter)) tagsBy.set(g.parameter, []); tagsBy.get(g.parameter).push(g.cond); });
199
+ const measured = [...here.values()]
200
+ .sort((a, b) => (oi(a.parameter) - oi(b.parameter)) || ((a.cond ? 1 : 0) - (b.cond ? 1 : 0)) || a.cond.localeCompare(b.cond))
201
+ .map(g => {
202
+ const f = fleet.get(g.k); const mh = _mean(g.vals), me = f ? _mean(f.vals) : null;
203
+ const tag = _condTagMap(tagsBy.get(g.parameter)).get(g.cond);
204
+ return { parameter: g.parameter, ...(g.cond ? { condition: _mcpCondLabel(g.m0), ...(tag ? { column: `${g.parameter} · ${tag}` } : {}) } : {}),
205
+ readings: g.readings, mean: _r(mh), fails: g.fails, readingsElsewhere: f ? f.readings : 0, meanElsewhere: _r(me),
206
+ biasPct: (mh != null && me != null && me !== 0) ? _r((mh - me) / Math.abs(me) * 100, 1) : null };
207
+ });
178
208
  const usedIn = _equipUsage(e, db);
179
209
  usedIn.testReports = tests.length;
180
210
  return { ...(_trimEquipment(e)), usedIn, usage: _usageRollup(db, tests, mine), measured };
@@ -278,15 +308,28 @@ const get_test_method = {
278
308
  // Usage = every measurement linked to this method (by id, else by exact name).
279
309
  const match = (m) => (m.parameterId && m.parameterId === p.id) || (!m.parameterId && _norm(m.parameter) === nm);
280
310
  const tests = (db.testResults || []).filter(r => (r.measurements || []).some(match));
281
- const vals = [], byInst = new Map();
311
+ // Values by RUN CONDITION (13q, as the app): the summary is the plain-name
312
+ // condition (no condition when there is any, else the most readings), with
313
+ // byCondition when there are two or more; byInstrument within a condition.
314
+ const byCond = new Map(), byInst = new Map(), condM = new Map();
282
315
  tests.forEach(r => (r.measurements || []).filter(match).forEach(m => {
283
- const v = _num(m); if (v != null) vals.push(v);
284
- const inst = _resolveInstrument(m, r, db); const key = inst.name || inst.equipmentId || '(unresolved)';
285
- if (!byInst.has(key)) byInst.set(key, { instrument: key, readings: 0, vals: [], fails: 0 });
316
+ const cond = _mcpCondSig(m); if (!condM.has(cond)) condM.set(cond, m);
317
+ const v = _num(m, r);
318
+ if (!byCond.has(cond)) byCond.set(cond, []);
319
+ if (v != null) byCond.get(cond).push(v);
320
+ const inst = _resolveInstrument(m, r, db); const ik = inst.name || inst.equipmentId || '(unresolved)';
321
+ const key = ik + '\u0000' + cond;
322
+ if (!byInst.has(key)) byInst.set(key, { instrument: ik, cond, readings: 0, vals: [], fails: 0 });
286
323
  const g = byInst.get(key); g.readings++; if (v != null) g.vals.push(v); if (_evalPassFail(m, r.templateId) === 'fail') g.fails++;
287
324
  }));
288
- const byInstrument = [...byInst.values()].map(g => ({ instrument: g.instrument, readings: g.readings, mean: _r(_mean(g.vals)), fails: g.fails })).sort((a, b) => b.readings - a.readings);
289
- const valueSummary = { readings: vals.length, mean: _r(_mean(vals)), sd: _r(_sd(vals)), min: vals.length ? Math.min(...vals) : null, max: vals.length ? Math.max(...vals) : null, unit: p.defaultUnit || '' };
325
+ const conds = [...byCond.keys()].sort((x, y) => ((x ? 1 : 0) - (y ? 1 : 0)) || (byCond.get(y).length - byCond.get(x).length) || x.localeCompare(y));
326
+ const primary = conds.length ? conds[0] : '';
327
+ const sum = (vals) => ({ readings: vals.length, mean: _r(_mean(vals)), sd: _r(_sd(vals)), min: vals.length ? Math.min(...vals) : null, max: vals.length ? Math.max(...vals) : null });
328
+ const condName = (c) => c ? _mcpCondLabel(condM.get(c)) : null;
329
+ const byInstrument = [...byInst.values()].map(g => ({ instrument: g.instrument, ...(conds.length > 1 ? { condition: condName(g.cond) } : {}), readings: g.readings, mean: _r(_mean(g.vals)), fails: g.fails })).sort((a, b) => b.readings - a.readings);
330
+ const valueSummary = { ...sum(byCond.get(primary) || []), unit: p.defaultUnit || '',
331
+ ...(conds.length > 1 ? { condition: condName(primary), note: 'measured under more than one run condition: this summary is the readings with no condition (else the condition with the most readings); byCondition has each, never averaged together',
332
+ byCondition: conds.map(c => ({ condition: condName(c), ...sum(byCond.get(c)) })) } : {}) };
290
333
  return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings, usingPanels, usage: _usageRollup(db, tests, match), valueSummary, byInstrument };
291
334
  },
292
335
  };