formlab-mcp 0.4.2 → 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 +9 -10
- package/cloud.js +63 -119
- package/index.js +13 -12
- package/package.json +4 -5
- package/server.json +14 -6
- package/tools/analytics.js +16 -3
- package/tools/library.js +11 -3
- package/token-store.js +0 -58
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.
|
|
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/cloud.js
CHANGED
|
@@ -1,133 +1,77 @@
|
|
|
1
1
|
// ============================================================
|
|
2
|
-
// CLOUD MODE — read the LIVE FormLab workspace
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
2
|
+
// CLOUD MODE — read the LIVE FormLab workspace via the `mcp-data` Edge Function.
|
|
3
|
+
// ------------------------------------------------------------
|
|
4
|
+
// v2 (formlab-mcp ≥0.5.0). The old v1 flow authenticated with the user's account
|
|
5
|
+
// REFRESH TOKEN directly against Supabase — which was broken by design: the
|
|
6
|
+
// refresh token is the full account session AND Supabase rotates it on every
|
|
7
|
+
// use, so the browser and this server fought over one rotation chain and the
|
|
8
|
+
// configured token worked exactly once ("Server disconnected" on the next start).
|
|
6
9
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Now we send a dedicated, revocable, READ-ONLY token (`flmcp_…`, minted from
|
|
11
|
+
// FormLab → Connect to MCP) to the `mcp-data` Edge Function, which:
|
|
12
|
+
// * connects to Postgres as the SELECT-only `mcp_readonly` role (read-only
|
|
13
|
+
// enforced by the DB, not by trust),
|
|
14
|
+
// * authenticates the token and scopes every query to the bound workspace via
|
|
15
|
+
// the existing RLS,
|
|
16
|
+
// * returns the same `db` shape we then feed to setStoreFromDb.
|
|
17
|
+
//
|
|
18
|
+
// The token is STATIC — it never rotates — so there is nothing to persist and no
|
|
19
|
+
// way for this server to invalidate itself. Revoke it from FormLab if needed.
|
|
12
20
|
// ============================================================
|
|
13
21
|
|
|
14
|
-
import { createClient } from '@supabase/supabase-js';
|
|
15
22
|
import { setStoreFromDb } from './data.js';
|
|
16
|
-
// Supabase rotates the refresh token on every use, and this client runs with
|
|
17
|
-
// persistSession:false — see token-store.js for why we bank the rotated token
|
|
18
|
-
// ourselves and what breaks when we don't.
|
|
19
|
-
import { readStoredToken as _readStoredToken, saveToken as _saveToken } from './token-store.js';
|
|
20
|
-
|
|
21
|
-
// Supabase table → db array key (mirrors the app's ENT.ENTITIES + db.js arrays).
|
|
22
|
-
// Safety/regulatory ride the ingredient record's `data`, so they come for free.
|
|
23
|
-
const TABLES = [
|
|
24
|
-
['projects', 'projects'],
|
|
25
|
-
['ingredients', 'ingredients'],
|
|
26
|
-
['formulations', 'formulations'],
|
|
27
|
-
['batches', 'batches'],
|
|
28
|
-
['samples', 'samples'],
|
|
29
|
-
['test_results', 'testResults'],
|
|
30
|
-
['templates', 'templates'],
|
|
31
|
-
['procedure_templates', 'procedureTemplates'],
|
|
32
|
-
['parameters', 'parameters'],
|
|
33
|
-
['equipment', 'equipment'],
|
|
34
|
-
['stock_movements', 'stockMovements'],
|
|
35
|
-
['lots', 'lots'],
|
|
36
|
-
['formula_versions', 'formulaVersions'],
|
|
37
|
-
['attachments', 'attachments'],
|
|
38
|
-
['notebook_entries', 'notebookEntries'],
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
const PAGE = 1000;
|
|
42
|
-
|
|
43
|
-
async function _fetchAll(supabase, table, workspaceId) {
|
|
44
|
-
const out = [];
|
|
45
|
-
for (let from = 0; ; from += PAGE) {
|
|
46
|
-
const { data, error } = await supabase
|
|
47
|
-
.from(table)
|
|
48
|
-
.select('data')
|
|
49
|
-
.eq('workspace_id', workspaceId)
|
|
50
|
-
.is('deleted_at', null)
|
|
51
|
-
.range(from, from + PAGE - 1);
|
|
52
|
-
if (error) {
|
|
53
|
-
// A table the backend hasn't migrated yet shouldn't sink the whole load.
|
|
54
|
-
if (/relation|does not exist|schema cache|find the table/i.test(error.message || '')) return out;
|
|
55
|
-
throw new Error(`${table}: ${error.message}`);
|
|
56
|
-
}
|
|
57
|
-
const rows = data || [];
|
|
58
|
-
for (const r of rows) if (r && r.data) out.push(r.data);
|
|
59
|
-
if (rows.length < PAGE) break;
|
|
60
|
-
}
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function _resolveWorkspace(supabase, userId, preferred) {
|
|
65
|
-
if (preferred) return preferred;
|
|
66
|
-
const { data, error } = await supabase
|
|
67
|
-
.from('workspace_members')
|
|
68
|
-
.select('workspace_id, role')
|
|
69
|
-
.eq('user_id', userId);
|
|
70
|
-
if (error) throw new Error(`workspace_members: ${error.message}`);
|
|
71
|
-
const rows = data || [];
|
|
72
|
-
if (!rows.length) throw new Error('No workspaces found for this account.');
|
|
73
|
-
const owned = rows.find(r => r.role === 'owner');
|
|
74
|
-
return (owned || rows[0]).workspace_id; // default to your OWN workspace
|
|
75
|
-
}
|
|
76
23
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
24
|
+
// Public defaults — same values the app ships in its client (js/supabase.js).
|
|
25
|
+
// Overridable via env for a self-hosted / preview backend.
|
|
26
|
+
export const DEFAULT_SUPABASE_URL = 'https://ufizhypibwqqpavhxdwt.supabase.co';
|
|
27
|
+
export const DEFAULT_ANON_KEY = 'sb_publishable_z83O_XZCXsHo9wghb4o3aw_6Qf4ikMP';
|
|
82
28
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
29
|
+
// One fetch of the workspace `db` from the Edge Function.
|
|
30
|
+
async function _fetchDb({ url, anonKey, token }) {
|
|
31
|
+
const endpoint = `${url.replace(/\/+$/, '')}/functions/v1/mcp-data`;
|
|
32
|
+
let res;
|
|
33
|
+
try {
|
|
34
|
+
res = await fetch(endpoint, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
// The gateway still wants a project apikey; OUR credential is the token
|
|
39
|
+
// in the body, which the function checks itself (deployed --no-verify-jwt).
|
|
40
|
+
'apikey': anonKey,
|
|
41
|
+
'Authorization': `Bearer ${anonKey}`,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({ token }),
|
|
44
|
+
});
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new Error(`could not reach the FormLab MCP endpoint (${endpoint}): ${e.message}`);
|
|
88
47
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// refreshSession with a bare refresh token mints a fresh session and sets it on
|
|
94
|
-
// the client, so all subsequent queries carry the user's JWT (→ RLS).
|
|
95
|
-
//
|
|
96
|
-
// Try the PERSISTED token first (the live end of the rotation chain from our
|
|
97
|
-
// last run), then fall back to the configured one. The fallback matters: after
|
|
98
|
-
// the user reconnects from FormLab, the env token is the fresh one and any
|
|
99
|
-
// stored descendant is dead.
|
|
100
|
-
const stored = _readStoredToken(refreshToken);
|
|
101
|
-
const candidates = stored && stored !== refreshToken ? [stored, refreshToken] : [refreshToken];
|
|
102
|
-
let sess = null, lastErr = null;
|
|
103
|
-
for (const token of candidates) {
|
|
104
|
-
const { data, error } = await supabase.auth.refreshSession({ refresh_token: token });
|
|
105
|
-
if (!error && data && data.session && data.session.user) { sess = data; break; }
|
|
106
|
-
lastErr = error;
|
|
48
|
+
let body = null;
|
|
49
|
+
try { body = await res.json(); } catch { /* non-JSON error page */ }
|
|
50
|
+
if (res.status === 401) {
|
|
51
|
+
throw new Error('the MCP token was rejected (missing, revoked, or malformed). Reconnect from FormLab → Settings → Account → AI tools (MCP) → Connect, and paste the new FORMLAB_MCP_TOKEN.');
|
|
107
52
|
}
|
|
108
|
-
if (!
|
|
109
|
-
throw new Error(`
|
|
53
|
+
if (!res.ok || !body || body.ok !== true || !body.db) {
|
|
54
|
+
throw new Error(`MCP endpoint error (HTTP ${res.status})${body && body.error ? ': ' + body.error : ''}`);
|
|
110
55
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// autoRefreshToken keeps rotating while we run — persist each new one too, so a
|
|
114
|
-
// long-running server still leaves a usable token behind when it exits.
|
|
115
|
-
try {
|
|
116
|
-
supabase.auth.onAuthStateChange((event, session) => {
|
|
117
|
-
if (event === 'TOKEN_REFRESHED' && session && session.refresh_token) {
|
|
118
|
-
_saveToken(session.refresh_token, refreshToken);
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
} catch (_) {}
|
|
56
|
+
return body;
|
|
57
|
+
}
|
|
122
58
|
|
|
123
|
-
|
|
124
|
-
|
|
59
|
+
// Bootstrap: one load up front, then refresh on an interval. Returns
|
|
60
|
+
// { workspaceId, refresh, stop } to mirror the old v1 signature.
|
|
61
|
+
export async function startCloud({ url, anonKey, token, refreshSeconds = 60, onReload } = {}) {
|
|
62
|
+
url = url || DEFAULT_SUPABASE_URL;
|
|
63
|
+
anonKey = anonKey || DEFAULT_ANON_KEY;
|
|
64
|
+
if (!token) {
|
|
65
|
+
throw new Error('Cloud mode needs FORMLAB_MCP_TOKEN (mint it from FormLab → Connect to MCP).');
|
|
66
|
+
}
|
|
125
67
|
|
|
68
|
+
let workspaceId = null;
|
|
126
69
|
const refresh = async () => {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
70
|
+
const body = await _fetchDb({ url, anonKey, token });
|
|
71
|
+
workspaceId = body.workspaceId || workspaceId;
|
|
72
|
+
setStoreFromDb(body.db, { source: 'cloud', workspaceId, schemaName: 'formlab-live', loadedAt: body.loadedAt || new Date().toISOString() });
|
|
73
|
+
if (onReload) { try { onReload(body.db); } catch (_) {} }
|
|
74
|
+
return body.db;
|
|
131
75
|
};
|
|
132
76
|
|
|
133
77
|
await refresh(); // initial synchronous load before tools accept calls
|
|
@@ -137,7 +81,7 @@ export async function startCloud({ url, anonKey, refreshToken, workspaceId, refr
|
|
|
137
81
|
timer = setInterval(() => {
|
|
138
82
|
refresh().catch(e => process.stderr.write(`[formlab-mcp] cloud refresh failed: ${e.message}\n`));
|
|
139
83
|
}, refreshSeconds * 1000);
|
|
140
|
-
if (timer.unref) timer.unref();
|
|
84
|
+
if (timer.unref) timer.unref();
|
|
141
85
|
}
|
|
142
|
-
return { workspaceId
|
|
86
|
+
return { workspaceId, refresh, stop: () => { if (timer) clearInterval(timer); } };
|
|
143
87
|
}
|
package/index.js
CHANGED
|
@@ -35,17 +35,18 @@ import * as projects from './tools/projects.js';
|
|
|
35
35
|
import * as notebook from './tools/notebook.js';
|
|
36
36
|
|
|
37
37
|
// ----- Data source: LIVE cloud workspace OR a static export file -----
|
|
38
|
-
// Cloud mode kicks in when
|
|
39
|
-
// FormLab → Connect to MCP). Otherwise we fall back to
|
|
40
|
-
|
|
38
|
+
// Cloud mode kicks in when a dedicated read-only MCP token is present (mint it
|
|
39
|
+
// from FormLab → Connect to MCP). Otherwise we fall back to export-file mode.
|
|
40
|
+
// FORMLAB_SUPABASE_URL / _ANON_KEY are optional overrides — cloud.js defaults to
|
|
41
|
+
// the public production values, so the config only NEEDS FORMLAB_MCP_TOKEN.
|
|
42
|
+
const CLOUD = !!process.env.FORMLAB_MCP_TOKEN;
|
|
41
43
|
|
|
42
44
|
if (CLOUD) {
|
|
43
45
|
try {
|
|
44
46
|
const h = await startCloud({
|
|
45
|
-
url:
|
|
46
|
-
anonKey:
|
|
47
|
-
|
|
48
|
-
workspaceId: process.env.FORMLAB_WORKSPACE_ID || null,
|
|
47
|
+
url: process.env.FORMLAB_SUPABASE_URL || undefined,
|
|
48
|
+
anonKey: process.env.FORMLAB_SUPABASE_ANON_KEY || undefined,
|
|
49
|
+
token: process.env.FORMLAB_MCP_TOKEN,
|
|
49
50
|
refreshSeconds: Number(process.env.FORMLAB_REFRESH_SECONDS || 60),
|
|
50
51
|
});
|
|
51
52
|
const s = getStore();
|
|
@@ -61,12 +62,12 @@ if (CLOUD) {
|
|
|
61
62
|
const filePath = process.argv[2] || process.env.FORMLAB_EXPORT;
|
|
62
63
|
if (!filePath) {
|
|
63
64
|
process.stderr.write([
|
|
64
|
-
'Usage: formlab-mcp <path-to-export.json>
|
|
65
|
-
' or: set
|
|
66
|
-
' FORMLAB_REFRESH_TOKEN (+ optional FORMLAB_WORKSPACE_ID) (LIVE cloud mode)',
|
|
65
|
+
'Usage: formlab-mcp <path-to-export.json> (file mode)',
|
|
66
|
+
' or: set FORMLAB_MCP_TOKEN (LIVE cloud mode)',
|
|
67
67
|
'',
|
|
68
68
|
'File mode: export via Settings → Import / Export → Export.',
|
|
69
|
-
'Cloud mode:
|
|
69
|
+
'Cloud mode: mint a read-only token in FormLab → Settings → Account →',
|
|
70
|
+
' AI tools (MCP) → Connect, and set it as FORMLAB_MCP_TOKEN.',
|
|
70
71
|
'',
|
|
71
72
|
].join('\n'));
|
|
72
73
|
process.exit(1);
|
|
@@ -113,7 +114,7 @@ const TOOLS = [
|
|
|
113
114
|
];
|
|
114
115
|
|
|
115
116
|
const server = new Server(
|
|
116
|
-
{ name: 'formlab-mcp', version: '0.
|
|
117
|
+
{ name: 'formlab-mcp', version: '0.5.1' },
|
|
117
118
|
{ capabilities: { tools: {} } }
|
|
118
119
|
);
|
|
119
120
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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 \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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"formlab-mcp": "./index.js"
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
"node": ">=18"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@modelcontextprotocol/sdk": "^1.0.4"
|
|
20
|
-
"@supabase/supabase-js": "^2.45.0"
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.0.4"
|
|
21
20
|
},
|
|
22
21
|
"optionalDependencies": {
|
|
23
22
|
"@rdkit/rdkit": "^2024.9.1"
|
|
@@ -39,4 +38,4 @@
|
|
|
39
38
|
"url": "https://github.com/juliu1980/FormLab",
|
|
40
39
|
"directory": "mcp"
|
|
41
40
|
}
|
|
42
|
-
}
|
|
41
|
+
}
|
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.1
|
|
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
|
|
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": "
|
|
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
|
@@ -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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
package/token-store.js
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// ROTATED-REFRESH-TOKEN STORE
|
|
3
|
-
// ------------------------------------------------------------
|
|
4
|
-
// Supabase ROTATES the refresh token on every use: refreshSession() invalidates
|
|
5
|
-
// the token you passed and issues a new one. cloud.js creates its client with
|
|
6
|
-
// persistSession:false, so before this module existed that new token only lived
|
|
7
|
-
// in memory — and the next process start re-read the SAME (now spent) token from
|
|
8
|
-
// the env and died with "auth failed", which Claude Desktop surfaces as
|
|
9
|
-
// "Server disconnected". Net effect: the configured token worked exactly ONCE.
|
|
10
|
-
//
|
|
11
|
-
// So we bank the rotated token and prefer it on the next start. Entries are keyed
|
|
12
|
-
// by a fingerprint of the ENV token: when the user reconnects from FormLab and
|
|
13
|
-
// pastes a new token, the fingerprint changes and we drop the stale chain rather
|
|
14
|
-
// than clinging to a dead descendant of the old one.
|
|
15
|
-
//
|
|
16
|
-
// The file holds a live account session — the same sensitivity as the Desktop
|
|
17
|
-
// config that already stores it in plaintext — so it lives under the user's home,
|
|
18
|
-
// dir 0700 / file 0600. Never log the token itself.
|
|
19
|
-
// ============================================================
|
|
20
|
-
|
|
21
|
-
import { createHash } from 'node:crypto';
|
|
22
|
-
import { homedir } from 'node:os';
|
|
23
|
-
import { join, dirname } from 'node:path';
|
|
24
|
-
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
25
|
-
|
|
26
|
-
export const tokenFile = () => join(homedir(), '.formlab-mcp', 'session.json');
|
|
27
|
-
|
|
28
|
-
// Short, non-reversible tag for "which configured token is this chain from".
|
|
29
|
-
export const fingerprint = (t) => createHash('sha256').update(String(t || '')).digest('hex').slice(0, 16);
|
|
30
|
-
|
|
31
|
-
// The live end of the rotation chain for `envToken`, or null when there isn't a
|
|
32
|
-
// usable one (no file, unreadable, or it belongs to a different env token).
|
|
33
|
-
export function readStoredToken(envToken) {
|
|
34
|
-
try {
|
|
35
|
-
const s = JSON.parse(readFileSync(tokenFile(), 'utf8'));
|
|
36
|
-
if (!s || !s.refreshToken) return null;
|
|
37
|
-
if (s.source !== fingerprint(envToken)) return null;
|
|
38
|
-
return s.refreshToken;
|
|
39
|
-
} catch (_) { return null; }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Persistence is an optimisation, not a hard requirement: a read-only home must
|
|
43
|
-
// not take the server down — worst case we're back to single-use behaviour.
|
|
44
|
-
export function saveToken(refreshToken, envToken) {
|
|
45
|
-
if (!refreshToken) return false;
|
|
46
|
-
try {
|
|
47
|
-
const f = tokenFile();
|
|
48
|
-
mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
|
|
49
|
-
writeFileSync(f, JSON.stringify({
|
|
50
|
-
refreshToken, source: fingerprint(envToken), updatedAt: new Date().toISOString(),
|
|
51
|
-
}, null, 2), { mode: 0o600 });
|
|
52
|
-
chmodSync(f, 0o600); // enforce even if the file already existed
|
|
53
|
-
return true;
|
|
54
|
-
} catch (e) {
|
|
55
|
-
process.stderr.write(`[formlab-mcp] could not persist refresh token: ${e.message}\n`);
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
}
|