formlab-mcp 0.6.36 → 0.6.37
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 +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/documents.js +7 -5
- package/tools/notebook.js +8 -2
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
37
37
|
| `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry, **unit cost**, **status** — `active` / `hold` / `quarantine` / `rejected`; held lots are blocked from consumption, with a `holdReason` — and the **approved source** it was received from: `source.supplier` + `source.manufacturer`, where recalls hinge on the manufacturer). Each lot also reports its **Certificate of Analysis** (`coa`: linked, or matched by lot number) and `coaMissing` for an in-stock lot without one. Filters: `ingredient_id`, `expiring_within_days`, `status` (e.g. `hold` to find lots held for a recall), `missing_coa` |
|
|
38
38
|
| `list_suppliers` | Supplier / manufacturer records with roll-ups — ingredients approved, lots received / on hand (+ value at paid cost), qualified / trial / disqualified source counts, last receipt; lots count by supplier **and** by manufacturer |
|
|
39
39
|
| `get_supplier` | One supplier in full: record, every ingredient it's an approved source for, every lot from it, and the **recall trace** (batches that consumed those lots → products, shipments, samples, sub-batches; lots on hold flagged) |
|
|
40
|
-
| `list_documents` | The document register (same as the app's Documents page): ingredient SDS / PDS / CoA / other with lot, version, source and expiry; test and notebook attachments; per-record uploads and links (formulas, batches, samples, suppliers, equipment…). File **metadata** only, never the text inside a file. Filters: `type`, `record`, `linked`, `query`, `lot`, `format`, `expiry`, `expiring_within_days`, `without_expiry`, `uploaded_within_days`. A `summary` block carries the page's tile counts: expired, expiring in 30 days, SDS/CoA without expiry, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, added last 7 days |
|
|
40
|
+
| `list_documents` | The document register (same as the app's Documents page): ingredient SDS / PDS / CoA / other with lot, version, source and expiry; test and notebook attachments; per-record uploads and links (formulas, batches, samples, suppliers, equipment…). File **metadata** only, never the text inside a file, with who added each file (`addedBy`) and when. Filters: `type`, `record`, `linked`, `query`, `lot`, `format`, `expiry`, `expiring_within_days`, `without_expiry`, `uploaded_within_days`, `added_by`. A `summary` block carries the page's tile counts: expired, expiring in 30 days, SDS/CoA without expiry, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, added last 7 days |
|
|
41
41
|
| `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` |
|
|
42
42
|
| `list_batches` | Filtered list of production / lab-prep events |
|
|
43
43
|
| `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes) and **actual mass** (yield, else summed as-prepared inputs — mL rows through that ingredient's density — else measured volume × density), **estimatedVolume** (mass ÷ the formula's finished density when no actual exists, flagged `estimate: true`), **plannedMass** for volume-target batches (target × finished density) and **formulaFinishedDensity**, the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage, and — as a finished-goods lot — its storage **`location`** + finished-goods **`stockLedger`** (shipments out + adjustments; `shippedQty` total) |
|
|
@@ -66,8 +66,8 @@ Ask Claude (or another MCP client) things like:
|
|
|
66
66
|
| `get_test_panel` | One Test Panel: ordered parameters with resolved method UIDs + specs, plus `usage` (reports created from it) and `performanceByParameter` |
|
|
67
67
|
| `list_projects` | Filtered list of projects (the buckets formulations are filed under) + per-project formulation count. Filters: `status`, `name_contains` |
|
|
68
68
|
| `get_project` | Full project record + the formulations filed under it |
|
|
69
|
-
| `list_notebook_entries` | ELN feed — dated authored notes attached to records. Filters: `entity_type`, `entity_id`, `author`, `text_contains`, `since`, `until` |
|
|
70
|
-
| `get_notebook_entry` | One notebook entry — full body, author, attachment metadata, resolved linked record |
|
|
69
|
+
| `list_notebook_entries` | ELN feed — dated authored notes attached to records. Append-only: a withdrawn note is kept with `retracted` = {at, by, reason}. Filters: `entity_type`, `entity_id`, `author`, `text_contains`, `since`, `until`, `retracted` |
|
|
70
|
+
| `get_notebook_entry` | One notebook entry — full body, author, attachment metadata, resolved linked record, and `retracted` when withdrawn |
|
|
71
71
|
|
|
72
72
|
## Install
|
|
73
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.37",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab \u2014 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",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.juliu1980/formlab-mcp",
|
|
4
4
|
"description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
|
|
5
|
-
"version": "0.6.
|
|
5
|
+
"version": "0.6.37",
|
|
6
6
|
"websiteUrl": "https://formvix.com/mcp",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/juliu1980/FormLab",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "formlab-mcp",
|
|
16
|
-
"version": "0.6.
|
|
16
|
+
"version": "0.6.37",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/documents.js
CHANGED
|
@@ -60,7 +60,7 @@ function allDocuments(db) {
|
|
|
60
60
|
lot: d.lotNumber || '', version: d.version != null ? String(d.version) : '', source: d.source || '',
|
|
61
61
|
expiryDate: d.expiryDate || null, expiryStatus: ex.status, daysToExpiry: ex.daysToExpiry,
|
|
62
62
|
sizeKb: Number.isFinite(+d.size) && d.size != null ? Math.round(+d.size / 102.4) / 10 : null,
|
|
63
|
-
uploadedAt: d.uploadedAt || null,
|
|
63
|
+
uploadedAt: d.uploadedAt || null, addedBy: d.addedBy || null,
|
|
64
64
|
});
|
|
65
65
|
}));
|
|
66
66
|
const testsById = new Map((db.testResults || []).map(t => [t.id, t]));
|
|
@@ -71,7 +71,7 @@ function allDocuments(db) {
|
|
|
71
71
|
record: 'test', linked: (t ? (t.uid || t.id) : 'Test') + (a.measurementParameter ? ' · ' + a.measurementParameter : ''), linkedId: t ? (t.uid || t.id) : null,
|
|
72
72
|
lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
|
|
73
73
|
sizeKb: Number.isFinite(+a.fileSize) && a.fileSize != null ? Math.round(+a.fileSize / 102.4) / 10 : null,
|
|
74
|
-
uploadedAt: a.uploadedAt || null,
|
|
74
|
+
uploadedAt: a.uploadedAt || null, addedBy: a.addedBy || null,
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
(db.notebookEntries || []).filter(live).forEach(ne => (ne.attachments || []).forEach(att => {
|
|
@@ -83,7 +83,7 @@ function allDocuments(db) {
|
|
|
83
83
|
record: 'notebook', linked: rec ? (rec.name || rec.uid || '') : (ne.entityType || 'note'), linkedId: rec ? (rec.uid || rec.id) : null,
|
|
84
84
|
lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
|
|
85
85
|
sizeKb: Number.isFinite(+att.size) && att.size != null ? Math.round(+att.size / 102.4) / 10 : null,
|
|
86
|
-
uploadedAt: att.uploadedAt || null,
|
|
86
|
+
uploadedAt: att.uploadedAt || null, addedBy: att.addedBy || ne.author || null, // a note's file is its author's
|
|
87
87
|
});
|
|
88
88
|
}));
|
|
89
89
|
Object.entries(ATTACH_COLL).forEach(([etype, coll]) => (db[coll] || []).filter(live).forEach(rec => (rec.fileAttachments || []).forEach(a => {
|
|
@@ -95,7 +95,7 @@ function allDocuments(db) {
|
|
|
95
95
|
...(isLink && a.url ? { url: a.url } : {}),
|
|
96
96
|
lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
|
|
97
97
|
sizeKb: (!isLink && Number.isFinite(+a.size) && a.size != null) ? Math.round(+a.size / 102.4) / 10 : null,
|
|
98
|
-
uploadedAt: a.createdAt || null,
|
|
98
|
+
uploadedAt: a.createdAt || null, addedBy: a.addedBy || null,
|
|
99
99
|
});
|
|
100
100
|
})));
|
|
101
101
|
return out;
|
|
@@ -121,7 +121,7 @@ function summary(db, docs) {
|
|
|
121
121
|
const list_documents = {
|
|
122
122
|
definition: {
|
|
123
123
|
name: 'list_documents',
|
|
124
|
-
description: 'The workspace document register — same list as the app\'s Documents page: ingredient SDS / PDS / CoA / other files (with lot, version, source, expiry), test-result and notebook attachments, and per-record uploads and links (formulas, batches, samples, suppliers, equipment…). Returns file METADATA only (never the text inside a file). Filters combine. The summary block matches the page\'s tiles: expired, expiring in 30 days, SDS/CoA without an expiry date, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, files added in the last 7 days. For the lots themselves use list_lots with missing_coa: true.',
|
|
124
|
+
description: 'The workspace document register — same list as the app\'s Documents page: ingredient SDS / PDS / CoA / other files (with lot, version, source, expiry), test-result and notebook attachments, and per-record uploads and links (formulas, batches, samples, suppliers, equipment…). Returns file METADATA only (never the text inside a file), including addedBy (who added it — null for files added before that was recorded) and uploadedAt. Filters combine. The summary block matches the page\'s tiles: expired, expiring in 30 days, SDS/CoA without an expiry date, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, files added in the last 7 days. For the lots themselves use list_lots with missing_coa: true.',
|
|
125
125
|
inputSchema: {
|
|
126
126
|
type: 'object',
|
|
127
127
|
properties: {
|
|
@@ -135,6 +135,7 @@ const list_documents = {
|
|
|
135
135
|
expiring_within_days: { type: 'number', description: 'Only documents whose expiry date is within this many days (already expired included).' },
|
|
136
136
|
without_expiry: { type: 'boolean', description: 'true = only SDS / CoA files with no expiry date (the "SDS / CoA without expiry" tile).' },
|
|
137
137
|
uploaded_within_days: { type: 'number', description: 'Only files uploaded within this many days.' },
|
|
138
|
+
added_by: { type: 'string', description: 'Who added the file: case-insensitive substring of their email / name. Files from before this was recorded have none and never match.' },
|
|
138
139
|
limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
|
|
139
140
|
},
|
|
140
141
|
},
|
|
@@ -156,6 +157,7 @@ const list_documents = {
|
|
|
156
157
|
if (args.expiring_within_days != null && !(d.daysToExpiry != null && d.daysToExpiry <= args.expiring_within_days)) return false;
|
|
157
158
|
if (args.without_expiry === true && !(d.record === 'ingredient' && d.expiryStatus === 'none' && (d.type === 'SDS' || d.type === 'COA'))) return false;
|
|
158
159
|
if (args.uploaded_within_days != null && !(d.ageDays != null && d.ageDays <= args.uploaded_within_days)) return false;
|
|
160
|
+
if (args.added_by && !norm(d.addedBy).includes(norm(args.added_by))) return false;
|
|
159
161
|
return true;
|
|
160
162
|
}).sort((a, b) => String(b.uploadedAt || '').localeCompare(String(a.uploadedAt || '')));
|
|
161
163
|
return { summary: summary(db, all), totalMatching: rows.length, returned: Math.min(rows.length, limit), documents: rows.slice(0, limit) };
|
package/tools/notebook.js
CHANGED
|
@@ -46,6 +46,10 @@ function _trimEntry(e, db, { full = false } = {}) {
|
|
|
46
46
|
updatedAt: e.updatedAt || null,
|
|
47
47
|
author: e.author || '',
|
|
48
48
|
attachmentCount: atts.length,
|
|
49
|
+
// Withdrawn by its author or the workspace owner (append-only notebook):
|
|
50
|
+
// the note is kept, but it is not a finding. null when not retracted.
|
|
51
|
+
retracted: (e.retracted && typeof e.retracted === 'object')
|
|
52
|
+
? { at: e.retracted.at || null, by: e.retracted.by || '', reason: e.retracted.reason || '' } : null,
|
|
49
53
|
};
|
|
50
54
|
if (!full) return { ...base, body: (e.body || '').slice(0, 240) };
|
|
51
55
|
return {
|
|
@@ -58,7 +62,7 @@ function _trimEntry(e, db, { full = false } = {}) {
|
|
|
58
62
|
const list_notebook_entries = {
|
|
59
63
|
definition: {
|
|
60
64
|
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.',
|
|
65
|
+
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. Entries are append-only: a withdrawn one is kept with retracted = {at, by, reason} — treat it as withdrawn, not as a finding. Use get_notebook_entry for the full body + attachments.',
|
|
62
66
|
inputSchema: {
|
|
63
67
|
type: 'object',
|
|
64
68
|
properties: {
|
|
@@ -68,6 +72,7 @@ const list_notebook_entries = {
|
|
|
68
72
|
text_contains: { type: 'string', description: 'Case-insensitive substring on the note body.' },
|
|
69
73
|
since: { type: 'string', description: 'ISO date/time — only entries at or after this timestamp.' },
|
|
70
74
|
until: { type: 'string', description: 'ISO date/time — only entries at or before this timestamp.' },
|
|
75
|
+
retracted: { type: 'boolean', description: 'false = only notes that stand (leave out retracted ones); true = only retracted notes. Omit for both.' },
|
|
71
76
|
limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
|
|
72
77
|
},
|
|
73
78
|
},
|
|
@@ -91,6 +96,7 @@ const list_notebook_entries = {
|
|
|
91
96
|
if (wantEntityId && e.entityId !== wantEntityId) return false;
|
|
92
97
|
if (args.author && !_norm(e.author).includes(_norm(args.author))) return false;
|
|
93
98
|
if (args.text_contains && !_norm(e.body).includes(_norm(args.text_contains))) return false;
|
|
99
|
+
if (typeof args.retracted === 'boolean' && (!!(e.retracted && typeof e.retracted === 'object')) !== args.retracted) return false;
|
|
94
100
|
if (sinceMs != null || untilMs != null) {
|
|
95
101
|
const t = Date.parse(_entryTs(e));
|
|
96
102
|
if (!isFinite(t)) return false;
|
|
@@ -110,7 +116,7 @@ const list_notebook_entries = {
|
|
|
110
116
|
const get_notebook_entry = {
|
|
111
117
|
definition: {
|
|
112
118
|
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),
|
|
119
|
+
description: 'Get one ELN notebook entry by id — full note body, author, timestamp, the record it is attached to (resolved to uid/name), attachment metadata (filename / type), and retracted = {at, by, reason} when the note was withdrawn. Attachment binaries are not returned.',
|
|
114
120
|
inputSchema: {
|
|
115
121
|
type: 'object',
|
|
116
122
|
properties: { id: { type: 'string', description: 'Internal id of the notebook entry.' } },
|