formlab-mcp 0.5.0 → 0.5.2
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 +10 -11
- package/index.js +1 -1
- package/package.json +1 -1
- package/server.json +14 -6
- package/tools/analytics.js +52 -6
- package/tools/library.js +11 -3
package/README.md
CHANGED
|
@@ -39,8 +39,8 @@ Ask Claude (or another MCP client) things like:
|
|
|
39
39
|
| `get_batch` | Full record + actual composition + samples + blend lineage |
|
|
40
40
|
| `list_samples` | Filtered list of physical specimens |
|
|
41
41
|
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
42
|
-
| `list_test_results` | Filtered list of test reports |
|
|
43
|
-
| `get_test_result` | Full
|
|
42
|
+
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
|
|
43
|
+
| `get_test_result` | Full report: every measurement's value, spec, and the **resolved instrument** (`instrumentSource`: row / method / run) |
|
|
44
44
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
45
45
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
46
46
|
| `get_coverage_matrix` | Which formulations × parameters have been measured |
|
|
@@ -93,23 +93,22 @@ The MCP server watches the file — re-export from FormLab and the next tool cal
|
|
|
93
93
|
Instead of an export file, point the server at your **live cloud workspace** so it's always current.
|
|
94
94
|
|
|
95
95
|
1. In FormLab (Pro): **Settings → Account → AI tools (MCP) → Connect…**
|
|
96
|
-
2.
|
|
96
|
+
2. Click **Create token**, copy the generated config, and paste it into Claude Desktop.
|
|
97
97
|
|
|
98
|
-
The config sets
|
|
98
|
+
The config sets a single env var, which switches the server into cloud mode:
|
|
99
99
|
|
|
100
100
|
| Env var | Value |
|
|
101
101
|
|---|---|
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `FORMLAB_WORKSPACE_ID` | (optional) workspace to read; defaults to your own |
|
|
102
|
+
| `FORMLAB_MCP_TOKEN` | a dedicated, **read-only**, revocable token (`flmcp_…`) |
|
|
103
|
+
| `FORMLAB_SUPABASE_URL` | (optional) override the backend URL — defaults to production |
|
|
104
|
+
| `FORMLAB_SUPABASE_ANON_KEY` | (optional) override the publishable key — defaults to production |
|
|
106
105
|
| `FORMLAB_REFRESH_SECONDS` | (optional) poll interval, default `60` |
|
|
107
106
|
|
|
108
|
-
When `
|
|
107
|
+
When `FORMLAB_MCP_TOKEN` is present, the server POSTs it to FormLab's `mcp-data` Edge Function, which returns your workspace scoped to you by row-level security and re-fetches every `FORMLAB_REFRESH_SECONDS`. It's **read-only** — enforced at the database (a dedicated `mcp_readonly` Postgres role with `SELECT`-only grants), not by trust.
|
|
109
108
|
|
|
110
|
-
>
|
|
109
|
+
> **Security.** The token grants **read-only** access to one workspace and holds **no account session** — only a SHA-256 hash is stored server-side, and it never rotates. Treat the config like a password (don't share or commit it). **Revoke or re-mint any time** from *Settings → Account → AI tools (MCP) → Connect*.
|
|
111
110
|
|
|
112
|
-
Cloud mode
|
|
111
|
+
Cloud mode has no extra dependency — it's a plain `fetch`.
|
|
113
112
|
|
|
114
113
|
## Wire it up to Claude Desktop
|
|
115
114
|
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
"websiteUrl": "https://formvix.com",
|
|
4
|
+
"description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
|
|
5
|
+
"version": "0.5.2",
|
|
6
|
+
"websiteUrl": "https://formvix.com/mcp",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/juliu1980/FormLab",
|
|
9
9
|
"source": "github",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "formlab-mcp",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.5.2",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
@@ -22,11 +22,19 @@
|
|
|
22
22
|
{
|
|
23
23
|
"type": "positional",
|
|
24
24
|
"name": "exportPath",
|
|
25
|
-
"description": "
|
|
26
|
-
"isRequired":
|
|
25
|
+
"description": "File mode: absolute path to a FormLab export JSON (Data → Export JSON). Omit when using cloud mode (set FORMLAB_MCP_TOKEN instead).",
|
|
26
|
+
"isRequired": false,
|
|
27
27
|
"format": "filepath",
|
|
28
28
|
"value": "/path/to/formlab-export.json"
|
|
29
29
|
}
|
|
30
|
+
],
|
|
31
|
+
"environmentVariables": [
|
|
32
|
+
{
|
|
33
|
+
"name": "FORMLAB_MCP_TOKEN",
|
|
34
|
+
"description": "Cloud mode: a dedicated read-only, revocable token (flmcp_…) minted in FormLab → Settings → Account → AI tools (MCP) → Connect. Reads your live workspace, scoped to you by row-level security. Omit for file mode.",
|
|
35
|
+
"isRequired": false,
|
|
36
|
+
"isSecret": true
|
|
37
|
+
}
|
|
30
38
|
]
|
|
31
39
|
}
|
|
32
40
|
]
|
package/tools/analytics.js
CHANGED
|
@@ -9,6 +9,34 @@
|
|
|
9
9
|
|
|
10
10
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
11
11
|
|
|
12
|
+
// Which instrument produced a measurement. Mirrors the app's _trMeasInstrument:
|
|
13
|
+
// a measurement's raw `instrument` is usually EMPTY because blank means inherit,
|
|
14
|
+
// so returning the stored field alone would be near-useless here. Resolve the
|
|
15
|
+
// same cascade the UI shows, and report the source so a consumer can tell a
|
|
16
|
+
// recorded fact from an inherited default:
|
|
17
|
+
// 'row' — the measurement names its own instrument
|
|
18
|
+
// 'method' — from the parameter's Test Method equipment (the usual case)
|
|
19
|
+
// 'run' — the report-level default (an assumption, not evidence)
|
|
20
|
+
function _resolveInstrument(m, t, db) {
|
|
21
|
+
if (m && (m.instrument || m.equipmentId)) {
|
|
22
|
+
return { instrument: m.instrument || '', equipmentId: m.equipmentId || null, instrumentSource: 'row' };
|
|
23
|
+
}
|
|
24
|
+
const params = (db && db.parameters) || [];
|
|
25
|
+
let pm = null;
|
|
26
|
+
if (m && m.parameterId) pm = params.find(p => p && p.id === m.parameterId) || null;
|
|
27
|
+
if (!pm && m) {
|
|
28
|
+
const nm = String(m.parameter || '').trim().toLowerCase();
|
|
29
|
+
if (nm) pm = params.find(p => p && String(p.name || '').trim().toLowerCase() === nm) || null;
|
|
30
|
+
}
|
|
31
|
+
if (pm && (pm.equipment || pm.equipmentId)) {
|
|
32
|
+
return { instrument: pm.equipment || '', equipmentId: pm.equipmentId || null, instrumentSource: 'method' };
|
|
33
|
+
}
|
|
34
|
+
if (t && (t.instrument || t.equipmentId)) {
|
|
35
|
+
return { instrument: t.instrument || '', equipmentId: t.equipmentId || null, instrumentSource: 'run' };
|
|
36
|
+
}
|
|
37
|
+
return { instrument: '', equipmentId: null, instrumentSource: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
function _trimTest(t, samplesById) {
|
|
13
41
|
if (!t) return null;
|
|
14
42
|
const s = t.sampleId ? samplesById.get(t.sampleId) : null;
|
|
@@ -20,12 +48,16 @@ function _trimTest(t, samplesById) {
|
|
|
20
48
|
testDate: t.testDate || '',
|
|
21
49
|
performedBy: t.performedBy || '',
|
|
22
50
|
lab: t.lab || '',
|
|
51
|
+
instrument: t.instrument || '', // run-level default; measurements may override
|
|
52
|
+
equipmentId: t.equipmentId || null,
|
|
53
|
+
controlSample: !!t.controlSample, // a re-measured reference — the valid basis for drift
|
|
23
54
|
measurementCount: (t.measurements || []).length,
|
|
24
55
|
parameters: [...new Set((t.measurements || []).map(m => m.parameter).filter(Boolean))],
|
|
25
56
|
};
|
|
26
57
|
}
|
|
27
58
|
|
|
28
|
-
function _trimMeasurement(m) {
|
|
59
|
+
function _trimMeasurement(m, t, db) {
|
|
60
|
+
const ins = _resolveInstrument(m, t, db);
|
|
29
61
|
return {
|
|
30
62
|
parameter: m.parameter || '',
|
|
31
63
|
value: m.value ?? null,
|
|
@@ -34,18 +66,21 @@ function _trimMeasurement(m) {
|
|
|
34
66
|
pointCount: Array.isArray(m.points) ? m.points.length : 0,
|
|
35
67
|
binCount: Array.isArray(m.bins) ? m.bins.length : 0,
|
|
36
68
|
acceptanceCriteria: m.acceptanceCriteria || null,
|
|
69
|
+
...ins, // instrument, equipmentId, instrumentSource
|
|
37
70
|
};
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
const list_test_results = {
|
|
41
74
|
definition: {
|
|
42
75
|
name: 'list_test_results',
|
|
43
|
-
description: 'List test result reports, optionally filtered by sample, parameter, date range, or lab. Returns metadata + parameters tested
|
|
76
|
+
description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). Returns metadata (incl. the run-level instrument and controlSample flag) + parameters tested; use get_test_result for per-measurement values and their resolved instruments.',
|
|
44
77
|
inputSchema: {
|
|
45
78
|
type: 'object',
|
|
46
79
|
properties: {
|
|
47
80
|
sample_id: { type: 'string', description: 'Filter to results for this sample (id or UID).' },
|
|
48
81
|
parameter: { type: 'string', description: 'Filter to results that include this parameter (case-insensitive substring).' },
|
|
82
|
+
value_min: { type: 'number', description: 'Only reports where the parameter (above) measured >= this value. Use with `parameter`; applies to any measurement if `parameter` is omitted.' },
|
|
83
|
+
value_max: { type: 'number', description: 'Only reports where the parameter (above) measured <= this value. Use with `parameter`; applies to any measurement if `parameter` is omitted.' },
|
|
49
84
|
since_date: { type: 'string', description: 'ISO date — only reports on/after this date.' },
|
|
50
85
|
until_date: { type: 'string', description: 'ISO date — only reports on/before this date.' },
|
|
51
86
|
lab: { type: 'string', description: 'Filter by lab name (case-insensitive substring).' },
|
|
@@ -69,8 +104,19 @@ const list_test_results = {
|
|
|
69
104
|
if (args.since_date && (t.testDate || '') < args.since_date) return false;
|
|
70
105
|
if (args.until_date && (t.testDate || '') > args.until_date) return false;
|
|
71
106
|
if (args.lab && !norm(t.lab).includes(norm(args.lab))) return false;
|
|
72
|
-
|
|
73
|
-
|
|
107
|
+
const hasRange = args.value_min != null || args.value_max != null;
|
|
108
|
+
if (args.parameter || hasRange) {
|
|
109
|
+
const pn = norm(args.parameter);
|
|
110
|
+
const found = (t.measurements || []).some(m => {
|
|
111
|
+
if (args.parameter && !norm(m.parameter).includes(pn)) return false;
|
|
112
|
+
if (hasRange) {
|
|
113
|
+
const v = parseFloat(m.value);
|
|
114
|
+
if (!Number.isFinite(v)) return false;
|
|
115
|
+
if (args.value_min != null && v < args.value_min) return false;
|
|
116
|
+
if (args.value_max != null && v > args.value_max) return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
74
120
|
if (!found) return false;
|
|
75
121
|
}
|
|
76
122
|
return true;
|
|
@@ -86,7 +132,7 @@ const list_test_results = {
|
|
|
86
132
|
const get_test_result = {
|
|
87
133
|
definition: {
|
|
88
134
|
name: 'get_test_result',
|
|
89
|
-
description: 'Get full details for a single test result report
|
|
135
|
+
description: 'Get full details for a single test result report: every measurement\'s value, unit, type, acceptance criteria, and the INSTRUMENT that produced it. instrument is RESOLVED (measurement override → the parameter\'s Test Method equipment → the report default) with instrumentSource telling you which — treat source "run" as an inherited assumption, not evidence. Accepts internal id or UID.',
|
|
90
136
|
inputSchema: {
|
|
91
137
|
type: 'object',
|
|
92
138
|
properties: {
|
|
@@ -102,7 +148,7 @@ const get_test_result = {
|
|
|
102
148
|
return {
|
|
103
149
|
..._trimTest(t, indexes.samplesById),
|
|
104
150
|
notes: t.notes || '',
|
|
105
|
-
measurements: (t.measurements || []).map(_trimMeasurement),
|
|
151
|
+
measurements: (t.measurements || []).map(m => _trimMeasurement(m, t, getStore().db)),
|
|
106
152
|
};
|
|
107
153
|
},
|
|
108
154
|
};
|
package/tools/library.js
CHANGED
|
@@ -176,7 +176,7 @@ const list_test_methods = {
|
|
|
176
176
|
const get_test_method = {
|
|
177
177
|
definition: {
|
|
178
178
|
name: 'get_test_method',
|
|
179
|
-
description: 'Get one Test Method by UID (e.g. PARAM-001) or id — full definition including SOP / sample prep / equipment / calculation / acceptance spec, plus sibling methods that share its analyte (the same property measured at other conditions).',
|
|
179
|
+
description: 'Get one Test Method by UID (e.g. PARAM-001) or id — full definition including SOP / sample prep / equipment / calculation / acceptance spec, plus sibling methods that share its analyte (the same property measured at other conditions) and the Test Panels that use this method.',
|
|
180
180
|
inputSchema: {
|
|
181
181
|
type: 'object',
|
|
182
182
|
properties: { id: { type: 'string', description: 'Test Method UID or internal id.' } },
|
|
@@ -191,7 +191,13 @@ const get_test_method = {
|
|
|
191
191
|
const siblings = an
|
|
192
192
|
? (db.parameters || []).filter(x => x.id !== p.id && _norm(x.analyte) === an).map(x => x.uid || x.name).slice(0, 20)
|
|
193
193
|
: [];
|
|
194
|
-
|
|
194
|
+
// Reverse usage: which Test Panels reference this method (by id, else by name).
|
|
195
|
+
const nm = _norm(p.name);
|
|
196
|
+
const usingPanels = (db.templates || []).filter(t => (t.parameters || []).some(row =>
|
|
197
|
+
(row.parameterId && row.parameterId === p.id) ||
|
|
198
|
+
(!row.parameterId && _norm(row.parameter || row.name) === nm)
|
|
199
|
+
)).map(t => t.uid || t.name).slice(0, 40);
|
|
200
|
+
return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings, usingPanels };
|
|
195
201
|
},
|
|
196
202
|
};
|
|
197
203
|
|
|
@@ -278,8 +284,10 @@ function _trimPanelParam(row, db) {
|
|
|
278
284
|
parameterId: row.parameterId || null,
|
|
279
285
|
methodUid: method ? (method.uid || null) : null,
|
|
280
286
|
unit: row.unit || (method && method.defaultUnit) || '',
|
|
281
|
-
...(row.spec ? { spec: row.spec } : {}),
|
|
287
|
+
...(row.spec || row.specRaw ? { spec: row.spec || row.specRaw } : {}),
|
|
282
288
|
...(row.method ? { method: row.method } : {}),
|
|
289
|
+
...(row.required ? { required: true } : {}),
|
|
290
|
+
...(row.minReps && row.minReps > 1 ? { minReps: row.minReps } : {}),
|
|
283
291
|
};
|
|
284
292
|
}
|
|
285
293
|
|