formlab-mcp 0.1.5 → 0.3.0
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 +35 -5
- package/cloud.js +114 -0
- package/data.js +14 -0
- package/index.js +62 -38
- package/package.json +7 -3
- package/server.json +2 -2
- package/tools/ingredients.js +224 -12
package/README.md
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
[](https://github.com/juliu1980/FormLab/blob/main/LICENSE)
|
|
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 local FormLab database — your formulations, ingredients, batches, samples and test
|
|
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 local FormLab database — your formulations, ingredients, batches, samples and test reports — **without your data ever leaving your machine**.
|
|
8
8
|
|
|
9
9
|
> Local-first + AI-native. Your proprietary recipes stay on your laptop; only the LLM's answer to your question travels.
|
|
10
10
|
|
|
11
|
+
Listed in the official MCP Registry as `io.github.juliu1980/formlab-mcp`.
|
|
12
|
+
|
|
11
13
|
## What it does
|
|
12
14
|
|
|
13
15
|
Ask Claude (or another MCP client) things like:
|
|
@@ -17,6 +19,9 @@ Ask Claude (or another MCP client) things like:
|
|
|
17
19
|
- _"What parameters fail most often in Q2 testing?"_
|
|
18
20
|
- _"Show me the DOE matrix at sample grain for all 'Anti-aging serum' family formulas."_
|
|
19
21
|
- _"What's untested? Which of my approved formulas have no measurements yet?"_
|
|
22
|
+
- _"List all peptide ingredients with purity above 95%."_
|
|
23
|
+
- _"Which botanicals do I source from Madagascar?"_
|
|
24
|
+
- _"Find ingredients where the sequence contains KTTKS."_
|
|
20
25
|
|
|
21
26
|
## Tools exposed
|
|
22
27
|
|
|
@@ -26,17 +31,19 @@ Ask Claude (or another MCP client) things like:
|
|
|
26
31
|
| `get_formulation` | Full record + flattened wt-% composition (sub-formulas expanded) |
|
|
27
32
|
| `find_similar_formulations` | Find formulas using a given ingredient ≥ threshold % |
|
|
28
33
|
| `compare_formulations` | Pairwise side-by-side composition diff |
|
|
29
|
-
| `list_ingredients` | Filtered list of raw materials |
|
|
30
|
-
| `get_ingredient` | Full record + supplier / stock / formulations using it |
|
|
34
|
+
| `list_ingredients` | Filtered list of raw materials. Filters: `family`, `supplier`, `name_contains`, `in_stock_only`, `ingredient_class` (small-molecule / surfactant / polymer / extract / fragrance / pigment / sequence / mixture), `sequence_contains` (e.g. `KTTKS` → Matrixyl), `taxon_contains` (e.g. `Centella`) |
|
|
35
|
+
| `get_ingredient` | Full record + supplier / **cost ($/kg)** / stock / formulations using it, plus **GHS safety** (pictograms, H/P codes, signal word), **per-jurisdiction regulatory status**, and **inventory lots** (balances + expiry). Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
|
|
36
|
+
| `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry). Filters: `ingredient_id`, `expiring_within_days`, `status` |
|
|
31
37
|
| `list_batches` | Filtered list of production / lab-prep events |
|
|
32
38
|
| `get_batch` | Full record + actual composition + samples + blend lineage |
|
|
33
39
|
| `list_samples` | Filtered list of physical specimens |
|
|
34
|
-
| `get_sample` | Full record + canonical variant + test
|
|
40
|
+
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
35
41
|
| `list_test_results` | Filtered list of test reports |
|
|
36
42
|
| `get_test_result` | Full record + every measurement value |
|
|
37
43
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
38
44
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
39
45
|
| `get_coverage_matrix` | Which formulations × parameters have been measured |
|
|
46
|
+
| `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES. Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
|
|
40
47
|
|
|
41
48
|
## Install
|
|
42
49
|
|
|
@@ -68,6 +75,29 @@ Requires **Node 18+**.
|
|
|
68
75
|
|
|
69
76
|
The MCP server watches the file — re-export from FormLab and the next tool call sees the fresh data without restarting the server.
|
|
70
77
|
|
|
78
|
+
## Or: LIVE cloud mode (no export needed)
|
|
79
|
+
|
|
80
|
+
Instead of an export file, point the server at your **live cloud workspace** so it's always current.
|
|
81
|
+
|
|
82
|
+
1. In FormLab (Pro): **Settings → Account → AI tools (MCP) → Connect…**
|
|
83
|
+
2. Copy the generated config and paste it into Claude Desktop (it merges with the file-mode example below).
|
|
84
|
+
|
|
85
|
+
The config sets these env vars (which switch the server into cloud mode):
|
|
86
|
+
|
|
87
|
+
| Env var | Value |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `FORMLAB_SUPABASE_URL` | your Supabase project URL (public) |
|
|
90
|
+
| `FORMLAB_SUPABASE_ANON_KEY` | publishable/anon key (public) |
|
|
91
|
+
| `FORMLAB_REFRESH_TOKEN` | your session refresh token — **a credential** |
|
|
92
|
+
| `FORMLAB_WORKSPACE_ID` | (optional) workspace to read; defaults to your own |
|
|
93
|
+
| `FORMLAB_REFRESH_SECONDS` | (optional) poll interval, default `60` |
|
|
94
|
+
|
|
95
|
+
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**.
|
|
96
|
+
|
|
97
|
+
> ⚠ **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).
|
|
98
|
+
|
|
99
|
+
Cloud mode needs `@supabase/supabase-js` (a normal dependency) — `npm install` in this folder, or `npx -y formlab-mcp` pulls it automatically.
|
|
100
|
+
|
|
71
101
|
## Wire it up to Claude Desktop
|
|
72
102
|
|
|
73
103
|
Add this to your `claude_desktop_config.json` (on macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
@@ -99,7 +129,7 @@ Or for local-dev (from the repo):
|
|
|
99
129
|
}
|
|
100
130
|
```
|
|
101
131
|
|
|
102
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
132
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 17 tools become callable in any conversation.
|
|
103
133
|
|
|
104
134
|
## Wire it up to Claude Code
|
|
105
135
|
|
package/cloud.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CLOUD MODE — read the LIVE FormLab workspace from Supabase instead of a
|
|
3
|
+
// static export file. The tools are unchanged: we assemble the same `db` shape
|
|
4
|
+
// (each synced table stores the full record in a `data` JSONB column) and set it
|
|
5
|
+
// via data.js setStoreFromDb, then re-fetch on an interval so it stays "live".
|
|
6
|
+
//
|
|
7
|
+
// v1 auth: the user's session REFRESH TOKEN (from FormLab's "Connect to MCP"
|
|
8
|
+
// panel). Read-only by usage; RLS scopes every query to the user's own
|
|
9
|
+
// workspaces. The refresh token is the full account session, though — a
|
|
10
|
+
// dedicated, revocable, read-only MCP token is the planned hardening (needs a
|
|
11
|
+
// small backend: a tokens table + an Edge Function to mint/validate).
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
import { createClient } from '@supabase/supabase-js';
|
|
15
|
+
import { setStoreFromDb } from './data.js';
|
|
16
|
+
|
|
17
|
+
// Supabase table → db array key (mirrors the app's ENT.ENTITIES + db.js arrays).
|
|
18
|
+
// Safety/regulatory ride the ingredient record's `data`, so they come for free.
|
|
19
|
+
const TABLES = [
|
|
20
|
+
['projects', 'projects'],
|
|
21
|
+
['ingredients', 'ingredients'],
|
|
22
|
+
['formulations', 'formulations'],
|
|
23
|
+
['batches', 'batches'],
|
|
24
|
+
['samples', 'samples'],
|
|
25
|
+
['test_results', 'testResults'],
|
|
26
|
+
['templates', 'templates'],
|
|
27
|
+
['procedure_templates', 'procedureTemplates'],
|
|
28
|
+
['parameters', 'parameters'],
|
|
29
|
+
['equipment', 'equipment'],
|
|
30
|
+
['stock_movements', 'stockMovements'],
|
|
31
|
+
['lots', 'lots'],
|
|
32
|
+
['formula_versions', 'formulaVersions'],
|
|
33
|
+
['attachments', 'attachments'],
|
|
34
|
+
['notebook_entries', 'notebookEntries'],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const PAGE = 1000;
|
|
38
|
+
|
|
39
|
+
async function _fetchAll(supabase, table, workspaceId) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let from = 0; ; from += PAGE) {
|
|
42
|
+
const { data, error } = await supabase
|
|
43
|
+
.from(table)
|
|
44
|
+
.select('data')
|
|
45
|
+
.eq('workspace_id', workspaceId)
|
|
46
|
+
.is('deleted_at', null)
|
|
47
|
+
.range(from, from + PAGE - 1);
|
|
48
|
+
if (error) {
|
|
49
|
+
// A table the backend hasn't migrated yet shouldn't sink the whole load.
|
|
50
|
+
if (/relation|does not exist|schema cache|find the table/i.test(error.message || '')) return out;
|
|
51
|
+
throw new Error(`${table}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
const rows = data || [];
|
|
54
|
+
for (const r of rows) if (r && r.data) out.push(r.data);
|
|
55
|
+
if (rows.length < PAGE) break;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function _resolveWorkspace(supabase, userId, preferred) {
|
|
61
|
+
if (preferred) return preferred;
|
|
62
|
+
const { data, error } = await supabase
|
|
63
|
+
.from('workspace_members')
|
|
64
|
+
.select('workspace_id, role')
|
|
65
|
+
.eq('user_id', userId);
|
|
66
|
+
if (error) throw new Error(`workspace_members: ${error.message}`);
|
|
67
|
+
const rows = data || [];
|
|
68
|
+
if (!rows.length) throw new Error('No workspaces found for this account.');
|
|
69
|
+
const owned = rows.find(r => r.role === 'owner');
|
|
70
|
+
return (owned || rows[0]).workspace_id; // default to your OWN workspace
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function _loadDb(supabase, workspaceId) {
|
|
74
|
+
const db = {};
|
|
75
|
+
for (const [table, key] of TABLES) db[key] = await _fetchAll(supabase, table, workspaceId);
|
|
76
|
+
return db;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Bootstrap a session from the refresh token, do an initial load, then refresh on
|
|
80
|
+
// an interval. Returns { workspaceId, userId, refresh, stop }.
|
|
81
|
+
export async function startCloud({ url, anonKey, refreshToken, workspaceId, refreshSeconds = 60, onReload } = {}) {
|
|
82
|
+
if (!url || !anonKey || !refreshToken) {
|
|
83
|
+
throw new Error('Cloud mode needs FORMLAB_SUPABASE_URL, FORMLAB_SUPABASE_ANON_KEY and FORMLAB_REFRESH_TOKEN (copy them from FormLab → Connect to MCP).');
|
|
84
|
+
}
|
|
85
|
+
const supabase = createClient(url, anonKey, {
|
|
86
|
+
auth: { persistSession: false, autoRefreshToken: true, detectSessionInUrl: false },
|
|
87
|
+
});
|
|
88
|
+
// refreshSession with a bare refresh token mints a fresh session and sets it on
|
|
89
|
+
// the client, so all subsequent queries carry the user's JWT (→ RLS).
|
|
90
|
+
const { data: sess, error: sErr } = await supabase.auth.refreshSession({ refresh_token: refreshToken });
|
|
91
|
+
if (sErr || !(sess && sess.session && sess.session.user)) {
|
|
92
|
+
throw new Error(`auth failed: ${(sErr && sErr.message) || 'could not start a session from the refresh token — it may have expired; reconnect from FormLab.'}`);
|
|
93
|
+
}
|
|
94
|
+
const userId = sess.session.user.id;
|
|
95
|
+
const ws = await _resolveWorkspace(supabase, userId, workspaceId);
|
|
96
|
+
|
|
97
|
+
const refresh = async () => {
|
|
98
|
+
const db = await _loadDb(supabase, ws);
|
|
99
|
+
setStoreFromDb(db, { source: 'cloud', workspaceId: ws, schemaName: 'formlab-live', loadedAt: new Date().toISOString() });
|
|
100
|
+
if (onReload) { try { onReload(db); } catch (_) {} }
|
|
101
|
+
return db;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await refresh(); // initial synchronous load before tools accept calls
|
|
105
|
+
|
|
106
|
+
let timer = null;
|
|
107
|
+
if (refreshSeconds > 0) {
|
|
108
|
+
timer = setInterval(() => {
|
|
109
|
+
refresh().catch(e => process.stderr.write(`[formlab-mcp] cloud refresh failed: ${e.message}\n`));
|
|
110
|
+
}, refreshSeconds * 1000);
|
|
111
|
+
if (timer.unref) timer.unref(); // don't keep the process alive just for the timer
|
|
112
|
+
}
|
|
113
|
+
return { workspaceId: ws, userId, refresh, stop: () => { if (timer) clearInterval(timer); } };
|
|
114
|
+
}
|
package/data.js
CHANGED
|
@@ -43,6 +43,20 @@ export function getStore() {
|
|
|
43
43
|
return _store;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Set the store directly from an in-memory db object (used by cloud mode — see
|
|
47
|
+
// cloud.js). Produces the SAME store shape as loadStore(), so every tool works
|
|
48
|
+
// identically whether the data came from an export file or the live workspace.
|
|
49
|
+
export function setStoreFromDb(db, meta) {
|
|
50
|
+
_store = {
|
|
51
|
+
db: db || {},
|
|
52
|
+
meta: meta || null,
|
|
53
|
+
loadedAt: new Date().toISOString(),
|
|
54
|
+
sourcePath: (meta && meta.source) || 'cloud',
|
|
55
|
+
indexes: _buildIndexes(db || {}),
|
|
56
|
+
};
|
|
57
|
+
return _store;
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
// Re-read the file on disk-change. Useful when the user re-exports
|
|
47
61
|
// FormLab while the MCP server is running — the next tool call sees
|
|
48
62
|
// fresh data without restarting the server.
|
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 17 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.
|
|
@@ -25,53 +25,77 @@ import {
|
|
|
25
25
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
26
26
|
|
|
27
27
|
import { loadStore, watchStore, getStore } from './data.js';
|
|
28
|
+
import { startCloud } from './cloud.js';
|
|
28
29
|
import * as formulations from './tools/formulations.js';
|
|
29
30
|
import * as ingredients from './tools/ingredients.js';
|
|
30
31
|
import * as lab from './tools/lab.js';
|
|
31
32
|
import * as analytics from './tools/analytics.js';
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// ----- Data source: LIVE cloud workspace OR a static export file -----
|
|
35
|
+
// Cloud mode kicks in when the Supabase env vars are present (copy them from
|
|
36
|
+
// FormLab → Connect to MCP). Otherwise we fall back to the export-file mode.
|
|
37
|
+
const CLOUD = process.env.FORMLAB_REFRESH_TOKEN && process.env.FORMLAB_SUPABASE_URL && process.env.FORMLAB_SUPABASE_ANON_KEY;
|
|
38
|
+
|
|
39
|
+
if (CLOUD) {
|
|
40
|
+
try {
|
|
41
|
+
const h = await startCloud({
|
|
42
|
+
url: process.env.FORMLAB_SUPABASE_URL,
|
|
43
|
+
anonKey: process.env.FORMLAB_SUPABASE_ANON_KEY,
|
|
44
|
+
refreshToken: process.env.FORMLAB_REFRESH_TOKEN,
|
|
45
|
+
workspaceId: process.env.FORMLAB_WORKSPACE_ID || null,
|
|
46
|
+
refreshSeconds: Number(process.env.FORMLAB_REFRESH_SECONDS || 60),
|
|
47
|
+
});
|
|
48
|
+
const s = getStore();
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
`[formlab-mcp] LIVE cloud mode · workspace ${h.workspaceId} · ` +
|
|
51
|
+
`${(s.db.ingredients||[]).length} ingredients · ${(s.db.formulations||[]).length} formulas · ` +
|
|
52
|
+
`refresh every ${Number(process.env.FORMLAB_REFRESH_SECONDS || 60)}s\n`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
process.stderr.write(`[formlab-mcp] cloud mode failed: ${e.message}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
const filePath = process.argv[2] || process.env.FORMLAB_EXPORT;
|
|
59
|
+
if (!filePath) {
|
|
60
|
+
process.stderr.write([
|
|
61
|
+
'Usage: formlab-mcp <path-to-export.json> (file mode)',
|
|
62
|
+
' or: set FORMLAB_SUPABASE_URL / FORMLAB_SUPABASE_ANON_KEY /',
|
|
63
|
+
' FORMLAB_REFRESH_TOKEN (+ optional FORMLAB_WORKSPACE_ID) (LIVE cloud mode)',
|
|
64
|
+
'',
|
|
65
|
+
'File mode: export via Settings → Import / Export → Export.',
|
|
66
|
+
'Cloud mode: copy the values from FormLab → Connect to MCP.',
|
|
67
|
+
'',
|
|
68
|
+
].join('\n'));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
loadStore(filePath);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
process.stderr.write(`[formlab-mcp] failed to load export: ${e.message}\n`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const store = getStore();
|
|
35
78
|
process.stderr.write([
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
79
|
+
`[formlab-mcp] loaded ${store.sourcePath}`,
|
|
80
|
+
store.meta
|
|
81
|
+
? ` schema=${store.meta.schemaName} v${store.meta.schemaVersion} exportedAt=${store.meta.exportedAt}`
|
|
82
|
+
: ` (legacy bare-db shape — no FAIR metadata)`,
|
|
83
|
+
` records: ${
|
|
84
|
+
[
|
|
85
|
+
`${(store.db.ingredients || []).length} ingredients`,
|
|
86
|
+
`${(store.db.formulations || []).length} formulas`,
|
|
87
|
+
`${(store.db.batches || []).length} batches`,
|
|
88
|
+
`${(store.db.samples || []).length} samples`,
|
|
89
|
+
`${(store.db.testResults || []).length} test results`,
|
|
90
|
+
].join(' · ')
|
|
91
|
+
}`,
|
|
41
92
|
'',
|
|
42
93
|
].join('\n'));
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
loadStore(filePath);
|
|
48
|
-
} catch (e) {
|
|
49
|
-
process.stderr.write(`[formlab-mcp] failed to load export: ${e.message}\n`);
|
|
50
|
-
process.exit(1);
|
|
94
|
+
watchStore((s) => {
|
|
95
|
+
process.stderr.write(`[formlab-mcp] reloaded — ${(s.db.formulations||[]).length} formulas now\n`);
|
|
96
|
+
});
|
|
51
97
|
}
|
|
52
98
|
|
|
53
|
-
const store = getStore();
|
|
54
|
-
process.stderr.write([
|
|
55
|
-
`[formlab-mcp] loaded ${store.sourcePath}`,
|
|
56
|
-
store.meta
|
|
57
|
-
? ` schema=${store.meta.schemaName} v${store.meta.schemaVersion} exportedAt=${store.meta.exportedAt}`
|
|
58
|
-
: ` (legacy bare-db shape — no FAIR metadata)`,
|
|
59
|
-
` records: ${
|
|
60
|
-
[
|
|
61
|
-
`${(store.db.ingredients || []).length} ingredients`,
|
|
62
|
-
`${(store.db.formulations || []).length} formulas`,
|
|
63
|
-
`${(store.db.batches || []).length} batches`,
|
|
64
|
-
`${(store.db.samples || []).length} samples`,
|
|
65
|
-
`${(store.db.testResults || []).length} test results`,
|
|
66
|
-
].join(' · ')
|
|
67
|
-
}`,
|
|
68
|
-
'',
|
|
69
|
-
].join('\n'));
|
|
70
|
-
|
|
71
|
-
watchStore((s) => {
|
|
72
|
-
process.stderr.write(`[formlab-mcp] reloaded — ${(s.db.formulations||[]).length} formulas now\n`);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
99
|
// All tools live in tools/*.js as a flat { definition, handler } pair.
|
|
76
100
|
// Concatenate the four modules' exports into a single registry the
|
|
77
101
|
// MCP server can use for both list_tools and call_tool dispatch.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
|
-
"description": "Read-only Model Context Protocol server for FormLab — lets Claude (and other MCP clients) read and analyze your
|
|
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
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"formlab-mcp": "./index.js"
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
"node": ">=18"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@modelcontextprotocol/sdk": "^1.0.4"
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
20
|
+
"@supabase/supabase-js": "^2.45.0"
|
|
21
|
+
},
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"@rdkit/rdkit": "^2024.9.1"
|
|
20
24
|
},
|
|
21
25
|
"keywords": [
|
|
22
26
|
"mcp",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.juliu1980/formlab-mcp",
|
|
4
4
|
"description": "Read-only MCP for FormLab — talk to your local lab notebook via Claude. Data stays local.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.7",
|
|
6
6
|
"websiteUrl": "https://formvix.com",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/juliu1980/FormLab",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "formlab-mcp",
|
|
16
|
-
"version": "0.1.
|
|
16
|
+
"version": "0.1.7",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/ingredients.js
CHANGED
|
@@ -6,23 +6,57 @@
|
|
|
6
6
|
|
|
7
7
|
import { getStore, resolveById } from '../data.js';
|
|
8
8
|
|
|
9
|
+
// FormLab's export is the raw db, so ingredients carry the APP field shapes:
|
|
10
|
+
// `cas` (not casNumber) and `cost = {value, currency, unit}` (not a flat
|
|
11
|
+
// costPerKg). Normalize here so the tools surface usable values instead of nulls.
|
|
12
|
+
const _MASS_TO_KG = { kg: 1, g: 0.001, mg: 0.000001, t: 1000, lb: 0.45359237, oz: 0.028349523 };
|
|
13
|
+
const _VOL_TO_L = { l: 1, ml: 0.001, gal: 3.785411784, qt: 0.946352946 };
|
|
14
|
+
|
|
15
|
+
function _densityKgPerL(i) {
|
|
16
|
+
if (!i || i.density == null) return null;
|
|
17
|
+
const d = Number(i.density); if (!isFinite(d) || d <= 0) return null;
|
|
18
|
+
const u = String(i.densityUnit || 'kg/L').toLowerCase().replace(/\s/g, '');
|
|
19
|
+
if (u === 'kg/m3' || u === 'kg/m³') return d / 1000;
|
|
20
|
+
if (u === 'lb/gal') return d * 0.1198264;
|
|
21
|
+
return d; // kg/L, g/mL, g/cm³ are numerically equal to kg/L
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Cost normalized to currency-per-kg from ing.cost {value, currency, unit}.
|
|
25
|
+
// Mass units convert directly; volumetric units need density; counts → null.
|
|
26
|
+
function _costPerKg(i) {
|
|
27
|
+
const c = i && i.cost;
|
|
28
|
+
if (!c || c.value == null || c.value === '') return null;
|
|
29
|
+
const v = Number(c.value); if (!isFinite(v)) return null;
|
|
30
|
+
const unit = String(c.unit || 'kg').toLowerCase();
|
|
31
|
+
if (_MASS_TO_KG[unit] != null) return +(v / _MASS_TO_KG[unit]).toFixed(6);
|
|
32
|
+
if (_VOL_TO_L[unit] != null) {
|
|
33
|
+
const dens = _densityKgPerL(i);
|
|
34
|
+
return dens ? +((v / _VOL_TO_L[unit]) / dens).toFixed(6) : null;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
9
39
|
function _trimIngredient(i) {
|
|
10
40
|
if (!i) return null;
|
|
41
|
+
const cost = i.cost || null;
|
|
11
42
|
return {
|
|
12
43
|
id: i.id,
|
|
13
44
|
uid: i.uid,
|
|
14
45
|
name: i.name,
|
|
15
46
|
family: i.family || '',
|
|
16
47
|
supplier: i.supplier || '',
|
|
17
|
-
casNumber: i.casNumber || '',
|
|
48
|
+
casNumber: i.cas || i.casNumber || '',
|
|
18
49
|
density: i.density != null ? i.density : null,
|
|
19
50
|
densityUnit: i.densityUnit || (i.density != null ? 'kg/L' : null),
|
|
20
|
-
|
|
21
|
-
|
|
51
|
+
cost: (cost && cost.value != null && cost.value !== '')
|
|
52
|
+
? { value: cost.value, currency: cost.currency || 'USD', unit: cost.unit || 'kg' } : null,
|
|
53
|
+
costPerKg: _costPerKg(i),
|
|
54
|
+
costCurrency: (cost && cost.currency) || null,
|
|
22
55
|
stockOnHand: i.stockOnHand != null ? i.stockOnHand : null,
|
|
23
56
|
stockUnit: i.stockUnit || null,
|
|
24
57
|
// Tier 1 expansion fields — keep optional so legacy exports stay clean.
|
|
25
58
|
...(i.ingredientClass ? { ingredientClass: i.ingredientClass } : {}),
|
|
59
|
+
...(i.inchiKey ? { inchiKey: i.inchiKey } : {}),
|
|
26
60
|
...(i.sequence ? { sequence: i.sequence } : {}),
|
|
27
61
|
...(i.taxon ? { taxon: i.taxon } : {}),
|
|
28
62
|
...(i.extractDetails ? { extractDetails: i.extractDetails } : {}),
|
|
@@ -48,6 +82,7 @@ const list_ingredients = {
|
|
|
48
82
|
ingredient_class: { type: 'string', description: 'Filter by symbolic class: small-molecule, surfactant, polymer, extract, fragrance, pigment, sequence, mixture.' },
|
|
49
83
|
sequence_contains: { type: 'string', description: 'Case-insensitive substring match on peptide / oligo sequence (e.g. "KTTKS" matches Matrixyl).' },
|
|
50
84
|
taxon_contains: { type: 'string', description: 'Case-insensitive substring match on botanical scientific name (e.g. "Centella").' },
|
|
85
|
+
inchikey: { type: 'string', description: 'Exact match on the InChIKey identity hash. The first 14 characters are the connectivity hash; pass either the full 27-char key or just the first 14 to dedup-search across tautomers/salts.' },
|
|
51
86
|
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
52
87
|
},
|
|
53
88
|
},
|
|
@@ -65,6 +100,14 @@ const list_ingredients = {
|
|
|
65
100
|
if (args.ingredient_class && norm(i.ingredientClass) !== norm(args.ingredient_class)) return false;
|
|
66
101
|
if (args.sequence_contains && !norm(i.sequence?.raw).includes(norm(args.sequence_contains))) return false;
|
|
67
102
|
if (args.taxon_contains && !norm(i.taxon?.scientificName).includes(norm(args.taxon_contains))) return false;
|
|
103
|
+
// InChIKey lookup — 14-char prefix matches connectivity only, full
|
|
104
|
+
// 27-char key matches connectivity + stereo + isotope. Both useful.
|
|
105
|
+
if (args.inchikey) {
|
|
106
|
+
const want = String(args.inchikey).trim().toUpperCase();
|
|
107
|
+
const have = String(i.inchiKey || '').toUpperCase();
|
|
108
|
+
if (!have) return false;
|
|
109
|
+
if (want.length === 14 ? !have.startsWith(want) : have !== want) return false;
|
|
110
|
+
}
|
|
68
111
|
return true;
|
|
69
112
|
});
|
|
70
113
|
return {
|
|
@@ -78,7 +121,7 @@ const list_ingredients = {
|
|
|
78
121
|
const get_ingredient = {
|
|
79
122
|
definition: {
|
|
80
123
|
name: 'get_ingredient',
|
|
81
|
-
description: 'Get full details for a single ingredient
|
|
124
|
+
description: 'Get full details for a single ingredient: cost ($/kg), GHS safety (pictograms / H- / P-codes / signal word), per-jurisdiction regulatory status, inventory lots (balances + expiry), recent stock movements, and the formulations using it. Accepts internal id or UID (e.g. ING-001).',
|
|
82
125
|
inputSchema: {
|
|
83
126
|
type: 'object',
|
|
84
127
|
properties: {
|
|
@@ -97,18 +140,42 @@ const get_ingredient = {
|
|
|
97
140
|
const usedIn = (db.formulations || []).filter(f =>
|
|
98
141
|
f && !f._trashed && (f.composition || []).some(c => c && c.ingredientId === ing.id)
|
|
99
142
|
).map(f => ({ id: f.id, uid: f.uid, name: f.name }));
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
note: m.note || ''
|
|
106
|
-
|
|
143
|
+
// Stock movements live in the top-level db.stockMovements ledger (each tagged
|
|
144
|
+
// with ingredientId + an `at` timestamp), NOT on the ingredient record.
|
|
145
|
+
const movements = (db.stockMovements || [])
|
|
146
|
+
.filter(m => m && m.ingredientId === ing.id)
|
|
147
|
+
.slice(-20)
|
|
148
|
+
.map(m => ({ ts: m.at || m.ts || null, reason: m.reason, delta: m.delta, unit: m.unit, lotId: m.lotId || null, note: m.note || '' }));
|
|
149
|
+
// GHS safety summary (pictograms / H- / P-codes / signal word).
|
|
150
|
+
const safety = ing.safety ? {
|
|
151
|
+
signalWord: ing.safety.signalWord || null,
|
|
152
|
+
pictograms: ing.safety.pictograms || [],
|
|
153
|
+
hazardStatements: ing.safety.hazardStatements || [],
|
|
154
|
+
precautionaryStatements: ing.safety.precautionaryStatements || [],
|
|
155
|
+
source: ing.safety.source || null,
|
|
156
|
+
} : null;
|
|
157
|
+
// Per-jurisdiction regulatory status (+ provenance + last-checked date).
|
|
158
|
+
const regulatory = Array.isArray(ing.regulatory) ? ing.regulatory.map(r => ({
|
|
159
|
+
jurisdiction: r.jurisdiction, status: r.status, note: r.note || '',
|
|
160
|
+
source: r.source || 'manual', checkedDate: r.checkedDate || null,
|
|
161
|
+
})) : [];
|
|
162
|
+
// Inventory lots for this ingredient (balances + expiry + traceability).
|
|
163
|
+
const lots = (db.lots || [])
|
|
164
|
+
.filter(l => l && l.ingredientId === ing.id)
|
|
165
|
+
.map(l => ({
|
|
166
|
+
id: l.id, uid: l.uid, lotNumber: l.lotNumber || '',
|
|
167
|
+
qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
|
|
168
|
+
receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
|
|
169
|
+
supplier: l.supplier || '', status: l.status || 'active',
|
|
170
|
+
}));
|
|
107
171
|
return {
|
|
108
172
|
..._trimIngredient(ing),
|
|
109
173
|
description: ing.description || '',
|
|
110
174
|
notes: ing.notes || '',
|
|
111
175
|
attributes: ing.attributes || {},
|
|
176
|
+
safety,
|
|
177
|
+
regulatory,
|
|
178
|
+
lots,
|
|
112
179
|
formulationCount: usedIn.length,
|
|
113
180
|
formulations: usedIn.slice(0, 30),
|
|
114
181
|
recentStockMovements: movements,
|
|
@@ -116,4 +183,149 @@ const get_ingredient = {
|
|
|
116
183
|
},
|
|
117
184
|
};
|
|
118
185
|
|
|
119
|
-
|
|
186
|
+
// =====================================================================
|
|
187
|
+
// find_by_smarts — SMARTS-pattern substructure search across every
|
|
188
|
+
// ingredient with a SMILES. Lazy-loads @rdkit/rdkit (declared as an
|
|
189
|
+
// optional dependency in package.json so the bare MCP install stays
|
|
190
|
+
// lightweight); returns a clear hint when the dep is missing.
|
|
191
|
+
// =====================================================================
|
|
192
|
+
|
|
193
|
+
let _rdkitNodePromise = null;
|
|
194
|
+
|
|
195
|
+
async function _loadRDKitNode() {
|
|
196
|
+
if (_rdkitNodePromise) return _rdkitNodePromise;
|
|
197
|
+
_rdkitNodePromise = (async () => {
|
|
198
|
+
try {
|
|
199
|
+
// Resolves to the RDKit-JS UMD when @rdkit/rdkit is npm-installed
|
|
200
|
+
// alongside the MCP server. Throws (caught below) if the optional
|
|
201
|
+
// dep wasn't installed.
|
|
202
|
+
const mod = await import('@rdkit/rdkit');
|
|
203
|
+
const initRDKitModule = mod.default || mod.initRDKitModule || mod;
|
|
204
|
+
if (typeof initRDKitModule !== 'function') {
|
|
205
|
+
throw new Error('@rdkit/rdkit loaded but initRDKitModule not callable');
|
|
206
|
+
}
|
|
207
|
+
return await initRDKitModule();
|
|
208
|
+
} catch (e) {
|
|
209
|
+
_rdkitNodePromise = null;
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
return _rdkitNodePromise;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const find_by_smarts = {
|
|
217
|
+
definition: {
|
|
218
|
+
name: 'find_by_smarts',
|
|
219
|
+
description: 'Find every ingredient whose SMILES contains the given SMARTS substructure pattern. Requires RDKit-JS (optional dependency); install with `npm install @rdkit/rdkit` in the mcp folder if you get a "not installed" error.',
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
smarts: { type: 'string', description: 'SMARTS substructure pattern. Examples: "c1ccccc1" (any aromatic 6-ring), "[OX2H1]" (any hydroxyl), "C(=O)O" (carboxylic acid), "[F,Cl,Br,I]" (any halogen), "[#7]" (any nitrogen atom).' },
|
|
224
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
225
|
+
},
|
|
226
|
+
required: ['smarts'],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
handler: async (args) => {
|
|
230
|
+
const smarts = String(args?.smarts || '').trim();
|
|
231
|
+
if (!smarts) return { error: 'smarts pattern is required.' };
|
|
232
|
+
const { db } = getStore();
|
|
233
|
+
let rdkit;
|
|
234
|
+
try {
|
|
235
|
+
rdkit = await _loadRDKitNode();
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return {
|
|
238
|
+
error: 'RDKit-JS is not installed in the MCP server. Run `npm install @rdkit/rdkit` in the mcp/ folder and try again.',
|
|
239
|
+
underlying: e?.message || String(e),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const candidates = (db.ingredients || []).filter(i => i && !i._trashed && i.smiles);
|
|
243
|
+
const matches = [];
|
|
244
|
+
let invalid = 0;
|
|
245
|
+
const limit = Math.min(1000, Math.max(1, args.limit || 100));
|
|
246
|
+
// Parse the query once.
|
|
247
|
+
const query = rdkit.get_qmol(smarts);
|
|
248
|
+
if (!query) return { error: `Invalid SMARTS: "${smarts}"` };
|
|
249
|
+
try {
|
|
250
|
+
for (const ing of candidates) {
|
|
251
|
+
const mol = rdkit.get_mol(ing.smiles);
|
|
252
|
+
if (!mol) { invalid++; continue; }
|
|
253
|
+
try {
|
|
254
|
+
const raw = mol.get_substruct_match(query);
|
|
255
|
+
if (raw) {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(raw);
|
|
258
|
+
if (Array.isArray(parsed.atoms) && parsed.atoms.length > 0) {
|
|
259
|
+
matches.push(_trimIngredient(ing));
|
|
260
|
+
if (matches.length >= limit) break;
|
|
261
|
+
}
|
|
262
|
+
} catch { /* skip parse fail */ }
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
mol.delete();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} finally {
|
|
269
|
+
query.delete();
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
smarts,
|
|
273
|
+
totalScanned: candidates.length,
|
|
274
|
+
invalidSmiles: invalid,
|
|
275
|
+
returned: matches.length,
|
|
276
|
+
ingredients: matches,
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// =====================================================================
|
|
282
|
+
// list_lots — inventory lots across ingredients (Tier 3). Each lot is a
|
|
283
|
+
// received batch of one ingredient with its own remaining balance, supplier,
|
|
284
|
+
// and expiry. Filter by ingredient, expiring-soon, or status.
|
|
285
|
+
// =====================================================================
|
|
286
|
+
const list_lots = {
|
|
287
|
+
definition: {
|
|
288
|
+
name: 'list_lots',
|
|
289
|
+
description: 'List inventory lots (received batches of an ingredient, each with its own remaining balance, supplier, and expiry). Optionally filter by ingredient, expiring-within-days, or status. For one ingredient\'s full lots + traceability, use get_ingredient.',
|
|
290
|
+
inputSchema: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
properties: {
|
|
293
|
+
ingredient_id: { type: 'string', description: 'Only lots for this ingredient (internal id or UID, e.g. ING-001).' },
|
|
294
|
+
expiring_within_days: { type: 'number', description: 'Only lots whose expiryDate is within this many days from now (negative days = already expired are always included).' },
|
|
295
|
+
status: { type: 'string', description: 'Filter by lot status (e.g. "active").' },
|
|
296
|
+
limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
handler: async (args) => {
|
|
301
|
+
const { db } = getStore();
|
|
302
|
+
const limit = Math.min(1000, Math.max(1, args.limit || 200));
|
|
303
|
+
let ing = null;
|
|
304
|
+
if (args.ingredient_id) {
|
|
305
|
+
ing = resolveById('ingredients', args.ingredient_id);
|
|
306
|
+
if (!ing) return { error: `No ingredient found for id "${args.ingredient_id}".` };
|
|
307
|
+
}
|
|
308
|
+
const nameById = new Map((db.ingredients || []).map(i => [i.id, i.name]));
|
|
309
|
+
const nowMs = Date.now();
|
|
310
|
+
const rows = (db.lots || []).filter(l => {
|
|
311
|
+
if (!l || l._trashed) return false;
|
|
312
|
+
if (ing && l.ingredientId !== ing.id) return false;
|
|
313
|
+
if (args.status && String(l.status || 'active').toLowerCase() !== String(args.status).toLowerCase()) return false;
|
|
314
|
+
if (args.expiring_within_days != null) {
|
|
315
|
+
if (!l.expiryDate) return false;
|
|
316
|
+
const days = Math.floor((Date.parse(l.expiryDate) - nowMs) / 86400000);
|
|
317
|
+
if (!(isFinite(days) && days <= args.expiring_within_days)) return false;
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}).map(l => ({
|
|
321
|
+
id: l.id, uid: l.uid, lotNumber: l.lotNumber || '',
|
|
322
|
+
ingredientId: l.ingredientId, ingredientName: nameById.get(l.ingredientId) || '',
|
|
323
|
+
qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
|
|
324
|
+
receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
|
|
325
|
+
supplier: l.supplier || '', status: l.status || 'active',
|
|
326
|
+
}));
|
|
327
|
+
return { totalMatching: rows.length, returned: Math.min(rows.length, limit), lots: rows.slice(0, limit) };
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export const tools = { list_ingredients, get_ingredient, find_by_smarts, list_lots };
|