formlab-mcp 0.6.15 → 0.6.16

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
@@ -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 36 tools become callable in any conversation.
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
- **36 read-only tools** covering formulations, ingredients, batches, samples,
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 36 tools (see ./tools/) over stdio. The MCP host (Claude
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.15",
3
+ "version": "0.6.16",
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.15",
5
+ "version": "0.6.16",
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.15",
16
+ "version": "0.6.16",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -231,4 +231,71 @@ const get_batch_pivot = {
231
231
  },
232
232
  };
233
233
 
234
- export const tools = { compare_batches, get_batch_pivot };
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 };