formlab-mcp 0.1.4 → 0.1.7

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
@@ -8,6 +8,8 @@ A **read-only Model Context Protocol server** for [FormLab](https://formvix.com)
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,8 +31,8 @@ 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 / stock / formulations using it. Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
31
36
  | `list_batches` | Filtered list of production / lab-prep events |
32
37
  | `get_batch` | Full record + actual composition + samples + blend lineage |
33
38
  | `list_samples` | Filtered list of physical specimens |
@@ -37,6 +42,7 @@ Ask Claude (or another MCP client) things like:
37
42
  | `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
38
43
  | `find_failures` | Pareto-style: parameters that fail acceptance most often |
39
44
  | `get_coverage_matrix` | Which formulations × parameters have been measured |
45
+ | `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
46
 
41
47
  ## Install
42
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
5
  "description": "Read-only Model Context Protocol server for FormLab — lets Claude (and other MCP clients) read and analyze your local FormLab export. Your recipes never leave your machine.",
6
6
  "type": "module",
@@ -18,6 +18,9 @@
18
18
  "dependencies": {
19
19
  "@modelcontextprotocol/sdk": "^1.0.4"
20
20
  },
21
+ "optionalDependencies": {
22
+ "@rdkit/rdkit": "^2024.9.1"
23
+ },
21
24
  "keywords": [
22
25
  "mcp",
23
26
  "model-context-protocol",
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.4",
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.4",
16
+ "version": "0.1.7",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -23,9 +23,15 @@ function _trimIngredient(i) {
23
23
  stockUnit: i.stockUnit || null,
24
24
  // Tier 1 expansion fields — keep optional so legacy exports stay clean.
25
25
  ...(i.ingredientClass ? { ingredientClass: i.ingredientClass } : {}),
26
+ ...(i.inchiKey ? { inchiKey: i.inchiKey } : {}),
26
27
  ...(i.sequence ? { sequence: i.sequence } : {}),
27
28
  ...(i.taxon ? { taxon: i.taxon } : {}),
28
- ...(i.extractDetails ? { extractDetails: i.extractDetails } : {}),
29
+ ...(i.extractDetails ? { extractDetails: i.extractDetails } : {}),
30
+ ...(i.sequenceDetails ? { sequenceDetails: i.sequenceDetails } : {}),
31
+ ...(i.polymerDetails ? { polymerDetails: i.polymerDetails } : {}),
32
+ ...(i.surfactantDetails ? { surfactantDetails: i.surfactantDetails } : {}),
33
+ ...(i.pigmentDetails ? { pigmentDetails: i.pigmentDetails } : {}),
34
+ ...(i.fragranceDetails ? { fragranceDetails: i.fragranceDetails } : {}),
29
35
  };
30
36
  }
31
37
 
@@ -43,6 +49,7 @@ const list_ingredients = {
43
49
  ingredient_class: { type: 'string', description: 'Filter by symbolic class: small-molecule, surfactant, polymer, extract, fragrance, pigment, sequence, mixture.' },
44
50
  sequence_contains: { type: 'string', description: 'Case-insensitive substring match on peptide / oligo sequence (e.g. "KTTKS" matches Matrixyl).' },
45
51
  taxon_contains: { type: 'string', description: 'Case-insensitive substring match on botanical scientific name (e.g. "Centella").' },
52
+ 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.' },
46
53
  limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
47
54
  },
48
55
  },
@@ -60,6 +67,14 @@ const list_ingredients = {
60
67
  if (args.ingredient_class && norm(i.ingredientClass) !== norm(args.ingredient_class)) return false;
61
68
  if (args.sequence_contains && !norm(i.sequence?.raw).includes(norm(args.sequence_contains))) return false;
62
69
  if (args.taxon_contains && !norm(i.taxon?.scientificName).includes(norm(args.taxon_contains))) return false;
70
+ // InChIKey lookup — 14-char prefix matches connectivity only, full
71
+ // 27-char key matches connectivity + stereo + isotope. Both useful.
72
+ if (args.inchikey) {
73
+ const want = String(args.inchikey).trim().toUpperCase();
74
+ const have = String(i.inchiKey || '').toUpperCase();
75
+ if (!have) return false;
76
+ if (want.length === 14 ? !have.startsWith(want) : have !== want) return false;
77
+ }
63
78
  return true;
64
79
  });
