formlab-mcp 0.6.5 → 0.6.7
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/index.js +18 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +177 -1
- package/tools/library.js +12 -1
package/index.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// call sees fresh data. No restart needed.
|
|
18
18
|
// ============================================================
|
|
19
19
|
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
20
23
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
21
24
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
22
25
|
import {
|
|
@@ -115,8 +118,22 @@ const TOOLS = [
|
|
|
115
118
|
...Object.values(notebook.tools),
|
|
116
119
|
];
|
|
117
120
|
|
|
121
|
+
// Version reported in the MCP handshake. Read from package.json rather than
|
|
122
|
+
// hard-coded: the literal here said 0.6.0 while the package was 0.6.5 (caught by
|
|
123
|
+
// the 2026-08-01 claims audit), because publishing bumps package.json and nobody
|
|
124
|
+
// remembers this line. npm always ships package.json, so the read is safe from
|
|
125
|
+
// an installed copy as well as from the repo.
|
|
126
|
+
const PKG_VERSION = (() => {
|
|
127
|
+
try {
|
|
128
|
+
const pkgPath = fileURLToPath(new URL('./package.json', import.meta.url));
|
|
129
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '0.0.0';
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return '0.0.0'; // never let a missing/unreadable manifest stop the server
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
|
|
118
135
|
const server = new Server(
|
|
119
|
-
{ name: 'formlab-mcp', version:
|
|
136
|
+
{ name: 'formlab-mcp', version: PKG_VERSION },
|
|
120
137
|
{ capabilities: { tools: {} } }
|
|
121
138
|
);
|
|
122
139
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab — 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.7",
|
|
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.7",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// - get_coverage_matrix (which items × parameters have been tested)
|
|
8
8
|
// - list_doe_designs (saved design records — the intent behind the runs)
|
|
9
9
|
// - get_doe_design (one design in full, incl. its run matrix)
|
|
10
|
+
// - get_stability (drift + I-chart + shelf-life, split by condition)
|
|
10
11
|
// ============================================================
|
|
11
12
|
|
|
12
13
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
@@ -68,6 +69,12 @@ function _trimMeasurement(m, t, db) {
|
|
|
68
69
|
pointCount: Array.isArray(m.points) ? m.points.length : 0,
|
|
69
70
|
binCount: Array.isArray(m.bins) ? m.bins.length : 0,
|
|
70
71
|
acceptanceCriteria: m.acceptanceCriteria || null,
|
|
72
|
+
// Captured run conditions this reading was measured under (e.g.
|
|
73
|
+
// {Storage: "40 °C / 75% RH"}, {RPM: "50"}) — the condition travels with the
|
|
74
|
+
// value, so a reading is self-describing and comparable across runs. Present
|
|
75
|
+
// only when captured. get_test_method reports which conditions a method
|
|
76
|
+
// declares (runConditions).
|
|
77
|
+
...((m.conditions && Object.keys(m.conditions).length) ? { conditions: m.conditions } : {}),
|
|
71
78
|
...ins, // instrument, equipmentId, instrumentSource
|
|
72
79
|
};
|
|
73
80
|
}
|
|
@@ -134,7 +141,7 @@ const list_test_results = {
|
|
|
134
141
|
const get_test_result = {
|
|
135
142
|
definition: {
|
|
136
143
|
name: 'get_test_result',
|
|
137
|
-
description: 'Get full details for a single test result report: every measurement\'s value, unit, type, acceptance criteria, and the INSTRUMENT that produced it. instrument is RESOLVED (measurement override → the parameter\'s Test Method equipment → the report default) with instrumentSource telling you which — treat source "run" as an inherited assumption, not evidence. Accepts internal id or UID.',
|
|
144
|
+
description: 'Get full details for a single test result report: every measurement\'s value, unit, type, acceptance criteria, captured run CONDITIONS (e.g. {Storage:"40 °C / 75% RH"} — the storage/temp/RPM the reading was measured under, so it is self-describing and comparable across runs; present only when captured), and the INSTRUMENT that produced it. instrument is RESOLVED (measurement override → the parameter\'s Test Method equipment → the report default) with instrumentSource telling you which — treat source "run" as an inherited assumption, not evidence. Accepts internal id or UID.',
|
|
138
145
|
inputSchema: {
|
|
139
146
|
type: 'object',
|
|
140
147
|
properties: {
|
|
@@ -680,6 +687,174 @@ const get_doe_design = {
|
|
|
680
687
|
},
|
|
681
688
|
};
|
|
682
689
|
|
|
690
|
+
// ============================================================
|
|
691
|
+
// get_stability — grounded stability / shelf-life analysis
|
|
692
|
+
// ------------------------------------------------------------
|
|
693
|
+
// The MCP analog of the in-app Stability & Trends view: for one entity
|
|
694
|
+
// (formulation / sample / batch) × parameter, it returns the DETERMINISTIC
|
|
695
|
+
// numbers — drift, I-chart, spec status, and an ICH-flavored shelf-life
|
|
696
|
+
// projection — split by storage condition when captured. Deterministic
|
|
697
|
+
// facts here, so the model narrates rather than re-derives the regression.
|
|
698
|
+
// ============================================================
|
|
699
|
+
const _MS_PER_MONTH = 30.4375 * 86400000;
|
|
700
|
+
const _round = (v, d = 4) => (v == null || !Number.isFinite(v)) ? null : +v.toFixed(d);
|
|
701
|
+
|
|
702
|
+
function _ols(xs, ys) {
|
|
703
|
+
const n = xs.length;
|
|
704
|
+
if (n < 2 || ys.length !== n) return null;
|
|
705
|
+
const mx = xs.reduce((a, b) => a + b, 0) / n, my = ys.reduce((a, b) => a + b, 0) / n;
|
|
706
|
+
let num = 0, den = 0, ssTot = 0;
|
|
707
|
+
for (let i = 0; i < n; i++) { num += (xs[i] - mx) * (ys[i] - my); den += (xs[i] - mx) ** 2; ssTot += (ys[i] - my) ** 2; }
|
|
708
|
+
if (den === 0) return null;
|
|
709
|
+
const slope = num / den, intercept = my - slope * mx;
|
|
710
|
+
let ssRes = 0;
|
|
711
|
+
for (let i = 0; i < n; i++) { const pr = intercept + slope * xs[i]; ssRes += (ys[i] - pr) ** 2; }
|
|
712
|
+
return { slope, intercept, r2: ssTot === 0 ? 1 : 1 - ssRes / ssTot };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Individuals (I) control chart: mean ± 3σ, σ from the average moving range.
|
|
716
|
+
function _iChart(ys) {
|
|
717
|
+
const n = ys.length;
|
|
718
|
+
if (n < 2) return null;
|
|
719
|
+
const mean = ys.reduce((a, b) => a + b, 0) / n;
|
|
720
|
+
let mrSum = 0;
|
|
721
|
+
for (let i = 1; i < n; i++) mrSum += Math.abs(ys[i] - ys[i - 1]);
|
|
722
|
+
const sigma = (mrSum / (n - 1)) / 1.128; // d2 for n=2
|
|
723
|
+
const ucl = mean + 3 * sigma, lcl = mean - 3 * sigma;
|
|
724
|
+
const ooc = [];
|
|
725
|
+
ys.forEach((y, i) => { if (sigma > 0 && (y > ucl || y < lcl)) ooc.push(i + 1); });
|
|
726
|
+
return { mean, sigma, ucl, lcl, ooc };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Shelf life: fit vs elapsed months, extrapolate to the spec limit the trend
|
|
730
|
+
// heads toward. Point-estimate crossing + the conservative 95% CI-bound crossing.
|
|
731
|
+
function _shelfLife(points, spec) {
|
|
732
|
+
if (!points || points.length < 3 || (spec.min == null && spec.max == null)) return null;
|
|
733
|
+
const t0 = Math.min(...points.map(p => p.date.getTime()));
|
|
734
|
+
const xs = points.map(p => (p.date.getTime() - t0) / _MS_PER_MONTH);
|
|
735
|
+
const ys = points.map(p => p.value);
|
|
736
|
+
const fit = _ols(xs, ys);
|
|
737
|
+
if (!fit || Math.abs(fit.slope) < 1e-9) return null;
|
|
738
|
+
const limit = fit.slope < 0 ? spec.min : spec.max;
|
|
739
|
+
if (limit == null) return null;
|
|
740
|
+
const edge = fit.slope < 0 ? 'lower' : 'upper';
|
|
741
|
+
const lastX = Math.max(...xs);
|
|
742
|
+
const remaining = (limit - fit.intercept) / fit.slope - lastX;
|
|
743
|
+
const n = xs.length, mx = xs.reduce((a, b) => a + b, 0) / n;
|
|
744
|
+
const ssX = xs.reduce((a, b) => a + (b - mx) ** 2, 0);
|
|
745
|
+
let ssR = 0; xs.forEach((x, i) => { const pr = fit.intercept + fit.slope * x; ssR += (ys[i] - pr) ** 2; });
|
|
746
|
+
const sResid = Math.sqrt(ssR / Math.max(1, n - 2));
|
|
747
|
+
const tcrit = n > 30 ? 1.96 : n > 10 ? 2.1 : 2.5;
|
|
748
|
+
const sign = fit.slope < 0 ? -1 : 1;
|
|
749
|
+
const bound = (tt) => fit.intercept + fit.slope * tt + sign * tcrit * sResid * Math.sqrt(1 / n + ((tt - mx) ** 2) / Math.max(1e-9, ssX));
|
|
750
|
+
let ciRemaining = null;
|
|
751
|
+
for (let tt = lastX; tt <= lastX + 120; tt += 0.25) {
|
|
752
|
+
if (fit.slope < 0 ? bound(tt) <= limit : bound(tt) >= limit) { ciRemaining = tt - lastX; break; }
|
|
753
|
+
}
|
|
754
|
+
return { edge, limit, remainingMonths: remaining, ciRemainingMonths: ciRemaining, r2: fit.r2 };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const get_stability = {
|
|
758
|
+
definition: {
|
|
759
|
+
name: 'get_stability',
|
|
760
|
+
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.',
|
|
761
|
+
inputSchema: {
|
|
762
|
+
type: 'object',
|
|
763
|
+
properties: {
|
|
764
|
+
entity_id: { type: 'string', description: 'A formulation, sample, or batch — id or UID (e.g. FRM-002, SMP-007, BTC-001). The type is auto-detected and sets the pooling level.' },
|
|
765
|
+
parameter: { type: 'string', description: 'The measured parameter to analyze (case-insensitive, e.g. "Niacinamide assay").' },
|
|
766
|
+
by_condition: { type: 'boolean', description: 'Split the series by captured storage condition (default true). Set false to pool all conditions into one trend.' },
|
|
767
|
+
},
|
|
768
|
+
required: ['entity_id', 'parameter'],
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
handler: async (args) => {
|
|
772
|
+
const db = getStore().db;
|
|
773
|
+
let entity = null, entityType = null;
|
|
774
|
+
for (const [type, coll] of [['formulation', 'formulations'], ['sample', 'samples'], ['batch', 'batches']]) {
|
|
775
|
+
const e = (db[coll] || []).find(x => x.id === args.entity_id || x.uid === args.entity_id);
|
|
776
|
+
if (e) { entity = e; entityType = type; break; }
|
|
777
|
+
}
|
|
778
|
+
if (!entity) return { error: `No formulation, sample, or batch found for "${args.entity_id}".` };
|
|
779
|
+
const sampleIds = entityType === 'sample'
|
|
780
|
+
? new Set([entity.id])
|
|
781
|
+
: new Set((db.samples || []).filter(s => (entityType === 'batch' ? s.batchId : s.formulationId) === entity.id).map(s => s.id));
|
|
782
|
+
|
|
783
|
+
const paramLc = (args.parameter || '').trim().toLowerCase();
|
|
784
|
+
if (!paramLc) return { error: 'parameter is required.' };
|
|
785
|
+
const points = [];
|
|
786
|
+
let specMin = null, specMax = null, unit = '';
|
|
787
|
+
(db.testResults || []).filter(t => sampleIds.has(t.sampleId)).forEach(t => {
|
|
788
|
+
const dStr = t.testDate || (t.createdAt ? String(t.createdAt).slice(0, 10) : null);
|
|
789
|
+
if (!dStr) return;
|
|
790
|
+
const date = new Date(dStr + (t.testTime && /^\d{4}-\d{2}-\d{2}$/.test(dStr) ? 'T' + t.testTime : ''));
|
|
791
|
+
if (!Number.isFinite(date.getTime())) return;
|
|
792
|
+
(t.measurements || []).forEach(m => {
|
|
793
|
+
if ((m.parameter || '').trim().toLowerCase() !== paramLc) return;
|
|
794
|
+
let value = parseFloat(m.value);
|
|
795
|
+
if (!Number.isFinite(value) && Array.isArray(m.points) && m.points.length) {
|
|
796
|
+
const last = m.points[m.points.length - 1]; value = parseFloat(last && (last.value ?? last.y)); // time-series → final
|
|
797
|
+
}
|
|
798
|
+
if (!Number.isFinite(value)) return;
|
|
799
|
+
if (m.unit && !unit) unit = m.unit;
|
|
800
|
+
const ac = m.acceptanceCriteria;
|
|
801
|
+
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); }
|
|
802
|
+
const cond = (m.conditions && Object.keys(m.conditions).length)
|
|
803
|
+
? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '';
|
|
804
|
+
points.push({ date, value, cond });
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
// Spec fallback to the Test Method library entry when no measurement carried one.
|
|
808
|
+
if (specMin == null && specMax == null) {
|
|
809
|
+
const pdef = (db.parameters || []).find(p => (p.name || '').trim().toLowerCase() === paramLc);
|
|
810
|
+
if (pdef && pdef.acceptanceCriteria) { specMin = pdef.acceptanceCriteria.min ?? null; specMax = pdef.acceptanceCriteria.max ?? null; }
|
|
811
|
+
}
|
|
812
|
+
points.sort((a, b) => a.date - b.date);
|
|
813
|
+
if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
|
|
814
|
+
|
|
815
|
+
const spec = { min: specMin, max: specMax };
|
|
816
|
+
const analyze = (pts) => {
|
|
817
|
+
const ys = pts.map(p => p.value);
|
|
818
|
+
const n = ys.length;
|
|
819
|
+
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) };
|
|
820
|
+
if (n < 2) { out.note = 'Fewer than 2 points — no trend/shelf-life.'; return out; }
|
|
821
|
+
const fit = _ols(pts.map((_, i) => i), ys);
|
|
822
|
+
if (fit) {
|
|
823
|
+
const days = (pts[n - 1].date - pts[0].date) / 86400000;
|
|
824
|
+
const perMonth = days > 0 ? (fit.slope * (n - 1) / days) * 30 : 0;
|
|
825
|
+
out.drift = { perMonth: _round(perMonth, 4), r2: _round(fit.r2, 3), direction: perMonth > 0 ? 'increasing' : perMonth < 0 ? 'decreasing' : 'flat' };
|
|
826
|
+
}
|
|
827
|
+
const ic = _iChart(ys);
|
|
828
|
+
if (ic) out.controlChart = { mean: _round(ic.mean), ucl: _round(ic.ucl), lcl: _round(ic.lcl), sigma: _round(ic.sigma), outOfControlPoints: ic.ooc };
|
|
829
|
+
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) };
|
|
830
|
+
const sl = _shelfLife(pts, spec);
|
|
831
|
+
if (sl) out.shelfLife = {
|
|
832
|
+
headingToward: `${sl.edge} spec (${_round(sl.limit)})`,
|
|
833
|
+
remainingMonths: _round(sl.remainingMonths, 1),
|
|
834
|
+
ci95Months: sl.ciRemainingMonths != null ? _round(sl.ciRemainingMonths, 1) : null,
|
|
835
|
+
r2: _round(sl.r2, 2), basis: 'linear extrapolation to spec crossing',
|
|
836
|
+
};
|
|
837
|
+
return out;
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const split = args.by_condition !== false && points.some(p => p.cond);
|
|
841
|
+
let series;
|
|
842
|
+
if (split) {
|
|
843
|
+
const byC = new Map();
|
|
844
|
+
points.forEach(p => { const k = p.cond || '(no condition)'; if (!byC.has(k)) byC.set(k, []); byC.get(k).push(p); });
|
|
845
|
+
series = [...byC.keys()].sort().map(k => ({ condition: k, ...analyze(byC.get(k)) }));
|
|
846
|
+
} else {
|
|
847
|
+
series = [{ condition: null, ...analyze(points) }];
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
|
|
852
|
+
parameter: args.parameter, unit, spec, pointCount: points.length, byCondition: split, series,
|
|
853
|
+
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.',
|
|
854
|
+
};
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
|
|
683
858
|
export const tools = {
|
|
684
859
|
list_test_results,
|
|
685
860
|
get_test_result,
|
|
@@ -688,4 +863,5 @@ export const tools = {
|
|
|
688
863
|
get_coverage_matrix,
|
|
689
864
|
list_doe_designs,
|
|
690
865
|
get_doe_design,
|
|
866
|
+
get_stability,
|
|
691
867
|
};
|
package/tools/library.js
CHANGED
|
@@ -133,6 +133,17 @@ function _trimMethod(p, { full = false } = {}) {
|
|
|
133
133
|
equipment: p.equipment || '', equipmentId: p.equipmentId || '',
|
|
134
134
|
calcFormula: p.calcFormula || '', version: p.version != null ? p.version : null,
|
|
135
135
|
approvedBy: p.approvedBy || '', notes: p.notes || '',
|
|
136
|
+
// Declared run conditions this method varies by (the vocabulary — heating
|
|
137
|
+
// rate, spindle RPM, storage temp/RH…). Each measurement using this method
|
|
138
|
+
// captures a value per condition (see get_test_result → measurement.conditions).
|
|
139
|
+
runConditions: (Array.isArray(p.conditions) ? p.conditions : [])
|
|
140
|
+
.filter(c => c && (c.key || c.label))
|
|
141
|
+
.map(c => ({
|
|
142
|
+
key: c.key || '', label: c.label || c.key || '', type: c.type || 'text',
|
|
143
|
+
...(c.unit ? { unit: c.unit } : {}),
|
|
144
|
+
...(Array.isArray(c.options) && c.options.length ? { options: c.options } : {}),
|
|
145
|
+
...(c.default != null && c.default !== '' ? { default: c.default } : {}),
|
|
146
|
+
})),
|
|
136
147
|
};
|
|
137
148
|
}
|
|
138
149
|
|
|
@@ -176,7 +187,7 @@ const list_test_methods = {
|
|
|
176
187
|
const get_test_method = {
|
|
177
188
|
definition: {
|
|
178
189
|
name: 'get_test_method',
|
|
179
|
-
description: 'Get one Test Method by UID (e.g. PARAM-001) or id — full definition including SOP / sample prep / equipment / calculation / acceptance spec, plus sibling methods that share its analyte (the same property measured at other conditions) and the Test Panels that use this method.',
|
|
190
|
+
description: 'Get one Test Method by UID (e.g. PARAM-001) or id — full definition including SOP / sample prep / equipment / calculation / acceptance spec, the declared runConditions this method varies by (the vocabulary — e.g. Storage temp/RH, spindle RPM; each measurement captures a value per condition), plus sibling methods that share its analyte (the same property measured at other conditions) and the Test Panels that use this method.',
|
|
180
191
|
inputSchema: {
|
|
181
192
|
type: 'object',
|
|
182
193
|
properties: { id: { type: 'string', description: 'Test Method UID or internal id.' } },
|