formlab-mcp 0.6.22 → 0.6.23
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 +3 -3
- package/package.json +2 -2
- package/tools/analytics.js +1 -0
- package/tools/library.js +97 -6
package/README.md
CHANGED
|
@@ -56,13 +56,13 @@ Ask Claude (or another MCP client) things like:
|
|
|
56
56
|
| `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 |
|
|
57
57
|
| `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES. Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
|
|
58
58
|
| `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…). Filters: `category`, `status`, `manufacturer`, `name_contains` |
|
|
59
|
-
| `get_equipment` | Full equipment record +
|
|
59
|
+
| `get_equipment` | Full equipment record + `usedIn` (panels, step presets, test methods, formula steps, batches, testReports resolved to it), `usage` (reports / formulas / batches / samples / pass rate / top formulas) and `measured` (per parameter: mean here vs mean on other instruments, biasPct) |
|
|
60
60
|
| `list_test_methods` | Filtered list of Test Methods (the parameter library). Filter by `analyte` (the property measured, e.g. `Viscosity` — finds every method for it), `category`, `name_contains`, `include_retired` |
|
|
61
|
-
| `get_test_method` |
|
|
61
|
+
| `get_test_method` | One Test Method: full definition, run conditions, analyte siblings, using panels, plus `usage`, `valueSummary` (mean / sd / min / max) and `byInstrument` |
|
|
62
62
|
| `list_step_presets` | Filtered list of Step Presets (reusable procedure steps: duration / temp / RPM / equipment). Filters: `industry`, `name_contains` |
|
|
63
63
|
| `get_step_preset` | Full step-preset record incl. the multi-value equipment list (`{name, equipmentId}`) |
|
|
64
64
|
| `list_test_panels` | Filtered list of Test Panels (reusable column-sets — the parameters measured together on a sample). Filters: `industry`, `name_contains` |
|
|
65
|
-
| `get_test_panel` |
|
|
65
|
+
| `get_test_panel` | One Test Panel: ordered parameters with resolved method UIDs + specs, plus `usage` (reports created from it) and `performanceByParameter` |
|
|
66
66
|
| `list_projects` | Filtered list of projects (the buckets formulations are filed under) + per-project formulation count. Filters: `status`, `name_contains` |
|
|
67
67
|
| `get_project` | Full project record + the formulations filed under it |
|
|
68
68
|
| `list_notebook_entries` | ELN feed — dated authored notes attached to records. Filters: `entity_type`, `entity_id`, `author`, `text_contains`, `since`, `until` |
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.23",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
|
-
"description": "Read-only Model Context Protocol server for FormLab
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"formlab-mcp": "./index.js"
|
package/tools/analytics.js
CHANGED
package/tools/library.js
CHANGED
|
@@ -12,6 +12,55 @@
|
|
|
12
12
|
// ============================================================
|
|
13
13
|
|
|
14
14
|
import { getStore, resolveById } from '../data.js';
|
|
15
|
+
import { _evalPassFail } from './analytics.js';
|
|
16
|
+
|
|
17
|
+
// ── Usage roll-ups (mirror the Usage tabs on the Panel / Method / Equipment
|
|
18
|
+
// detail pages) ────────────────────────────────────────────────────────
|
|
19
|
+
// Resolve a measurement's instrument the way the app does: named on the row →
|
|
20
|
+
// the parameter's Test Method equipment → the report's run default.
|
|
21
|
+
function _resolveInstrument(m, t, db) {
|
|
22
|
+
if (m.equipmentId || m.instrument) return { equipmentId: m.equipmentId || '', name: m.instrument || '', source: 'row' };
|
|
23
|
+
const pm = m.parameterId ? (db.parameters || []).find(p => p.id === m.parameterId)
|
|
24
|
+
: (db.parameters || []).find(p => _norm(p.name) === _norm(m.parameter));
|
|
25
|
+
if (pm && (pm.equipmentId || pm.equipment)) return { equipmentId: pm.equipmentId || '', name: pm.equipment || '', source: 'method' };
|
|
26
|
+
if (t && (t.equipmentId || t.instrument)) return { equipmentId: t.equipmentId || '', name: t.instrument || '', source: 'run' };
|
|
27
|
+
return { equipmentId: '', name: '', source: '' };
|
|
28
|
+
}
|
|
29
|
+
function _num(m) { const v = parseFloat(m && m.value); return Number.isFinite(v) ? v : null; }
|
|
30
|
+
function _mean(a) { return a.length ? a.reduce((x, y) => x + y, 0) / a.length : null; }
|
|
31
|
+
function _sd(a) { if (a.length < 2) return null; const mu = _mean(a); return Math.sqrt(a.reduce((acc, x) => acc + (x - mu) ** 2, 0) / (a.length - 1)); }
|
|
32
|
+
function _r(v, d = 3) { return v == null ? null : +v.toFixed(d); }
|
|
33
|
+
// Where a set of reports (optionally restricted to measurements passing
|
|
34
|
+
// `measFilter`) landed: totals + by-formula breakdown (top 10). `fails` uses the
|
|
35
|
+
// measurement's own acceptance criteria (panel spec inheritance is app-side).
|
|
36
|
+
function _usageRollup(db, tests, measFilter) {
|
|
37
|
+
const sById = new Map((db.samples || []).map(s => [s.id, s]));
|
|
38
|
+
const bById = new Map((db.batches || []).map(b => [b.id, b]));
|
|
39
|
+
const fById = new Map((db.formulations || []).map(f => [f.id, f]));
|
|
40
|
+
const date = t => String(t.testDate || (t.createdAt || '').slice(0, 10) || '');
|
|
41
|
+
const byF = new Map(); const forms = new Set(), batches = new Set(), samples = new Set(), labs = new Set();
|
|
42
|
+
let readings = 0, fails = 0, withSpec = 0, last = '';
|
|
43
|
+
tests.forEach(t => {
|
|
44
|
+
const s = sById.get(t.sampleId) || null; const b = s ? bById.get(s.batchId) : null;
|
|
45
|
+
const f = (s && s.formulationId) ? fById.get(s.formulationId) : (b ? fById.get(b.formulationId) : null);
|
|
46
|
+
if (s) samples.add(s.id); if (b) batches.add(b.id); if (f) forms.add(f.id); if (t.lab) labs.add(t.lab);
|
|
47
|
+
const d = date(t); if (d > last) last = d;
|
|
48
|
+
let tf = 0;
|
|
49
|
+
(t.measurements || []).filter(m => !measFilter || measFilter(m, t)).forEach(m => {
|
|
50
|
+
readings++;
|
|
51
|
+
const pf = _evalPassFail(m); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') { fails++; tf++; }
|
|
52
|
+
});
|
|
53
|
+
const key = f ? f.id : '(no formula)';
|
|
54
|
+
if (!byF.has(key)) byF.set(key, { formula: f ? (f.uid || f.name) : null, formulaName: f ? f.name : null, reports: 0, lastRun: '', fails: 0 });
|
|
55
|
+
const g = byF.get(key); g.reports++; if (d > g.lastRun) g.lastRun = d; g.fails += tf;
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
reports: tests.length, readings, lastUsed: last || null,
|
|
59
|
+
formulas: forms.size, batches: batches.size, samples: samples.size, labs: labs.size,
|
|
60
|
+
fails, passRate: withSpec ? _r(100 * (withSpec - fails) / withSpec, 0) : null,
|
|
61
|
+
byFormula: [...byF.values()].sort((a, b) => b.reports - a.reports).slice(0, 10),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
15
64
|
|
|
16
65
|
const _norm = (s) => String(s == null ? '' : s).toLowerCase();
|
|
17
66
|
const _clampLimit = (n) => Math.min(1000, Math.max(1, n || 100));
|
|
@@ -99,7 +148,7 @@ const list_equipment = {
|
|
|
99
148
|
const get_equipment = {
|
|
100
149
|
definition: {
|
|
101
150
|
name: 'get_equipment',
|
|
102
|
-
description: 'Get one equipment record by UID (e.g. EQP-001) or id, including
|
|
151
|
+
description: 'Get one equipment record by UID (e.g. EQP-001) or id, including `usedIn` (everywhere its DEFINITION is referenced: test panels, step presets, test methods, formula steps, batches, plus testReports = reports with a measurement resolved to it), `usage` (those reports: totals, last used, distinct formulas/batches/samples, pass rate, top formulas) and `measured` (per parameter: readings and mean on this unit vs readings/mean on all OTHER instruments, with biasPct — the calibration question). Instrument resolution follows the app: named on the measurement → the Test Method\'s equipment → the report\'s run default.',
|
|
103
152
|
inputSchema: {
|
|
104
153
|
type: 'object',
|
|
105
154
|
properties: { id: { type: 'string', description: 'Equipment UID or internal id.' } },
|
|
@@ -110,7 +159,25 @@ const get_equipment = {
|
|
|
110
159
|
const { db } = getStore();
|
|
111
160
|
const e = resolveById('equipment', args.id);
|
|
112
161
|
if (!e) return { error: `No equipment found for "${args.id}".` };
|
|
113
|
-
|
|
162
|
+
// Measurements whose RESOLVED instrument is this unit (row → method → run).
|
|
163
|
+
const nm = _norm(e.name);
|
|
164
|
+
const mine = (m, t) => { const r = _resolveInstrument(m, t, db); return r.equipmentId ? r.equipmentId === e.id : (!!nm && _norm(r.name) === nm); };
|
|
165
|
+
const tests = (db.testResults || []).filter(t => (t.measurements || []).some(m => mine(m, t)));
|
|
166
|
+
const here = new Map(), fleet = new Map();
|
|
167
|
+
(db.testResults || []).forEach(t => (t.measurements || []).forEach(m => {
|
|
168
|
+
const pn = (m.parameter || '').trim(); if (!pn) return;
|
|
169
|
+
const v = _num(m); const isMine = mine(m, t); const bucket = isMine ? here : fleet;
|
|
170
|
+
if (!bucket.has(pn)) bucket.set(pn, { parameter: pn, readings: 0, vals: [], fails: 0 });
|
|
171
|
+
const g = bucket.get(pn); g.readings++; if (v != null) g.vals.push(v); if (isMine && _evalPassFail(m) === 'fail') g.fails++;
|
|
172
|
+
}));
|
|
173
|
+
const measured = [...here.values()].map(g => {
|
|
174
|
+
const f = fleet.get(g.parameter); const mh = _mean(g.vals), me = f ? _mean(f.vals) : null;
|
|
175
|
+
return { parameter: g.parameter, readings: g.readings, mean: _r(mh), fails: g.fails, readingsElsewhere: f ? f.readings : 0, meanElsewhere: _r(me),
|
|
176
|
+
biasPct: (mh != null && me != null && me !== 0) ? _r((mh - me) / Math.abs(me) * 100, 1) : null };
|
|
177
|
+
}).sort((a, b) => b.readings - a.readings);
|
|
178
|
+
const usedIn = _equipUsage(e, db);
|
|
179
|
+
usedIn.testReports = tests.length;
|
|
180
|
+
return { ...(_trimEquipment(e)), usedIn, usage: _usageRollup(db, tests, mine), measured };
|
|
114
181
|
},
|
|
115
182
|
};
|
|
116
183
|
|
|
@@ -187,7 +254,7 @@ const list_test_methods = {
|
|
|
187
254
|
const get_test_method = {
|
|
188
255
|
definition: {
|
|
189
256
|
name: 'get_test_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),
|
|
257
|
+
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), sibling methods that share its analyte, the Test Panels that use it, plus `usage` (reports / readings linked to this method: totals, last used, distinct formulas/batches/samples, pass rate, top formulas), `valueSummary` (mean / sd / min / max over numeric readings) and `byInstrument` (readings, mean and fails per resolved instrument — a mean that differs between units is a calibration question).',
|
|
191
258
|
inputSchema: {
|
|
192
259
|
type: 'object',
|
|
193
260
|
properties: { id: { type: 'string', description: 'Test Method UID or internal id.' } },
|
|
@@ -208,7 +275,19 @@ const get_test_method = {
|
|
|
208
275
|
(row.parameterId && row.parameterId === p.id) ||
|
|
209
276
|
(!row.parameterId && _norm(row.parameter || row.name) === nm)
|
|
210
277
|
)).map(t => t.uid || t.name).slice(0, 40);
|
|
211
|
-
|
|
278
|
+
// Usage = every measurement linked to this method (by id, else by exact name).
|
|
279
|
+
const match = (m) => (m.parameterId && m.parameterId === p.id) || (!m.parameterId && _norm(m.parameter) === nm);
|
|
280
|
+
const tests = (db.testResults || []).filter(r => (r.measurements || []).some(match));
|
|
281
|
+
const vals = [], byInst = new Map();
|
|
282
|
+
tests.forEach(r => (r.measurements || []).filter(match).forEach(m => {
|
|
283
|
+
const v = _num(m); if (v != null) vals.push(v);
|
|
284
|
+
const inst = _resolveInstrument(m, r, db); const key = inst.name || inst.equipmentId || '(unresolved)';
|
|
285
|
+
if (!byInst.has(key)) byInst.set(key, { instrument: key, readings: 0, vals: [], fails: 0 });
|
|
286
|
+
const g = byInst.get(key); g.readings++; if (v != null) g.vals.push(v); if (_evalPassFail(m) === 'fail') g.fails++;
|
|
287
|
+
}));
|
|
288
|
+
const byInstrument = [...byInst.values()].map(g => ({ instrument: g.instrument, readings: g.readings, mean: _r(_mean(g.vals)), fails: g.fails })).sort((a, b) => b.readings - a.readings);
|
|
289
|
+
const valueSummary = { readings: vals.length, mean: _r(_mean(vals)), sd: _r(_sd(vals)), min: vals.length ? Math.min(...vals) : null, max: vals.length ? Math.max(...vals) : null, unit: p.defaultUnit || '' };
|
|
290
|
+
return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings, usingPanels, usage: _usageRollup(db, tests, match), valueSummary, byInstrument };
|
|
212
291
|
},
|
|
213
292
|
};
|
|
214
293
|
|
|
@@ -356,7 +435,7 @@ const list_test_panels = {
|
|
|
356
435
|
const get_test_panel = {
|
|
357
436
|
definition: {
|
|
358
437
|
name: 'get_test_panel',
|
|
359
|
-
description: 'Get one Test Panel by UID (e.g. TMPL-001) or id — full ordered parameter list (each with its resolved Test Method UID, unit, and spec), instruments, and
|
|
438
|
+
description: 'Get one Test Panel by UID (e.g. TMPL-001) or id — full ordered parameter list (each with its resolved Test Method UID, unit, and spec), instruments, notes, plus `usage` (reports created from this panel: totals, last used, distinct formulas/batches/samples, pass rate, top formulas) and `performanceByParameter` (readings / fails per parameter). Usage counts reports created from the panel, not parameter overlap.',
|
|
360
439
|
inputSchema: {
|
|
361
440
|
type: 'object',
|
|
362
441
|
properties: { id: { type: 'string', description: 'Test Panel UID or internal id.' } },
|
|
@@ -367,7 +446,19 @@ const get_test_panel = {
|
|
|
367
446
|
const { db } = getStore();
|
|
368
447
|
const t = resolveById('templates', args.id);
|
|
369
448
|
if (!t) return { error: `No test panel found for "${args.id}".` };
|
|
370
|
-
|
|
449
|
+
// Usage = reports created FROM this panel (templateId) — not parameter overlap.
|
|
450
|
+
const tests = (db.testResults || []).filter(r => r.templateId === t.id);
|
|
451
|
+
const perf = (t.parameters || []).map(row => {
|
|
452
|
+
const nm = _norm(row.parameter || row.name), pid = row.parameterId || '';
|
|
453
|
+
let n = 0, fails = 0, withSpec = 0;
|
|
454
|
+
tests.forEach(r => (r.measurements || []).forEach(m => {
|
|
455
|
+
const hit = (pid && m.parameterId === pid) || ((!m.parameterId || !pid) && _norm(m.parameter) === nm);
|
|
456
|
+
if (!hit) return; n++;
|
|
457
|
+
const pf = _evalPassFail(m); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') fails++;
|
|
458
|
+
}));
|
|
459
|
+
return { parameter: row.parameter || row.name, readings: n, fails, failRatePct: withSpec ? _r(100 * fails / withSpec, 0) : null };
|
|
460
|
+
});
|
|
461
|
+
return { ...(_trimPanel(t, db, { full: true })), usage: _usageRollup(db, tests, null), performanceByParameter: perf };
|
|
371
462
|
},
|
|
372
463
|
};
|
|
373
464
|
|