formlab-mcp 0.6.25 → 0.6.27

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.25",
3
+ "version": "0.6.27",
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.25",
5
+ "version": "0.6.27",
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.25",
16
+ "version": "0.6.27",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -983,7 +983,30 @@ function _shelfLife(points, spec) {
983
983
  for (let tt = lastX; tt <= lastX + 120; tt += 0.25) {
984
984
  if (fit.slope < 0 ? bound(tt) <= limit : bound(tt) >= limit) { ciRemaining = tt - lastX; break; }
985
985
  }
986
- return { edge, limit, remainingMonths: remaining, ciRemainingMonths: ciRemaining, r2: fit.r2 };
986
+ return { edge, limit, remainingMonths: remaining, ciRemainingMonths: ciRemaining, r2: fit.r2, elapsedMonths: lastX, lastDate: points[points.length - 1].date };
987
+ }
988
+
989
+ // Same verdict rule as the app (js/views/stability.js stabShelfLifeAssess):
990
+ // months from T0 vs the target shelf life — short (trend crosses first),
991
+ // marginal (only the 95% bound does), meets; no target → by time left
992
+ // (<3 mo high, <12 mo medium). Nothing past the 5-year horizon is reported.
993
+ function _shelfLifeAssess(sl, targetMonths, now) {
994
+ if (!sl || !(sl.r2 >= 0.3) || !(sl.remainingMonths > 0.05)) return null;
995
+ const pointTotal = sl.elapsedMonths + sl.remainingMonths;
996
+ const ciTotal = sl.ciRemainingMonths != null ? sl.elapsedMonths + sl.ciRemainingMonths : null;
997
+ // Overdue: the projected crossing (last test + remaining) is already past.
998
+ const crossMs = sl.lastDate ? sl.lastDate.getTime() + sl.remainingMonths * _MS_PER_MONTH : null;
999
+ const nowMs = now != null ? +now : Date.now();
1000
+ const overdue = crossMs != null && crossMs < nowMs;
1001
+ const crossDate = crossMs != null ? new Date(crossMs).toISOString().slice(0, 10) : null;
1002
+ if (targetMonths) {
1003
+ const verdict = pointTotal < targetMonths ? 'short' : (ciTotal != null && ciTotal < targetMonths) ? 'marginal' : 'meets';
1004
+ if (verdict === 'meets' && sl.remainingMonths > 60) return null;
1005
+ const level = (overdue && verdict !== 'meets') ? 'high' : verdict === 'short' ? 'high' : verdict === 'marginal' ? 'medium' : 'info';
1006
+ return { verdict, level, pointTotal, ciTotal, overdue, crossDate };
1007
+ }
1008
+ if (sl.remainingMonths > 60) return null;
1009
+ return { verdict: null, level: (overdue || sl.remainingMonths < 3) ? 'high' : sl.remainingMonths < 12 ? 'medium' : 'info', pointTotal, ciTotal, overdue, crossDate };
987
1010
  }
988
1011
 
989
1012
  const get_stability = {
@@ -1045,6 +1068,14 @@ const get_stability = {
1045
1068
  if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
1046
1069
 
1047
1070
  const spec = { min: specMin, max: specMax };
1071
+ // Target shelf life: the formula's own Shelf life, else the workspace
1072
+ // default when this store carries it (the app's Settings → Stability).
1073
+ const formula = entityType === 'formulation' ? entity
1074
+ : (db.formulations || []).find(f => f.id === entity.formulationId) || null;
1075
+ const ownT = formula ? parseFloat(formula.shelfLifeMonths) : NaN;
1076
+ const defT = parseFloat(db.defaultShelfLifeMonths);
1077
+ const target = (Number.isFinite(ownT) && ownT > 0) ? { months: ownT, source: 'formula' }
1078
+ : (Number.isFinite(defT) && defT > 0) ? { months: defT, source: 'workspace default' } : null;
1048
1079
  const analyze = (pts) => {
1049
1080
  const ys = pts.map(p => p.value);
1050
1081
  const n = ys.length;
@@ -1060,11 +1091,19 @@ const get_stability = {
1060
1091
  if (ic) out.controlChart = { mean: _round(ic.mean), ucl: _round(ic.ucl), lcl: _round(ic.lcl), sigma: _round(ic.sigma), outOfControlPoints: ic.ooc };
1061
1092
  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) };
1062
1093
  const sl = _shelfLife(pts, spec);
1094
+ const as = _shelfLifeAssess(sl, target && target.months);
1063
1095
  if (sl) out.shelfLife = {
1064
1096
  headingToward: `${sl.edge} spec (${_round(sl.limit)})`,
1065
1097
  remainingMonths: _round(sl.remainingMonths, 1),
1066
1098
  ci95Months: sl.ciRemainingMonths != null ? _round(sl.ciRemainingMonths, 1) : null,
1099
+ fromT0Months: _round(sl.elapsedMonths + sl.remainingMonths, 1),
1100
+ ci95FromT0Months: sl.ciRemainingMonths != null ? _round(sl.elapsedMonths + sl.ciRemainingMonths, 1) : null,
1067
1101
  r2: _round(sl.r2, 2), basis: 'linear extrapolation to spec crossing',
1102
+ verdict: as ? as.verdict : null,
1103
+ concern: as ? as.level : null,
1104
+ projectedCrossDate: as ? as.crossDate : null,
1105
+ overdue: as ? as.overdue : false,
1106
+ reportable: !!as,
1068
1107
  };
1069
1108
  return out;
1070
1109
  };
@@ -1082,12 +1121,13 @@ const get_stability = {
1082
1121
  return {
1083
1122
  entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
1084
1123
  parameter: args.parameter, unit, spec, pointCount: points.length, byCondition: split, series,
1085
- note: 'shelfLife.remainingMonths is the point-estimate months from the last test to the spec crossing; ci95Months is the conservative (ICH-style) estimate where the 95% confidence bound crosses spec. Numbers are computed here — narrate them, do not recompute.',
1124
+ targetShelfLife: target ? { months: target.months, source: target.source } : null,
1125
+ 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.',
1086
1126
  };
1087
1127
  },
1088
1128
  };
1089
1129
 
1090
- export { _evalPassFail, _resolveSpec };
1130
+ export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess };
1091
1131
  export const tools = {
1092
1132
  list_test_results,
1093
1133
  get_test_result,
@@ -22,6 +22,8 @@ function _trimFormulation(f) {
22
22
  application: f.application || '',
23
23
  owner: f.owner || '',
24
24
  regulatoryCategory: f.regulatoryCategory || '',
25
+ // Target shelf life (months): sets finished-goods expiry AND is what
26
+ // get_stability judges the projected shelf life against.
25
27
  shelfLifeMonths: f.shelfLifeMonths ?? null,
26
28
  // Density of the MIXED product (g/mL), when declared — volume-targeted
27
29
  // batches scale by it instead of the ideal-mix estimate. null = not set.