formlab-mcp 0.6.15 → 0.6.17
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 +2 -1
- package/SUBMISSION.md +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/batch-analyze.js +70 -3
- package/tools/lab.js +15 -2
package/README.md
CHANGED
|
@@ -47,6 +47,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
47
47
|
| `get_coverage_matrix` | Which formulations × parameters have been measured (TEST coverage) |
|
|
48
48
|
| `compare_batches` | Reproducibility of 2+ runs (ideally of one formula): each run's **yield %**, actual produced mass, cost/kg; per-ingredient **drift** across the runs vs the formula's proposed wt-%; and the biggest **outlier** run. `basis`: `wt_percent` (default) or `amount` |
|
|
49
49
|
| `get_batch_pivot` | Production analytics — aggregate batches by `group_by` (formula / status / month / prepared_by / project) × `metric` (count, avg_yield_pct, total_produced_kg, avg_cost_per_batch, total_samples, total_tests), with share + total for additive metrics. Optional `batch_uids` scope + `status` filter |
|
|
50
|
+
| `get_project_pivot` | Portfolio analytics — aggregate projects by `group_by` (status / phase / priority / business_unit / site / customer / lead, or `cf:<custom field>`) × `metric` (count, total_formulas / batches / samples, total_cost, avg_formulas / batches / cost_per_batch), rolling child activity + cost up by any project attribute. Optional `project_uids` scope |
|
|
50
51
|
| `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 |
|
|
51
52
|
| `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?"* |
|
|
52
53
|
| `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` |
|
|
@@ -148,7 +149,7 @@ Or for local-dev (from the repo):
|
|
|
148
149
|
}
|
|
149
150
|
```
|
|
150
151
|
|
|
151
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
152
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 37 tools become callable in any conversation.
|
|
152
153
|
|
|
153
154
|
## Wire it up to Claude Code
|
|
154
155
|
|
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
|
+
**37 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.
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// FORMLAB_EXPORT=/path/to/export.json node index.js
|
|
8
8
|
// formlab-mcp /path/to/export.json (when installed via npm)
|
|
9
9
|
//
|
|
10
|
-
// Exposes
|
|
10
|
+
// Exposes 37 tools (see ./tools/) over stdio. The MCP host (Claude
|
|
11
11
|
// Desktop, Claude Code, etc.) handles tool discovery, invocation
|
|
12
12
|
// and response formatting. We just register handlers and stay out
|
|
13
13
|
// of the way.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.17",
|
|
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.17",
|
|
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.17",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/batch-analyze.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// ============================================================
|
|
11
11
|
|
|
12
12
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
13
|
-
import { _batchActualMassKg, _massToKg } from './lab.js';
|
|
13
|
+
import { _batchActualMassKg, _massToKg, _batchUnitMassKg } from './lab.js';
|
|
14
14
|
import { _costPerKg } from './ingredients.js';
|
|
15
15
|
|
|
16
16
|
// ---- shared compute (mirrors _bcActualPctMap / _bcProposedPctMap / _bcSpreadLevel) ----
|
|
@@ -43,7 +43,7 @@ function _amountMap(b) {
|
|
|
43
43
|
return out;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function _targetKg(b) { const q = parseFloat(b.targetQuantity);
|
|
46
|
+
function _targetKg(b) { const q = parseFloat(b.targetQuantity); if (!isFinite(q)) return null; const mk = _massToKg(q, b.unit); if (mk != null) return mk; const umk = _batchUnitMassKg(b); return umk != null ? q * umk : null; }
|
|
47
47
|
function _yieldPct(b) { const tk = _targetKg(b), mk = _batchActualMassKg(b); return (tk && tk > 0 && mk != null) ? +(mk / tk * 100).toFixed(1) : null; }
|
|
48
48
|
|
|
49
49
|
// Blend cost per kg = Σ ingredient costPerKg × weightFraction over the flattened
|
|
@@ -231,4 +231,71 @@ const get_batch_pivot = {
|
|
|
231
231
|
},
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
-
|
|
234
|
+
// ============================================================
|
|
235
|
+
// get_project_pivot — portfolio analytics (parity with the in-app Projects
|
|
236
|
+
// Portfolio Pivot, js/views/projects-pivot.js). Group the project portfolio by
|
|
237
|
+
// any project attribute and measure count or rolled-up child activity / cost.
|
|
238
|
+
// ============================================================
|
|
239
|
+
const _PROJ_GROUP_LABEL = { status: 'Status', phase: 'Phase', priority: 'Priority', business_unit: 'Business Unit', site: 'Site', customer: 'Customer', lead: 'Lead' };
|
|
240
|
+
const _PROJ_GROUP_FIELD = { status: 'status', phase: 'phase', priority: 'priority', business_unit: 'businessUnit', site: 'site', customer: 'customer', lead: 'lead' };
|
|
241
|
+
const _PROJ_METRIC_LABEL = { count: 'Project count', total_formulas: 'Total formulas', total_batches: 'Total batches', total_samples: 'Total samples', total_cost: 'Total batch cost', avg_formulas: 'Avg formulas / project', avg_batches: 'Avg batches / project', avg_cost_per_batch: 'Avg cost / batch' };
|
|
242
|
+
|
|
243
|
+
function _projFormsOf(p, db) { return (db.formulations || []).filter(f => f.projectId === p.id); }
|
|
244
|
+
function _projBatchesOf(p, db) { const ids = new Set(_projFormsOf(p, db).map(f => f.id)); return (db.batches || []).filter(b => ids.has(b.formulationId)); }
|
|
245
|
+
function _projSampleCount(p, db) { const ids = new Set(_projFormsOf(p, db).map(f => f.id)); return (db.samples || []).filter(s => ids.has(s.formulationId)).length; }
|
|
246
|
+
function _projBatchCost(p, db) { let sum = 0, count = 0; _projBatchesOf(p, db).forEach(b => { const f = (db.formulations || []).find(x => x.id === b.formulationId) || null; const perKg = _batchCostPerKg(b, f); const mk = _batchActualMassKg(b) || _targetKg(b); if (perKg != null && mk != null && isFinite(perKg * mk)) { sum += perKg * mk; count++; } }); return { sum, count }; }
|
|
247
|
+
function _projGroupKey(p, groupBy) {
|
|
248
|
+
if (groupBy && groupBy.startsWith('cf:')) { const name = groupBy.slice(3); const cf = (p.customFields || []).find(c => c && c.name === name); return (cf && cf.value) || '(none)'; }
|
|
249
|
+
const field = _PROJ_GROUP_FIELD[groupBy];
|
|
250
|
+
return (p[field] || '').trim() || `(no ${(_PROJ_GROUP_LABEL[groupBy] || groupBy).toLowerCase()})`;
|
|
251
|
+
}
|
|
252
|
+
function _projMetricVal(projs, metric, db) {
|
|
253
|
+
if (metric === 'count') return projs.length;
|
|
254
|
+
if (metric === 'total_formulas') return projs.reduce((s, p) => s + _projFormsOf(p, db).length, 0);
|
|
255
|
+
if (metric === 'total_batches') return projs.reduce((s, p) => s + _projBatchesOf(p, db).length, 0);
|
|
256
|
+
if (metric === 'total_samples') return projs.reduce((s, p) => s + _projSampleCount(p, db), 0);
|
|
257
|
+
if (metric === 'total_cost') { let sum = 0, count = 0; projs.forEach(p => { const c = _projBatchCost(p, db); sum += c.sum; count += c.count; }); return count ? +sum.toFixed(2) : null; }
|
|
258
|
+
if (metric === 'avg_cost_per_batch') { let sum = 0, count = 0; projs.forEach(p => { const c = _projBatchCost(p, db); sum += c.sum; count += c.count; }); return count ? +(sum / count).toFixed(2) : null; }
|
|
259
|
+
if (metric === 'avg_formulas') return projs.length ? +(projs.reduce((s, p) => s + _projFormsOf(p, db).length, 0) / projs.length).toFixed(1) : null;
|
|
260
|
+
if (metric === 'avg_batches') return projs.length ? +(projs.reduce((s, p) => s + _projBatchesOf(p, db).length, 0) / projs.length).toFixed(1) : null;
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const get_project_pivot = {
|
|
265
|
+
definition: {
|
|
266
|
+
name: 'get_project_pivot',
|
|
267
|
+
description: 'Aggregate the project portfolio by a project attribute (status / phase / priority / business_unit / site / customer / lead, or a custom field via "cf:<name>") and a metric (count, total_formulas, total_batches, total_samples, total_cost, avg_formulas, avg_batches, avg_cost_per_batch). The portfolio-manager view: rolls child formulas/batches/samples/cost up by any project dimension. total_cost = each batch cost/kg × size summed over the group; avg_cost_per_batch is weighted (total ÷ costed batches).',
|
|
268
|
+
inputSchema: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties: {
|
|
271
|
+
group_by: { type: 'string', description: 'status | phase | priority | business_unit | site | customer | lead | cf:<custom field name> (default status).' },
|
|
272
|
+
metric: { type: 'string', enum: ['count', 'total_formulas', 'total_batches', 'total_samples', 'total_cost', 'avg_formulas', 'avg_batches', 'avg_cost_per_batch'], description: 'Metric per group (default count).' },
|
|
273
|
+
project_uids: { type: 'array', items: { type: 'string' }, description: 'Optional — scope to these projects; omit for all.' },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
handler: async (args) => {
|
|
278
|
+
const db = getStore().db;
|
|
279
|
+
let groupBy = args.group_by || 'status';
|
|
280
|
+
if (!(_PROJ_GROUP_LABEL[groupBy] || (typeof groupBy === 'string' && groupBy.startsWith('cf:')))) groupBy = 'status';
|
|
281
|
+
const metric = _PROJ_METRIC_LABEL[args.metric] ? args.metric : 'count';
|
|
282
|
+
let projects = db.projects || [];
|
|
283
|
+
let scope = 'all';
|
|
284
|
+
if (Array.isArray(args.project_uids) && args.project_uids.length) {
|
|
285
|
+
const ids = new Set(args.project_uids.map(u => { const p = resolveById('projects', u); return p ? p.id : null; }).filter(Boolean));
|
|
286
|
+
projects = projects.filter(p => ids.has(p.id));
|
|
287
|
+
scope = 'selected';
|
|
288
|
+
}
|
|
289
|
+
const groups = {};
|
|
290
|
+
projects.forEach(p => { const k = _projGroupKey(p, groupBy); (groups[k] = groups[k] || []).push(p); });
|
|
291
|
+
const additive = ['count', 'total_formulas', 'total_batches', 'total_samples', 'total_cost'].includes(metric);
|
|
292
|
+
const rows = Object.entries(groups).map(([key, ps]) => ({ key, projects: ps.length, value: _projMetricVal(ps, metric, db) }))
|
|
293
|
+
.sort((a, b) => (b.value == null ? -Infinity : b.value) - (a.value == null ? -Infinity : a.value) || a.key.localeCompare(b.key));
|
|
294
|
+
const total = (additive && rows.some(r => r.value != null)) ? +rows.reduce((s, r) => s + (r.value || 0), 0).toFixed(2) : null;
|
|
295
|
+
if (additive && total) rows.forEach(r => { r.share_pct = +((r.value || 0) / total * 100).toFixed(1); });
|
|
296
|
+
const groupByLabel = _PROJ_GROUP_LABEL[groupBy] || (groupBy.startsWith('cf:') ? groupBy.slice(3) : groupBy);
|
|
297
|
+
return { group_by: groupBy, group_by_label: groupByLabel, metric, metric_label: _PROJ_METRIC_LABEL[metric], scope, project_count: projects.length, additive, total, rows };
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export const tools = { compare_batches, get_batch_pivot, get_project_pivot };
|
package/tools/lab.js
CHANGED
|
@@ -18,9 +18,19 @@ const _MASS_TO_KG = { mg: 1e-6, cg: 1e-5, dg: 1e-4, g: 1e-3, hg: 0.1, kg: 1, oz:
|
|
|
18
18
|
const _VOL_TO_L = { 'µl': 1e-6, ul: 1e-6, ml: 1e-3, cl: 1e-2, dl: 0.1, l: 1, gal: 3.78541, fl_oz: 0.0295735, floz: 0.0295735, pt: 0.473176, qt: 0.946353, tsp: 0.00492892, tbsp: 0.0147868, cup: 0.236588 };
|
|
19
19
|
function _massToKg(v, u) { const f = _MASS_TO_KG[String(u || '').trim().toLowerCase()]; return (f != null && isFinite(v)) ? v * f : null; }
|
|
20
20
|
function _volToL(v, u) { const f = _VOL_TO_L[String(u || '').trim().toLowerCase()]; return (f != null && isFinite(v)) ? v * f : null; }
|
|
21
|
+
// Mass (kg) of one count-unit (tablet/capsule/…) from b.unitMass {value, unit};
|
|
22
|
+
// null when unset. Lets dose-form batches derive a total mass (count × unit mass).
|
|
23
|
+
function _batchUnitMassKg(b) {
|
|
24
|
+
if (!b || !b.unitMass) return null;
|
|
25
|
+
const v = parseFloat(b.unitMass.value);
|
|
26
|
+
return (isFinite(v) && v > 0) ? _massToKg(v, b.unitMass.unit) : null;
|
|
27
|
+
}
|
|
21
28
|
function _batchActualMassKg(b) {
|
|
22
29
|
const y = parseFloat(b.actualYield);
|
|
23
|
-
if (isFinite(y)) {
|
|
30
|
+
if (isFinite(y)) {
|
|
31
|
+
const kg = _massToKg(y, b.unit); if (kg != null) return kg;
|
|
32
|
+
const umk = _batchUnitMassKg(b); if (umk != null) return y * umk; // count unit × per-unit mass
|
|
33
|
+
}
|
|
24
34
|
if (Array.isArray(b.actualComposition)) {
|
|
25
35
|
let t = 0, any = false;
|
|
26
36
|
for (const c of b.actualComposition) { const kg = _massToKg(parseFloat(c.amount), c.unit); if (kg != null) { t += kg; any = true; } }
|
|
@@ -68,6 +78,9 @@ function _trimBatch(b, formulationsById) {
|
|
|
68
78
|
status: b.status || '',
|
|
69
79
|
targetQuantity: b.targetQuantity ?? null,
|
|
70
80
|
unit: b.unit || '',
|
|
81
|
+
// Per-count-unit mass for dose-form batches (tablet/capsule) — lets a count
|
|
82
|
+
// batch derive a total mass (count × unit mass) for cost / yield.
|
|
83
|
+
unitMass: (b.unitMass && b.unitMass.value != null) ? { value: b.unitMass.value, unit: b.unitMass.unit || 'mg' } : null,
|
|
71
84
|
actualYield: b.actualYield ?? null,
|
|
72
85
|
// Finished-product density (g/mL === kg/L), measured on the blend, + the
|
|
73
86
|
// derived/measured actual VOLUME (mass is the additive quantity; volume is
|
|
@@ -303,4 +316,4 @@ const get_sample = {
|
|
|
303
316
|
export const tools = { list_batches, get_batch, list_samples, get_sample };
|
|
304
317
|
|
|
305
318
|
// Exported for the batch-analyze tools so they reuse the SAME mass logic.
|
|
306
|
-
export { _batchActualMassKg, _massToKg };
|
|
319
|
+
export { _batchActualMassKg, _massToKg, _batchUnitMassKg };
|