formlab-mcp 0.1.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 ADDED
@@ -0,0 +1,136 @@
1
+ # formlab-mcp
2
+
3
+ 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 results — **without your data ever leaving your machine**.
4
+
5
+ > Local-first + AI-native. Your proprietary recipes stay on your laptop; only the LLM's answer to your question travels.
6
+
7
+ ## What it does
8
+
9
+ Ask Claude (or another MCP client) things like:
10
+
11
+ - _"Which of my formulations use silicone fluid at 5% or more?"_
12
+ - _"Compare the composition of Formula FORM-001 and FORM-004 side by side."_
13
+ - _"What parameters fail most often in Q2 testing?"_
14
+ - _"Show me the DOE matrix at sample grain for all 'Anti-aging serum' family formulas."_
15
+ - _"What's untested? Which of my approved formulas have no measurements yet?"_
16
+
17
+ ## Tools exposed
18
+
19
+ | Tool | Purpose |
20
+ |---|---|
21
+ | `list_formulations` | Filtered list of recipes |
22
+ | `get_formulation` | Full record + flattened wt-% composition (sub-formulas expanded) |
23
+ | `find_similar_formulations` | Find formulas using a given ingredient ≥ threshold % |
24
+ | `compare_formulations` | Pairwise side-by-side composition diff |
25
+ | `list_ingredients` | Filtered list of raw materials |
26
+ | `get_ingredient` | Full record + supplier / stock / formulations using it |
27
+ | `list_batches` | Filtered list of production / lab-prep events |
28
+ | `get_batch` | Full record + actual composition + samples + blend lineage |
29
+ | `list_samples` | Filtered list of physical specimens |
30
+ | `get_sample` | Full record + canonical variant + test results + blend lineage |
31
+ | `list_test_results` | Filtered list of test reports |
32
+ | `get_test_result` | Full record + every measurement value |
33
+ | `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
34
+ | `find_failures` | Pareto-style: parameters that fail acceptance most often |
35
+ | `get_coverage_matrix` | Which formulations × parameters have been measured |
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ # From npm (once published):
41
+ npm install -g formlab-mcp
42
+
43
+ # Or run directly without installing:
44
+ npx formlab-mcp /path/to/formlab-export.json
45
+ ```
46
+
47
+ For local development from this repo:
48
+
49
+ ```bash
50
+ cd mcp
51
+ npm install
52
+ node index.js /path/to/formlab-export.json
53
+ ```
54
+
55
+ Requires **Node 18+**.
56
+
57
+ ## Get your FormLab export
58
+
59
+ 1. Open FormLab
60
+ 2. Sidebar → **⇅ Import / Export**
61
+ 3. Click **Export** — saves `formlab-export-YYYY-MM-DD.json` to your downloads folder
62
+ 4. Point this MCP at the downloaded file
63
+
64
+ The MCP server watches the file — re-export from FormLab and the next tool call sees the fresh data without restarting the server.
65
+
66
+ ## Wire it up to Claude Desktop
67
+
68
+ Add this to your `claude_desktop_config.json` (on macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`):
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "formlab": {
74
+ "command": "npx",
75
+ "args": ["formlab-mcp", "/Users/you/Downloads/formlab-export-2026-05-30.json"]
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Or for local-dev (from the repo):
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "formlab": {
87
+ "command": "node",
88
+ "args": [
89
+ "/Users/you/Documents/GitHub/formlab/mcp/index.js",
90
+ "/Users/you/Downloads/formlab-export-2026-05-30.json"
91
+ ]
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 15 tools become callable in any conversation.
98
+
99
+ ## Wire it up to Claude Code
100
+
101
+ ```bash
102
+ claude mcp add formlab npx formlab-mcp /Users/you/Downloads/formlab-export.json
103
+ ```
104
+
105
+ Or with a stored env var:
106
+
107
+ ```bash
108
+ export FORMLAB_EXPORT=/Users/you/Downloads/formlab-export-2026-05-30.json
109
+ claude mcp add formlab npx formlab-mcp
110
+ ```
111
+
112
+ ## Privacy and architecture
113
+
114
+ - **No network calls.** The server runs on stdio between Claude and your local Node process. Your data file never leaves your disk.
115
+ - **No FormLab cloud.** FormLab itself is a local-first app; the MCP follows the same model.
116
+ - **Hot-reload on file change.** Re-export from FormLab while the server is running — the next tool call picks up fresh data.
117
+ - **Read-only.** Tier 1 cannot mutate your FormLab data. (Write tools are planned for a future paid "Pro" tier — register / batch / log-test mutations with cascade-safe confirmation.)
118
+
119
+ ## Data shape
120
+
121
+ The server accepts both FormLab export shapes:
122
+
123
+ - **Wrapped FAIR export**: `{ "fair": {...metadata...}, "data": {...db...} }`
124
+ - **Legacy bare-db JSON**: `{...db...}` directly
125
+
126
+ The wrapped form is the default since 2026; the legacy form is supported for older exports.
127
+
128
+ ## Tier 2 (planned, paid)
129
+
130
+ Live file-sync with write tools: `create_formulation`, `update_formulation`, `log_test_result`, `create_batch`, etc. Mutations from Claude write back to the same JSON FormLab reads. Conflict detection, cascade-safe deletes, schema versioning.
131
+
132
+ Not yet shipped — see the FormLab roadmap.
133
+
134
+ ## License
135
+
136
+ MIT
package/data.js ADDED
@@ -0,0 +1,163 @@
1
+ // ============================================================
2
+ // DATA LOADER — accepts both FormLab export shapes
3
+ // 1. Wrapped FAIR export: { fair: {...}, data: {...db...} }
4
+ // 2. Legacy bare-db JSON: {...db...} directly
5
+ // And exposes a single normalized "store" object plus helpers
6
+ // for the tools to query against.
7
+ // ============================================================
8
+
9
+ import { readFileSync, watch } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ // Module-level mutable store — re-assigned on hot-reload when the
13
+ // watched file changes. Tools read from getStore() to always see
14
+ // the latest data without holding a stale reference.
15
+ let _store = null;
16
+ let _path = null;
17
+
18
+ export function loadStore(filePath) {
19
+ _path = resolve(filePath);
20
+ const raw = readFileSync(_path, 'utf8');
21
+ const parsed = JSON.parse(raw);
22
+ // Detect wrapped vs legacy shape. A wrapped export has top-level
23
+ // `fair` metadata + `data` payload. Legacy is just the db object
24
+ // with ingredients/formulations/etc. at the root.
25
+ const db = (parsed && typeof parsed === 'object' && parsed.fair && parsed.data)
26
+ ? parsed.data
27
+ : parsed;
28
+ const meta = (parsed && parsed.fair) || null;
29
+ _store = {
30
+ db,
31
+ meta,
32
+ loadedAt: new Date().toISOString(),
33
+ sourcePath: _path,
34
+ // Pre-compute commonly-used indexes for O(1) lookups instead
35
+ // of repeated linear scans across every tool call.
36
+ indexes: _buildIndexes(db),
37
+ };
38
+ return _store;
39
+ }
40
+
41
+ export function getStore() {
42
+ if (!_store) throw new Error('Data store not loaded. Call loadStore(path) first.');
43
+ return _store;
44
+ }
45
+
46
+ // Re-read the file on disk-change. Useful when the user re-exports
47
+ // FormLab while the MCP server is running — the next tool call sees
48
+ // fresh data without restarting the server.
49
+ export function watchStore(onReload) {
50
+ if (!_path) throw new Error('Cannot watch — no path loaded.');
51
+ let timer = null;
52
+ watch(_path, (eventType) => {
53
+ if (eventType !== 'change') return;
54
+ // Debounce: editors often fire multiple change events per save
55
+ clearTimeout(timer);
56
+ timer = setTimeout(() => {
57
+ try {
58
+ loadStore(_path);
59
+ if (onReload) onReload(_store);
60
+ } catch (e) {
61
+ // Don't crash the server on a malformed re-export — keep the
62
+ // previous good state in memory and surface the error on the
63
+ // next tool call.
64
+ console.error('[formlab-mcp] reload failed:', e.message);
65
+ }
66
+ }, 150);
67
+ });
68
+ }
69
+
70
+ function _buildIndexes(db) {
71
+ const byId = (arr) => {
72
+ const m = new Map();
73
+ (arr || []).forEach(x => { if (x && x.id) m.set(x.id, x); });
74
+ return m;
75
+ };
76
+ return {
77
+ formulationsById: byId(db.formulations),
78
+ ingredientsById: byId(db.ingredients),
79
+ batchesById: byId(db.batches),
80
+ samplesById: byId(db.samples),
81
+ testResultsById: byId(db.testResults),
82
+ projectsById: byId(db.projects),
83
+ templatesById: byId(db.templates),
84
+ parametersById: byId(db.parameters),
85
+ equipmentById: byId(db.equipment),
86
+ };
87
+ }
88
+
89
+ // ============================================================
90
+ // Shared helpers used across tools
91
+ // ============================================================
92
+
93
+ // Resolve a record by either UID (FORM-001) or internal id. Lets
94
+ // users reference entities by the human-readable UID they see in
95
+ // FormLab without having to dig out the opaque internal id.
96
+ export function resolveById(collection, idOrUid) {
97
+ const db = getStore().db;
98
+ const arr = db[collection] || [];
99
+ if (!idOrUid) return null;
100
+ return arr.find(x => x && (x.id === idOrUid || x.uid === idOrUid)) || null;
101
+ }
102
+
103
+ // Flatten a formulation's composition into leaf ingredient wt-%.
104
+ // Walks sub-formula rows recursively (cap depth to break cycles).
105
+ // Mirrors the algorithm in FormLab's js/nesting.js _flattenComposition.
106
+ export function flattenComposition(formulation, depth = 0) {
107
+ const MAX_DEPTH = 5;
108
+ if (!formulation || !Array.isArray(formulation.composition) || depth > MAX_DEPTH) {
109
+ return { rows: [] };
110
+ }
111
+ const { db } = getStore();
112
+ const rows = [];
113
+ const allPct = formulation.composition.every(c => {
114
+ const u = String(c.unit || 'wt%').trim().toLowerCase();
115
+ return u.startsWith('wt') || u === '%';
116
+ });
117
+ // Total mass denominator for renormalization when sub-formulas are present
118
+ let totalWeight = 0;
119
+ formulation.composition.forEach(c => {
120
+ if (!c) return;
121
+ const amt = parseFloat(c.amount);
122
+ if (!isFinite(amt)) return;
123
+ totalWeight += amt;
124
+ });
125
+ if (totalWeight <= 0) return { rows };
126
+
127
+ formulation.composition.forEach(c => {
128
+ if (!c) return;
129
+ const amt = parseFloat(c.amount);
130
+ if (!isFinite(amt)) return;
131
+ const fraction = amt / totalWeight;
132
+ if (c.formulationId) {
133
+ const sub = db.formulations.find(g => g.id === c.formulationId);
134
+ if (!sub) return;
135
+ const sub_flat = flattenComposition(sub, depth + 1);
136
+ sub_flat.rows.forEach(r => {
137
+ rows.push({
138
+ ingredientId: r.ingredientId,
139
+ ingredientName: r.ingredientName,
140
+ weightFraction: r.weightFraction * fraction,
141
+ role: r.role || c.role || '',
142
+ });
143
+ });
144
+ } else if (c.ingredientId) {
145
+ const ing = db.ingredients.find(i => i.id === c.ingredientId);
146
+ rows.push({
147
+ ingredientId: c.ingredientId,
148
+ ingredientName: ing?.name || c.ingredientId,
149
+ weightFraction: allPct ? amt / 100 : fraction,
150
+ role: c.role || '',
151
+ });
152
+ }
153
+ });
154
+ // Coalesce duplicate leaves (same ingredient reachable via multiple
155
+ // sub-formulas) by summing their weight fractions.
156
+ const merged = new Map();
157
+ rows.forEach(r => {
158
+ const prev = merged.get(r.ingredientId);
159
+ if (prev) prev.weightFraction += r.weightFraction;
160
+ else merged.set(r.ingredientId, { ...r });
161
+ });
162
+ return { rows: [...merged.values()] };
163
+ }
package/index.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================
3
+ // FormLab MCP server — read-only entry point.
4
+ //
5
+ // Usage:
6
+ // node index.js /path/to/formlab-export-2026-05-30.json
7
+ // FORMLAB_EXPORT=/path/to/export.json node index.js
8
+ // formlab-mcp /path/to/export.json (when installed via npm)
9
+ //
10
+ // Exposes 13 tools (see ./tools/) over stdio. The MCP host (Claude
11
+ // Desktop, Claude Code, etc.) handles tool discovery, invocation
12
+ // and response formatting. We just register handlers and stay out
13
+ // of the way.
14
+ //
15
+ // Hot-reload: if the export file changes on disk while the server
16
+ // is running, the store is re-read automatically so the next tool
17
+ // call sees fresh data. No restart needed.
18
+ // ============================================================
19
+
20
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
21
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
22
+ import {
23
+ CallToolRequestSchema,
24
+ ListToolsRequestSchema,
25
+ } from '@modelcontextprotocol/sdk/types.js';
26
+
27
+ import { loadStore, watchStore, getStore } from './data.js';
28
+ import * as formulations from './tools/formulations.js';
29
+ import * as ingredients from './tools/ingredients.js';
30
+ import * as lab from './tools/lab.js';
31
+ import * as analytics from './tools/analytics.js';
32
+
33
+ const filePath = process.argv[2] || process.env.FORMLAB_EXPORT;
34
+ if (!filePath) {
35
+ process.stderr.write([
36
+ 'Usage: formlab-mcp <path-to-export.json>',
37
+ 'Or set FORMLAB_EXPORT=<path> and call without args.',
38
+ '',
39
+ 'Export your FormLab database via Settings → Import / Export → Export,',
40
+ 'then point this server at the downloaded formlab-export-YYYY-MM-DD.json.',
41
+ '',
42
+ ].join('\n'));
43
+ process.exit(1);
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);
51
+ }
52
+
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
+ // All tools live in tools/*.js as a flat { definition, handler } pair.
76
+ // Concatenate the four modules' exports into a single registry the
77
+ // MCP server can use for both list_tools and call_tool dispatch.
78
+ const TOOLS = [
79
+ ...Object.values(formulations.tools),
80
+ ...Object.values(ingredients.tools),
81
+ ...Object.values(lab.tools),
82
+ ...Object.values(analytics.tools),
83
+ ];
84
+
85
+ const server = new Server(
86
+ { name: 'formlab-mcp', version: '0.1.0' },
87
+ { capabilities: { tools: {} } }
88
+ );
89
+
90
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
91
+ tools: TOOLS.map(t => t.definition),
92
+ }));
93
+
94
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
95
+ const { name, arguments: args } = req.params;
96
+ const tool = TOOLS.find(t => t.definition.name === name);
97
+ if (!tool) {
98
+ return {
99
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
100
+ isError: true,
101
+ };
102
+ }
103
+ try {
104
+ const result = await tool.handler(args || {});
105
+ // All tool handlers return either a plain object/array (auto-
106
+ // serialized as JSON text) or a pre-formatted string. The MCP
107
+ // protocol wants content as { type: 'text', text: '...' }.
108
+ const text = typeof result === 'string'
109
+ ? result
110
+ : JSON.stringify(result, null, 2);
111
+ return { content: [{ type: 'text', text }] };
112
+ } catch (e) {
113
+ return {
114
+ content: [{ type: 'text', text: `Error: ${e.message}` }],
115
+ isError: true,
116
+ };
117
+ }
118
+ });
119
+
120
+ const transport = new StdioServerTransport();
121
+ await server.connect(transport);
122
+ process.stderr.write('[formlab-mcp] ready — listening on stdio\n');
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "formlab-mcp",
3
+ "version": "0.1.0",
4
+ "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.",
5
+ "type": "module",
6
+ "bin": {
7
+ "formlab-mcp": "./index.js"
8
+ },
9
+ "main": "index.js",
10
+ "scripts": {
11
+ "start": "node index.js",
12
+ "dev": "node --watch index.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.0.4"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "formlab",
24
+ "chemistry",
25
+ "formulation",
26
+ "lab-notebook",
27
+ "doe",
28
+ "design-of-experiments",
29
+ "claude"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/juliu1980/FormLab",
35
+ "directory": "mcp"
36
+ }
37
+ }