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 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 record + every measurement value |
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. Copy the generated config and paste it into Claude Desktop (it merges with the file-mode example below).
96
+ 2. Click **Create token**, copy the generated config, and paste it into Claude Desktop.
97
97
 
98
- The config sets these env vars (which switch the server into cloud mode):
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
- | `FORMLAB_SUPABASE_URL` | your Supabase project URL (public) |
103
- | `FORMLAB_SUPABASE_ANON_KEY` | publishable/anon key (public) |
104
- | `FORMLAB_REFRESH_TOKEN` | your session refresh token — **a credential** |
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 `FORMLAB_REFRESH_TOKEN` + URL + key are present, the server reads the workspace directly (RLS-scoped to you), re-fetching every `FORMLAB_REFRESH_SECONDS`. It's **read-only**.
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
- > ⚠ **Security.** The refresh token grants read access to your account — treat the config like a password (don't share or commit it). To revoke: FormLab → Account → *Sign out of all devices*. A dedicated, revocable, **read-only** MCP token is the planned hardening (needs a small backend: a tokens table + an Edge Function to mint/validate).
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 needs `@supabase/supabase-js` (a normal dependency) — `npm install` in this folder, or `npx -y formlab-mcp` pulls it automatically.
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
@@ -114,7 +114,7 @@ const TOOLS = [
114
114
  ];
115
115
 
116
116
  const server = new Server(
117
- { name: 'formlab-mcp', version: '0.5.0' },
117
+ { name: 'formlab-mcp', version: '0.5.2' },
118
118
  { capabilities: { tools: {} } }
119
119
  );
120
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.5.0",
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 — talk to your local lab notebook via Claude. Data stays local.",
5
- "version": "0.1.7",
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.1.7",
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": "Absolute path to a FormLab export JSON file (Data → Export JSON in the FormLab UI).",
26
- "isRequired": true,
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
  ]
@@ -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. Use get_test_result for full measurement values.',
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
- if (args.parameter) {
73
- const found = (t.measurements || []).some(m => norm(m.parameter).includes(norm(args.parameter)));
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 including every measurement\'s value, unit, type, and acceptance criteria. Accepts internal id or UID.',
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
- return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings };
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