formlab-mcp 0.6.37 → 0.6.38
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 +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +49 -19
- package/tools/designspace.js +13 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.38",
|
|
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.38",
|
|
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.38",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -164,8 +164,8 @@ const list_test_results = {
|
|
|
164
164
|
const found = (t.measurements || []).some(m => {
|
|
165
165
|
if (args.parameter && !norm(m.parameter).includes(pn)) return false;
|
|
166
166
|
if (hasRange) {
|
|
167
|
-
const v =
|
|
168
|
-
if (
|
|
167
|
+
const v = _mcpRepNumber(m); // a curve's final value, not its point count (13a)
|
|
168
|
+
if (v == null) return false;
|
|
169
169
|
if (args.value_min != null && v < args.value_min) return false;
|
|
170
170
|
if (args.value_max != null && v > args.value_max) return false;
|
|
171
171
|
}
|
|
@@ -352,7 +352,9 @@ const get_doe_matrix = {
|
|
|
352
352
|
const cellParamValue = (r, col) => {
|
|
353
353
|
const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
|
|
354
354
|
if (!ms.length) return null;
|
|
355
|
-
|
|
355
|
+
// The app's DOE Matrix numbers (13a): a curve's final value, a
|
|
356
|
+
// distribution's D50, an ordinal's 1-based rank; text → its first reading.
|
|
357
|
+
const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
|
|
356
358
|
if (!nums.length) return ms[0].value ?? null;
|
|
357
359
|
const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
|
|
358
360
|
return +mean.toFixed(4);
|
|
@@ -405,18 +407,44 @@ const get_doe_matrix = {
|
|
|
405
407
|
// so a parameter measured 3× on one occasion counts once in failure rates and
|
|
406
408
|
// coverage, exactly as Test Analytics does.
|
|
407
409
|
const FAIL_MIN_N = 5; // app: _TA_FAIL_MIN_N — rates on fewer readings are "low n"
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
410
|
+
// One number per measurement — the app's flMeasNum (js/utils.js, 13a), kept
|
|
411
|
+
// in step. Never parseFloat(m.value): a saved time series / distribution has
|
|
412
|
+
// "12 points" / "8 bins" there, and an ordinal label "6B" parses to 6.
|
|
413
|
+
// scalar → value; time_series → opts.ts ('final' default | 'initial' | 'peak');
|
|
414
|
+
// distribution → opts.dist ('D50' default, a named bin only — no stand-in);
|
|
415
|
+
// ordinal → null, or with opts.ordinal === 'rank' its 1-based scale position;
|
|
416
|
+
// categorical → null.
|
|
417
|
+
function _mcpMeasType(m) {
|
|
418
|
+
const T = ['scalar', 'ordinal', 'categorical', 'time_series', 'distribution'];
|
|
419
|
+
if (m && m.type && T.includes(m.type)) return m.type;
|
|
420
|
+
return m && m.valueType === 'Categorical' ? 'categorical' : 'scalar';
|
|
421
|
+
}
|
|
422
|
+
function _mcpRepNumber(m, opts) {
|
|
423
|
+
const o = opts || {};
|
|
424
|
+
if (!m) return null;
|
|
425
|
+
const type = _mcpMeasType(m);
|
|
426
|
+
if (type === 'time_series') {
|
|
427
|
+
if (!Array.isArray(m.points) || !m.points.length) return null;
|
|
411
428
|
const agg = _mcpTsAggregate(m.points);
|
|
412
|
-
|
|
413
|
-
|
|
429
|
+
if (!agg.length) return null;
|
|
430
|
+
const means = agg.map(g => g.mean);
|
|
431
|
+
const red = o.ts || 'final';
|
|
432
|
+
const v = red === 'initial' ? means[0] : red === 'peak' ? Math.max(...means) : means[means.length - 1];
|
|
433
|
+
return isFinite(v) ? v : null;
|
|
414
434
|
}
|
|
415
|
-
if (type === 'distribution'
|
|
416
|
-
|
|
417
|
-
const
|
|
435
|
+
if (type === 'distribution') {
|
|
436
|
+
if (!Array.isArray(m.bins) || !m.bins.length) return null;
|
|
437
|
+
const want = String(o.dist || 'D50').toUpperCase();
|
|
438
|
+
const b = m.bins.find(x => String((x && x.label) || '').toUpperCase().replace(/\s/g, '') === want);
|
|
439
|
+
const v = b ? parseFloat(b.value) : NaN;
|
|
418
440
|
return isFinite(v) ? v : null;
|
|
419
441
|
}
|
|
442
|
+
if (type === 'ordinal') {
|
|
443
|
+
if (o.ordinal !== 'rank' || !Array.isArray(m.scale)) return null;
|
|
444
|
+
const i = m.scale.map(x => String(x).trim().toUpperCase()).indexOf(String(m.value == null ? '' : m.value).trim().toUpperCase());
|
|
445
|
+
return i >= 0 ? i + 1 : null;
|
|
446
|
+
}
|
|
447
|
+
if (type === 'categorical') return null;
|
|
420
448
|
const v = parseFloat(m.value);
|
|
421
449
|
return isFinite(v) ? v : null;
|
|
422
450
|
}
|
|
@@ -1153,6 +1181,7 @@ const get_stability = {
|
|
|
1153
1181
|
const paramLc = (args.parameter || '').trim().toLowerCase();
|
|
1154
1182
|
if (!paramLc) return { error: 'parameter is required.' };
|
|
1155
1183
|
const points = [];
|
|
1184
|
+
const skipped = { ordinal: 0, categorical: 0 };
|
|
1156
1185
|
let specMin = null, specMax = null, unit = '';
|
|
1157
1186
|
// Each reading keeps the spec it was judged by (reading → panel row →
|
|
1158
1187
|
// method). Mirrors the app: one band only when they all agree; 2+ distinct
|
|
@@ -1165,11 +1194,12 @@ const get_stability = {
|
|
|
1165
1194
|
if (!Number.isFinite(date.getTime())) return;
|
|
1166
1195
|
(t.measurements || []).forEach(m => {
|
|
1167
1196
|
if ((m.parameter || '').trim().toLowerCase() !== paramLc) return;
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1197
|
+
// As the app's Stability chart (13a): a curve → its final value (not
|
|
1198
|
+
// "12 points" → 12), and ordinal / text readings left out and counted.
|
|
1199
|
+
const _ty = _mcpMeasType(m);
|
|
1200
|
+
if (_ty === 'ordinal' || _ty === 'categorical') { skipped[_ty]++; return; }
|
|
1201
|
+
const value = _mcpRepNumber(m);
|
|
1202
|
+
if (value == null) return;
|
|
1173
1203
|
if (m.unit && !unit) unit = m.unit;
|
|
1174
1204
|
const rs = _resolveSpec(m, t.templateId), ac = rs.spec;
|
|
1175
1205
|
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;
|
|
@@ -1208,7 +1238,7 @@ const get_stability = {
|
|
|
1208
1238
|
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
1239
|
});
|
|
1210
1240
|
points.sort((a, b) => (a.date - b.date) || a.cond.localeCompare(b.cond));
|
|
1211
|
-
if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
|
|
1241
|
+
if (!points.length) return { error: `No dated numeric "${args.parameter}" measurements on ${entity.uid || entity.id}.${skipped.ordinal || skipped.categorical ? ` ${skipped.ordinal} ordinal and ${skipped.categorical} text reading(s) exist but have no number to trend — use list_test_results / find_failures for their pass/fail.` : ''}` };
|
|
1212
1242
|
|
|
1213
1243
|
const spec = { min: specMin, max: specMax };
|
|
1214
1244
|
// Target shelf life: the formula's own Shelf life, else the workspace
|
|
@@ -1275,14 +1305,14 @@ const get_stability = {
|
|
|
1275
1305
|
|
|
1276
1306
|
return {
|
|
1277
1307
|
entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
|
|
1278
|
-
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,
|
|
1308
|
+
parameter: args.parameter, unit, spec, ...((skipped.ordinal || skipped.categorical) ? { skipped, skippedNote: 'Ordinal (ranked scale) and text readings have no number to trend and are left out, as in the app.' } : {}), ...(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,
|
|
1279
1309
|
targetShelfLife: target ? { months: target.months, source: target.source } : null,
|
|
1280
1310
|
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: medium <3 mo or overdue, else info). A projection never reaches \"high\": high is reserved for a MEASURED out-of-spec value (spec.latestInSpec=false). short/overdue → medium, marginal/meets → info. 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.',
|
|
1281
1311
|
};
|
|
1282
1312
|
},
|
|
1283
1313
|
};
|
|
1284
1314
|
|
|
1285
|
-
export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
|
|
1315
|
+
export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
|
|
1286
1316
|
export const tools = {
|
|
1287
1317
|
list_test_results,
|
|
1288
1318
|
get_test_result,
|
package/tools/designspace.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// ============================================================
|
|
28
28
|
|
|
29
29
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
30
|
+
import { _mcpRepNumber } from './analytics.js';
|
|
30
31
|
|
|
31
32
|
// ---- composition helper -----------------------------------------------------
|
|
32
33
|
// Ingredient wt-% map for one formula, mirroring the app's _asmIngredientMap:
|
|
@@ -344,12 +345,20 @@ function _roleCandidates(forms, roleMaps) {
|
|
|
344
345
|
});
|
|
345
346
|
return out.sort((a, b) => b.score - a.score);
|
|
346
347
|
}
|
|
347
|
-
// A formula's value for a measured parameter
|
|
348
|
-
//
|
|
348
|
+
// A formula's value for a measured parameter, as the app's
|
|
349
|
+
// _asmFormulaValueForCol (13a): 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
352
|
function _paramValue(f, param, db) {
|
|
350
353
|
const sids = new Set((db.samples || []).filter(s => s.formulationId === f.id).map(s => s.id));
|
|
351
|
-
const
|
|
352
|
-
(db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param)
|
|
354
|
+
const ms = [];
|
|
355
|
+
(db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param) ms.push(m); }); });
|
|
356
|
+
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
|
+
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
|
+
const pool = primary ? ms.filter(m => sig(m) === primary[0]) : ms;
|
|
361
|
+
const vals = pool.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
|
|
353
362
|
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
|
354
363
|
}
|
|
355
364
|
function _spearman(xs, ys) {
|