formlab-mcp 0.5.2 → 0.6.0
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 -1
- package/index.js +2 -2
- package/package.json +1 -1
- package/tools/analytics.js +179 -0
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ Ask Claude (or another MCP client) things like:
|
|
|
44
44
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
45
45
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
46
46
|
| `get_coverage_matrix` | Which formulations × parameters have been measured |
|
|
47
|
+
| `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` |
|
|
48
|
+
| `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 |
|
|
47
49
|
| `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). |
|
|
48
50
|
| `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…). Filters: `category`, `status`, `manufacturer`, `name_contains` |
|
|
49
51
|
| `get_equipment` | Full equipment record + a summary of where it's used (panels, step presets, test methods, formulas, batches) |
|
|
@@ -141,7 +143,7 @@ Or for local-dev (from the repo):
|
|
|
141
143
|
}
|
|
142
144
|
```
|
|
143
145
|
|
|
144
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
146
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 32 tools become callable in any conversation.
|
|
145
147
|
|
|
146
148
|
## Wire it up to Claude Code
|
|
147
149
|
|
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 32 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.
|
|
@@ -114,7 +114,7 @@ const TOOLS = [
|
|
|
114
114
|
];
|
|
115
115
|
|
|
116
116
|
const server = new Server(
|
|
117
|
-
{ name: 'formlab-mcp', version: '0.
|
|
117
|
+
{ name: 'formlab-mcp', version: '0.6.0' },
|
|
118
118
|
{ capabilities: { tools: {} } }
|
|
119
119
|
);
|
|
120
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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/tools/analytics.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// - get_doe_matrix (the same shape the DOE Matrix UI produces)
|
|
6
6
|
// - find_failures (Pareto-style: which params fail most)
|
|
7
7
|
// - get_coverage_matrix (which items × parameters have been tested)
|
|
8
|
+
// - list_doe_designs (saved design records — the intent behind the runs)
|
|
9
|
+
// - get_doe_design (one design in full, incl. its run matrix)
|
|
8
10
|
// ============================================================
|
|
9
11
|
|
|
10
12
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
@@ -463,10 +465,187 @@ const get_coverage_matrix = {
|
|
|
463
465
|
},
|
|
464
466
|
};
|
|
465
467
|
|
|
468
|
+
// ============================================================
|
|
469
|
+
// SAVED DOE DESIGNS
|
|
470
|
+
// ------------------------------------------------------------
|
|
471
|
+
// get_doe_matrix reconstructs an EXECUTED design after the fact from
|
|
472
|
+
// formulations + tests. These two read the design RECORD instead: the factors
|
|
473
|
+
// and their ranges, the constraints, the model the runs were optimised for, the
|
|
474
|
+
// run budget, the seed and the resulting D-efficiency — the intent behind the
|
|
475
|
+
// runs, which cannot be recovered from the runs themselves.
|
|
476
|
+
// ============================================================
|
|
477
|
+
|
|
478
|
+
// Human-readable constraint, e.g. "Water + Glycerin <= 40". Mirrors the app's
|
|
479
|
+
// _asmDoeConstraintLabel, including its ratio special case, so the MCP never
|
|
480
|
+
// describes a rule differently from the UI.
|
|
481
|
+
function _doeConstraintText(c, nameOf) {
|
|
482
|
+
if (c && c.ratioGroup && c.ratioRole && (c.terms || []).length === 2) {
|
|
483
|
+
const bound = -(+c.terms[1].coef || 0);
|
|
484
|
+
return `${nameOf(c.terms[0].id)} : ${nameOf(c.terms[1].id)} ${c.ratioRole === 'lo' ? 'at least' : 'at most'} ${+bound.toFixed(6)}:1`;
|
|
485
|
+
}
|
|
486
|
+
const parts = ((c && c.terms) || []).map((t, i) => {
|
|
487
|
+
const coef = +t.coef || 0;
|
|
488
|
+
const mag = Math.abs(coef);
|
|
489
|
+
return `${coef < 0 ? '- ' : (i ? '+ ' : '')}${mag === 1 ? '' : +mag.toFixed(6) + '*'}${nameOf(t.id)}`;
|
|
490
|
+
});
|
|
491
|
+
const op = c && c.op === '>=' ? '>=' : c && c.op === '==' ? '=' : '<=';
|
|
492
|
+
return `${parts.join(' ')} ${op} ${+(+(c && c.rhs) || 0).toFixed(6)}`;
|
|
493
|
+
}
|
|
494
|
+
const _DOE_MODEL_LABELS = {
|
|
495
|
+
linear: 'Linear (main effects)',
|
|
496
|
+
quadratic: 'Quadratic',
|
|
497
|
+
'quadratic-int': 'Quadratic + interactions (full response surface)',
|
|
498
|
+
'scheffe-linear': 'Scheffé linear (mixture — no intercept)',
|
|
499
|
+
'scheffe-quadratic': 'Scheffé quadratic (mixture — adds two-way blending terms)',
|
|
500
|
+
'scheffe-cubic': 'Scheffé special cubic (mixture — adds three-way blending terms)',
|
|
501
|
+
};
|
|
502
|
+
const _DOE_TYPE_LABELS = {
|
|
503
|
+
full: 'Full-factorial', pb: 'Plackett-Burman', ccd: 'Central Composite',
|
|
504
|
+
bbd: 'Box-Behnken', lhs: 'Latin Hypercube', simplex: 'Simplex-Lattice', dopt: 'D-optimal',
|
|
505
|
+
};
|
|
506
|
+
// Factor names come off the RECORD first so a design still reads correctly
|
|
507
|
+
// after an ingredient is renamed or deleted.
|
|
508
|
+
function _doeNamer(d, db) {
|
|
509
|
+
const byId = new Map((d.factors || []).map(f => [f.ingredientId, f.name]));
|
|
510
|
+
return (id) => byId.get(id)
|
|
511
|
+
|| (((db.ingredients || []).find(i => i.id === id) || {}).name)
|
|
512
|
+
|| id || '?';
|
|
513
|
+
}
|
|
514
|
+
function _trimDoeDesign(d, db) {
|
|
515
|
+
const nameOf = _doeNamer(d, db);
|
|
516
|
+
const proj = d.projectId ? (db.projects || []).find(p => p.id === d.projectId) : null;
|
|
517
|
+
return {
|
|
518
|
+
id: d.id,
|
|
519
|
+
uid: d.uid,
|
|
520
|
+
name: d.name,
|
|
521
|
+
designType: d.designType,
|
|
522
|
+
designTypeLabel: _DOE_TYPE_LABELS[d.designType] || d.designType,
|
|
523
|
+
project: proj ? proj.name : null,
|
|
524
|
+
runCount: d.runCount || 0,
|
|
525
|
+
factorCount: (d.factors || []).length,
|
|
526
|
+
factors: (d.factors || []).map(f => ({ ingredient: f.name || nameOf(f.ingredientId), low: f.low, high: f.high, unit: f.unit || '%' })),
|
|
527
|
+
constraints: (d.constraints || []).map(c => _doeConstraintText(c, nameOf)),
|
|
528
|
+
mixtureMode: !!d.mixtureMode,
|
|
529
|
+
// Only D-optimal computes one; null here means "not applicable", not "failed".
|
|
530
|
+
dEfficiency: Number.isFinite(d.dEfficiency) ? +d.dEfficiency.toFixed(2) : null,
|
|
531
|
+
droppedRuns: d.droppedRuns || 0,
|
|
532
|
+
response: d.responseText || null,
|
|
533
|
+
createdAt: d.createdAt,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const list_doe_designs = {
|
|
538
|
+
definition: {
|
|
539
|
+
name: 'list_doe_designs',
|
|
540
|
+
description: 'List saved DOE designs — the recipe behind each generated batch of runs: design type, factors and their ranges, constraints, run count and D-efficiency. This is the design INTENT; use get_doe_matrix for the executed runs and their measured results. Use get_doe_design for the full run matrix of one design.',
|
|
541
|
+
inputSchema: {
|
|
542
|
+
type: 'object',
|
|
543
|
+
properties: {
|
|
544
|
+
project: { type: 'string', description: 'Filter by project name or id.' },
|
|
545
|
+
design_type: { type: 'string', description: 'Filter by design type: full, pb, ccd, bbd, lhs, simplex, dopt.' },
|
|
546
|
+
constrained_only: { type: 'boolean', description: 'Only designs that carry linear constraints.' },
|
|
547
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring match on design name.' },
|
|
548
|
+
limit: { type: 'number', description: 'Max rows to return (default 50, max 500).' },
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
handler: async (args) => {
|
|
553
|
+
const { db, indexes } = getStore();
|
|
554
|
+
const limit = Math.min(500, Math.max(1, args.limit || 50));
|
|
555
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
556
|
+
const all = (db.doeDesigns || []).filter(d => d && !d._trashed);
|
|
557
|
+
const filtered = all.filter(d => {
|
|
558
|
+
if (args.design_type && norm(d.designType) !== norm(args.design_type)) return false;
|
|
559
|
+
if (args.constrained_only && !(d.constraints || []).length) return false;
|
|
560
|
+
if (args.name_contains && !norm(d.name).includes(norm(args.name_contains))) return false;
|
|
561
|
+
if (args.project) {
|
|
562
|
+
const p = indexes.projectsById.get(args.project)
|
|
563
|
+
|| (db.projects || []).find(x => norm(x.name) === norm(args.project));
|
|
564
|
+
if (!p || d.projectId !== p.id) return false;
|
|
565
|
+
}
|
|
566
|
+
return true;
|
|
567
|
+
}).sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
568
|
+
return {
|
|
569
|
+
totalMatching: filtered.length,
|
|
570
|
+
returned: Math.min(filtered.length, limit),
|
|
571
|
+
doeDesigns: filtered.slice(0, limit).map(d => _trimDoeDesign(d, db)),
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const get_doe_design = {
|
|
577
|
+
definition: {
|
|
578
|
+
name: 'get_doe_design',
|
|
579
|
+
description: 'Get one saved DOE design in full: factors and ranges, constraints, the model it was optimised for, generation settings (run budget, replicates, centre points, seed), D-efficiency, and the complete run matrix with the formulation each run became. Everything needed to reproduce or audit the design.',
|
|
580
|
+
inputSchema: {
|
|
581
|
+
type: 'object',
|
|
582
|
+
properties: {
|
|
583
|
+
design: { type: 'string', description: 'Design id or UID (e.g. "DOE-0001").' },
|
|
584
|
+
include_runs: { type: 'boolean', description: 'Include the full run matrix. Default true.' },
|
|
585
|
+
},
|
|
586
|
+
required: ['design'],
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
handler: async (args) => {
|
|
590
|
+
const { db } = getStore();
|
|
591
|
+
const d = resolveById('doeDesigns', args.design);
|
|
592
|
+
if (!d) return { error: `No DOE design matched "${args.design}".` };
|
|
593
|
+
const nameOf = _doeNamer(d, db);
|
|
594
|
+
const base = _trimDoeDesign(d, db);
|
|
595
|
+
const st = d.settings || {};
|
|
596
|
+
// Only report the settings that actually apply to this design type —
|
|
597
|
+
// an axial distance on a Plackett-Burman would be noise presented as fact.
|
|
598
|
+
const settings = { replicates: st.replicates };
|
|
599
|
+
if (d.designType === 'dopt') {
|
|
600
|
+
settings.runBudget = st.doptRuns;
|
|
601
|
+
settings.modelOptimisedFor = _DOE_MODEL_LABELS[st.doptModel] || st.doptModel;
|
|
602
|
+
// A mixture design is modelled on the simplex, which changes what the
|
|
603
|
+
// coefficients mean — worth stating rather than leaving to be inferred
|
|
604
|
+
// from the model name.
|
|
605
|
+
if (/^scheffe-/.test(String(st.doptModel || ''))) {
|
|
606
|
+
settings.mixtureNote = 'Scheffé (mixture) model: no intercept and no pure squares, because components summing to a constant make those terms inestimable. Coefficients read as blend effects, not factor effects.';
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (d.designType === 'lhs') { settings.runBudget = st.nRuns; settings.randomSeed = st.seed; }
|
|
610
|
+
if (d.designType === 'ccd') { settings.centerPoints = st.centerPoints; settings.axialDistance = st.alpha; }
|
|
611
|
+
if (d.designType === 'bbd') settings.centerPoints = st.centerPoints;
|
|
612
|
+
if (d.designType === 'simplex') settings.latticeDegree = st.simplexDegree;
|
|
613
|
+
|
|
614
|
+
const formsById = new Map((db.formulations || []).map(f => [f.id, f]));
|
|
615
|
+
const linked = (d.formulationIds || []).map(id => formsById.get(id)).filter(Boolean);
|
|
616
|
+
const out = {
|
|
617
|
+
...base,
|
|
618
|
+
balanceFactor: d.mixtureMode ? nameOf(d.balanceFactorId) : null,
|
|
619
|
+
settings,
|
|
620
|
+
augmentedFromProjectId: d.augmentedFromProjectId || null,
|
|
621
|
+
formulationsGenerated: (d.formulationIds || []).length,
|
|
622
|
+
formulationsStillPresent: linked.length,
|
|
623
|
+
notes: d.notes || null,
|
|
624
|
+
};
|
|
625
|
+
if (Number.isFinite(d.dEfficiency)) {
|
|
626
|
+
out.dEfficiencyNote = 'D-efficiency compares designs fitting the SAME model at the SAME run count. It is a relative score, not a pass mark, and cannot be compared across different models.';
|
|
627
|
+
}
|
|
628
|
+
if (args.include_runs !== false) {
|
|
629
|
+
const facs = d.factors || [];
|
|
630
|
+
out.factorOrder = facs.map(f => f.name || nameOf(f.ingredientId));
|
|
631
|
+
out.runs = (d.runs || []).map((r, i) => {
|
|
632
|
+
const row = { run: i + 1 };
|
|
633
|
+
facs.forEach(f => { row[f.name || nameOf(f.ingredientId)] = r[f.ingredientId]; });
|
|
634
|
+
const lf = formsById.get((d.formulationIds || [])[i]);
|
|
635
|
+
row.formulation = lf ? (lf.name || lf.uid) : null;
|
|
636
|
+
return row;
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return out;
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
|
|
466
643
|
export const tools = {
|
|
467
644
|
list_test_results,
|
|
468
645
|
get_test_result,
|
|
469
646
|
get_doe_matrix,
|
|
470
647
|
find_failures,
|
|
471
648
|
get_coverage_matrix,
|
|
649
|
+
list_doe_designs,
|
|
650
|
+
get_doe_design,
|
|
472
651
|
};
|