formlab-mcp 0.6.30 → 0.6.31

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.30",
3
+ "version": "0.6.31",
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.30",
5
+ "version": "0.6.31",
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.30",
16
+ "version": "0.6.31",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -1040,6 +1040,10 @@ const get_stability = {
1040
1040
  if (!paramLc) return { error: 'parameter is required.' };
1041
1041
  const points = [];
1042
1042
  let specMin = null, specMax = null, unit = '';
1043
+ // Each reading keeps the spec it was judged by (reading → panel row →
1044
+ // method). Mirrors the app: one band only when they all agree; 2+ distinct
1045
+ // specs (two panels) → no band + `specVaries`, never an intersection.
1046
+ const specByKey = new Map();
1043
1047
  (db.testResults || []).filter(t => sampleIds.has(t.sampleId)).forEach(t => {
1044
1048
  const dStr = t.testDate || (t.createdAt ? String(t.createdAt).slice(0, 10) : null);
1045
1049
  if (!dStr) return;
@@ -1053,15 +1057,25 @@ const get_stability = {
1053
1057
  }
1054
1058
  if (!Number.isFinite(value)) return;
1055
1059
  if (m.unit && !unit) unit = m.unit;
1056
- const ac = _resolveSpec(m, t.templateId).spec;
1057
- if (ac) { if (Number.isFinite(ac.min)) specMin = specMin == null ? ac.min : Math.max(specMin, ac.min); if (Number.isFinite(ac.max)) specMax = specMax == null ? ac.max : Math.min(specMax, ac.max); }
1060
+ const rs = _resolveSpec(m, t.templateId), ac = rs.spec;
1061
+ const sp = (ac && (Number.isFinite(ac.min) || Number.isFinite(ac.max))) ? { min: Number.isFinite(ac.min) ? ac.min : null, max: Number.isFinite(ac.max) ? ac.max : null } : null;
1062
+ if (sp) {
1063
+ const k = `${sp.min}|${sp.max}`;
1064
+ if (!specByKey.has(k)) specByKey.set(k, { ...sp, sources: new Set(), readings: 0 });
1065
+ const e = specByKey.get(k); e.readings++;
1066
+ const tmpl = rs.source === 'template' ? (db.templates || []).find(x => x.id === t.templateId) : null;
1067
+ e.sources.add(tmpl ? (tmpl.name || tmpl.uid || 'panel') : rs.source === 'parameter' ? 'Test Method default' : 'set on the reading');
1068
+ }
1058
1069
  const cond = (m.conditions && Object.keys(m.conditions).length)
1059
1070
  ? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '';
1060
- points.push({ date, value, cond });
1071
+ points.push({ date, value, cond, spec: sp });
1061
1072
  });
1062
1073
  });
1074
+ const specList = [...specByKey.values()].map(e => ({ min: e.min, max: e.max, readings: e.readings, sources: [...e.sources] }));
1075
+ if (specList.length === 1) { specMin = specList[0].min; specMax = specList[0].max; }
1076
+ const specVaries = specList.length > 1 ? specList : null;
1063
1077
  // Spec fallback to the Test Method library entry when no measurement carried one.
1064
- if (specMin == null && specMax == null) {
1078
+ if (specMin == null && specMax == null && !specVaries) {
1065
1079
  const pdef = (db.parameters || []).find(p => (p.name || '').trim().toLowerCase() === paramLc);
1066
1080
  if (pdef && pdef.acceptanceCriteria) { specMin = pdef.acceptanceCriteria.min ?? null; specMax = pdef.acceptanceCriteria.max ?? null; }
1067
1081
  }
@@ -1099,6 +1113,10 @@ const get_stability = {
1099
1113
  const ic = _iChart(ys);
1100
1114
  if (ic) out.controlChart = { mean: _round(ic.mean), ucl: _round(ic.ucl), lcl: _round(ic.lcl), sigma: _round(ic.sigma), outOfControlPoints: ic.ooc };
1101
1115
  if (spec.min != null || spec.max != null) out.spec = { ...spec, latestInSpec: (spec.min == null || ys[n - 1] >= spec.min) && (spec.max == null || ys[n - 1] <= spec.max) };
1116
+ else if (specVaries) {
1117
+ const ls = [...pts].reverse().find(p => p.spec);
1118
+ if (ls) out.spec = { ...ls.spec, judgedBy: 'the latest reading\'s own panel spec (specs differ by panel)', latestInSpec: (ls.spec.min == null || ys[n - 1] >= ls.spec.min) && (ls.spec.max == null || ys[n - 1] <= ls.spec.max) };
1119
+ }
1102
1120
  const sl = _shelfLife(pts, spec);
1103
1121
  const as = _shelfLifeAssess(sl, target && target.months);
1104
1122
  if (sl) out.shelfLife = {
@@ -1129,7 +1147,7 @@ const get_stability = {
1129
1147
 
1130
1148
  return {
1131
1149
  entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
1132
- parameter: args.parameter, unit, spec, pointCount: points.length, byCondition: split, series,
1150
+ parameter: args.parameter, unit, spec, ...(specVaries ? { specVaries, specNote: 'Readings were judged against different specs (different panels): no single band, no shelf-life projection; each reading keeps its own pass/fail.' } : {}), pointCount: points.length, byCondition: split, series,
1133
1151
  targetShelfLife: target ? { months: target.months, source: target.source } : null,
1134
1152
  note: 'shelfLife.fromT0Months / ci95FromT0Months are the projected shelf life counted from the first test (T0): point estimate and the conservative (ICH Q1E) 95%-bound crossing. remainingMonths / ci95Months are the same, counted from the last test. verdict compares against targetShelfLife: "short" = the trend crosses spec before the target, "marginal" = the trend clears it but the 95% bound does not, "meets" = both clear it; null = no target set (then concern is by time left: high <3 mo, medium <12 mo). overdue=true means projectedCrossDate is already past today (time left runs from the last test), so the sample is probably out of spec now — recommend a re-test. reportable=false means the app shows no projection (R² < 0.3, crossing already past, or beyond 5 years). Numbers are computed here — narrate them, do not recompute.',
1135
1153
  };