65
80
  return {
@@ -111,4 +126,99 @@ const get_ingredient = {
111
126
  },
112
127
  };
113
128
 
114
- export const tools = { list_ingredients, get_ingredient };
129
+ // =====================================================================
130
+ // find_by_smarts — SMARTS-pattern substructure search across every
131
+ // ingredient with a SMILES. Lazy-loads @rdkit/rdkit (declared as an
132
+ // optional dependency in package.json so the bare MCP install stays
133
+ // lightweight); returns a clear hint when the dep is missing.
134
+ // =====================================================================
135
+
136
+ let _rdkitNodePromise = null;
137
+
138
+ async function _loadRDKitNode() {
139
+ if (_rdkitNodePromise) return _rdkitNodePromise;
140
+ _rdkitNodePromise = (async () => {
141
+ try {
142
+ // Resolves to the RDKit-JS UMD when @rdkit/rdkit is npm-installed
143
+ // alongside the MCP server. Throws (caught below) if the optional
144
+ // dep wasn't installed.
145
+ const mod = await import('@rdkit/rdkit');
146
+ const initRDKitModule = mod.default || mod.initRDKitModule || mod;
147
+ if (typeof initRDKitModule !== 'function') {
148
+ throw new Error('@rdkit/rdkit loaded but initRDKitModule not callable');
149
+ }
150
+ return await initRDKitModule();
151
+ } catch (e) {
152
+ _rdkitNodePromise = null;
153
+ throw e;
154
+ }
155
+ })();
156
+ return _rdkitNodePromise;
157
+ }
158
+
159
+ const find_by_smarts = {
160
+ definition: {
161
+ name: 'find_by_smarts',
162
+ 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.',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ 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).' },
167
+ limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
168
+ },
169
+ required: ['smarts'],
170
+ },
171
+ },
172
+ handler: async (args) => {
173
+ const smarts = String(args?.smarts || '').trim();
174
+ if (!smarts) return { error: 'smarts pattern is required.' };
175
+ const { db } = getStore();
176
+ let rdkit;
177
+ try {
178
+ rdkit = await _loadRDKitNode();
179
+ } catch (e) {
180
+ return {
181
+ error: 'RDKit-JS is not installed in the MCP server. Run `npm install @rdkit/rdkit` in the mcp/ folder and try again.',
182
+ underlying: e?.message || String(e),
183
+ };
184
+ }
185
+ const candidates = (db.ingredients || []).filter(i => i && !i._trashed && i.smiles);
186
+ const matches = [];
187
+ let invalid = 0;
188
+ const limit = Math.min(1000, Math.max(1, args.limit || 100));
189
+ // Parse the query once.
190
+ const query = rdkit.get_qmol(smarts);
191
+ if (!query) return { error: `Invalid SMARTS: "${smarts}"` };
192
+ try {
193
+ for (const ing of candidates) {
194
+ const mol = rdkit.get_mol(ing.smiles);
195
+ if (!mol) { invalid++; continue; }
196
+ try {
197
+ const raw = mol.get_substruct_match(query);
198
+ if (raw) {
199
+ try {
200
+ const parsed = JSON.parse(raw);
201
+ if (Array.isArray(parsed.atoms) && parsed.atoms.length > 0) {
202
+ matches.push(_trimIngredient(ing));
203
+ if (matches.length >= limit) break;
204
+ }
205
+ } catch { /* skip parse fail */ }
206
+ }
207
+ } finally {
208
+ mol.delete();
209
+ }
210
+ }
211
+ } finally {
212
+ query.delete();
213
+ }
214
+ return {
215
+ smarts,
216
+ totalScanned: candidates.length,
217
+ invalidSmiles: invalid,
218
+ returned: matches.length,
219
+ ingredients: matches,
220
+ };
221
+ },
222
+ };
223
+
224
+ export const tools = { list_ingredients, get_ingredient, find_by_smarts };