formlab-mcp 0.5.0 → 0.5.1

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,7 +39,7 @@ 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 |
42
+ | `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
43
43
  | `get_test_result` | Full record + every measurement value |
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 |
@@ -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.1' },
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.1",
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.1",
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.1",
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
  ]
@@ -40,12 +40,14 @@ function _trimMeasurement(m) {
40
40
  const list_test_results = {
41
41
  definition: {
42
42
  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.',
43
+ 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 + parameters tested; use get_test_result for full measurement values.',
44
44
  inputSchema: {
45
45
  type: 'object',
46
46
  properties: {
47
47
  sample_id: { type: 'string', description: 'Filter to results for this sample (id or UID).' },
48
48
  parameter: { type: 'string', description: 'Filter to results that include this parameter (case-insensitive substring).' },
49
+ 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.' },
50
+ 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
51
  since_date: { type: 'string', description: 'ISO date — only reports on/after this date.' },
50
52
  until_date: { type: 'string', description: 'ISO date — only reports on/before this date.' },
51
53
  lab: { type: 'string', description: 'Filter by lab name (case-insensitive substring).' },
@@ -69,8 +71,19 @@ const list_test_results = {
69
71
  if (args.since_date && (t.testDate || '') < args.since_date) return false;
70
72
  if (args.until_date && (t.testDate || '') > args.until_date) return false;
71
73
  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)));
74
+ const hasRange = args.value_min != null || args.value_max != null;
75
+ if (args.parameter || hasRange) {
76
+ const pn = norm(args.parameter);
77
+ const found = (t.measurements || []).some(m => {
78
+ if (args.parameter && !norm(m.parameter).includes(pn)) return false;
79
+ if (hasRange) {
80
+ const v = parseFloat(m.value);
81
+ if (!Number.isFinite(v)) return false;
82
+ if (args.value_min != null && v < args.value_min) return false;
83
+ if (args.value_max != null && v > args.value_max) return false;
84
+ }
85
+ return true;
86
+ });
74
87
  if (!found) return false;
75
88
  }
76
89
  return true;
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