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 +39 -25
- package/index.js +2 -2
- package/package.json +16 -8
- package/server.json +4 -9
- package/vendor/cost.js +40 -12
- package/vendor/equipment-calibration.js +42 -18
- package/SUBMISSION.md +0 -132
- package/supabase/.temp/linked-project.json +0 -1
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# formlab-mcp
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/formlab-mcp)
|
|
4
|
-
[](https://
|
|
4
|
+
[](https://www.npmjs.com/package/formlab-mcp)
|
|
5
5
|
[](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
|
|
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
|
-
>
|
|
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
|
-
|
|
85
|
+
To read or run the source, unpack it from npm (it ships in the package, MIT):
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
|
|
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.
|
|
99
|
-
3.
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
- **
|
|
177
|
-
- **
|
|
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
|
|
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 →
|
|
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.
|
|
3
|
+
"version": "0.6.57",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
|
-
"description": "Read-only Model Context Protocol server for FormLab
|
|
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
|
|
5
|
-
"version": "0.6.
|
|
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.
|
|
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, '"');
|
|
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()); //
|
|
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
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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) {
|
|
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','${
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
80
|
+
const interval = eqCalInterval(e);
|
|
65
81
|
const hist = eqCalHistory(e);
|
|
66
82
|
const last = hist[0] || null;
|
|
67
|
-
|
|
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 (
|
|
72
|
-
|
|
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 =
|
|
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('${
|
|
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}','${
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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}','${
|
|
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) [](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"}
|