formlab-mcp 0.5.1 → 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
@@ -40,10 +40,12 @@ Ask Claude (or another MCP client) things like:
40
40
  | `list_samples` | Filtered list of physical specimens |
41
41
  | `get_sample` | Full record + canonical variant + test reports + blend lineage |
42
42
  | `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
43
- | `get_test_result` | Full record + every measurement value |
43
+ | `get_test_result` | Full report: every measurement's value, spec, and the **resolved instrument** (`instrumentSource`: row / method / run) |
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.1' },
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.1",
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/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.5.1",
5
+ "version": "0.5.2",
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.5.1",
16
+ "version": "0.5.2",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -5,10 +5,40 @@
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';
11
13
 
14
+ // Which instrument produced a measurement. Mirrors the app's _trMeasInstrument:
15
+ // a measurement's raw `instrument` is usually EMPTY because blank means inherit,
16
+ // so returning the stored field alone would be near-useless here. Resolve the
17
+ // same cascade the UI shows, and report the source so a consumer can tell a
18
+ // recorded fact from an inherited default:
19
+ // 'row' — the measurement names its own instrument
20
+ // 'method' — from the parameter's Test Method equipment (the usual case)
21
+ // 'run' — the report-level default (an assumption, not evidence)
22
+ function _resolveInstrument(m, t, db) {
23
+ if (m && (m.instrument || m.equipmentId)) {
24
+ return { instrument: m.instrument || '', equipmentId: m.equipmentId || null, instrumentSource: 'row' };
25
+ }
26
+ const params = (db && db.parameters) || [];
27
+ let pm = null;
28
+ if (m && m.parameterId) pm = params.find(p => p && p.id === m.parameterId) || null;
29
+ if (!pm && m) {
30
+ const nm = String(m.parameter || '').trim().toLowerCase();
31
+ if (nm) pm = params.find(p => p && String(p.name || '').trim().toLowerCase() === nm) || null;
32
+ }
33
+ if (pm && (pm.equipment || pm.equipmentId)) {
34
+ return { instrument: pm.equipment || '', equipmentId: pm.equipmentId || null, instrumentSource: 'method' };
35
+ }
36
+ if (t && (t.instrument || t.equipmentId)) {
37
+ return { instrument: t.instrument || '', equipmentId: t.equipmentId || null, instrumentSource: 'run' };
38
+ }
39
+ return { instrument: '', equipmentId: null, instrumentSource: null };
40
+ }
41
+
12
42
  function _trimTest(t, samplesById) {
13
43
  if (!t) return null;
14
44
  const s = t.sampleId ? samplesById.get(t.sampleId) : null;
@@ -20,12 +50,16 @@ function _trimTest(t, samplesById) {
20
50
  testDate: t.testDate || '',
21
51
  performedBy: t.performedBy || '',
22
52
  lab: t.lab || '',
53
+ instrument: t.instrument || '', // run-level default; measurements may override
54
+ equipmentId: t.equipmentId || null,
55
+ controlSample: !!t.controlSample, // a re-measured reference — the valid basis for drift
23
56
  measurementCount: (t.measurements || []).length,
24
57
  parameters: [...new Set((t.measurements || []).map(m => m.parameter).filter(Boolean))],
25
58
  };
26
59
  }
27
60
 
28
- function _trimMeasurement(m) {
61
+ function _trimMeasurement(m, t, db) {
62
+ const ins = _resolveInstrument(m, t, db);
29
63
  return {
30
64
  parameter: m.parameter || '',
31
65
  value: m.value ?? null,
@@ -34,13 +68,14 @@ function _trimMeasurement(m) {
34
68
  pointCount: Array.isArray(m.points) ? m.points.length : 0,
35
69
  binCount: Array.isArray(m.bins) ? m.bins.length : 0,
36
70
  acceptanceCriteria: m.acceptanceCriteria || null,
71
+ ...ins, // instrument, equipmentId, instrumentSource
37
72
  };
38
73
  }
39
74
 
40
75
  const list_test_results = {
41
76
  definition: {
42
77
  name: 'list_test_results',
43
- description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). Returns metadata + parameters tested; use get_test_result for full measurement values.',
78
+ description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). Returns metadata (incl. the run-level instrument and controlSample flag) + parameters tested; use get_test_result for per-measurement values and their resolved instruments.',
44
79
  inputSchema: {
45
80
  type: 'object',
46
81
  properties: {
@@ -99,7 +134,7 @@ const list_test_results = {
99
134
  const get_test_result = {
100
135
  definition: {
101
136
  name: 'get_test_result',
102
- description: 'Get full details for a single test result report including every measurement\'s value, unit, type, and acceptance criteria. Accepts internal id or UID.',
137
+ description: 'Get full details for a single test result report: every measurement\'s value, unit, type, acceptance criteria, 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.',
103
138
  inputSchema: {
104
139
  type: 'object',
105
140
  properties: {
@@ -115,7 +150,7 @@ const get_test_result = {
115
150
  return {
116
151
  ..._trimTest(t, indexes.samplesById),
117
152
  notes: t.notes || '',
118
- measurements: (t.measurements || []).map(_trimMeasurement),
153
+ measurements: (t.measurements || []).map(m => _trimMeasurement(m, t, getStore().db)),
119
154
  };
120
155
  },
121
156
  };
@@ -430,10 +465,187 @@ const get_coverage_matrix = {
430
465
  },
431
466
  };
432
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
+
433
643
  export const tools = {
434
644
  list_test_results,
435
645
  get_test_result,
436
646
  get_doe_matrix,
437
647
  find_failures,
438
648
  get_coverage_matrix,
649
+ list_doe_designs,
650
+ get_doe_design,
439
651
  };