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.
@@ -0,0 +1,103 @@
1
+ // ============================================================
2
+ // INGREDIENT TOOLS
3
+ // - list_ingredients: filtered list
4
+ // - get_ingredient: full record incl. supplier, density, stock, cost
5
+ // ============================================================
6
+
7
+ import { getStore, resolveById } from '../data.js';
8
+
9
+ function _trimIngredient(i) {
10
+ if (!i) return null;
11
+ return {
12
+ id: i.id,
13
+ uid: i.uid,
14
+ name: i.name,
15
+ family: i.family || '',
16
+ supplier: i.supplier || '',
17
+ casNumber: i.casNumber || '',
18
+ density: i.density != null ? i.density : null,
19
+ densityUnit: i.densityUnit || (i.density != null ? 'kg/L' : null),
20
+ costPerKg: i.costPerKg != null ? i.costPerKg : null,
21
+ costCurrency: i.costCurrency || (i.costPerKg != null ? 'USD' : null),
22
+ stockOnHand: i.stockOnHand != null ? i.stockOnHand : null,
23
+ stockUnit: i.stockUnit || null,
24
+ };
25
+ }
26
+
27
+ const list_ingredients = {
28
+ definition: {
29
+ name: 'list_ingredients',
30
+ description: 'List ingredients (raw materials) in the FormLab library, optionally filtered by family, supplier, or name pattern. Returns metadata; use get_ingredient for full details.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ family: { type: 'string', description: 'Filter by family (e.g. "Pigment", "Surfactant"). Case-insensitive substring.' },
35
+ supplier: { type: 'string', description: 'Filter by supplier name. Case-insensitive substring.' },
36
+ name_contains: { type: 'string', description: 'Case-insensitive substring on ingredient name.' },
37
+ in_stock_only: { type: 'boolean', description: 'When true, return only ingredients with stockOnHand > 0.' },
38
+ limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
39
+ },
40
+ },
41
+ },
42
+ handler: async (args) => {
43
+ const { db } = getStore();
44
+ const limit = Math.min(1000, Math.max(1, args.limit || 100));
45
+ const norm = (s) => String(s || '').toLowerCase();
46
+ const filtered = (db.ingredients || []).filter(i => {
47
+ if (!i || i._trashed) return false;
48
+ if (args.family && !norm(i.family).includes(norm(args.family))) return false;
49
+ if (args.supplier && !norm(i.supplier).includes(norm(args.supplier))) return false;
50
+ if (args.name_contains && !norm(i.name).includes(norm(args.name_contains))) return false;
51
+ if (args.in_stock_only && !(i.stockOnHand > 0)) return false;
52
+ return true;
53
+ });
54
+ return {
55
+ totalMatching: filtered.length,
56
+ returned: Math.min(filtered.length, limit),
57
+ ingredients: filtered.slice(0, limit).map(_trimIngredient),
58
+ };
59
+ },
60
+ };
61
+
62
+ const get_ingredient = {
63
+ definition: {
64
+ name: 'get_ingredient',
65
+ description: 'Get full details for a single ingredient including stock movements, formulations using it, and stock-on-hand. Accepts internal id or UID (e.g. ING-001).',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ id: { type: 'string', description: 'Internal id or UID (ING-001) of the ingredient.' },
70
+ },
71
+ required: ['id'],
72
+ },
73
+ },
74
+ handler: async (args) => {
75
+ const { db } = getStore();
76
+ const ing = resolveById('ingredients', args.id);
77
+ if (!ing) return { error: `No ingredient found for id "${args.id}".` };
78
+ // Find formulations that reference this ingredient at the TOP level
79
+ // (sub-formulas would need a recursive walk — list_formulations +
80
+ // find_similar_formulations cover that case).
81
+ const usedIn = (db.formulations || []).filter(f =>
82
+ f && !f._trashed && (f.composition || []).some(c => c && c.ingredientId === ing.id)
83
+ ).map(f => ({ id: f.id, uid: f.uid, name: f.name }));
84
+ const movements = (ing.stockMovements || []).slice(-20).map(m => ({
85
+ ts: m.ts,
86
+ reason: m.reason,
87
+ delta: m.delta,
88
+ unit: m.unit,
89
+ note: m.note || '',
90
+ }));
91
+ return {
92
+ ..._trimIngredient(ing),
93
+ description: ing.description || '',
94
+ notes: ing.notes || '',
95
+ attributes: ing.attributes || {},
96
+ formulationCount: usedIn.length,
97
+ formulations: usedIn.slice(0, 30),
98
+ recentStockMovements: movements,
99
+ };
100
+ },
101
+ };
102
+
103
+ export const tools = { list_ingredients, get_ingredient };
package/tools/lab.js ADDED
@@ -0,0 +1,234 @@
1
+ // ============================================================
2
+ // LAB TOOLS — batches and samples
3
+ // - list_batches
4
+ // - get_batch
5
+ // - list_samples
6
+ // - get_sample
7
+ // ============================================================
8
+
9
+ import { getStore, resolveById } from '../data.js';
10
+
11
+ function _trimBatch(b, formulationsById) {
12
+ if (!b) return null;
13
+ const f = b.formulationId ? formulationsById.get(b.formulationId) : null;
14
+ return {
15
+ id: b.id,
16
+ uid: b.uid,
17
+ formulation: f ? { id: f.id, uid: f.uid, name: f.name } : null,
18
+ description: b.description || '',
19
+ status: b.status || '',
20
+ targetQuantity: b.targetQuantity ?? null,
21
+ unit: b.unit || '',
22
+ actualYield: b.actualYield ?? null,
23
+ preparedDate: b.preparedDate || '',
24
+ preparedBy: b.preparedBy || '',
25
+ process: b.process || '',
26
+ parentBatchId: b.parentBatchId || null,
27
+ isBlend: Array.isArray(b.sourceBlend) && b.sourceBlend.length > 0,
28
+ blendParentCount: Array.isArray(b.sourceBlend) ? b.sourceBlend.length : 0,
29
+ };
30
+ }
31
+
32
+ function _trimSample(s, formulationsById, batchesById) {
33
+ if (!s) return null;
34
+ const f = s.formulationId ? formulationsById.get(s.formulationId) : null;
35
+ const b = s.batchId ? batchesById.get(s.batchId) : null;
36
+ return {
37
+ id: s.id,
38
+ uid: s.uid,
39
+ name: s.name || '',
40
+ formulation: f ? { id: f.id, uid: f.uid, name: f.name } : null,
41
+ batch: b ? { id: b.id, uid: b.uid } : null,
42
+ status: s.status || '',
43
+ quantity: s.quantity ?? null,
44
+ unit: s.unit || '',
45
+ preparedDate: s.preparedDate || '',
46
+ preparedBy: s.preparedBy || '',
47
+ storageLocation: s.storageLocation || '',
48
+ isBlend: Array.isArray(s.sourceBlend) && s.sourceBlend.length > 0,
49
+ blendParentCount: Array.isArray(s.sourceBlend) ? s.sourceBlend.length : 0,
50
+ hasActualVariant: Array.isArray(s.actualVariants) && s.actualVariants.length > 0,
51
+ };
52
+ }
53
+
54
+ const list_batches = {
55
+ definition: {
56
+ name: 'list_batches',
57
+ description: 'List batches (production / lab-prep events), optionally filtered by formulation, status, or origin-only (hides batches derived from other batches). Returns metadata; use get_batch for the full consumption ledger and samples.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ formulation_id: { type: 'string', description: 'Filter to batches of this formulation (id or UID).' },
62
+ status: { type: 'string', description: 'Filter by status (case-insensitive substring).' },
63
+ origin_only: { type: 'boolean', description: 'When true, exclude batches that have a parentBatchId.' },
64
+ since_date: { type: 'string', description: 'ISO date (YYYY-MM-DD). Only batches prepared on/after this date.' },
65
+ limit: { type: 'number', description: 'Max rows (default 50, max 500).' },
66
+ },
67
+ },
68
+ },
69
+ handler: async (args) => {
70
+ const { db, indexes } = getStore();
71
+ const limit = Math.min(500, Math.max(1, args.limit || 50));
72
+ const norm = (s) => String(s || '').toLowerCase();
73
+ let formId = null;
74
+ if (args.formulation_id) {
75
+ const f = resolveById('formulations', args.formulation_id);
76
+ if (!f) return { error: `No formulation matched "${args.formulation_id}".` };
77
+ formId = f.id;
78
+ }
79
+ const filtered = (db.batches || []).filter(b => {
80
+ if (!b || b._trashed) return false;
81
+ if (formId && b.formulationId !== formId) return false;
82
+ if (args.status && !norm(b.status).includes(norm(args.status))) return false;
83
+ if (args.origin_only && b.parentBatchId) return false;
84
+ if (args.since_date && (b.preparedDate || '') < args.since_date) return false;
85
+ return true;
86
+ });
87
+ return {
88
+ totalMatching: filtered.length,
89
+ returned: Math.min(filtered.length, limit),
90
+ batches: filtered.slice(0, limit).map(b => _trimBatch(b, indexes.formulationsById)),
91
+ };
92
+ },
93
+ };
94
+
95
+ const get_batch = {
96
+ definition: {
97
+ name: 'get_batch',
98
+ description: 'Get full details for a single batch including its actual composition (as-prepared, when logged), child samples, lineage (parent / sub-blend parents), and inventory consumption note. Accepts internal id or UID.',
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ id: { type: 'string', description: 'Internal id or UID (BATCH-001) of the batch.' },
103
+ },
104
+ required: ['id'],
105
+ },
106
+ },
107
+ handler: async (args) => {
108
+ const { db, indexes } = getStore();
109
+ const b = resolveById('batches', args.id);
110
+ if (!b) return { error: `No batch found for id "${args.id}".` };
111
+ const samples = (db.samples || []).filter(s => s.batchId === b.id);
112
+ const parents = Array.isArray(b.sourceBlend)
113
+ ? b.sourceBlend.map(p => {
114
+ if (p.batchId) {
115
+ const pb = indexes.batchesById.get(p.batchId);
116
+ return { kind: 'batch', id: p.batchId, uid: pb?.uid, amount: p.amount, unit: p.unit };
117
+ } else if (p.sampleId) {
118
+ const ps = indexes.samplesById.get(p.sampleId);
119
+ return { kind: 'sample', id: p.sampleId, uid: ps?.uid, amount: p.amount, unit: p.unit };
120
+ }
121
+ return p;
122
+ })
123
+ : null;
124
+ return {
125
+ ..._trimBatch(b, indexes.formulationsById),
126
+ notes: b.notes || '',
127
+ equipmentUsed: b.equipmentUsed || '',
128
+ actualCompositionRowCount: Array.isArray(b.actualComposition) ? b.actualComposition.length : 0,
129
+ hasActualComposition: Array.isArray(b.actualComposition) && b.actualComposition.length > 0,
130
+ sourceBlendParents: parents,
131
+ samples: samples.map(s => ({ id: s.id, uid: s.uid, name: s.name || '', status: s.status })),
132
+ sampleCount: samples.length,
133
+ };
134
+ },
135
+ };
136
+
137
+ const list_samples = {
138
+ definition: {
139
+ name: 'list_samples',
140
+ description: 'List samples (physical specimens), optionally filtered by batch, formulation, status, or origin (which batch they came from). Returns metadata; use get_sample for tests and variant composition.',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ batch_id: { type: 'string', description: 'Filter to samples from this batch (id or UID).' },
145
+ formulation_id: { type: 'string', description: 'Filter to samples of this formulation (id or UID).' },
146
+ status: { type: 'string', description: 'Filter by status (case-insensitive substring).' },
147
+ is_blend_only: { type: 'boolean', description: 'When true, return only samples with sourceBlend lineage.' },
148
+ limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
149
+ },
150
+ },
151
+ },
152
+ handler: async (args) => {
153
+ const { db, indexes } = getStore();
154
+ const limit = Math.min(1000, Math.max(1, args.limit || 100));
155
+ const norm = (s) => String(s || '').toLowerCase();
156
+ let batchId = null, formId = null;
157
+ if (args.batch_id) {
158
+ const b = resolveById('batches', args.batch_id);
159
+ if (!b) return { error: `No batch matched "${args.batch_id}".` };
160
+ batchId = b.id;
161
+ }
162
+ if (args.formulation_id) {
163
+ const f = resolveById('formulations', args.formulation_id);
164
+ if (!f) return { error: `No formulation matched "${args.formulation_id}".` };
165
+ formId = f.id;
166
+ }
167
+ const filtered = (db.samples || []).filter(s => {
168
+ if (!s || s._trashed) return false;
169
+ if (batchId && s.batchId !== batchId) return false;
170
+ if (formId && s.formulationId !== formId) return false;
171
+ if (args.status && !norm(s.status).includes(norm(args.status))) return false;
172
+ if (args.is_blend_only && !(Array.isArray(s.sourceBlend) && s.sourceBlend.length)) return false;
173
+ return true;
174
+ });
175
+ return {
176
+ totalMatching: filtered.length,
177
+ returned: Math.min(filtered.length, limit),
178
+ samples: filtered.slice(0, limit).map(s => _trimSample(s, indexes.formulationsById, indexes.batchesById)),
179
+ };
180
+ },
181
+ };
182
+
183
+ const get_sample = {
184
+ definition: {
185
+ name: 'get_sample',
186
+ description: 'Get full details for a single sample including its actual variant composition, all test results bound to it, blend lineage, and storage info. Accepts internal id or UID (e.g. SAMP-0001).',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ id: { type: 'string', description: 'Internal id or UID of the sample.' },
191
+ },
192
+ required: ['id'],
193
+ },
194
+ },
195
+ handler: async (args) => {
196
+ const { db, indexes } = getStore();
197
+ const s = resolveById('samples', args.id);
198
+ if (!s) return { error: `No sample found for id "${args.id}".` };
199
+ const tests = (db.testResults || []).filter(t => t.sampleId === s.id);
200
+ const canonical = (s.actualVariants || []).find(v => v.isCanonical) || (s.actualVariants || [])[0] || null;
201
+ const parents = Array.isArray(s.sourceBlend)
202
+ ? s.sourceBlend.map(p => {
203
+ if (p.batchId) {
204
+ const pb = indexes.batchesById.get(p.batchId);
205
+ return { kind: 'batch', id: p.batchId, uid: pb?.uid, amount: p.amount, unit: p.unit };
206
+ } else if (p.sampleId) {
207
+ const ps = indexes.samplesById.get(p.sampleId);
208
+ return { kind: 'sample', id: p.sampleId, uid: ps?.uid, amount: p.amount, unit: p.unit };
209
+ }
210
+ return p;
211
+ })
212
+ : null;
213
+ return {
214
+ ..._trimSample(s, indexes.formulationsById, indexes.batchesById),
215
+ notes: s.notes || '',
216
+ canonicalVariant: canonical ? {
217
+ name: canonical.name || '',
218
+ isAutoSeeded: !!canonical._autoSeeded,
219
+ compositionRowCount: Array.isArray(canonical.composition) ? canonical.composition.length : 0,
220
+ } : null,
221
+ sourceBlendParents: parents,
222
+ testCount: tests.length,
223
+ tests: tests.map(t => ({
224
+ id: t.id,
225
+ uid: t.uid,
226
+ reportName: t.reportName || '',
227
+ testDate: t.testDate || '',
228
+ measurementCount: (t.measurements || []).length,
229
+ })),
230
+ };
231
+ },
232
+ };
233
+
234
+ export const tools = { list_batches, get_batch, list_samples, get_sample };