formlab-mcp 0.6.55 → 0.6.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.55",
3
+ "version": "0.6.56",
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.6.55",
5
+ "version": "0.6.56",
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.55",
16
+ "version": "0.6.56",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
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, '"');
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; }
@@ -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
  }