formlab-mcp 0.6.55 → 0.6.57

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
@@ -1,14 +1,14 @@
1
1
  # formlab-mcp
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/formlab-mcp.svg)](https://www.npmjs.com/package/formlab-mcp)
4
- [![license](https://img.shields.io/npm/l/formlab-mcp.svg)](https://github.com/juliu1980/FormLab/blob/main/LICENSE)
4
+ [![license](https://img.shields.io/npm/l/formlab-mcp.svg)](https://www.npmjs.com/package/formlab-mcp)
5
5
  [![FormLab](https://img.shields.io/badge/site-formvix.com-2563eb.svg)](https://formvix.com)
6
6
 
7
- A **read-only Model Context Protocol server** for [FormLab](https://formvix.com). Lets Claude (or any MCP-compatible AI assistant) read and analyze your local FormLab database — your formulations, ingredients, batches, samples and test reports — **without your data ever leaving your machine**.
7
+ A **read-only Model Context Protocol server** for [FormLab](https://formvix.com). Lets Claude (or any MCP-compatible AI assistant) read and analyze your FormLab data: formulations, ingredients, batches, samples and test reports, from a local export file or your live cloud workspace.
8
8
 
9
- > Local-first + AI-native. Your proprietary recipes stay on your laptop; only the LLM's answer to your question travels.
9
+ > In file mode the server makes no network calls of its own. What a tool returns goes to the model your MCP client uses (for Claude Desktop, Anthropic), so only connect data you're comfortable sending to that provider. See [Privacy and data flow](#privacy-and-data-flow).
10
10
 
11
- Listed in the official MCP Registry as `io.github.juliu1980/formlab-mcp`.
11
+ Listed in the official MCP Registry as `io.github.juliu1980/formlab-mcp`. The source ships in the npm package (MIT).
12
12
 
13
13
  ## What it does
14
14
 
@@ -82,24 +82,34 @@ npm install -g formlab-mcp
82
82
  formlab-mcp /path/to/formlab-export.json
83
83
  ```
84
84
 
85
- For local development from this repo:
85
+ To read or run the source, unpack it from npm (it ships in the package, MIT):
86
86
 
87
87
  ```bash
88
- cd mcp
89
- npm install
88
+ npm pack formlab-mcp && tar xzf formlab-mcp-*.tgz
89
+ cd package && npm install
90
90
  node index.js /path/to/formlab-export.json
91
91
  ```
92
92
 
93
93
  Requires **Node 18+**.
94
94
 
95
+ ## Try it with sample data
96
+
97
+ No FormLab account needed: a sample export (the app's Showcase demo) is published on formvix.com.
98
+
99
+ ```bash
100
+ curl -O https://formvix.com/downloads/formlab-demo-export.json
101
+ npx formlab-mcp ./formlab-demo-export.json
102
+ ```
103
+
104
+ Point your MCP client at that file the same way as your own export (see below).
105
+
95
106
  ## Get your FormLab export
96
107
 
97
108
  1. Open FormLab
98
- 2. Sidebar → **⇅ Import / Export**
99
- 3. Click **Export** — saves `formlab-export-YYYY-MM-DD.json` to your downloads folder
100
- 4. Point this MCP at the downloaded file
109
+ 2. **Settings → Data → Export JSON**. This saves `formlab-export-YYYY-MM-DD.json` to your downloads folder.
110
+ 3. Point this MCP at the downloaded file
101
111
 
102
- The MCP server watches the file — re-export from FormLab and the next tool call sees the fresh data without restarting the server.
112
+ The MCP server watches the file: re-export from FormLab and the next tool call sees the fresh data without restarting the server.
103
113
 
104
114
  ## Or: LIVE cloud mode (no export needed)
105
115
 
@@ -138,7 +148,7 @@ Add this to your `claude_desktop_config.json` (on macOS: `~/Library/Application
138
148
  }
139
149
  ```
140
150
 
141
- Or for local-dev (from the repo):
151
+ Or from the unpacked package:
142
152
 
143
153
  ```json
144
154
  {
@@ -146,7 +156,7 @@ Or for local-dev (from the repo):
146
156
  "formlab": {
147
157
  "command": "node",
148
158
  "args": [
149
- "/Users/you/formlab/mcp/index.js",
159
+ "/Users/you/package/index.js",
150
160
  "/Users/you/Downloads/formlab-export-2026-05-30.json"
151
161
  ]
152
162
  }
@@ -154,7 +164,7 @@ Or for local-dev (from the repo):
154
164
  }
155
165
  ```
156
166
 
157
- Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 40 tools become callable in any conversation.
167
+ Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 41 tools become callable in any conversation.
158
168
 
159
169
  ## Wire it up to Claude Code
160
170
 
@@ -169,12 +179,22 @@ export FORMLAB_EXPORT=/Users/you/Downloads/formlab-export-2026-05-30.json
169
179
  claude mcp add formlab npx formlab-mcp
170
180
  ```
171
181
 
172
- ## Privacy and architecture
182
+ ## Privacy and data flow
183
+
184
+ **In both modes, what a tool returns goes to your model.** The server answers your MCP client over stdio, and the client sends tool results (your formulations, test data and so on) to the model it uses: for Claude Desktop, Anthropic. Only connect data you're comfortable sending to that provider.
173
185
 
174
- - **No network calls.** The server runs on stdio between Claude and your local Node process. Your data file never leaves your disk.
175
- - **No FormLab cloud.** FormLab itself is a local-first app; the MCP follows the same model.
176
- - **Hot-reload on file change.** Re-export from FormLab while the server is running — the next tool call picks up fresh data.
177
- - **Read-only.** Tier 1 cannot mutate your FormLab data. (Write tools are planned for a future paid "Pro" tier — register / batch / log-test mutations with cascade-safe confirmation.)
186
+ **File mode** (an export file):
187
+
188
+ - **No network calls of its own.** The server reads the export from your disk and talks to your MCP client over stdio. It does not upload the file anywhere.
189
+ - **Hot-reload on file change.** Re-export from FormLab while the server is running and the next tool call picks up fresh data.
190
+
191
+ **Cloud mode** (`FORMLAB_MCP_TOKEN` set):
192
+
193
+ - The server sends your token to FormLab's `mcp-data` Edge Function and receives your workspace in return, re-read every 60 seconds (`FORMLAB_REFRESH_SECONDS`).
194
+ - The function reads through a dedicated `mcp_readonly` Postgres role with `SELECT`-only grants, and row-level security limits it to the workspace the token was made for.
195
+ - FormLab stores only a SHA-256 hash of the token. It holds no account session and you can revoke it at any time.
196
+
197
+ **Read-only, in both modes.** No tool can change your FormLab data.
178
198
 
179
199
  ## Data shape
180
200
 
@@ -185,12 +205,6 @@ The server accepts both FormLab export shapes:
185
205
 
186
206
  The wrapped form is the default since 2026; the legacy form is supported for older exports.
187
207
 
188
- ## Tier 2 (planned, paid)
189
-
190
- Live file-sync with write tools: `create_formulation`, `update_formulation`, `log_test_result`, `create_batch`, etc. Mutations from Claude write back to the same JSON FormLab reads. Conflict detection, cascade-safe deletes, schema versioning.
191
-
192
- Not yet shipped — see the FormLab roadmap.
193
-
194
208
  ## License
195
209
 
196
210
  MIT
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 40 tools (see ./tools/) over stdio. The MCP host (Claude
10
+ // Exposes 41 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.
@@ -72,7 +72,7 @@ if (CLOUD) {
72
72
  'Usage: formlab-mcp <path-to-export.json> (file mode)',
73
73
  ' or: set FORMLAB_MCP_TOKEN (LIVE cloud mode)',
74
74
  '',
75
- 'File mode: export via Settings → Import / Export → Export.',
75
+ 'File mode: export via Settings → Data → Export JSON.',
76
76
  'Cloud mode: mint a read-only token in FormLab → Settings → Account →',
77
77
  ' AI tools (MCP) → Connect, and set it as FORMLAB_MCP_TOKEN.',
78
78
  '',
package/package.json CHANGED
@@ -1,13 +1,26 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.55",
3
+ "version": "0.6.57",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
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.",
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
+ "homepage": "https://formvix.com/mcp",
6
7
  "type": "module",
7
8
  "bin": {
8
9
  "formlab-mcp": "./index.js"
9
10
  },
10
11
  "main": "index.js",
12
+ "files": [
13
+ "index.js",
14
+ "data.js",
15
+ "cloud.js",
16
+ "calibration.js",
17
+ "peptide.js",
18
+ "theoretical.js",
19
+ "tools/",
20
+ "vendor/",
21
+ "server.json",
22
+ "README.md"
23
+ ],
11
24
  "scripts": {
12
25
  "start": "node index.js",
13
26
  "dev": "node --watch index.js"
@@ -32,10 +45,5 @@
32
45
  "design-of-experiments",
33
46
  "claude"
34
47
  ],
35
- "license": "MIT",
36
- "repository": {
37
- "type": "git",
38
- "url": "https://github.com/juliu1980/FormLab",
39
- "directory": "mcp"
40
- }
48
+ "license": "MIT"
41
49
  }
package/server.json CHANGED
@@ -1,19 +1,14 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.juliu1980/formlab-mcp",
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.55",
4
+ "description": "Read-only MCP server for FormLab formulation data: a local export file or your live cloud workspace.",
5
+ "version": "0.6.57",
6
6
  "websiteUrl": "https://formvix.com/mcp",
7
- "repository": {
8
- "url": "https://github.com/juliu1980/FormLab",
9
- "source": "github",
10
- "subfolder": "mcp"
11
- },
12
7
  "packages": [
13
8
  {
14
9
  "registryType": "npm",
15
10
  "identifier": "formlab-mcp",
16
- "version": "0.6.55",
11
+ "version": "0.6.57",
17
12
  "runtimeHint": "npx",
18
13
  "transport": {
19
14
  "type": "stdio"
@@ -22,7 +17,7 @@
22
17
  {
23
18
  "type": "positional",
24
19
  "name": "exportPath",
25
- "description": "File mode: absolute path to a FormLab export JSON (Data → Export JSON). Omit when using cloud mode (set FORMLAB_MCP_TOKEN instead).",
20
+ "description": "File mode: absolute path to a FormLab export JSON (Settings → Data → Export JSON). Omit when using cloud mode (set FORMLAB_MCP_TOKEN instead).",
26
21
  "isRequired": false,
27
22
  "format": "filepath",
28
23
  "value": "/path/to/formlab-export.json"
package/vendor/cost.js CHANGED
@@ -138,28 +138,39 @@ function getAllUnits() {
138
138
  const custom = Object.keys((typeof db !== 'undefined' && db?.customUnits) || {});
139
139
  return [...new Set([...builtin, ...custom])];
140
140
  }
141
+ // The units people reach for first, in that order (22x). Every list puts these ahead of the
142
+ // rest of their group (the picker groups by Mass / Volume / …), so a list opens on g, kg, mg —
143
+ // not on µg, ug, mcg, the size order the conversion tables are written in. Everything else,
144
+ // custom units included, keeps its place after them.
145
+ const _UNITS_COMMON_FIRST = ['wt%', 'vol%', 'g', 'kg', 'mg', 'µg', 'lb', 'oz', 't', 'tonne', 'ton',
146
+ 'mL', 'L', 'µL', 'gal', 'qt', 'pt', 'fl_oz', 'm³', 'mm', 'cm', 'm', 'in', 'ft', 'units', 'each'];
147
+ function unitsCommonFirst(units) {
148
+ const rank = new Map(_UNITS_COMMON_FIRST.map((u, i) => [u, i]));
149
+ return units.map((u, i) => [u, rank.has(u) ? rank.get(u) : 1000 + i]).sort((a, b) => a[1] - b[1]).map(x => x[0]);
150
+ }
141
151
  function ensureUnitDatalists() {
142
152
  if (typeof document === 'undefined' || !document.body) return;
143
153
  const q = (s) => String(s).replace(/"/g, '&quot;');
144
154
  const make = (id, units) => {
145
155
  let dl = document.getElementById(id);
146
156
  if (!dl) { dl = document.createElement('datalist'); dl.id = id; document.body.appendChild(dl); }
147
- dl.innerHTML = units.map((u) => `<option value="${q(u)}"></option>`).join('');
157
+ dl.innerHTML = unitsCommonFirst(units).map((u) => `<option value="${q(u)}"></option>`).join('');
148
158
  };
149
159
  make('fl-units-all', getAllUnits()); // composition / batch quantity
150
160
  make('fl-units-cost', getCostUnits()); // ingredient set-price (mass/vol/length/each)
151
- make('fl-units-qty', getQuantityUnits()); // physical quantity made (mass/vol/count, no fractions)
161
+ make('fl-units-qty', getQuantityUnits()); // an amount made: batch target, sample amount (mass/vol/count)
162
+ make('fl-units-vol', Object.keys(getVolUnits())); // a measured volume (22w)
152
163
  }
153
164
 
154
- // Units for a physical quantity made (sample / batch yield): mass, volume,
155
- // count — NOT fractions (wt%/ppm), which only make sense in a recipe ratio.
156
- // Mirrors the curated set the old <select> used (`_quantityUnitOptions`).
165
+ // Units for an amount made — a batch's target quantity and a sample's amount (22w: one list
166
+ // for both; the batch had borrowed the composition list, wt% / ppm / lengths / mol included,
167
+ // while samples lacked tonne, bbl, m³…): every mass and volume unit (your custom ones
168
+ // included), plus a count. NOT fractions (wt% / ppm), which only make sense in a recipe ratio,
169
+ // and not lengths.
157
170
  function getQuantityUnits() {
158
- const mass = ['mg', 'g', 'kg', 'ton', 'lb', 'oz'];
159
- const vol = ['µL', 'mL', 'L', 'tsp', 'tbsp', 'cup', 'fl_oz', 'pt', 'qt', 'gal', 'drum'];
160
- const count = ['units', 'each'];
161
- const custom = Object.keys((typeof db !== 'undefined' && db?.customUnits) || {});
162
- return [...new Set([...mass, ...vol, ...count, ...custom])];
171
+ const customCount = Object.entries((typeof db !== 'undefined' && db?.customUnits) || {})
172
+ .filter(([_, v]) => v?.dimension === 'count').map(([k]) => k);
173
+ return [...new Set([...Object.keys(getMassUnits()), ...Object.keys(getVolUnits()), 'units', 'each', ...customCount])];
163
174
  }
164
175
 
165
176
  // ============================================================
@@ -309,9 +320,25 @@ function getQuantityUnits() {
309
320
  }
310
321
 
311
322
  document.addEventListener('focusin', function (e) { if (isCombo(e.target) && !refocusing) open(e.target); });
323
+ // The ▾ drawn at the right end (css input.fl-unit-combo) acts like a ▾ button, as
324
+ // js/combo.js's fields do (22v): a hand over it, and a click there closes an OPEN list.
325
+ // "Open" is judged at mousedown: a click into an unfocused field opens it on focusin, and
326
+ // that same click must not close it again.
327
+ var ARROW_W = 28, openAtDown = false;
328
+ function onArrow(el, e) { var r = el.getBoundingClientRect(); return e.clientX >= r.right - ARROW_W; }
329
+ document.addEventListener('mousemove', function (e) {
330
+ if (!isCombo(e.target)) return;
331
+ var c = onArrow(e.target, e) ? 'pointer' : '';
332
+ if (e.target.style.cursor !== c) e.target.style.cursor = c;
333
+ });
334
+ document.addEventListener('mouseout', function (e) { if (isCombo(e.target)) e.target.style.cursor = ''; });
312
335
  // A click on the field reopens the list after a pick (the field kept focus,
313
336
  // so no focusin fires). Capture phase: dialogs stop click propagation.
314
- document.addEventListener('click', function (e) { if (isCombo(e.target) && !isOpen()) open(e.target); }, true);
337
+ document.addEventListener('click', function (e) {
338
+ if (!isCombo(e.target)) return;
339
+ if (openAtDown && onArrow(e.target, e)) { openAtDown = false; hide(); return; }
340
+ if (!isOpen()) open(e.target);
341
+ }, true);
315
342
  // Typing directly in the field filters too (only matters when there's no
316
343
  // search box — i.e. short lists; otherwise focus is in the search box).
317
344
  document.addEventListener('input', function (e) {
@@ -322,6 +349,7 @@ function getQuantityUnits() {
322
349
  document.addEventListener('keydown', function (e) { if (isCombo(e.target)) navKeydown(e); });
323
350
  // mousedown (capture) so a click on a suggestion wins over the input's blur.
324
351
  document.addEventListener('mousedown', function (e) {
352
+ if (isCombo(e.target)) openAtDown = isOpen() && activeInput === e.target;
325
353
  if (!isOpen()) return;
326
354
  var item = e.target.closest && e.target.closest('.fl-unit-combo-item');
327
355
  if (item) { e.preventDefault(); choose(item.getAttribute('data-val')); return; }
@@ -1839,7 +1867,7 @@ function _weighoutDensityLineHtml(w) {
1839
1867
  let src = d.how === 'measured' ? 'measured' : (d.how === 'spec' ? 'from spec' : 'entered');
1840
1868
  if (d.batchId && typeof db !== 'undefined') {
1841
1869
  const b = (db.batches || []).find(x => x.id === d.batchId);
1842
- if (b) src += ` on <a href="#" onclick="navigate('batch-detail','${esc(b.id)}');return false" style="color:var(--dk-blue-fg, #2563eb)">${esc(b.uid)}</a>`;
1870
+ if (b) src += ` on <a href="#" onclick="navigate('batch-detail','${escJsAttr(b.id)}');return false" style="color:var(--dk-blue-fg, #2563eb)">${esc(b.uid)}</a>`;
1843
1871
  }
1844
1872
  const parts = (w.partsSum != null && isFinite(w.partsSum) && Math.abs(w.partsSum - w.qty) > 1e-6)
1845
1873
  ? ` Pure-ingredient volumes sum to ${+w.partsSum.toFixed(2)} ${esc(w.unit)} — volumes aren't additive on mixing; the masses are what to weigh.` : '';
@@ -16,6 +16,10 @@
16
16
  // ============================================================
17
17
 
18
18
  const EQ_CAL_SOON_DAYS = 30;
19
+ // The longest interval taken as real (20 years). Anything above, at or below zero, or not a
20
+ // number counts as "no interval": a typo like 1e9 months used to throw in the date maths
21
+ // and take the Equipment list and the dashboard down with it.
22
+ const EQ_CAL_MAX_MONTHS = 240;
19
23
  const EQ_CAL_RESULTS = [
20
24
  { value: 'pass', label: 'Pass', hint: 'within tolerance as found' },
21
25
  { value: 'adjusted', label: 'Adjusted, then pass', hint: 'out of tolerance as found, adjusted and verified' },
@@ -30,19 +34,31 @@ function _eqCalToday() {
30
34
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
31
35
  }
32
36
  function eqCalOn() { return typeof hasFeature !== 'function' || hasFeature('equipment.calibration'); }
37
+ // Whole months, 1..EQ_CAL_MAX_MONTHS, else 0.
38
+ function eqCalInterval(e) {
39
+ const n = Math.round(Number(e && e.calIntervalMonths));
40
+ return (n >= 1 && n <= EQ_CAL_MAX_MONTHS) ? n : 0;
41
+ }
42
+ const _EQ_CAL_ISO = /^\d{4}-\d{2}-\d{2}$/;
43
+ // Ids go into onclick="fn('…')": escJsAttr (utils.js) also covers the ' that esc() leaves.
44
+ function _eqCalJs(v) { return typeof escJsAttr === 'function' ? escJsAttr(v) : String(v == null ? '' : v).replace(/[^\w-]/g, ''); }
33
45
 
34
- // Newest first; entries without a date last.
46
+ // Newest first; entries without a date last. Two on the same day (a fail, then the
47
+ // adjusted re-calibration) are ordered by when they were logged, the later first.
35
48
  function eqCalHistory(e) {
36
- const list = (e && Array.isArray(e.calibrations)) ? e.calibrations.filter(Boolean) : [];
37
- return list.slice().sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
49
+ const list = (e && Array.isArray(e.calibrations)) ? e.calibrations.filter(c => c && typeof c === 'object') : [];
50
+ return list.slice().sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))
51
+ || String(b.loggedAt || '').localeCompare(String(a.loggedAt || '')));
38
52
  }
39
53
 
40
54
  // Calendar months, clamped to the month's last day (Jan 31 + 1 mo = Feb 28/29), as
41
55
  // flDateAddIso (utils.js) — kept here so the MCP server's vendored copy needs nothing else.
42
56
  function _eqCalAddMonths(iso, months) {
43
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(iso || '')) || !(months > 0)) return '';
57
+ months = Math.round(Number(months));
58
+ if (!_EQ_CAL_ISO.test(String(iso || '')) || !(months > 0) || months > EQ_CAL_MAX_MONTHS) return '';
44
59
  const [y, m, d] = String(iso).split('-').map(Number);
45
60
  const first = new Date(Date.UTC(y, m - 1 + months, 1));
61
+ if (!isFinite(first.getTime())) return '';
46
62
  const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
47
63
  return new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), Math.min(d, last))).toISOString().slice(0, 10);
48
64
  }
@@ -61,15 +77,18 @@ function _eqCalDays(fromIso, toIso) {
61
77
  // due: the next due date (the latest entry's own nextDue, else its date + the interval)
62
78
  function eqCalStatus(e, todayIso) {
63
79
  const today = todayIso || _eqCalToday();
64
- const interval = Number(e && e.calIntervalMonths) || 0;
80
+ const interval = eqCalInterval(e);
65
81
  const hist = eqCalHistory(e);
66
82
  const last = hist[0] || null;
67
- const base = { state: 'none', due: '', last: last ? (last.date || '') : '', lastResult: last ? (last.result || '') : '', interval, daysToDue: null, count: hist.length };
83
+ // Results are written lower-case; an imported "FAIL" is still a fail.
84
+ const result = last ? String(last.result || '').trim().toLowerCase() : '';
85
+ const base = { state: 'none', due: '', last: last ? (last.date || '') : '', lastResult: result, interval, daysToDue: null, count: hist.length };
68
86
  if (!e || e.status === 'Retired') return base;
69
87
  if (!last) return interval ? { ...base, state: 'never' } : base;
70
88
  // A failed instrument has no next due date: it needs a passing calibration first.
71
- if (last.result === 'fail') return { ...base, state: 'failed' };
72
- const due = last.nextDue || _eqCalAddMonths(last.date, interval) || '';
89
+ if (result === 'fail') return { ...base, state: 'failed' };
90
+ // A next due that is not a date (an import's "soon") falls back to the interval.
91
+ const due = (_EQ_CAL_ISO.test(String(last.nextDue || '')) ? last.nextDue : '') || _eqCalAddMonths(last.date, interval) || '';
73
92
  const days = due ? _eqCalDays(today, due) : null;
74
93
  if (!due) return { ...base, state: 'ok' };
75
94
  const state = days < 0 ? 'overdue' : days <= EQ_CAL_SOON_DAYS ? 'soon' : 'ok';
@@ -118,12 +137,12 @@ function eqCalCardHtml(e) {
118
137
  </div></div>`;
119
138
  }
120
139
  const st = eqCalStatus(e);
121
- const interval = Number(e.calIntervalMonths) || 0;
140
+ const interval = eqCalInterval(e);
122
141
  const files = Array.isArray(e.fileAttachments) ? e.fileAttachments : [];
123
142
  const certName = (id) => { const f = files.find(a => a && a.id === id); return f ? (f.name || f.title || f.url || 'Certificate') : ''; };
124
143
  const editBtns = on ? `<div style="display:flex;gap:6px">
125
- <button class="btn btn-secondary btn-sm" onclick="openEqCalIntervalModal('${e.id}')">${interval ? 'Every ' + interval + ' mo' : 'Set interval'}</button>
126
- <button class="btn btn-primary btn-sm" onclick="openEqCalLogModal('${e.id}')">+ Log calibration</button>
144
+ <button class="btn btn-secondary btn-sm" onclick="openEqCalIntervalModal('${_eqCalJs(e.id)}')">${interval ? 'Every ' + interval + ' mo' : 'Set interval'}</button>
145
+ <button class="btn btn-primary btn-sm" onclick="openEqCalLogModal('${_eqCalJs(e.id)}')">+ Log calibration</button>
127
146
  </div>` : `<button class="btn btn-secondary btn-sm" onclick="proUpsell('equipment.calibration')"><span class="fl-pro" aria-hidden="true"></span> Pro</button>`;
128
147
  const summary = st.state === 'none'
129
148
  ? `<div style="color:var(--text-muted);font-size:13px">Not tracked yet. Set how often it is calibrated, or log its last calibration.</div>`
@@ -138,9 +157,9 @@ function eqCalCardHtml(e) {
138
157
  <td>${esc(c.by || '—')}</td>
139
158
  <td>${c.result ? `<span class="badge" style="${_EQ_CAL_TONE[c.result === 'fail' ? 'failed' : c.result === 'adjusted' ? 'soon' : 'ok']};font-size:11px">${esc(_EQ_CAL_RESULT_LABEL[c.result] || c.result)}</span>` : '—'}</td>
140
159
  <td style="font-variant-numeric:tabular-nums">${esc(c.nextDue || '—')}</td>
141
- <td>${c.certificateId && certName(c.certificateId) ? `<a style="color:var(--primary);cursor:pointer" onclick="attachOpenInTab('${esc(c.certificateId)}')">${esc(certName(c.certificateId))}</a>` : (c.certificateRef ? esc(c.certificateRef) : '<span style="color:var(--dk-dim, #cbd5e1)">—</span>')}</td>
160
+ <td>${c.certificateId && certName(c.certificateId) ? `<a style="color:var(--primary);cursor:pointer" onclick="attachOpenInTab('${_eqCalJs(c.certificateId)}')">${esc(certName(c.certificateId))}</a>` : (c.certificateRef ? esc(c.certificateRef) : '<span style="color:var(--dk-dim, #cbd5e1)">—</span>')}</td>
142
161
  <td style="color:var(--text-muted);max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(c.notes || '')}">${esc(c.notes || '')}</td>
143
- <td style="text-align:right;white-space:nowrap">${on ? `<button class="btn btn-secondary btn-sm" onclick="openEqCalLogModal('${e.id}','${esc(c.id)}')" title="Edit this calibration">${typeof ICON !== 'undefined' && ICON.edit ? ICON.edit : 'Edit'}</button>` : ''}</td>
162
+ <td style="text-align:right;white-space:nowrap">${on ? `<button class="btn btn-secondary btn-sm" onclick="openEqCalLogModal('${_eqCalJs(e.id)}','${_eqCalJs(c.id)}')" title="Edit this calibration">${typeof ICON !== 'undefined' && ICON.edit ? ICON.edit : 'Edit'}</button>` : ''}</td>
144
163
  </tr>`).join('');
145
164
  const table = hist.length ? `<div class="table-wrap" style="margin-top:14px"><table class="eq-cal-table">
146
165
  <thead><tr><th>Date</th><th>Performed by</th><th>Result</th><th>Next due</th><th>Certificate</th><th>Notes</th><th></th></tr></thead>
@@ -158,12 +177,13 @@ function openEqCalIntervalModal(eqId) {
158
177
  if (!e) return;
159
178
  showModal('Calibration interval', `
160
179
  <div class="form-group"><label>Calibrate every</label>
161
- <div style="display:flex;align-items:center;gap:8px"><input id="eq-cal-interval" type="number" min="0" step="1" style="width:110px" value="${esc(e.calIntervalMonths || '')}" placeholder="12"> <span>months</span></div>
180
+ <div style="display:flex;align-items:center;gap:8px"><input id="eq-cal-interval" type="number" min="0" step="1" style="width:110px" value="${esc(eqCalInterval(e) || '')}" placeholder="12" max="${EQ_CAL_MAX_MONTHS}"> <span>months</span></div>
162
181
  <div style="font-size:12px;color:var(--text-muted);margin-top:6px">Sets the next due date from each calibration's date. Leave empty to stop tracking a due date (the history stays).</div>
163
182
  </div>`, () => {
164
183
  const raw = (document.getElementById('eq-cal-interval') || {}).value;
165
184
  const n = raw === '' ? null : Math.round(Number(raw));
166
185
  if (n != null && !(n >= 0)) { toast('Enter a number of months.'); return false; }
186
+ if (n > EQ_CAL_MAX_MONTHS) { toast(`Up to ${EQ_CAL_MAX_MONTHS} months (20 years).`); return false; }
167
187
  updateEquipment(eqId, { calIntervalMonths: n || null });
168
188
  if (typeof renderEquipmentDetail === 'function') renderEquipmentDetail(eqId);
169
189
  return true;
@@ -179,13 +199,13 @@ function openEqCalLogModal(eqId, calId) {
179
199
  const e = typeof getEquipment === 'function' ? getEquipment(eqId) : (db.equipment || []).find(x => x.id === eqId);
180
200
  if (!e) return;
181
201
  const c = calId ? (e.calibrations || []).find(x => x && x.id === calId) : null;
182
- const interval = Number(e.calIntervalMonths) || 0;
202
+ const interval = eqCalInterval(e);
183
203
  const date = c ? (c.date || '') : _eqCalToday();
184
204
  const files = Array.isArray(e.fileAttachments) ? e.fileAttachments : [];
185
205
  const certOpts = `<option value="">— None —</option>` + files.map(a => `<option value="${esc(a.id)}"${c && c.certificateId === a.id ? ' selected' : ''}>${esc(a.name || a.title || a.url || a.id)}</option>`).join('');
186
206
  const hint = (t) => `<div style="font-size:11.5px;color:var(--text-muted);margin-top:4px;line-height:1.4">${t}</div>`;
187
207
  const body = `<div class="form-grid">
188
- <div class="form-group"><label>Date calibrated *</label><input id="eq-cal-date" type="date" value="${esc(date)}" onchange="_eqCalAutoDue(${interval})"></div>
208
+ <div class="form-group"><label>Date calibrated *</label><input id="eq-cal-date" type="date" value="${esc(date)}" max="${_eqCalToday()}" onchange="_eqCalAutoDue(${interval})"></div>
189
209
  <div class="form-group"><label>Performed by</label><input id="eq-cal-by" value="${esc(c ? (c.by || '') : _eqCalMe())}" placeholder="Name, or the service provider" data-fl-person="equipment.calibrations[].by testResults.performedBy equipment.owner"></div>
190
210
  <div class="form-group span-2"><label>Result *</label>
191
211
  <select id="eq-cal-result">${EQ_CAL_RESULTS.map(r => `<option value="${r.value}"${(c ? c.result : 'pass') === r.value ? ' selected' : ''}>${esc(r.label)} — ${esc(r.hint)}</option>`).join('')}</select>
@@ -211,9 +231,13 @@ function openEqCalLogModal(eqId, calId) {
211
231
  loggedBy: c ? (c.loggedBy || '') : _eqCalMe(),
212
232
  };
213
233
  if (!/^\d{4}-\d{2}-\d{2}$/.test(rec.date)) { toast('Enter the date it was calibrated.'); return false; }
234
+ if (rec.date > _eqCalToday()) { toast('The calibration date is in the future. Log it once it is done.'); return false; }
214
235
  if (rec.nextDue && rec.nextDue < rec.date) { toast('Next due is before the calibration date.'); return false; }
215
236
  Object.keys(rec).forEach(k => rec[k] === undefined && delete rec[k]);
216
- const list = (e.calibrations || []).filter(x => x && x.id !== rec.id);
237
+ // The record as it is NOW: a calibration synced in from another device while this
238
+ // dialog was open must not be dropped by a list read when it opened.
239
+ const cur = (typeof getEquipment === 'function' ? getEquipment(eqId) : null) || e;
240
+ const list = (cur.calibrations || []).filter(x => x && x.id !== rec.id);
217
241
  list.push(rec);
218
242
  const patch = { calibrations: list };
219
243
  updateEquipment(eqId, patch);
@@ -225,7 +249,7 @@ function openEqCalLogModal(eqId, calId) {
225
249
  // Delete sits in the dialog's footer, left of the buttons.
226
250
  setTimeout(() => {
227
251
  const foot = document.querySelector('#modal-backdrop .modal-footer');
228
- if (foot && !foot.querySelector('.eq-cal-del')) foot.insertAdjacentHTML('afterbegin', `<button class="btn btn-danger btn-sm eq-cal-del" style="margin-right:auto" onclick="deleteEqCal('${eqId}','${esc(c.id)}')">Delete</button>`);
252
+ if (foot && !foot.querySelector('.eq-cal-del')) foot.insertAdjacentHTML('afterbegin', `<button class="btn btn-danger btn-sm eq-cal-del" style="margin-right:auto" onclick="deleteEqCal('${_eqCalJs(eqId)}','${_eqCalJs(c.id)}')">Delete</button>`);
229
253
  }, 0);
230
254
  }
231
255
  }
package/SUBMISSION.md DELETED
@@ -1,132 +0,0 @@
1
- # MCP submission — ready-to-paste materials
2
-
3
- Both directory paths are prepped. Path B is fastest; Path A is the canonical
4
- registry placement. Recommended order: B first (immediate discoverability),
5
- then A (long-term legitimacy).
6
-
7
- ---
8
-
9
- ## Path B — `awesome-mcp-servers` PR
10
-
11
- Three lists worth submitting to, in rough order of reach:
12
-
13
- 1. **[punkpeye/awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers)** (~50k stars, primary)
14
- 2. **[appcypher/awesome-mcp-servers](https://github.com/appcypher/awesome-mcp-servers)**
15
- 3. **[wong2/awesome-mcp-servers](https://github.com/wong2/awesome-mcp-servers)**
16
-
17
- ### Entry markdown (drop into the right category section)
18
-
19
- ```markdown
20
- - [**formlab-mcp**](https://github.com/juliu1980/FormLab/tree/main/mcp) - Local-first read access to your [FormLab](https://formvix.com) lab notebook — formulations, ingredients, batches, samples, test results, DOE matrices. Data stays on your machine; only the LLM's answer travels. [Listed in the official MCP Registry.](https://registry.modelcontextprotocol.io/?search=formlab) [![npm](https://img.shields.io/npm/v/formlab-mcp.svg)](https://www.npmjs.com/package/formlab-mcp)
21
- ```
22
-
23
- ### Suggested category
24
-
25
- Best fit on `punkpeye/awesome-mcp-servers`: **"📊 Data Science Tools"** or
26
- **"🔬 Knowledge & Memory"** depending on how the list is structured. Skim the
27
- TOC and pick whichever section already has domain-specific lab/research
28
- servers. If neither fits, **"🛠️ Other Tools and Integrations"** is the safe
29
- catch-all.
30
-
31
- ### PR title
32
-
33
- ```
34
- Add formlab-mcp — local-first lab-notebook server for chem/cosmetic R&D
35
- ```
36
-
37
- ### PR body
38
-
39
- ```markdown
40
- Adds **formlab-mcp**, a read-only MCP server that exposes a [FormLab](https://formvix.com)
41
- lab notebook export to Claude (or any MCP client). FormLab is a browser-based
42
- local-first ELN for formulation R&D — paints, coatings, cosmetics, polymers.
43
-
44
- The server lets users ask Claude things like _"which of my formulations use
45
- silicone fluid at 5%+?"_, _"compare FORM-001 and FORM-004 side by side"_,
46
- _"what parameters fail acceptance most often?"_, or _"show me the DOE matrix
47
- at sample grain for Anti-Aging Serum family"_ — all against a JSON export
48
- that stays on the user's laptop.
49
-
50
- **37 read-only tools** covering formulations, ingredients, batches, samples,
51
- test results, DOE matrices + saved designs, similarity, failure analysis,
52
- test-coverage AND composition design-space mapping (varying ingredients,
53
- untested gaps, PCA), equipment, test methods, panels, projects and ELN notes.
54
-
55
- - Repo: https://github.com/juliu1980/FormLab/tree/main/mcp
56
- - npm: https://www.npmjs.com/package/formlab-mcp
57
- - Listed in the official MCP Registry: `io.github.juliu1980/formlab-mcp`
58
- - Node 18+; runs via `npx formlab-mcp /path/to/export.json`
59
- - License: MIT
60
-
61
- Happy to move the entry to a different category if there's a better fit.
62
- ```
63
-
64
- ### Submission workflow
65
-
66
- For each fork target above:
67
-
68
- 1. Visit https://github.com/punkpeye/awesome-mcp-servers (or the alt)
69
- 2. Open `README.md` → click ✏ (Edit) → GitHub will fork on your behalf
70
- 3. Find the appropriate `###` category section
71
- 4. Paste the **Entry markdown** in alphabetical order within the section
72
- 5. Commit message: `Add formlab-mcp`
73
- 6. Open PR with the **PR title** and **PR body** above
74
-
75
- Total time for all three: ~15 minutes.
76
-
77
- ---
78
-
79
- ## Path A — Official MCP Registry
80
-
81
- This places `formlab-mcp` in `https://registry.modelcontextprotocol.io`,
82
- which is what future MCP-aware tools will query first.
83
-
84
- ### Prereqs
85
-
86
- ```bash
87
- # Go 1.24+ — needed to build mcp-publisher
88
- brew install go
89
-
90
- # Build the publisher CLI
91
- git clone https://github.com/modelcontextprotocol/registry
92
- cd registry
93
- make publisher # produces ./bin/mcp-publisher
94
- sudo mv bin/mcp-publisher /usr/local/bin/ # or add to PATH
95
- ```
96
-
97
- ### Manifest
98
-
99
- Already written to `mcp/server.json` in this repo. Uses:
100
- - Namespace: `io.github.juliu1980/formlab-mcp` (GitHub OAuth proves ownership)
101
- - Status: `active`
102
- - Repository: `https://github.com/juliu1980/FormLab` with subfolder `mcp`
103
- - Version: pulled from `package.json` (currently `0.6.12`)
104
- - Package: npm `formlab-mcp` with stdio transport + positional `exportPath` arg
105
-
106
- ### Publish (the OAuth step is interactive)
107
-
108
- ```bash
109
- cd ~/GitHub/formlab/mcp
110
- mcp-publisher login # opens GitHub OAuth in your browser as juliu1980
111
- mcp-publisher publish # reads server.json and submits
112
- ```
113
-
114
- ### Verify
115
-
116
- After publish, the server appears at:
117
- - `https://registry.modelcontextprotocol.io/v0/servers?search=formlab`
118
-
119
- ### Versioning future updates
120
-
121
- Bump `package.json` `version` AND `mcp/server.json`'s `version` + the package
122
- version, then re-run `mcp-publisher publish`. The CLI rejects duplicate
123
- version submissions.
124
-
125
- ---
126
-
127
- ## After both paths land
128
-
129
- - Post to X / LinkedIn announcing it (memory file has a draft)
130
- - Cross-post to r/cosmeticchemistry, r/paint, r/Chemistry — any lab-adjacent
131
- subreddits where local-first + AI lands well
132
- - Add a section to the formvix.com landing page linking to the registry entry
@@ -1 +0,0 @@
1
- {"ref":"ufizhypibwqqpavhxdwt","name":"FormLab","organization_id":"pbplgvokbayhflgrvygl","organization_slug":"pbplgvokbayhflgrvygl"}