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 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 30 tools become callable in any conversation.
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 30 tools (see ./tools/) over stdio. The MCP host (Claude
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.5.2' },
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.5.2",
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",
@@ -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
  };