formlab-mcp 0.1.7 → 0.4.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 +41 -4
- package/cloud.js +114 -0
- package/data.js +14 -0
- package/index.js +71 -41
- package/package.json +4 -3
- package/tools/ingredients.js +196 -12
- package/tools/library.js +360 -0
- package/tools/notebook.js +128 -0
- package/tools/projects.js +90 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
|
|
|
@@ -32,17 +32,31 @@ Ask Claude (or another MCP client) things like:
|
|
|
32
32
|
| `find_similar_formulations` | Find formulas using a given ingredient ≥ threshold % |
|
|
33
33
|
| `compare_formulations` | Pairwise side-by-side composition diff |
|
|
34
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` |
|
|
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` |
|
|
37
|
+
| `list_inventory` | Portfolio stock rollup — one row per ingredient with on-hand qty, summed lot balance, nearest expiry, and a low-stock flag (on-hand ≤ `reorderThreshold`, or zero). Filters: `low_stock_only`, `expiring_within_days`, `family` |
|
|
36
38
|
| `list_batches` | Filtered list of production / lab-prep events |
|
|
37
39
|
| `get_batch` | Full record + actual composition + samples + blend lineage |
|
|
38
40
|
| `list_samples` | Filtered list of physical specimens |
|
|
39
|
-
| `get_sample` | Full record + canonical variant + test
|
|
41
|
+
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
40
42
|
| `list_test_results` | Filtered list of test reports |
|
|
41
43
|
| `get_test_result` | Full record + every measurement value |
|
|
42
44
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
43
45
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
44
46
|
| `get_coverage_matrix` | Which formulations × parameters have been measured |
|
|
45
47
|
| `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). |
|
|
48
|
+
| `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…). Filters: `category`, `status`, `manufacturer`, `name_contains` |
|
|
49
|
+
| `get_equipment` | Full equipment record + a summary of where it's used (panels, step presets, test methods, formulas, batches) |
|
|
50
|
+
| `list_test_methods` | Filtered list of Test Methods (the parameter library). Filter by `analyte` (the property measured, e.g. `Viscosity` — finds every method for it), `category`, `name_contains`, `include_retired` |
|
|
51
|
+
| `get_test_method` | Full method definition (SOP / sample prep / equipment / calculation / acceptance spec) + sibling methods sharing its analyte |
|
|
52
|
+
| `list_step_presets` | Filtered list of Step Presets (reusable procedure steps: duration / temp / RPM / equipment). Filters: `industry`, `name_contains` |
|
|
53
|
+
| `get_step_preset` | Full step-preset record incl. the multi-value equipment list (`{name, equipmentId}`) |
|
|
54
|
+
| `list_test_panels` | Filtered list of Test Panels (reusable column-sets — the parameters measured together on a sample). Filters: `industry`, `name_contains` |
|
|
55
|
+
| `get_test_panel` | Full panel record — ordered parameter list (each with resolved Test Method UID, unit, spec) + instruments |
|
|
56
|
+
| `list_projects` | Filtered list of projects (the buckets formulations are filed under) + per-project formulation count. Filters: `status`, `name_contains` |
|
|
57
|
+
| `get_project` | Full project record + the formulations filed under it |
|
|
58
|
+
| `list_notebook_entries` | ELN feed — dated authored notes attached to records. Filters: `entity_type`, `entity_id`, `author`, `text_contains`, `since`, `until` |
|
|
59
|
+
| `get_notebook_entry` | One notebook entry — full body, author, attachment metadata, resolved linked record |
|
|
46
60
|
|
|
47
61
|
## Install
|
|
48
62
|
|
|
@@ -74,6 +88,29 @@ Requires **Node 18+**.
|
|
|
74
88
|
|
|
75
89
|
The MCP server watches the file — re-export from FormLab and the next tool call sees the fresh data without restarting the server.
|
|
76
90
|
|
|
91
|
+
## Or: LIVE cloud mode (no export needed)
|
|
92
|
+
|
|
93
|
+
Instead of an export file, point the server at your **live cloud workspace** so it's always current.
|
|
94
|
+
|
|
95
|
+
1. In FormLab (Pro): **Settings → Account → AI tools (MCP) → Connect…**
|
|
96
|
+
2. Copy the generated config and paste it into Claude Desktop (it merges with the file-mode example below).
|
|
97
|
+
|
|
98
|
+
The config sets these env vars (which switch the server into cloud mode):
|
|
99
|
+
|
|
100
|
+
| Env var | Value |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `FORMLAB_SUPABASE_URL` | your Supabase project URL (public) |
|
|
103
|
+
| `FORMLAB_SUPABASE_ANON_KEY` | publishable/anon key (public) |
|
|
104
|
+
| `FORMLAB_REFRESH_TOKEN` | your session refresh token — **a credential** |
|
|
105
|
+
| `FORMLAB_WORKSPACE_ID` | (optional) workspace to read; defaults to your own |
|
|
106
|
+
| `FORMLAB_REFRESH_SECONDS` | (optional) poll interval, default `60` |
|
|
107
|
+
|
|
108
|
+
When `FORMLAB_REFRESH_TOKEN` + URL + key are present, the server reads the workspace directly (RLS-scoped to you), re-fetching every `FORMLAB_REFRESH_SECONDS`. It's **read-only**.
|
|
109
|
+
|
|
110
|
+
> ⚠ **Security.** The refresh token grants read access to your account — treat the config like a password (don't share or commit it). To revoke: FormLab → Account → *Sign out of all devices*. A dedicated, revocable, **read-only** MCP token is the planned hardening (needs a small backend: a tokens table + an Edge Function to mint/validate).
|
|
111
|
+
|
|
112
|
+
Cloud mode needs `@supabase/supabase-js` (a normal dependency) — `npm install` in this folder, or `npx -y formlab-mcp` pulls it automatically.
|
|
113
|
+
|
|
77
114
|
## Wire it up to Claude Desktop
|
|
78
115
|
|
|
79
116
|
Add this to your `claude_desktop_config.json` (on macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
@@ -105,7 +142,7 @@ Or for local-dev (from the repo):
|
|
|
105
142
|
}
|
|
106
143
|
```
|
|
107
144
|
|
|
108
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
145
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 30 tools become callable in any conversation.
|
|
109
146
|
|
|
110
147
|
## Wire it up to Claude Code
|
|
111
148
|
|
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 30 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,65 +25,95 @@ 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';
|
|
33
|
+
import * as library from './tools/library.js';
|
|
34
|
+
import * as projects from './tools/projects.js';
|
|
35
|
+
import * as notebook from './tools/notebook.js';
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
// ----- Data source: LIVE cloud workspace OR a static export file -----
|
|
38
|
+
// Cloud mode kicks in when the Supabase env vars are present (copy them from
|
|
39
|
+
// FormLab → Connect to MCP). Otherwise we fall back to the export-file mode.
|
|
40
|
+
const CLOUD = process.env.FORMLAB_REFRESH_TOKEN && process.env.FORMLAB_SUPABASE_URL && process.env.FORMLAB_SUPABASE_ANON_KEY;
|
|
41
|
+
|
|
42
|
+
if (CLOUD) {
|
|
43
|
+
try {
|
|
44
|
+
const h = await startCloud({
|
|
45
|
+
url: process.env.FORMLAB_SUPABASE_URL,
|
|
46
|
+
anonKey: process.env.FORMLAB_SUPABASE_ANON_KEY,
|
|
47
|
+
refreshToken: process.env.FORMLAB_REFRESH_TOKEN,
|
|
48
|
+
workspaceId: process.env.FORMLAB_WORKSPACE_ID || null,
|
|
49
|
+
refreshSeconds: Number(process.env.FORMLAB_REFRESH_SECONDS || 60),
|
|
50
|
+
});
|
|
51
|
+
const s = getStore();
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`[formlab-mcp] LIVE cloud mode · workspace ${h.workspaceId} · ` +
|
|
54
|
+
`${(s.db.ingredients||[]).length} ingredients · ${(s.db.formulations||[]).length} formulas · ` +
|
|
55
|
+
`refresh every ${Number(process.env.FORMLAB_REFRESH_SECONDS || 60)}s\n`);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
process.stderr.write(`[formlab-mcp] cloud mode failed: ${e.message}\n`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
const filePath = process.argv[2] || process.env.FORMLAB_EXPORT;
|
|
62
|
+
if (!filePath) {
|
|
63
|
+
process.stderr.write([
|
|
64
|
+
'Usage: formlab-mcp <path-to-export.json> (file mode)',
|
|
65
|
+
' or: set FORMLAB_SUPABASE_URL / FORMLAB_SUPABASE_ANON_KEY /',
|
|
66
|
+
' FORMLAB_REFRESH_TOKEN (+ optional FORMLAB_WORKSPACE_ID) (LIVE cloud mode)',
|
|
67
|
+
'',
|
|
68
|
+
'File mode: export via Settings → Import / Export → Export.',
|
|
69
|
+
'Cloud mode: copy the values from FormLab → Connect to MCP.',
|
|
70
|
+
'',
|
|
71
|
+
].join('\n'));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
loadStore(filePath);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
process.stderr.write(`[formlab-mcp] failed to load export: ${e.message}\n`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const store = getStore();
|
|
35
81
|
process.stderr.write([
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
82
|
+
`[formlab-mcp] loaded ${store.sourcePath}`,
|
|
83
|
+
store.meta
|
|
84
|
+
? ` schema=${store.meta.schemaName} v${store.meta.schemaVersion} exportedAt=${store.meta.exportedAt}`
|
|
85
|
+
: ` (legacy bare-db shape — no FAIR metadata)`,
|
|
86
|
+
` records: ${
|
|
87
|
+
[
|
|
88
|
+
`${(store.db.ingredients || []).length} ingredients`,
|
|
89
|
+
`${(store.db.formulations || []).length} formulas`,
|
|
90
|
+
`${(store.db.batches || []).length} batches`,
|
|
91
|
+
`${(store.db.samples || []).length} samples`,
|
|
92
|
+
`${(store.db.testResults || []).length} test results`,
|
|
93
|
+
].join(' · ')
|
|
94
|
+
}`,
|
|
41
95
|
'',
|
|
42
96
|
].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);
|
|
97
|
+
watchStore((s) => {
|
|
98
|
+
process.stderr.write(`[formlab-mcp] reloaded — ${(s.db.formulations||[]).length} formulas now\n`);
|
|
99
|
+
});
|
|
51
100
|
}
|
|
52
101
|
|
|
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
102
|
// All tools live in tools/*.js as a flat { definition, handler } pair.
|
|
76
|
-
// Concatenate
|
|
77
|
-
//
|
|
103
|
+
// Concatenate every module's exports into a single registry the MCP
|
|
104
|
+
// server can use for both list_tools and call_tool dispatch.
|
|
78
105
|
const TOOLS = [
|
|
79
106
|
...Object.values(formulations.tools),
|
|
80
107
|
...Object.values(ingredients.tools),
|
|
81
108
|
...Object.values(lab.tools),
|
|
82
109
|
...Object.values(analytics.tools),
|
|
110
|
+
...Object.values(library.tools),
|
|
111
|
+
...Object.values(projects.tools),
|
|
112
|
+
...Object.values(notebook.tools),
|
|
83
113
|
];
|
|
84
114
|
|
|
85
115
|
const server = new Server(
|
|
86
|
-
{ name: 'formlab-mcp', version: '0.
|
|
116
|
+
{ name: 'formlab-mcp', version: '0.4.0' },
|
|
87
117
|
{ capabilities: { tools: {} } }
|
|
88
118
|
);
|
|
89
119
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,8 @@
|
|
|
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"
|
|
20
21
|
},
|
|
21
22
|
"optionalDependencies": {
|
|
22
23
|
"@rdkit/rdkit": "^2024.9.1"
|
package/tools/ingredients.js
CHANGED
|
@@ -6,19 +6,52 @@
|
|
|
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.
|
|
@@ -88,7 +121,7 @@ const list_ingredients = {
|
|
|
88
121
|
const get_ingredient = {
|
|
89
122
|
definition: {
|
|
90
123
|
name: 'get_ingredient',
|
|
91
|
-
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).',
|
|
92
125
|
inputSchema: {
|
|
93
126
|
type: 'object',
|
|
94
127
|
properties: {
|
|
@@ -107,18 +140,42 @@ const get_ingredient = {
|
|
|
107
140
|
const usedIn = (db.formulations || []).filter(f =>
|
|
108
141
|
f && !f._trashed && (f.composition || []).some(c => c && c.ingredientId === ing.id)
|
|
109
142
|
).map(f => ({ id: f.id, uid: f.uid, name: f.name }));
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
note: m.note || ''
|
|
116
|
-
|
|
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
|
+
}));
|
|
117
171
|
return {
|
|
118
172
|
..._trimIngredient(ing),
|
|
119
173
|
description: ing.description || '',
|
|
120
174
|
notes: ing.notes || '',
|
|
121
175
|
attributes: ing.attributes || {},
|
|
176
|
+
safety,
|
|
177
|
+
regulatory,
|
|
178
|
+
lots,
|
|
122
179
|
formulationCount: usedIn.length,
|
|
123
180
|
formulations: usedIn.slice(0, 30),
|
|
124
181
|
recentStockMovements: movements,
|
|
@@ -221,4 +278,131 @@ const find_by_smarts = {
|
|
|
221
278
|
},
|
|
222
279
|
};
|
|
223
280
|
|
|
224
|
-
|
|
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
|
+
// =====================================================================
|
|
332
|
+
// list_inventory — portfolio stock view. One row per ingredient rolling
|
|
333
|
+
// up on-hand quantity, lot balances, nearest expiry, and a low-stock flag
|
|
334
|
+
// (stockOnHand at/below its reorderThreshold, or zero). Answers "what's
|
|
335
|
+
// low / expiring / out" across the whole library in a single call —
|
|
336
|
+
// list_lots is per-lot and get_ingredient is one ingredient at a time.
|
|
337
|
+
// =====================================================================
|
|
338
|
+
const list_inventory = {
|
|
339
|
+
definition: {
|
|
340
|
+
name: 'list_inventory',
|
|
341
|
+
description: 'Portfolio inventory status — one row per ingredient with on-hand quantity, summed lot balance, lot count, nearest lot expiry, and a low-stock flag (on-hand at/below its reorder threshold, or zero). Filter to low-stock or expiring items. Sorted low-stock first, then by on-hand ascending.',
|
|
342
|
+
inputSchema: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
properties: {
|
|
345
|
+
low_stock_only: { type: 'boolean', description: 'Only ingredients flagged low (on-hand ≤ reorderThreshold, or on-hand is 0).' },
|
|
346
|
+
expiring_within_days: { type: 'number', description: 'Only ingredients with a lot expiring within this many days from now (already-expired lots always qualify).' },
|
|
347
|
+
family: { type: 'string', description: 'Filter by ingredient family. Case-insensitive substring.' },
|
|
348
|
+
limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
handler: async (args) => {
|
|
353
|
+
const { db } = getStore();
|
|
354
|
+
const limit = Math.min(1000, Math.max(1, args.limit || 200));
|
|
355
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
356
|
+
const nowMs = Date.now();
|
|
357
|
+
// Group lots by ingredient once (avoids an O(n²) scan per ingredient).
|
|
358
|
+
const lotsByIng = new Map();
|
|
359
|
+
(db.lots || []).forEach(l => {
|
|
360
|
+
if (!l || l._trashed || !l.ingredientId) return;
|
|
361
|
+
if (!lotsByIng.has(l.ingredientId)) lotsByIng.set(l.ingredientId, []);
|
|
362
|
+
lotsByIng.get(l.ingredientId).push(l);
|
|
363
|
+
});
|
|
364
|
+
const rows = (db.ingredients || []).filter(i => {
|
|
365
|
+
if (!i || i._trashed) return false;
|
|
366
|
+
if (args.family && !norm(i.family).includes(norm(args.family))) return false;
|
|
367
|
+
return true;
|
|
368
|
+
}).map(i => {
|
|
369
|
+
const lots = lotsByIng.get(i.id) || [];
|
|
370
|
+
const onHand = i.stockOnHand != null ? Number(i.stockOnHand) : null;
|
|
371
|
+
const reorder = i.reorderThreshold != null ? Number(i.reorderThreshold) : null;
|
|
372
|
+
const lotBalance = lots.reduce((s, l) => s + (isFinite(Number(l.qtyRemaining)) ? Number(l.qtyRemaining) : 0), 0);
|
|
373
|
+
const expiries = lots.map(l => Date.parse(l.expiryDate)).filter(isFinite);
|
|
374
|
+
const nearestExpiryMs = expiries.length ? Math.min(...expiries) : null;
|
|
375
|
+
const lowStock = (onHand === 0) || (onHand != null && reorder != null && onHand <= reorder);
|
|
376
|
+
return {
|
|
377
|
+
id: i.id, uid: i.uid, name: i.name || '', family: i.family || '',
|
|
378
|
+
stockOnHand: onHand, stockUnit: i.stockUnit || null,
|
|
379
|
+
reorderThreshold: reorder,
|
|
380
|
+
lotCount: lots.length,
|
|
381
|
+
lotBalance: lots.length ? +lotBalance.toFixed(4) : null,
|
|
382
|
+
nearestExpiry: nearestExpiryMs != null ? new Date(nearestExpiryMs).toISOString().slice(0, 10) : null,
|
|
383
|
+
daysToNearestExpiry: nearestExpiryMs != null ? Math.floor((nearestExpiryMs - nowMs) / 86400000) : null,
|
|
384
|
+
lowStock,
|
|
385
|
+
};
|
|
386
|
+
}).filter(r => {
|
|
387
|
+
if (args.low_stock_only && !r.lowStock) return false;
|
|
388
|
+
if (args.expiring_within_days != null) {
|
|
389
|
+
if (r.daysToNearestExpiry == null) return false;
|
|
390
|
+
if (r.daysToNearestExpiry > args.expiring_within_days) return false;
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}).sort((a, b) => {
|
|
394
|
+
if (a.lowStock !== b.lowStock) return a.lowStock ? -1 : 1;
|
|
395
|
+
const av = a.stockOnHand == null ? Infinity : a.stockOnHand;
|
|
396
|
+
const bv = b.stockOnHand == null ? Infinity : b.stockOnHand;
|
|
397
|
+
return av - bv;
|
|
398
|
+
});
|
|
399
|
+
return {
|
|
400
|
+
totalMatching: rows.length,
|
|
401
|
+
returned: Math.min(rows.length, limit),
|
|
402
|
+
lowStockCount: rows.filter(r => r.lowStock).length,
|
|
403
|
+
inventory: rows.slice(0, limit),
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
export const tools = { list_ingredients, get_ingredient, find_by_smarts, list_lots, list_inventory };
|
package/tools/library.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// LIBRARY TOOLS — the shared reference entities that Panels,
|
|
3
|
+
// Step Presets, formula steps, and test results point at:
|
|
4
|
+
// - Equipment (list_equipment / get_equipment)
|
|
5
|
+
// - Test Methods (list_test_methods / get_test_method) [db.parameters]
|
|
6
|
+
// - Step Presets (list_step_presets / get_step_preset) [db.procedureTemplates]
|
|
7
|
+
// - Test Panels (list_test_panels / get_test_panel) [db.templates]
|
|
8
|
+
//
|
|
9
|
+
// All read-only. These expose the first-class Equipment registry,
|
|
10
|
+
// the parameter/analyte library, and reusable procedure steps —
|
|
11
|
+
// previously synced into the store but not queryable.
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
import { getStore, resolveById } from '../data.js';
|
|
15
|
+
|
|
16
|
+
const _norm = (s) => String(s == null ? '' : s).toLowerCase();
|
|
17
|
+
const _clampLimit = (n) => Math.min(1000, Math.max(1, n || 100));
|
|
18
|
+
|
|
19
|
+
// ── Equipment ───────────────────────────────────────────────
|
|
20
|
+
function _trimEquipment(e) {
|
|
21
|
+
if (!e) return null;
|
|
22
|
+
return {
|
|
23
|
+
id: e.id, uid: e.uid, name: e.name || '',
|
|
24
|
+
category: e.category || '', status: e.status || '',
|
|
25
|
+
model: e.model || '', serial: e.serial || '',
|
|
26
|
+
manufacturer: e.manufacturer || '', location: e.location || '',
|
|
27
|
+
owner: e.owner || '', assetTag: e.assetTag || '',
|
|
28
|
+
...(e.notes ? { notes: e.notes } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Where an equipment record is referenced. Prefers the stable equipmentId,
|
|
33
|
+
// falls back to a case-insensitive exact name match (legacy free-text entries).
|
|
34
|
+
function _equipUsage(e, db) {
|
|
35
|
+
const id = e.id;
|
|
36
|
+
const nm = _norm(e.name);
|
|
37
|
+
const instMatch = (arr) => (arr || []).some(x =>
|
|
38
|
+
(x.equipmentId && x.equipmentId === id) || (!x.equipmentId && _norm(x.name) === nm));
|
|
39
|
+
const fieldMatch = (eqId, eqName) =>
|
|
40
|
+
(eqId && eqId === id) || (!eqId && nm && _norm(eqName) === nm);
|
|
41
|
+
// A step references equipment via the new equipmentList[] or the legacy flat field.
|
|
42
|
+
const stepMatch = (s) =>
|
|
43
|
+
(Array.isArray(s.equipmentList) && s.equipmentList.some(le => fieldMatch(le.equipmentId, le.name)))
|
|
44
|
+
|| fieldMatch(s.equipmentId, s.equipment);
|
|
45
|
+
|
|
46
|
+
const panels = (db.templates || []).filter(t => instMatch(t.instruments));
|
|
47
|
+
const presets = (db.procedureTemplates || []).filter(t =>
|
|
48
|
+
instMatch(t.instruments) || (Array.isArray(t.equipmentList) && t.equipmentList.some(le => fieldMatch(le.equipmentId, le.name))) || fieldMatch(t.equipmentId, t.equipment));
|
|
49
|
+
const methods = (db.parameters || []).filter(p => fieldMatch(p.equipmentId, p.equipment));
|
|
50
|
+
const formulas = (db.formulations || []).filter(f => (f.steps || []).some(stepMatch));
|
|
51
|
+
const batches = (db.batches || []).filter(b =>
|
|
52
|
+
fieldMatch(b.equipmentUsedId, b.equipmentUsed) || (b.steps || []).some(stepMatch));
|
|
53
|
+
const name = (arr) => arr.map(x => x.uid || x.name).slice(0, 20);
|
|
54
|
+
return {
|
|
55
|
+
total: panels.length + presets.length + methods.length + formulas.length + batches.length,
|
|
56
|
+
panels: name(panels), stepPresets: name(presets), testMethods: name(methods),
|
|
57
|
+
formulas: name(formulas), batches: name(batches),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const list_equipment = {
|
|
62
|
+
definition: {
|
|
63
|
+
name: 'list_equipment',
|
|
64
|
+
description: 'List equipment / instruments in the FormLab Equipment registry (mixers, ovens, viscometers, balances…), optionally filtered by category, status, manufacturer, or name. Returns metadata; use get_equipment for one record plus where it is used.',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
category: { type: 'string', description: 'Filter by category (e.g. "Viscometers & rheology", "Mixing & dispersion"). Case-insensitive substring.' },
|
|
69
|
+
status: { type: 'string', description: 'Filter by status (e.g. "In service", "Retired"). Case-insensitive substring.' },
|
|
70
|
+
manufacturer: { type: 'string', description: 'Filter by manufacturer. Case-insensitive substring.' },
|
|
71
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring on equipment name / model / serial / asset tag.' },
|
|
72
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
handler: async (args) => {
|
|
77
|
+
const { db } = getStore();
|
|
78
|
+
const limit = _clampLimit(args.limit);
|
|
79
|
+
const filtered = (db.equipment || []).filter(e => {
|
|
80
|
+
if (!e || e._trashed) return false;
|
|
81
|
+
if (args.category && !_norm(e.category).includes(_norm(args.category))) return false;
|
|
82
|
+
if (args.status && !_norm(e.status).includes(_norm(args.status))) return false;
|
|
83
|
+
if (args.manufacturer && !_norm(e.manufacturer).includes(_norm(args.manufacturer))) return false;
|
|
84
|
+
if (args.name_contains) {
|
|
85
|
+
const q = _norm(args.name_contains);
|
|
86
|
+
const hay = [e.name, e.model, e.serial, e.assetTag].map(_norm).join(' ');
|
|
87
|
+
if (!hay.includes(q)) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
totalMatching: filtered.length,
|
|
93
|
+
returned: Math.min(filtered.length, limit),
|
|
94
|
+
equipment: filtered.slice(0, limit).map(_trimEquipment),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const get_equipment = {
|
|
100
|
+
definition: {
|
|
101
|
+
name: 'get_equipment',
|
|
102
|
+
description: 'Get one equipment record by UID (e.g. EQP-001) or id, including a summary of everywhere it is referenced (test panels, step presets, test methods, formulas, batches).',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: { id: { type: 'string', description: 'Equipment UID or internal id.' } },
|
|
106
|
+
required: ['id'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
handler: async (args) => {
|
|
110
|
+
const { db } = getStore();
|
|
111
|
+
const e = resolveById('equipment', args.id);
|
|
112
|
+
if (!e) return { error: `No equipment found for "${args.id}".` };
|
|
113
|
+
return { ...(_trimEquipment(e)), usedIn: _equipUsage(e, db) };
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── Test Methods (parameters) ───────────────────────────────
|
|
118
|
+
function _trimMethod(p, { full = false } = {}) {
|
|
119
|
+
if (!p) return null;
|
|
120
|
+
const base = {
|
|
121
|
+
id: p.id, uid: p.uid, name: p.name || '',
|
|
122
|
+
analyte: p.analyte || '',
|
|
123
|
+
category: p.category || '', type: p.type || 'scalar',
|
|
124
|
+
defaultUnit: p.defaultUnit || '', defaultMethod: p.defaultMethod || '',
|
|
125
|
+
spec: p.specRaw || '', decimals: p.decimals != null ? p.decimals : null,
|
|
126
|
+
retired: !!p.retired,
|
|
127
|
+
};
|
|
128
|
+
if (!full) return base;
|
|
129
|
+
return {
|
|
130
|
+
...base,
|
|
131
|
+
acceptanceCriteria: p.acceptanceCriteria || null,
|
|
132
|
+
sopText: p.sopText || '', samplePrep: p.samplePrep || '',
|
|
133
|
+
equipment: p.equipment || '', equipmentId: p.equipmentId || '',
|
|
134
|
+
calcFormula: p.calcFormula || '', version: p.version != null ? p.version : null,
|
|
135
|
+
approvedBy: p.approvedBy || '', notes: p.notes || '',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const list_test_methods = {
|
|
140
|
+
definition: {
|
|
141
|
+
name: 'list_test_methods',
|
|
142
|
+
description: 'List Test Methods (the parameter library — the canonical definition of each measured reading: viscosity, gloss, pH…), optionally filtered by analyte (the property measured), category, name, or retired state. Methods of the same property at different conditions share an analyte.',
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: {
|
|
146
|
+
analyte: { type: 'string', description: 'Filter by analyte / property (e.g. "Viscosity"). Case-insensitive substring — finds every method measuring that property.' },
|
|
147
|
+
category: { type: 'string', description: 'Filter by category. Case-insensitive substring.' },
|
|
148
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring on method name / default method-standard.' },
|
|
149
|
+
include_retired: { type: 'boolean', description: 'Include retired methods (default false).' },
|
|
150
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
handler: async (args) => {
|
|
155
|
+
const { db } = getStore();
|
|
156
|
+
const limit = _clampLimit(args.limit);
|
|
157
|
+
const filtered = (db.parameters || []).filter(p => {
|
|
158
|
+
if (!p || p._trashed) return false;
|
|
159
|
+
if (!args.include_retired && p.retired) return false;
|
|
160
|
+
if (args.analyte && !_norm(p.analyte).includes(_norm(args.analyte))) return false;
|
|
161
|
+
if (args.category && !_norm(p.category).includes(_norm(args.category))) return false;
|
|
162
|
+
if (args.name_contains) {
|
|
163
|
+
const q = _norm(args.name_contains);
|
|
164
|
+
if (!_norm(p.name).includes(q) && !_norm(p.defaultMethod).includes(q)) return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
totalMatching: filtered.length,
|
|
170
|
+
returned: Math.min(filtered.length, limit),
|
|
171
|
+
testMethods: filtered.slice(0, limit).map(p => _trimMethod(p)),
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const get_test_method = {
|
|
177
|
+
definition: {
|
|
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).',
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: { id: { type: 'string', description: 'Test Method UID or internal id.' } },
|
|
183
|
+
required: ['id'],
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
handler: async (args) => {
|
|
187
|
+
const { db } = getStore();
|
|
188
|
+
const p = resolveById('parameters', args.id);
|
|
189
|
+
if (!p) return { error: `No test method found for "${args.id}".` };
|
|
190
|
+
const an = _norm(p.analyte);
|
|
191
|
+
const siblings = an
|
|
192
|
+
? (db.parameters || []).filter(x => x.id !== p.id && _norm(x.analyte) === an).map(x => x.uid || x.name).slice(0, 20)
|
|
193
|
+
: [];
|
|
194
|
+
return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings };
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// ── Step Presets (procedureTemplates) ───────────────────────
|
|
199
|
+
function _trimPreset(t, { full = false } = {}) {
|
|
200
|
+
if (!t) return null;
|
|
201
|
+
const eqList = (Array.isArray(t.equipmentList) && t.equipmentList.length)
|
|
202
|
+
? t.equipmentList.map(e => ({ name: e.name || '', equipmentId: e.equipmentId || '' })).filter(e => e.name)
|
|
203
|
+
: (t.equipment ? [{ name: t.equipment, equipmentId: t.equipmentId || '' }] : []);
|
|
204
|
+
const base = {
|
|
205
|
+
id: t.id, uid: t.uid, name: t.name || '',
|
|
206
|
+
industry: t.industry || '',
|
|
207
|
+
duration: t.duration != null ? t.duration : null,
|
|
208
|
+
temperature: t.temperature != null ? t.temperature : null,
|
|
209
|
+
rpm: t.rpm != null ? t.rpm : null,
|
|
210
|
+
equipment: eqList,
|
|
211
|
+
};
|
|
212
|
+
if (!full) return { ...base, description: (t.description || '').slice(0, 200) };
|
|
213
|
+
return { ...base, description: t.description || '', notes: t.notes || '' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const list_step_presets = {
|
|
217
|
+
definition: {
|
|
218
|
+
name: 'list_step_presets',
|
|
219
|
+
description: 'List Step Presets (reusable manufacturing-step definitions — duration / temperature / RPM / equipment used when building a formula procedure), optionally filtered by industry or name. Equipment is the multi-value list of {name, equipmentId}.',
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
industry: { type: 'string', description: 'Filter by industry (e.g. "Paints & Coatings"). Case-insensitive substring.' },
|
|
224
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring on preset name / description.' },
|
|
225
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
handler: async (args) => {
|
|
230
|
+
const { db } = getStore();
|
|
231
|
+
const limit = _clampLimit(args.limit);
|
|
232
|
+
const filtered = (db.procedureTemplates || []).filter(t => {
|
|
233
|
+
if (!t || t._trashed) return false;
|
|
234
|
+
if (args.industry && !_norm(t.industry).includes(_norm(args.industry))) return false;
|
|
235
|
+
if (args.name_contains) {
|
|
236
|
+
const q = _norm(args.name_contains);
|
|
237
|
+
if (!_norm(t.name).includes(q) && !_norm(t.description).includes(q)) return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
return {
|
|
242
|
+
totalMatching: filtered.length,
|
|
243
|
+
returned: Math.min(filtered.length, limit),
|
|
244
|
+
stepPresets: filtered.slice(0, limit).map(t => _trimPreset(t)),
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const get_step_preset = {
|
|
250
|
+
definition: {
|
|
251
|
+
name: 'get_step_preset',
|
|
252
|
+
description: 'Get one Step Preset by UID (e.g. PROC-001) or id — full description, notes, conditions, and equipment list (each {name, equipmentId}).',
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: { id: { type: 'string', description: 'Step Preset UID or internal id.' } },
|
|
256
|
+
required: ['id'],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
handler: async (args) => {
|
|
260
|
+
const t = resolveById('procedureTemplates', args.id);
|
|
261
|
+
if (!t) return { error: `No step preset found for "${args.id}".` };
|
|
262
|
+
return _trimPreset(t, { full: true });
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// ── Test Panels (templates) ─────────────────────────────────
|
|
267
|
+
// A Test Panel is a reusable column-set: the ordered list of parameters
|
|
268
|
+
// (test methods) measured together on a sample, plus the instruments used.
|
|
269
|
+
// Each parameter row carries {parameter (name), parameterId, unit, spec…};
|
|
270
|
+
// we resolve parameterId back to the Test Method UID for cross-reference.
|
|
271
|
+
function _trimPanelParam(row, db) {
|
|
272
|
+
if (!row) return null;
|
|
273
|
+
const method = row.parameterId
|
|
274
|
+
? (db.parameters || []).find(p => p && p.id === row.parameterId)
|
|
275
|
+
: null;
|
|
276
|
+
return {
|
|
277
|
+
parameter: row.parameter || row.name || '',
|
|
278
|
+
parameterId: row.parameterId || null,
|
|
279
|
+
methodUid: method ? (method.uid || null) : null,
|
|
280
|
+
unit: row.unit || (method && method.defaultUnit) || '',
|
|
281
|
+
...(row.spec ? { spec: row.spec } : {}),
|
|
282
|
+
...(row.method ? { method: row.method } : {}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _trimPanel(t, db, { full = false } = {}) {
|
|
287
|
+
if (!t) return null;
|
|
288
|
+
const params = Array.isArray(t.parameters) ? t.parameters : [];
|
|
289
|
+
const base = {
|
|
290
|
+
id: t.id, uid: t.uid, name: t.name || '',
|
|
291
|
+
industry: t.industry || '',
|
|
292
|
+
instruments: t.instruments || [],
|
|
293
|
+
parameterCount: params.length,
|
|
294
|
+
};
|
|
295
|
+
if (!full) return { ...base, description: (t.description || '').slice(0, 200) };
|
|
296
|
+
return {
|
|
297
|
+
...base,
|
|
298
|
+
description: t.description || '',
|
|
299
|
+
notes: t.notes || '',
|
|
300
|
+
parameters: params.map(row => _trimPanelParam(row, db)),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const list_test_panels = {
|
|
305
|
+
definition: {
|
|
306
|
+
name: 'list_test_panels',
|
|
307
|
+
description: 'List Test Panels (reusable column-sets — the ordered group of parameters measured together on a sample, e.g. a "QC release" panel), optionally filtered by industry or name. Returns metadata + parameter count; use get_test_panel for the full parameter list.',
|
|
308
|
+
inputSchema: {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: {
|
|
311
|
+
industry: { type: 'string', description: 'Filter by industry. Case-insensitive substring.' },
|
|
312
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring on panel name / description.' },
|
|
313
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
handler: async (args) => {
|
|
318
|
+
const { db } = getStore();
|
|
319
|
+
const limit = _clampLimit(args.limit);
|
|
320
|
+
const filtered = (db.templates || []).filter(t => {
|
|
321
|
+
if (!t || t._trashed) return false;
|
|
322
|
+
if (args.industry && !_norm(t.industry).includes(_norm(args.industry))) return false;
|
|
323
|
+
if (args.name_contains) {
|
|
324
|
+
const q = _norm(args.name_contains);
|
|
325
|
+
if (!_norm(t.name).includes(q) && !_norm(t.description).includes(q)) return false;
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
totalMatching: filtered.length,
|
|
331
|
+
returned: Math.min(filtered.length, limit),
|
|
332
|
+
testPanels: filtered.slice(0, limit).map(t => _trimPanel(t, db)),
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const get_test_panel = {
|
|
338
|
+
definition: {
|
|
339
|
+
name: 'get_test_panel',
|
|
340
|
+
description: 'Get one Test Panel by UID (e.g. TMPL-001) or id — full ordered parameter list (each with its resolved Test Method UID, unit, and spec), instruments, and notes.',
|
|
341
|
+
inputSchema: {
|
|
342
|
+
type: 'object',
|
|
343
|
+
properties: { id: { type: 'string', description: 'Test Panel UID or internal id.' } },
|
|
344
|
+
required: ['id'],
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
handler: async (args) => {
|
|
348
|
+
const { db } = getStore();
|
|
349
|
+
const t = resolveById('templates', args.id);
|
|
350
|
+
if (!t) return { error: `No test panel found for "${args.id}".` };
|
|
351
|
+
return _trimPanel(t, db, { full: true });
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
export const tools = {
|
|
356
|
+
list_equipment, get_equipment,
|
|
357
|
+
list_test_methods, get_test_method,
|
|
358
|
+
list_step_presets, get_step_preset,
|
|
359
|
+
list_test_panels, get_test_panel,
|
|
360
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NOTEBOOK TOOLS — the ELN thread. Each notebook entry is a dated,
|
|
3
|
+
// authored note attached to some record (ingredient, formulation,
|
|
4
|
+
// batch, sample, test result, project…) via {entityType, entityId}.
|
|
5
|
+
// - list_notebook_entries: filtered feed (by entity, author, text, date)
|
|
6
|
+
// - get_notebook_entry: one entry, full body + attachment metadata
|
|
7
|
+
//
|
|
8
|
+
// The store syncs notebook_entries but nothing surfaced them, so the
|
|
9
|
+
// lab's written record was invisible to the model. Read-only.
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
import { getStore, resolveById } from '../data.js';
|
|
13
|
+
|
|
14
|
+
const _norm = (s) => String(s == null ? '' : s).toLowerCase();
|
|
15
|
+
const _clampLimit = (n) => Math.min(1000, Math.max(1, n || 100));
|
|
16
|
+
|
|
17
|
+
// entityType → the db collection it points at, so we can resolve a
|
|
18
|
+
// human label (uid / name) for the record an entry is attached to.
|
|
19
|
+
const _ENTITY_COLLECTION = {
|
|
20
|
+
ingredient: 'ingredients', formulation: 'formulations', batch: 'batches',
|
|
21
|
+
sample: 'samples', test: 'testResults', testResult: 'testResults',
|
|
22
|
+
project: 'projects', equipment: 'equipment', parameter: 'parameters',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function _resolveEntity(entityType, entityId, db) {
|
|
26
|
+
const coll = _ENTITY_COLLECTION[entityType];
|
|
27
|
+
if (!coll || !entityId) return null;
|
|
28
|
+
const rec = (db[coll] || []).find(x => x && (x.id === entityId || x.uid === entityId));
|
|
29
|
+
if (!rec) return null;
|
|
30
|
+
return { uid: rec.uid || null, name: rec.name || rec.title || '' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _entryTs(e) { return e.ts || e.createdAt || e.updatedAt || null; }
|
|
34
|
+
|
|
35
|
+
function _trimEntry(e, db, { full = false } = {}) {
|
|
36
|
+
if (!e) return null;
|
|
37
|
+
const linked = _resolveEntity(e.entityType, e.entityId, db);
|
|
38
|
+
const atts = Array.isArray(e.attachments) ? e.attachments : [];
|
|
39
|
+
const base = {
|
|
40
|
+
id: e.id,
|
|
41
|
+
entityType: e.entityType || '',
|
|
42
|
+
entityId: e.entityId || '',
|
|
43
|
+
entityUid: linked ? linked.uid : null,
|
|
44
|
+
entityName: linked ? linked.name : '',
|
|
45
|
+
ts: _entryTs(e),
|
|
46
|
+
updatedAt: e.updatedAt || null,
|
|
47
|
+
author: e.author || '',
|
|
48
|
+
attachmentCount: atts.length,
|
|
49
|
+
};
|
|
50
|
+
if (!full) return { ...base, body: (e.body || '').slice(0, 240) };
|
|
51
|
+
return {
|
|
52
|
+
...base,
|
|
53
|
+
body: e.body || '',
|
|
54
|
+
attachments: atts.map(a => ({ id: a.id || null, filename: a.filename || '', type: a.type || '' })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const list_notebook_entries = {
|
|
59
|
+
definition: {
|
|
60
|
+
name: 'list_notebook_entries',
|
|
61
|
+
description: 'List ELN notebook entries — dated, authored notes attached to records — optionally filtered by the record they hang off (entity_type + entity_id), author, text, or date range. Newest first. Use get_notebook_entry for the full body + attachments.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
entity_type: { type: 'string', description: 'Kind of record the note is attached to: ingredient, formulation, batch, sample, test, project, equipment, parameter.' },
|
|
66
|
+
entity_id: { type: 'string', description: 'The specific record the note is attached to (internal id or UID, e.g. FORM-001). Resolved to its id automatically.' },
|
|
67
|
+
author: { type: 'string', description: 'Filter by author. Case-insensitive substring.' },
|
|
68
|
+
text_contains: { type: 'string', description: 'Case-insensitive substring on the note body.' },
|
|
69
|
+
since: { type: 'string', description: 'ISO date/time — only entries at or after this timestamp.' },
|
|
70
|
+
until: { type: 'string', description: 'ISO date/time — only entries at or before this timestamp.' },
|
|
71
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
handler: async (args) => {
|
|
76
|
+
const { db } = getStore();
|
|
77
|
+
const limit = _clampLimit(args.limit);
|
|
78
|
+
// Resolve a UID-or-id entity reference down to its internal id so the
|
|
79
|
+
// filter matches regardless of which form the caller passed.
|
|
80
|
+
let wantEntityId = args.entity_id || null;
|
|
81
|
+
if (wantEntityId) {
|
|
82
|
+
const coll = _ENTITY_COLLECTION[args.entity_type] || null;
|
|
83
|
+
const rec = coll ? (db[coll] || []).find(x => x && (x.id === wantEntityId || x.uid === wantEntityId)) : null;
|
|
84
|
+
if (rec) wantEntityId = rec.id;
|
|
85
|
+
}
|
|
86
|
+
const sinceMs = args.since ? Date.parse(args.since) : null;
|
|
87
|
+
const untilMs = args.until ? Date.parse(args.until) : null;
|
|
88
|
+
const filtered = (db.notebookEntries || []).filter(e => {
|
|
89
|
+
if (!e || e._trashed) return false;
|
|
90
|
+
if (args.entity_type && _norm(e.entityType) !== _norm(args.entity_type)) return false;
|
|
91
|
+
if (wantEntityId && e.entityId !== wantEntityId) return false;
|
|
92
|
+
if (args.author && !_norm(e.author).includes(_norm(args.author))) return false;
|
|
93
|
+
if (args.text_contains && !_norm(e.body).includes(_norm(args.text_contains))) return false;
|
|
94
|
+
if (sinceMs != null || untilMs != null) {
|
|
95
|
+
const t = Date.parse(_entryTs(e));
|
|
96
|
+
if (!isFinite(t)) return false;
|
|
97
|
+
if (sinceMs != null && t < sinceMs) return false;
|
|
98
|
+
if (untilMs != null && t > untilMs) return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}).sort((a, b) => Date.parse(_entryTs(b) || 0) - Date.parse(_entryTs(a) || 0));
|
|
102
|
+
return {
|
|
103
|
+
totalMatching: filtered.length,
|
|
104
|
+
returned: Math.min(filtered.length, limit),
|
|
105
|
+
entries: filtered.slice(0, limit).map(e => _trimEntry(e, db)),
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const get_notebook_entry = {
|
|
111
|
+
definition: {
|
|
112
|
+
name: 'get_notebook_entry',
|
|
113
|
+
description: 'Get one ELN notebook entry by id — full note body, author, timestamp, the record it is attached to (resolved to uid/name), and attachment metadata (filename / type). Attachment binaries are not returned.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: { id: { type: 'string', description: 'Internal id of the notebook entry.' } },
|
|
117
|
+
required: ['id'],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
handler: async (args) => {
|
|
121
|
+
const { db } = getStore();
|
|
122
|
+
const e = (db.notebookEntries || []).find(x => x && x.id === args.id);
|
|
123
|
+
if (!e) return { error: `No notebook entry found for id "${args.id}".` };
|
|
124
|
+
return _trimEntry(e, db, { full: true });
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const tools = { list_notebook_entries, get_notebook_entry };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// PROJECT TOOLS
|
|
3
|
+
// - list_projects: the project register (name / status / description)
|
|
4
|
+
// - get_project: one project + the formulations filed under it
|
|
5
|
+
//
|
|
6
|
+
// Projects are the top-level organizing bucket: formulations point at a
|
|
7
|
+
// project via `projectId`. Previously the store synced projects but they
|
|
8
|
+
// were only usable as a *filter* on list_formulations — never listable or
|
|
9
|
+
// openable on their own. These two tools close that gap.
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
import { getStore, resolveById } from '../data.js';
|
|
13
|
+
|
|
14
|
+
const _norm = (s) => String(s == null ? '' : s).toLowerCase();
|
|
15
|
+
const _clampLimit = (n) => Math.min(1000, Math.max(1, n || 100));
|
|
16
|
+
|
|
17
|
+
function _trimProject(p, formulationCount) {
|
|
18
|
+
if (!p) return null;
|
|
19
|
+
return {
|
|
20
|
+
id: p.id, uid: p.uid, name: p.name || '',
|
|
21
|
+
status: p.status || '',
|
|
22
|
+
description: (p.description || '').slice(0, 200),
|
|
23
|
+
createdAt: p.createdAt || null,
|
|
24
|
+
updatedAt: p.updatedAt || null,
|
|
25
|
+
formulationCount,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Formulations filed under a project (top-level projectId link).
|
|
30
|
+
function _projectFormulas(projectId, db) {
|
|
31
|
+
return (db.formulations || []).filter(f => f && !f._trashed && f.projectId === projectId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const list_projects = {
|
|
35
|
+
definition: {
|
|
36
|
+
name: 'list_projects',
|
|
37
|
+
description: 'List projects — the top-level buckets that formulations are filed under — optionally filtered by status or name. Each row includes how many formulations belong to the project. Use get_project for one project plus its formulation list.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
status: { type: 'string', description: 'Filter by status (e.g. "Active", "On hold"). Case-insensitive substring.' },
|
|
42
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring on project name / description.' },
|
|
43
|
+
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
handler: async (args) => {
|
|
48
|
+
const { db } = getStore();
|
|
49
|
+
const limit = _clampLimit(args.limit);
|
|
50
|
+
const filtered = (db.projects || []).filter(p => {
|
|
51
|
+
if (!p || p._trashed) return false;
|
|
52
|
+
if (args.status && !_norm(p.status).includes(_norm(args.status))) return false;
|
|
53
|
+
if (args.name_contains) {
|
|
54
|
+
const q = _norm(args.name_contains);
|
|
55
|
+
if (!_norm(p.name).includes(q) && !_norm(p.description).includes(q)) return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
totalMatching: filtered.length,
|
|
61
|
+
returned: Math.min(filtered.length, limit),
|
|
62
|
+
projects: filtered.slice(0, limit).map(p => _trimProject(p, _projectFormulas(p.id, db).length)),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const get_project = {
|
|
68
|
+
definition: {
|
|
69
|
+
name: 'get_project',
|
|
70
|
+
description: 'Get one project by UID (e.g. PROJ-001) or id — full description plus the list of formulations filed under it (id / uid / name / status).',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: { id: { type: 'string', description: 'Project UID or internal id.' } },
|
|
74
|
+
required: ['id'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
handler: async (args) => {
|
|
78
|
+
const { db } = getStore();
|
|
79
|
+
const p = resolveById('projects', args.id);
|
|
80
|
+
if (!p) return { error: `No project found for "${args.id}".` };
|
|
81
|
+
const formulas = _projectFormulas(p.id, db);
|
|
82
|
+
return {
|
|
83
|
+
..._trimProject(p, formulas.length),
|
|
84
|
+
description: p.description || '',
|
|
85
|
+
formulations: formulas.slice(0, 200).map(f => ({ id: f.id, uid: f.uid, name: f.name || '', status: f.status || '' })),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const tools = { list_projects, get_project };
|