formlab-mcp 0.6.10 → 0.6.12
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/README.md +6 -5
- package/SUBMISSION.md +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +60 -2
- package/tools/lab.js +3 -0
package/README.md
CHANGED
|
@@ -29,22 +29,23 @@ Ask Claude (or another MCP client) things like:
|
|
|
29
29
|
| Tool | Purpose |
|
|
30
30
|
|---|---|
|
|
31
31
|
| `list_formulations` | Filtered list of recipes |
|
|
32
|
-
| `get_formulation` | Full record + flattened wt-% composition (sub-formulas expanded) |
|
|
32
|
+
| `get_formulation` | Full record + flattened wt-% composition (sub-formulas expanded), the ordered **step procedure**, user **custom fields**, and any **process-factor values** (DOE inputs — cure temp, mix time, RPM, pH…) |
|
|
33
33
|
| `find_similar_formulations` | Find formulas using a given ingredient ≥ threshold % |
|
|
34
34
|
| `compare_formulations` | Pairwise side-by-side composition diff |
|
|
35
35
|
| `list_ingredients` | Filtered list of raw materials. Filters: `family`, `supplier`, `name_contains`, `in_stock_only`, `ingredient_class` (small-molecule / surfactant / polymer / extract / fragrance / pigment / sequence / mixture), `sequence_contains` (e.g. `KTTKS` → Matrixyl), `taxon_contains` (e.g. `Centella`) |
|
|
36
|
-
| `get_ingredient` | Full record + supplier / **cost ($/kg)** / stock / formulations using it, plus **GHS safety** (pictograms, H/P codes, signal word), **per-jurisdiction regulatory status**, and **inventory lots** (balances + expiry). Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
|
|
36
|
+
| `get_ingredient` | Full record + supplier / **cost ($/kg)** / stock / chemical name / molecular weight / storage / formulations using it, plus **GHS safety** (pictograms, H/P codes, signal word), **per-jurisdiction regulatory status**, and **inventory lots** (balances + expiry). Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
|
|
37
37
|
| `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry). Filters: `ingredient_id`, `expiring_within_days`, `status` |
|
|
38
38
|
| `list_inventory` | Portfolio stock rollup — one row per ingredient with on-hand qty, summed lot balance, nearest expiry, and a low-stock flag (on-hand ≤ `reorderThreshold`, or zero). Filters: `low_stock_only`, `expiring_within_days`, `family` |
|
|
39
39
|
| `list_batches` | Filtered list of production / lab-prep events |
|
|
40
|
-
| `get_batch` | Full record + actual composition + samples + blend lineage |
|
|
40
|
+
| `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes), the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage |
|
|
41
41
|
| `list_samples` | Filtered list of physical specimens |
|
|
42
42
|
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
43
43
|
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
|
|
44
|
-
| `get_test_result` | Full report: every measurement's value, spec, and the **resolved instrument** (`instrumentSource`: row / method / run) |
|
|
44
|
+
| `get_test_result` | Full report: every measurement's value, spec, any per-measurement run **conditions**, and the **resolved instrument** (`instrumentSource`: row / method / run). Complex types carry a `representative` (time-series → final, distribution → D50); a time series adds `aggregated` (per-timestamp mean ± sd) and `pointReplicates` (repeat readings at one `t`). Report-level `seriesReplicates` lists parameters measured 2+ times as separate rows — point vs series replicates are distinct |
|
|
45
45
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
46
46
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
47
47
|
| `get_coverage_matrix` | Which formulations × parameters have been measured (TEST coverage) |
|
|
48
|
+
| `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), split by storage condition |
|
|
48
49
|
| `get_design_space` | COMPOSITION design space for one project: which ingredients vary and over what observed wt-% ranges (ranked), an occupied-region summary ("where you've been"), the biggest untested **interior gap** as ready-to-seed DOE ranges (a combination you could have made but skipped), and an optional standardised **2-component PCA** of all varying ingredients — loadings, variance-explained, scree, and a full-dimensional gap. Matches the in-app Design Space Viewer exactly. Answers *"where haven't I explored?"* / *"what should I formulate next?"* |
|
|
49
50
|
| `list_doe_designs` | Saved DOE designs — the *recipe* behind each batch of runs: design type, factors + ranges, constraints, run count, D-efficiency. Filters: `project`, `design_type`, `constrained_only`, `name_contains` |
|
|
50
51
|
| `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
|
|
@@ -137,7 +138,7 @@ Or for local-dev (from the repo):
|
|
|
137
138
|
"formlab": {
|
|
138
139
|
"command": "node",
|
|
139
140
|
"args": [
|
|
140
|
-
"/Users/you/
|
|
141
|
+
"/Users/you/formlab/mcp/index.js",
|
|
141
142
|
"/Users/you/Downloads/formlab-export-2026-05-30.json"
|
|
142
143
|
]
|
|
143
144
|
}
|
package/SUBMISSION.md
CHANGED
|
@@ -47,7 +47,7 @@ _"what parameters fail acceptance most often?"_, or _"show me the DOE matrix
|
|
|
47
47
|
at sample grain for Anti-Aging Serum family"_ — all against a JSON export
|
|
48
48
|
that stays on the user's laptop.
|
|
49
49
|
|
|
50
|
-
**
|
|
50
|
+
**34 read-only tools** covering formulations, ingredients, batches, samples,
|
|
51
51
|
test results, DOE matrices + saved designs, similarity, failure analysis,
|
|
52
52
|
test-coverage AND composition design-space mapping (varying ingredients,
|
|
53
53
|
untested gaps, PCA), equipment, test methods, panels, projects and ELN notes.
|
|
@@ -100,13 +100,13 @@ Already written to `mcp/server.json` in this repo. Uses:
|
|
|
100
100
|
- Namespace: `io.github.juliu1980/formlab-mcp` (GitHub OAuth proves ownership)
|
|
101
101
|
- Status: `active`
|
|
102
102
|
- Repository: `https://github.com/juliu1980/FormLab` with subfolder `mcp`
|
|
103
|
-
- Version: pulled from `package.json` (currently `0.
|
|
103
|
+
- Version: pulled from `package.json` (currently `0.6.12`)
|
|
104
104
|
- Package: npm `formlab-mcp` with stdio transport + positional `exportPath` arg
|
|
105
105
|
|
|
106
106
|
### Publish (the OAuth step is interactive)
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
cd
|
|
109
|
+
cd ~/GitHub/formlab/mcp
|
|
110
110
|
mcp-publisher login # opens GitHub OAuth in your browser as juliu1980
|
|
111
111
|
mcp-publisher publish # reads server.json and submits
|
|
112
112
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
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.12",
|
|
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.12",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -59,15 +59,57 @@ function _trimTest(t, samplesById) {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Aggregate a time series by timestamp — the read-only mirror of the app's
|
|
63
|
+
// _tsAggregateByT. Any t appearing 2+ times is a set of POINT replicates
|
|
64
|
+
// (repeat readings at that timestamp), averaged to mean ± sd. One entry per
|
|
65
|
+
// distinct t, sorted by t. A series with no duplicate t aggregates to itself.
|
|
66
|
+
function _mcpTsAggregate(points) {
|
|
67
|
+
const groups = new Map();
|
|
68
|
+
for (const p of (Array.isArray(points) ? points : [])) {
|
|
69
|
+
const t = parseFloat(p && (p.t ?? p.x));
|
|
70
|
+
const v = parseFloat(p && (p.value ?? p.y));
|
|
71
|
+
if (!Number.isFinite(t) || !Number.isFinite(v)) continue;
|
|
72
|
+
if (!groups.has(t)) groups.set(t, []);
|
|
73
|
+
groups.get(t).push(v);
|
|
74
|
+
}
|
|
75
|
+
const out = [];
|
|
76
|
+
[...groups.keys()].sort((a, b) => a - b).forEach(t => {
|
|
77
|
+
const vals = groups.get(t); const n = vals.length;
|
|
78
|
+
const mean = vals.reduce((a, b) => a + b, 0) / n;
|
|
79
|
+
const sd = n >= 2 ? Math.sqrt(vals.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1)) : 0;
|
|
80
|
+
out.push({ t: _round(t, 6), n, mean: _round(mean, 6), sd: _round(sd, 6) });
|
|
81
|
+
});
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
function _trimMeasurement(m, t, db) {
|
|
63
86
|
const ins = _resolveInstrument(m, t, db);
|
|
87
|
+
const type = m.type || 'scalar';
|
|
88
|
+
// Reduce a complex type to the single representative every analysis surface
|
|
89
|
+
// uses (time_series → final point, distribution → D50), and — for a series —
|
|
90
|
+
// expose per-timestamp aggregation so an agent can see POINT replicates
|
|
91
|
+
// (repeat readings at one t) as mean ± sd. This is DISTINCT from SERIES
|
|
92
|
+
// replicates (the same parameter measured as separate measurement rows in the
|
|
93
|
+
// report); see the report-level `seriesReplicates` on get_test_result.
|
|
94
|
+
const complex = {};
|
|
95
|
+
if (type === 'time_series' && Array.isArray(m.points) && m.points.length) {
|
|
96
|
+
const agg = _mcpTsAggregate(m.points);
|
|
97
|
+
complex.aggregated = agg; // [{t,n,mean,sd}]
|
|
98
|
+
complex.pointReplicates = agg.some(g => g.n >= 2); // any t with n≥2
|
|
99
|
+
const last = agg[agg.length - 1];
|
|
100
|
+
complex.representative = last ? { value: last.mean, basis: 'final' } : null;
|
|
101
|
+
} else if (type === 'distribution' && Array.isArray(m.bins) && m.bins.length) {
|
|
102
|
+
const d50 = m.bins.find(b => String(b.label || '').toUpperCase().replace(/\s/g, '') === 'D50');
|
|
103
|
+
complex.representative = d50 ? { value: _round(parseFloat(d50.value), 6), basis: 'D50' } : null;
|
|
104
|
+
}
|
|
64
105
|
return {
|
|
65
106
|
parameter: m.parameter || '',
|
|
66
107
|
value: m.value ?? null,
|
|
67
108
|
unit: m.unit || '',
|
|
68
|
-
type
|
|
109
|
+
type,
|
|
69
110
|
pointCount: Array.isArray(m.points) ? m.points.length : 0,
|
|
70
111
|
binCount: Array.isArray(m.bins) ? m.bins.length : 0,
|
|
112
|
+
...complex,
|
|
71
113
|
acceptanceCriteria: m.acceptanceCriteria || null,
|
|
72
114
|
// Captured run conditions this reading was measured under (e.g.
|
|
73
115
|
// {Storage: "40 °C / 75% RH"}, {RPM: "50"}) — the condition travels with the
|
|
@@ -141,7 +183,7 @@ const list_test_results = {
|
|
|
141
183
|
const get_test_result = {
|
|
142
184
|
definition: {
|
|
143
185
|
name: 'get_test_result',
|
|
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.',
|
|
186
|
+
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. For complex measurements a `representative` value is included (time_series → final point, distribution → D50); a time_series also carries `aggregated` (per-timestamp mean ± sd) and `pointReplicates` (true when a timestamp has repeat readings — POINT replicates, averaged per t). The report-level `seriesReplicates` lists parameters measured 2+ times as SEPARATE rows (SERIES replicates). These two notions are different: point replicates are within one run, series replicates are repeated runs. Accepts internal id or UID.',
|
|
145
187
|
inputSchema: {
|
|
146
188
|
type: 'object',
|
|
147
189
|
properties: {
|
|
@@ -154,9 +196,25 @@ const get_test_result = {
|
|
|
154
196
|
const { indexes } = getStore();
|
|
155
197
|
const t = resolveById('testResults', args.id);
|
|
156
198
|
if (!t) return { error: `No test result found for id "${args.id}".` };
|
|
199
|
+
// SERIES replicates: a parameter measured 2+ times in THIS report as separate
|
|
200
|
+
// measurement rows (grouped by parameter+method+unit). Distinct from POINT
|
|
201
|
+
// replicates, which are repeat readings at one timestamp WITHIN a single
|
|
202
|
+
// time-series measurement (see each measurement's `pointReplicates`).
|
|
203
|
+
const _repCount = new Map();
|
|
204
|
+
(t.measurements || []).forEach(m => {
|
|
205
|
+
const k = `${(m.parameter || '').trim().toLowerCase()}|${(m.method || '').trim().toLowerCase()}|${(m.unit || '').trim().toLowerCase()}`;
|
|
206
|
+
_repCount.set(k, (_repCount.get(k) || 0) + 1);
|
|
207
|
+
});
|
|
208
|
+
const seriesReplicates = (t.measurements || [])
|
|
209
|
+
.filter((m, i, arr) => arr.findIndex(x => (x.parameter || '') === (m.parameter || '') && (x.method || '') === (m.method || '') && (x.unit || '') === (m.unit || '')) === i)
|
|
210
|
+
.map(m => ({ parameter: m.parameter || '', n: _repCount.get(`${(m.parameter || '').trim().toLowerCase()}|${(m.method || '').trim().toLowerCase()}|${(m.unit || '').trim().toLowerCase()}`) || 1 }))
|
|
211
|
+
.filter(r => r.n >= 2);
|
|
157
212
|
return {
|
|
158
213
|
..._trimTest(t, indexes.samplesById),
|
|
159
214
|
notes: t.notes || '',
|
|
215
|
+
// Parameters measured 2+ times as separate rows (series replicates). Empty
|
|
216
|
+
// when none. Point replicates live on each measurement (`pointReplicates`).
|
|
217
|
+
seriesReplicates,
|
|
160
218
|
measurements: (t.measurements || []).map(m => _trimMeasurement(m, t, getStore().db)),
|
|
161
219
|
};
|
|
162
220
|
},
|
package/tools/lab.js
CHANGED
|
@@ -69,6 +69,9 @@ function _trimBatch(b, formulationsById) {
|
|
|
69
69
|
preparedDate: b.preparedDate || '',
|
|
70
70
|
preparedBy: b.preparedBy || '',
|
|
71
71
|
process: b.process || '',
|
|
72
|
+
// Per-batch process-factor values (DOE Matrix "+ Factor" at batch grain) —
|
|
73
|
+
// recorded process conditions (cure temp, mix time, RPM, pH, …) as DOE INPUTS.
|
|
74
|
+
processFactors: (b.processFactors || []).filter(x => x && x.name).map(x => ({ name: x.name, value: x.value != null ? x.value : '', unit: x.unit || '' })),
|
|
72
75
|
parentBatchId: b.parentBatchId || null,
|
|
73
76
|
isBlend: Array.isArray(b.sourceBlend) && b.sourceBlend.length > 0,
|
|
74
77
|
blendParentCount: Array.isArray(b.sourceBlend) ? b.sourceBlend.length : 0,
|