'use strict';
const db = require('./db.js');
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { z } = require('zod');
// ── Helpers ────────────────────────────────────────────────
function today() {
return new Date().toISOString().slice(0, 10);
}
function json(obj) {
return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
}
function errResult(msg) {
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true };
}
// Like the HTTP safeErr: only surface error text on db.isSafeError's allowlist
// (validator messages); otherwise return a generic message so unexpected errors
// — raw SQLite "constraint failed:
.", fs paths, etc. — don't leak
// schema/internals to the MCP client. Use for caught exceptions; intentional
// tool messages still pass through errResult() directly.
function safeErrResult(e) {
const msg = String((e && e.message) || '');
return errResult(db.isSafeError(msg) ? msg : 'Internal error');
}
// Build a map of bagId → current location by replaying scan log
function buildBagLocationMap(scanLog) {
const map = new Map();
for (const e of scanLog) {
if (e.action === 'ADD' && e.to) {
map.set(e.bag, e.to);
} else if (e.action === 'MOVE' && e.to) {
map.set(e.bag, e.to);
} else if (e.action === 'REMOVE') {
map.delete(e.bag);
}
}
return map;
}
// ── ZPL Label Generation ─────────────────────────────────
// Ported from app.js — server-side copy so MCP tools can generate labels
// without a browser. Canvas is 400×240 dots (50×30mm @ 203dpi).
function bcParams(val, qzMult) {
const mods = 35 + val.length * 11;
let mw = 3;
qzMult = qzMult || 10;
const qz = (m) => m * qzMult;
while (mw > 1 && mods * mw + 2 * qz(mw) > 400) mw--;
const w = mods * mw;
return { mw, x: Math.max(qz(mw), Math.round((400 - w) / 2)) };
}
function zplText(s) {
return String(s || '').replace(/[\^~]/g, '');
}
function fmtDt(d) {
if (!(d instanceof Date)) d = new Date(d);
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yy = String(d.getFullYear()).slice(-2);
return dd + '.' + mm + '.' + yy;
}
// Label size from LABEL_WIDTH_DOTS / LABEL_HEIGHT_DOTS env vars.
// Default 400×240 dots = 50×30mm at 203dpi (Zebra GK420d small label).
// Field positions in itemsToZPL/bagLabelItems/labLabelItems assume
// 400 dots wide; significantly different sizes need their own layout.
const _LABEL_W = (() => {
const v = parseInt(process.env.LABEL_WIDTH_DOTS, 10);
return Number.isFinite(v) && v >= 100 && v <= 4000 ? v : 400;
})();
const _LABEL_H = (() => {
const v = parseInt(process.env.LABEL_HEIGHT_DOTS, 10);
return Number.isFinite(v) && v >= 100 && v <= 4000 ? v : 240;
})();
function itemsToZPL(items) {
// ^LT0/^LS0 reset stored offsets, ^PON/^FWN force normal orientation.
let z = '^XA^PW' + _LABEL_W + '^LL' + _LABEL_H + '^CI28^LH0,0^LT0^LS0^PON^FWN';
for (const it of items) {
if (it.type === 'barcode') {
z +=
'^FO' +
it.x +
',' +
it.y +
'^BY' +
it.mw +
',2.0,' +
it.h +
'^BCN,' +
it.h +
',N,N,N^FD' +
zplText(it.val) +
'^FS';
} else if (it.type === 'text') {
const fw = it.fontW || it.fontH;
const bw = it.blockW || 400;
const bx = it.x || 0;
z +=
'^FO' + bx + ',' + it.y + '^FB' + bw + ',1,0,C^A0N,' + it.fontH + ',' + fw + '^FD' + zplText(it.text) + '^FS';
if (it.bold) {
z +=
'^FO' +
(bx + 1) +
',' +
it.y +
'^FB' +
bw +
',1,0,C^A0N,' +
it.fontH +
',' +
fw +
'^FD' +
zplText(it.text) +
'^FS';
}
} else if (it.type === 'qr') {
// Reset label home immediately before QR to ensure ^FO works from true origin.
z += '^LH0,0^FO' + it.x + ',' + it.y + '^BQN,2,' + (it.mag || 4) + '^FDMA,' + zplText(it.val) + '^FS';
}
}
return z + '^XZ';
}
// Siehe app.js: dieselbe Beschriftung, damit gedruckt und angezeigt nicht
// auseinanderlaufen.
function labelKg(kg) {
return String(Math.round(kg * 1000) / 1000).replace('.', ',') + 'kg';
}
function bagLabelKg(batch, bagId) {
const w = batch && batch.bagWeights ? batch.bagWeights[bagId] : null;
if (Number.isFinite(w) && w > 0) return w;
return Number.isFinite(batch && batch.bagKg) && batch.bagKg > 0 ? batch.bagKg : null;
}
// Width a centred text line may use. The bottom-right badge sits over the band
// the last line(s) occupy, so any line overlapping it vertically is narrowed to
// stop short — the text re-centres in the space left rather than running under
// the box. Lines clear of the badge keep the full width they always had.
function _lineBlockW(y, fontH, badge) {
return badge && y < badge.y + badge.h && badge.y < y + fontH ? badge.x - 6 : 400;
}
// ─── Jede Zeile passt ────────────────────────────────────────
// Ein Textfeld auf dem Etikett ist ein ^FB-Block für eine Zeile, und der
// schneidet nichts ab: was nicht hineinpasst, bricht der Drucker um und druckt
// es ÜBER die Zeile. So stand der Stammcode mitten auf "Ganoderma", weil
// "Reishi – 2,0kg – Ganoderma multipileum KQ07" für eine Zeile zu lang war.
// Deshalb geht keine Zeile mehr zum Drucker, die nicht passt: sie wird hier
// gemessen, und was zu lang ist, kommt in die zweite Zeile. Eine Nummer wird
// stattdessen kleiner geschrieben, die liest man in einem Stück.
//
// Die Breiten sind die von Helvetica Bold, in Tausendsteln der Schriftbreite.
// Schrift 0 des Druckers (CG Triumvirate Bold Condensed), an diesem Etikett
// nachgemessen, ist bei Großbuchstaben so breit, bei Ziffern und
// Kleinbuchstaben schmaler; nur den Bindestrich zieht sie breiter, er steht mit
// 600. Dazu 5 % Luft in labelTextW(): was hier passt, passt auf dem Etikett.
// Ein Zeichen, das nicht in der Liste steht, zählt ganz breit.
//
// Dieser Block steht wortgleich in app.js und in mcp-server.js, die beide
// Etiketten drucken; test/etiketten-zeilen.test.js hält die zwei gleich.
const _LABEL_EM = [
[" \u00a0,./\\|ijlI'\u00b7", 278],
['!():;[]ft', 333],
['r*{}', 389],
['z', 500],
['0123456789acekvsxyJ_#$"', 556],
['+<=>~', 584],
['-', 600],
['bdghnopquFLTZ?\u00df', 611],
['EPSVXY', 667],
['ABCDHKNRU&', 722],
['GOQw', 778],
['M', 833],
['m%', 889],
['W', 944],
['@', 975]
];
let _labelEmMap = null;
function labelTextW(text, fontW) {
if (!_labelEmMap) {
_labelEmMap = new Map();
for (const [zeichen, breite] of _LABEL_EM) for (const c of zeichen) _labelEmMap.set(c, breite);
}
let em = 0;
// Umlaute und Akzente zählen wie ihr Grundbuchstabe: ä wie a.
for (const c of String(text)) em += _labelEmMap.get(c) || _labelEmMap.get(c.normalize('NFD')[0]) || 1000;
return (em * fontW * 1.05) / 1000;
}
// Zeichen, die Schrift 0 nicht hat. Der Gedankenstrich zwischen "Reishi" und
// "2,0kg" kam auf demselben Etikett gar nicht erst heraus, an seiner Stelle
// stand eine Lücke. Ersetzt wird im Text der Zeile selbst und nicht erst beim
// Drucken: so misst labelTextW(), was gedruckt wird, und die Vorschau zeigt es.
const _LABEL_ERSATZ = {
'\u2013': '-',
'\u2014': '-',
'\u2026': '...',
'\u2190': '<-',
'\u2192': '->',
'\u2018': "'",
'\u2019': "'",
'\u201a': "'",
'\u201c': '"',
'\u201d': '"',
'\u201e': '"'
};
function labelGlyphs(s) {
return String(s || '').replace(
/[\u2013\u2014\u2026\u2190\u2192\u2018-\u201a\u201c-\u201e]/g,
(c) => _LABEL_ERSATZ[c]
);
}
// Bricht `text` in höchstens `maxZeilen` Zeilen, keine breiter als `breite(i)`
// — wieviel Platz eine Zeile hat, hängt davon ab, wo sie steht, neben dem
// Zähler unten rechts ist es weniger. Umbrochen wird an Leerzeichen, ein Wort,
// das allein zu lang ist, hinter seinem letzten Bindestrich, der noch passt.
// Bleibt dann noch etwas übrig, endet die letzte Zeile mit "...", und das
// Ergebnis trägt `gekuerzt`.
//
// Der Gedankenstrich zwischen zwei Angaben steht nie am Anfang oder Ende einer
// Zeile, dort trennt schon der Umbruch. Ein geschütztes Leerzeichen hält
// zusammen, was zusammengehört ("<- PD-…"), und wird gedruckt wie ein normales.
function labelWrap(text, fontW, breite, maxZeilen) {
const w = (s) => labelTextW(s, fontW);
const rest = String(text || '')
.split(' ')
.filter(Boolean)
.map((s) => ({ s: labelGlyphs(s).replace(/\u00a0/g, ' '), trenner: s === '\u2013' }));
const alsZeile = (toks) => {
let a = 0;
let e = toks.length;
while (a < e && toks[a].trenner) a++;
while (e > a && toks[e - 1].trenner) e--;
return toks
.slice(a, e)
.map((x) => x.s)
.join(' ');
};
const zeilen = [];
while (zeilen.length < maxZeilen) {
while (rest.length && rest[0].trenner) rest.shift();
if (!rest.length) break;
const max = breite(zeilen.length);
const toks = [];
while (rest.length && w(alsZeile(toks.concat(rest[0]))) <= max) toks.push(rest.shift());
if (!toks.length) {
// Schon das erste Wort allein ist zu breit für die Zeile.
const wort = rest[0].s;
let n = Math.max(1, wort.length - 1);
while (n > 1 && w(wort.slice(0, n)) > max) n--;
const strich = wort.lastIndexOf('-', n - 1);
if (strich > 0) n = strich + 1;
toks.push({ s: wort.slice(0, n), trenner: false });
if (n < wort.length) rest[0] = { s: wort.slice(n), trenner: false };
else rest.shift();
}
zeilen.push(alsZeile(toks));
}
while (rest.length && rest[0].trenner) rest.shift();
if (rest.length && zeilen.length) {
// Lieber ein ganzes Wort weglassen als eines anschneiden. Angeschnitten wird
// nur, was allein in der Zeile steht.
const max = breite(zeilen.length - 1);
const woerter = zeilen[zeilen.length - 1].split(' ');
while (woerter.length > 1 && w(woerter.join(' ') + '...') > max) woerter.pop();
let letzte = woerter.join(' ');
while (letzte && w(letzte + '...') > max) letzte = letzte.slice(0, -1);
zeilen[zeilen.length - 1] = letzte.replace(/[\s-]+$/, '') + '...';
zeilen.gekuerzt = true;
}
return zeilen;
}
// Setzt Absätze untereinander, ab `y`. Ein Absatz ist { text, fontH, bold,
// maxZeilen, minH, kuerzbar }. Jede Zeile bekommt die Breite, die auf ihrer
// Höhe frei ist, und steht 4 Punkte unter der vorigen — die Abstände, die das
// Etikett immer hatte. Zurück kommen die Zeilen und die Unterkante der letzten.
//
// Mit minH ist der Absatz eine Nummer, und die liest man am besten in einem
// Stück: sie wird erst kleiner, bis minH, solange sie dann in eine Zeile
// passt. Passt sie auch so nicht, bekommt sie die größte Schrift, in der sie
// in ihren Zeilen ganz dasteht — umbrochen, aber nicht auch noch winzig.
function labelLines(absaetze, y, badge) {
const items = [];
let unten = y;
for (const a of absaetze) {
if (!a.text) continue;
const setze = (h) => {
const zeileY = (i) => y + i * (h + 4);
const breite = (i) => _lineBlockW(zeileY(i), h, badge);
const max = a.maxZeilen === undefined ? 1 : a.maxZeilen;
return { h, zeileY, breite, zeilen: labelWrap(a.text, h, (i) => breite(i) - 6, max) };
};
let satz = null;
if (a.minH) {
const groessen = [];
for (let h = a.fontH; h >= a.minH; h -= 2) groessen.push(h);
const versuche = groessen.map(setze);
satz =
versuche.find((s) => s.zeilen.length === 1 && !s.zeilen.gekuerzt) ||
versuche.find((s) => !s.zeilen.gekuerzt) ||
versuche[versuche.length - 1];
} else {
satz = setze(a.fontH);
}
satz.zeilen.forEach((text, i) => {
const item = { type: 'text', y: satz.zeileY(i), blockW: satz.breite(i), fontH: satz.h, text: text };
if (a.bold) item.bold = true;
items.push(item);
});
if (satz.zeilen.length) {
unten = satz.zeileY(satz.zeilen.length - 1) + satz.h;
y = unten + 4;
}
}
return { items: items, unten: unten };
}
// Tiefer als die Unterkante des Zählers unten rechts (232) steht kein Text.
const LABEL_UNTEN = 234;
// Code und Zeilen zu einem Etikett. Passt der Text nicht unter den Code, wird
// ein kürzbarer Absatz — die Angaben zur Sorte — eine Zeile kürzer, und seine
// letzte endet mit "...". Reicht auch eine Zeile nicht (unter einem QR, neben
// einer Kultur mit langer Herkunft), fällt er ganz weg. Die Nummer und das
// Datum kürzt nichts.
function labelLayout(bcVal, qr, absaetze, badge) {
let liste = absaetze;
for (;;) {
const versuch = qr ? _labelQr(bcVal, liste, badge) : _labelBarcode(bcVal, liste, badge);
const i = liste.findIndex((a) => a.kuerzbar && a.maxZeilen > 0);
if (versuch.passt || i < 0) return versuch.items;
liste = liste.map((a, j) => (j === i ? Object.assign({}, a, { maxZeilen: a.maxZeilen - 1 }) : a));
}
}
// Strichcode oben, die Zeilen darunter, das Ganze senkrecht mittig, aber nie
// über y=40: darüber schneidet dieses Material ab. Braucht der Text mehr Platz,
// als unter einem 90 Punkte hohen Code frei ist, gibt der Code Höhe ab, in
// Schritten bis 60 — 7,5 mm liest jede Kamera noch.
function _labelBarcode(bcVal, absaetze, badge) {
const bc = bcParams(bcVal);
const hoehe = labelLines(absaetze, 0, null).unten;
let versuch = null;
for (let bcH = 90; bcH >= 60; bcH -= 6) {
const bcY = Math.max(40, Math.round((240 - (bcH + 6 + hoehe)) / 2));
const text = labelLines(absaetze, bcY + bcH + 6, badge);
const code = { type: 'barcode', x: bc.x, y: bcY, w: 400 - 2 * bc.x, h: bcH, val: bcVal, mw: bc.mw };
versuch = { passt: text.unten <= LABEL_UNTEN, items: [code].concat(text.items) };
if (versuch.passt) break;
}
return versuch;
}
// QR oben links, mag 5 → 125 Punkte (25 Module), die Zeilen darunter über die
// ganze Breite.
function _labelQr(bcVal, absaetze, badge) {
const text = labelLines(absaetze, 155, badge);
const code = { type: 'qr', x: 0, y: 10, size: 125, mag: 5, val: bcVal };
return { passt: text.unten <= 240, items: [code].concat(text.items) };
}
function bagLabelItems(bagId, batch, detail, barcodeNum, qr, bagKg) {
const bcVal = barcodeNum ? String(barcodeNum) : bagId.replace(/-/g, '_');
// Dieselben Zeilen wie in app.js: die Nummer (lieber kleiner als umbrochen),
// die Angaben zur Sorte bis zwei Zeilen, bei "full" das Fälligkeitsdatum.
const absaetze = [{ text: bagId, fontH: qr ? 28 : 24, minH: 18, maxZeilen: 2 }];
if (detail === 'sorte' || detail === 'full') {
const species = batch.strainName || batch.species || '';
const strainTxt = (batch.strainText || '').trim();
const rawNotes = (batch.notes || '').trim();
const notes = rawNotes.length > 13 ? rawNotes.slice(0, 13) + '\u2026' : rawNotes;
const parts = [species];
if (bagKg != null) parts.push(labelKg(bagKg));
if (strainTxt) parts.push(strainTxt);
if (notes) parts.push(notes);
absaetze.push({ text: parts.filter(Boolean).join(' \u2013 '), fontH: 24, maxZeilen: 2, kuerzbar: true });
}
if (detail === 'full' && batch.due) {
const due = new Date(batch.due);
const dueStr =
String(due.getDate()).padStart(2, '0') +
'.' +
String(due.getMonth() + 1).padStart(2, '0') +
'.' +
due.getFullYear();
absaetze.push({ text: 'F\u00e4llig: ' + dueStr, fontH: qr ? 24 : 28, minH: 18, bold: true });
}
return labelLayout(bcVal, qr, absaetze, null);
}
function labLabelItems(id, c, detail, barcodeNum, qr) {
// Build info line matching batch label pattern: strainName – strainText – notes(13)
const species = c.strainName || c.species || '';
const strainTxt = (c.strainText || '').trim();
const rawNotes = (c.notes || '').trim();
const notes = rawNotes.length > 13 ? rawNotes.slice(0, 13) + '\u2026' : rawNotes;
const spParts = [species];
if (strainTxt) spParts.push(strainTxt);
if (notes) spParts.push(notes);
const bcVal = barcodeNum ? String(barcodeNum) : id.replace(/-/g, '_');
// Die Herkunft hängt mit einem geschützten Leerzeichen an ihrem Pfeil, wie
// in app.js: bricht die erste Zeile um, steht "<- PD-…" zusammen darunter.
const absaetze = [
{ text: c.parentId ? id + ' \u2190\u00a0' + c.parentId : id, fontH: qr ? 28 : 24, minH: 18, maxZeilen: 2 }
];
if (detail === 'sorte' || detail === 'full') {
absaetze.push({ text: spParts.filter(Boolean).join(' \u2013 '), fontH: 24, maxZeilen: 2, kuerzbar: true });
}
if (detail === 'full' && c.created) {
absaetze.push({ text: fmtDt(c.created), fontH: qr ? 24 : 28, minH: 18, bold: true });
}
return labelLayout(bcVal, qr, absaetze, null);
}
// ── MCP Server Factory ────────────────────────────────────
// S-01: `printer` may also carry `auth = { userId, role }` describing
// the bearer token that opened this session. Destructive tools call
// `requireAdminRole(auth)` and short-circuit with an error result when the
// caller is not an admin. The legacy static MCP token is treated as admin
// (preserves historical behaviour); OAuth tokens carry the user's role.
function createMcpServer(database, onWrite, printer) {
const server = new McpServer({
name: 'meistertracker',
version: '1.0.0'
});
// Fail closed: with no auth context, default to no role (not admin) so a caller
// that forgets to pass auth cannot reach admin-only tools. The real server path
// always supplies printer.auth from checkMcpAuth (legacy static token -> admin).
const auth = (printer && printer.auth) || { userId: null, role: null, username: null };
function notify() {
if (typeof onWrite === 'function') onWrite();
}
function isAdminCaller() {
return !!(auth && auth.role === 'admin');
}
// Returns a refusal, or null when the caller may proceed. Every call site
// reads `const adminErr = requireAdminRole(); if (adminErr) return adminErr;`
// — truthy means refuse, at all ten of them. A tool that wants to *narrow* an
// answer rather than refuse it asks isAdminCaller() instead: reusing this one
// for that would make truthy mean two opposite things, and the next
// maintainer sweeping for missing `if (adminErr) return adminErr;` would turn
// the narrowing into a refusal.
function requireAdminRole() {
return isAdminCaller() ? null : errResult('admin role required');
}
// ──────────────────────────────────────────────────────────
// READ TOOLS
// ──────────────────────────────────────────────────────────
server.tool(
'daily_briefing',
"Get today's operational briefing: batches due/overdue, open tasks by assignee, low-stock alerts, and calendar events. READ-ONLY overview — use other tools to take action on items.",
{ date: z.string().optional().describe('ISO date (YYYY-MM-DD), defaults to today') },
async ({ date }) => {
const batches = db.getAllBatches(database);
// The same filter GET /api/data applies. Without it this briefing was the
// way around it: it hands back task text grouped by assignee, and an MCP
// token is something any worker can mint for themselves through the OAuth
// flow. A read hole with two doors is not closed until both are.
const manualTasks = db
.getAllTasks(database)
.filter((t) => db.canUserSeeTask(database, t, auth.username, auth.role === 'admin'));
const inventory = db.getInventory(database, 20);
const calendarEvents = db.getCalendarEvents(database);
const target = date || today();
// Batches due today or overdue. A contaminated bag is not an active one:
// a batch with only those left is nothing to act on.
const bagLoc = db.getStockBagZoneMap(database);
const dueToday = [];
const overdue = [];
for (const b of batches) {
if (!b.due) continue;
const dueDate = b.due.slice(0, 10);
const activeBags = b.bags.filter((id) => bagLoc.has(id));
if (activeBags.length === 0) continue;
if (dueDate === target)
dueToday.push({
batchId: b.batchId,
species: b.species,
strain: b.strain,
due: b.due,
activeBags: activeBags.length,
totalBags: b.bags.length
});
else if (dueDate < target)
overdue.push({
batchId: b.batchId,
species: b.species,
strain: b.strain,
due: b.due,
activeBags: activeBags.length,
totalBags: b.bags.length
});
}
// Open tasks grouped by assignee
const openTasks = manualTasks.filter((t) => !t.done);
// S-10: null-prototype accumulator. `assignee` is free text from the
// task row, and on a plain {} the key "__proto__" is truthy, so the
// initialiser is skipped and .push() runs on Object.prototype — which
// has no push, so the tool throws instead of answering.
const tasksByAssignee = Object.create(null);
for (const t of openTasks) {
const key = t.assignee || 'Unassigned';
if (!tasksByAssignee[key]) tasksByAssignee[key] = [];
tasksByAssignee[key].push({ id: t.id, text: t.text, priority: t.priority, dueDate: t.dueDate });
}
// Low-stock alerts
const inv = inventory;
const lowStock = [];
for (const mat of ['hardwood', 'wheatbran', 'gypsum', 'grain']) {
const stock = inv.stock[mat];
const threshold = inv.thresholds[mat].minKg;
if (stock < threshold) lowStock.push({ material: mat, stockKg: stock, thresholdKg: threshold });
}
// Calendar events for today
const todayEvents = calendarEvents.filter((e) => {
const start = e.startDate;
const end = e.endDate || e.startDate;
return start <= target && end >= target;
});
// Maintenance due/overdue
let maintenanceDue = [];
try {
const allDue = db.getMaintenanceDue(database);
maintenanceDue = allDue.filter((m) => !m.scheduledDate || m.scheduledDate <= target);
} catch (_) {
// maintenance_log table may not exist yet
}
return json({
date: target,
batchesDueToday: dueToday,
batchesOverdue: overdue,
openTaskCount: openTasks.length,
tasksByAssignee,
lowStockAlerts: lowStock,
maintenanceDue,
calendarEvents: todayEvents.map((e) => ({
id: e.id,
title: e.title,
startDate: e.startDate,
endDate: e.endDate,
allDay: e.allDay,
startTime: e.startTime,
endTime: e.endTime,
category: e.category,
assignees: e.assignees
}))
});
}
);
server.tool(
'list_batches',
'List production batches with optional filters. READ-ONLY listing. To modify batches use update_batch (metadata), add_bags_to_batch (more bags), rename_batch (change ID), or delete_batch (remove entirely).',
{
strainId: z
.number()
.optional()
.describe('Filter by Pilzsorte id (exact match). Use list_mushroom_strains to discover ids.'),
species: z.string().optional().describe('Filter by species (partial match, case-insensitive)'),
strain: z.string().optional().describe('Filter by strain kuerzel (partial match, case-insensitive)'),
batchType: z.enum(['block', 'grain', 'liquid']).optional().describe('Filter by batch type'),
dueBefore: z.string().optional().describe('ISO date — batches due before this date'),
dueAfter: z.string().optional().describe('ISO date — batches due after this date')
},
async (params) => {
let batches = db.getAllBatches(database);
if (params.strainId != null) batches = batches.filter((b) => b.strainId === params.strainId);
if (params.species) {
const s = params.species.toLowerCase();
batches = batches.filter((b) => b.species && b.species.toLowerCase().includes(s));
}
if (params.strain) {
const s = params.strain.toLowerCase();
batches = batches.filter((b) => b.strain && b.strain.toLowerCase().includes(s));
}
if (params.batchType) batches = batches.filter((b) => b.batchType === params.batchType);
if (params.dueBefore) batches = batches.filter((b) => b.due && b.due.slice(0, 10) < params.dueBefore);
if (params.dueAfter) batches = batches.filter((b) => b.due && b.due.slice(0, 10) > params.dueAfter);
return json(
batches.map((b) => ({
batchId: b.batchId,
species: b.species,
strain: b.strain,
strainId: b.strainId,
strainName: b.strainName,
strainKuerzel: b.strainKuerzel,
qty: b.qty,
days: b.days,
batchType: b.batchType,
created: b.created,
due: b.due,
bagCount: b.bags.length,
notes: b.notes
}))
);
}
);
server.tool(
'get_batch_details',
'Get full details for a single batch: bags with current locations, scan history, harvest totals, and per-flush breakdown. READ-ONLY. To modify use update_batch, to record harvests use log_harvest, to move bags use move_bags.',
{ batchId: z.string().describe('Batch ID') },
async ({ batchId }) => {
const batch = db.readBatchById(database, batchId);
if (!batch) return errResult('Batch not found: ' + batchId);
const scanLog = db.getScanLog(database);
const bagLoc = buildBagLocationMap(scanLog);
const scans = scanLog.filter((e) => e.batch === batchId);
const harvests = db.getAllHarvests(database).filter((h) => h.batch === batchId);
const totalGrams = harvests.reduce((sum, h) => sum + h.grams, 0);
const byFlush = Object.create(null);
for (const h of harvests) {
const key = 'flush_' + h.flush;
byFlush[key] = (byFlush[key] || 0) + h.grams;
}
const bags = batch.bags.map((id) => ({
bagId: id,
bagKg: batch.bagWeights ? batch.bagWeights[id] || batch.bagKg || 3 : batch.bagKg || 3,
currentLocation: bagLoc.get(id) || null,
harvests: harvests.filter((h) => h.bag === id).map((h) => ({ grams: h.grams, flush: h.flush, time: h.time }))
}));
return json({
...batch,
bags,
scanHistory: scans.slice(-50),
harvestSummary: { totalGrams, harvestCount: harvests.length, byFlush }
});
}
);
server.tool(
'get_zone_overview',
'Get all zones with rack counts, current bag counts, and capacity utilization. READ-ONLY. To manage zones use manage_zones, to manage racks use manage_racks.',
{},
async () => {
const zones = db.getZonesWithRacks(database).map((z) => {
const bagCount = db.zoneBagCount(database, z.id);
const racks = z.racks.map((r) => ({
id: r.id,
bagCount: db.rackBagCount(database, r.id)
}));
return {
id: z.id,
name: z.name,
role: z.role,
color: z.color,
maxCapacity: z.maxCapacity,
bagCount,
capacityPct: z.maxCapacity ? Math.round((bagCount / z.maxCapacity) * 100) : null,
racks
};
});
return json(zones);
}
);
server.tool(
'get_inventory_status',
'Get current substrate inventory levels, thresholds, low-stock alerts, and recent transactions. READ-ONLY. To log deliveries or usage use update_inventory.',
{},
async () => {
const inv = db.getInventory(database, 20);
const alerts = [];
for (const mat of ['hardwood', 'wheatbran', 'gypsum', 'grain']) {
if (inv.stock[mat] < inv.thresholds[mat].minKg) {
alerts.push({ material: mat, stockKg: inv.stock[mat], thresholdKg: inv.thresholds[mat].minKg });
}
}
return json({
stock: inv.stock,
thresholds: inv.thresholds,
avgComposition: inv.avgComposition,
alerts,
recentLog: inv.log
});
}
);
server.tool(
'get_tasks',
'List team tasks with optional filters for assignee, priority, completion status, or date range. READ-ONLY. To create tasks use create_task, to modify use update_task.',
{
assignee: z.string().optional().describe('Filter by assignee name'),
priority: z.enum(['low', 'med', 'high']).optional().describe('Filter by priority'),
done: z.boolean().optional().describe('true=completed, false=open'),
dueBefore: z.string().optional().describe('ISO date — tasks due before this date'),
dueAfter: z.string().optional().describe('ISO date — tasks due after this date')
},
async (params) => {
// The third door. daily_briefing and GET /api/data both filter; this one
// is a dedicated task lister on the same server, reachable with the same
// self-minted worker token, and it takes an `assignee` parameter — so it
// answered "show me everything marked private for this colleague" in one
// call. A read hole is not closed until every door asks.
let tasks = db
.getAllTasks(database)
.filter((t) => db.canUserSeeTask(database, t, auth.username, auth.role === 'admin'));
if (params.assignee) {
const a = params.assignee.toLowerCase();
tasks = tasks.filter((t) => t.assignee && t.assignee.toLowerCase().includes(a));
}
if (params.priority) tasks = tasks.filter((t) => t.priority === params.priority);
if (params.done !== undefined) tasks = tasks.filter((t) => t.done === params.done);
if (params.dueBefore) tasks = tasks.filter((t) => t.dueDate && t.dueDate < params.dueBefore);
if (params.dueAfter) tasks = tasks.filter((t) => t.dueDate && t.dueDate > params.dueAfter);
return json(
tasks.map((t) => ({
id: t.id,
text: t.text,
priority: t.priority,
done: t.done,
assignee: t.assignee,
dueDate: t.dueDate,
description: t.description,
created: t.created
}))
);
}
);
server.tool(
'get_calendar_events',
'Get calendar events within a date range, optionally filtered by category. READ-ONLY. To create events use create_calendar_event.',
{
startDate: z.string().optional().describe('ISO date — start of range (default: today)'),
endDate: z.string().optional().describe('ISO date — end of range (default: 30 days from start)'),
category: z.string().optional().describe('Filter by event category')
},
async (params) => {
const start = params.startDate || today();
const end = params.endDate || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
let events = db.getCalendarEvents(database).filter((e) => {
const eStart = e.startDate;
const eEnd = e.endDate || e.startDate;
return eEnd >= start && eStart <= end;
});
if (params.category) events = events.filter((e) => e.category === params.category);
return json(
events.map((e) => ({
id: e.id,
title: e.title,
description: e.description,
startDate: e.startDate,
endDate: e.endDate,
allDay: e.allDay,
startTime: e.startTime,
endTime: e.endTime,
category: e.category,
color: e.color,
assignees: e.assignees
}))
);
}
);
server.tool(
'list_cultures',
'List mushroom cultures with optional filters for type (MC/PD/LC/G2G), Pilzsorte (strainId), species, or status. READ-ONLY. To modify use update_culture, to delete use delete_culture.',
{
type: z.string().optional().describe('Culture type (MC, PD, or LC)'),
strainId: z
.number()
.optional()
.describe('Filter by Pilzsorte id (exact match). Use list_mushroom_strains to discover ids.'),
species: z.string().optional().describe('Filter by species (partial match)'),
status: z.string().optional().describe('Filter by status (e.g. active, stored, used, sold, contam)')
},
async (params) => {
let cultures = db.getAllCultures(database);
if (params.type) cultures = cultures.filter((c) => c.type === params.type);
if (params.strainId != null) cultures = cultures.filter((c) => c.strainId === params.strainId);
if (params.species) {
const s = params.species.toLowerCase();
cultures = cultures.filter((c) => c.species && c.species.toLowerCase().includes(s));
}
if (params.status) cultures = cultures.filter((c) => c.status === params.status);
return json(cultures);
}
);
server.tool(
'get_harvest_report',
'Get harvest data aggregated by batch, species, or month with totals and per-flush breakdowns. READ-ONLY. To record a new harvest use log_harvest.',
{
groupBy: z.enum(['batch', 'species', 'month']).optional().describe('Aggregation dimension (default: batch)'),
batchId: z.string().optional().describe('Filter to a specific batch'),
species: z.string().optional().describe('Filter by species'),
startDate: z.string().optional().describe('ISO date — harvests after this date'),
endDate: z.string().optional().describe('ISO date — harvests before this date')
},
async (params) => {
let harvests = db.getAllHarvests(database);
if (params.batchId) harvests = harvests.filter((h) => h.batch === params.batchId);
if (params.species) {
const s = params.species.toLowerCase();
harvests = harvests.filter((h) => h.species && h.species.toLowerCase().includes(s));
}
if (params.startDate) harvests = harvests.filter((h) => h.time && h.time.slice(0, 10) >= params.startDate);
if (params.endDate) harvests = harvests.filter((h) => h.time && h.time.slice(0, 10) <= params.endDate);
const groupBy = params.groupBy || 'batch';
// S-10: keyed by batch id / species / month, all row data.
const groups = Object.create(null);
for (const h of harvests) {
let key;
if (groupBy === 'batch') key = h.batch || 'unknown';
else if (groupBy === 'species') key = h.species || 'unknown';
else key = h.time ? h.time.slice(0, 7) : 'unknown'; // month: YYYY-MM
if (!groups[key])
groups[key] = { totalGrams: 0, count: 0, byFlush: Object.create(null), byQuality: Object.create(null) };
groups[key].totalGrams += h.grams;
groups[key].count += 1;
const fKey = 'flush_' + h.flush;
groups[key].byFlush[fKey] = (groups[key].byFlush[fKey] || 0) + h.grams;
const qKey = h.quality || 'ungraded';
groups[key].byQuality[qKey] = (groups[key].byQuality[qKey] || 0) + 1;
}
// Overall quality distribution
const qualityDist = Object.create(null);
for (const h of harvests) {
const q = h.quality || 'ungraded';
qualityDist[q] = (qualityDist[q] || 0) + 1;
}
return json({
groupBy,
totalHarvests: harvests.length,
totalGrams: harvests.reduce((s, h) => s + h.grams, 0),
qualityDistribution: qualityDist,
groups
});
}
);
server.tool(
'get_scan_log',
'Get scan log entries (bag movements/placements/removals) with optional filters. READ-ONLY history. To log new bag movements use move_bags.',
{
batchId: z.string().optional().describe('Filter by batch ID'),
bagId: z.string().optional().describe('Filter by bag ID'),
action: z.enum(['ADD', 'MOVE', 'REMOVE']).optional().describe('Filter by action type'),
reason: z.string().optional().describe('Filter by reason (e.g. contam_tricho, dried_out)'),
limit: z.number().optional().describe('Max entries to return (default: 50, max: 500)'),
startDate: z.string().optional().describe('ISO date — entries after this date'),
endDate: z.string().optional().describe('ISO date — entries before this date')
},
async (params) => {
let log = db.getScanLog(database);
if (params.batchId) log = log.filter((e) => e.batch === params.batchId);
if (params.bagId) log = log.filter((e) => e.bag === params.bagId);
if (params.action) log = log.filter((e) => e.action === params.action);
if (params.reason) log = log.filter((e) => e.reason === params.reason);
if (params.startDate) log = log.filter((e) => e.time && e.time.slice(0, 10) >= params.startDate);
if (params.endDate) log = log.filter((e) => e.time && e.time.slice(0, 10) <= params.endDate);
const limit = Math.min(params.limit || 50, 500);
log = log.slice(-limit);
return json(log);
}
);
// ──────────────────────────────────────────────────────────
// WRITE TOOLS
// ──────────────────────────────────────────────────────────
server.tool(
'create_batch',
'Create a new production batch with auto-generated bags. Prefer strainId (use list_mushroom_strains); when omitted, species is required. Does NOT auto-deduct inventory — use update_inventory separately for substrate usage. Does NOT place bags in zones — use move_bags to ADD bags after creation.',
{
batchId: z
.string()
.regex(/^[A-Za-z0-9_\-@.:]{1,100}$/, 'batchId: letters/digits/_-@.: only, max 100')
.describe('Batch ID (e.g. FB-2025-042)'),
strainId: z
.number()
.optional()
.describe('Pilzsorte id. When set, species/strain are auto-filled from mushroom_strains.'),
species: z.string().max(200).optional().describe('Mushroom species (required when strainId is omitted)'),
qty: z.number().int().min(1).max(10000).describe('Number of bags (1–10000)'),
days: z.number().int().min(1).max(3650).describe('Incubation days (1–3650)'),
strain: z.string().max(200).optional().describe('Strain kuerzel (free-text fallback when strainId is omitted)'),
subHardwood: z.number().optional().describe('Substrate hardwood %'),
subWheatbran: z.number().optional().describe('Substrate wheat bran %'),
subRh: z.number().optional().describe('Substrate relative humidity %'),
subGypsum: z.boolean().optional().describe('Include gypsum?'),
bagKg: z.number().optional().describe('Bag weight in kg (default: 3)'),
batchType: z.enum(['block', 'grain', 'liquid']).optional().describe('Batch type (default: block)'),
grainRh: z
.number()
.optional()
.describe(
'Grain water content % (for batchType=grain only). Wet bag weight × (1 - grainRh/100) = dry grain deducted. Typical wheat = 52.'
),
sourceId: z.string().optional().describe('Source culture ID'),
recipeId: z
.number()
.optional()
.describe('Recipe ID — auto-fills substrate fields (hardwood%, wheatbran%, gypsum%, rh%) from recipe'),
notes: z.string().optional().describe('Notes')
},
async (params) => {
try {
if (!params.strainId && !params.species) {
return errResult('Either strainId or species is required');
}
// Apply recipe if provided
let subHardwood = params.subHardwood || 0;
let subWheatbran = params.subWheatbran || 0;
let subRh = params.subRh || 0;
let subGypsum = params.subGypsum || false;
if (params.recipeId) {
const recipe = db.getRecipeById(database, params.recipeId);
if (!recipe) return errResult('Recipe not found: ' + params.recipeId);
subHardwood = params.subHardwood != null ? params.subHardwood : recipe.hardwoodPct;
subWheatbran = params.subWheatbran != null ? params.subWheatbran : recipe.wheatbranPct;
subRh = params.subRh != null ? params.subRh : recipe.rhPct;
subGypsum = params.subGypsum != null ? params.subGypsum : recipe.gypsumPct > 0;
}
const created = new Date().toISOString();
const due = new Date(Date.now() + params.days * 86400000).toISOString();
const bags = [];
for (let i = 1; i <= params.qty; i++) {
bags.push(params.batchId + '-' + String(i).padStart(2, '0'));
}
db.insertBatch(
database,
{
batchId: params.batchId,
strainId: params.strainId || null,
species: params.species,
strain: params.strain || null,
qty: params.qty,
days: params.days,
substrate: {
hardwood: subHardwood,
wheatbran: subWheatbran,
rh: subRh,
gypsum: subGypsum
},
bagKg: params.bagKg || 3,
batchType: params.batchType || 'block',
grainRh: params.grainRh || 0,
sourceId: params.sourceId || null,
notes: params.notes || '',
created,
due,
bags
},
// I-22: no inventory deltas from create_batch (the tool description
// already documents that callers must use update_inventory separately),
// but pass userId for consistency in case that contract changes.
null,
auth.userId || null
);
notify();
const persisted = db.readBatchById(database, params.batchId);
return json({
success: true,
batchId: params.batchId,
created,
due,
bagCount: params.qty,
bags,
species: persisted ? persisted.species : params.species,
strain: persisted ? persisted.strain : params.strain || null,
strainId: persisted ? persisted.strainId : params.strainId || null,
strainName: persisted ? persisted.strainName : null,
strainKuerzel: persisted ? persisted.strainKuerzel : null
});
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'update_batch',
'Update batch METADATA ONLY: notes, species, strain, strainId, days, due date. NOT for contamination (→ move_bags with MOVE to contam zone). NOT for harvests/weights (→ log_harvest). NOT for inventory changes (→ update_inventory). NOT for adding bags (→ add_bags_to_batch). Pass strainId to switch Pilzsorte — species/strain auto-update.',
{
batchId: z.string().describe('Batch ID'),
strainId: z.number().optional().describe('Pilzsorte id (auto-fills species/strain from mushroom_strains)'),
notes: z.string().optional(),
species: z.string().optional(),
strain: z.string().optional(),
days: z.number().optional(),
due: z.string().optional().describe('ISO date')
},
async (params) => {
try {
const { batchId, ...fields } = params;
const updates = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) updates[k] = v;
}
if (!Object.keys(updates).length) return errResult('No fields to update');
db.updateBatchField(database, batchId, updates);
notify();
const persisted = db.readBatchById(database, batchId);
return json({
success: true,
batchId,
updated: Object.keys(updates),
species: persisted ? persisted.species : null,
strain: persisted ? persisted.strain : null,
strainId: persisted ? persisted.strainId : null,
strainName: persisted ? persisted.strainName : null,
strainKuerzel: persisted ? persisted.strainKuerzel : null
});
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'create_task',
'Create a team task (todo/work item). ONLY for real team work: cleaning, ordering, maintenance, reviews. NOT for documenting contamination (→ move_bags). NOT for recording harvests (→ log_harvest). NOT for inventory changes (→ update_inventory). NOT for logging bag movements (→ move_bags).',
{
text: z.string().describe('Task description'),
priority: z.enum(['low', 'med', 'high']).optional().describe('Priority (default: med)'),
assignee: z.string().optional().describe('Assignee name'),
dueDate: z.string().optional().describe('ISO date for due date'),
description: z.string().optional().describe('Additional details')
},
async (params) => {
try {
const id = db.insertTask(database, {
text: params.text,
priority: params.priority || 'med',
done: false,
created: new Date().toISOString(),
assignee: params.assignee || null,
dueDate: params.dueDate || null,
description: params.description || null
});
notify();
return json({ success: true, id: Number(id), text: params.text, assignee: params.assignee || null });
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'update_task',
'Update an existing team task — change text, priority, assignee, due date, description, or mark as done/undone. ONLY for modifying existing tasks. NOT for physical actions like contamination or harvests.',
{
id: z.number().describe('Task ID'),
text: z.string().optional(),
priority: z.enum(['low', 'med', 'high']).optional(),
done: z.boolean().optional().describe('Mark as done (true) or reopen (false)'),
assignee: z.string().optional(),
dueDate: z.string().optional().describe('ISO date'),
description: z.string().optional()
},
async (params) => {
try {
const { id, ...fields } = params;
const updates = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) updates[k] = v;
}
if (!Object.keys(updates).length) return errResult('No fields to update');
db.updateTaskById(database, id, updates);
notify();
return json({ success: true, id, updated: Object.keys(updates) });
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'create_calendar_event',
'Create a calendar event (e.g. inoculation day, harvest window, team meeting). To assign to specific users pass assigneeNames (case-insensitive usernames) or assigneeIds. If BOTH are omitted or empty the event is shown to EVERYONE — so when the user asks to assign to specific people you MUST provide one of these fields.',
{
title: z.string().describe('Event title'),
startDate: z.string().describe('ISO date (YYYY-MM-DD)'),
endDate: z.string().optional().describe('ISO date (YYYY-MM-DD)'),
allDay: z.boolean().optional().describe('All-day event (default: true)'),
startTime: z.string().optional().describe('Start time (HH:MM)'),
endTime: z.string().optional().describe('End time (HH:MM)'),
category: z.string().optional().describe('Event category (default: custom)'),
color: z.string().optional().describe('Hex color (e.g. #4CAF50)'),
description: z.string().optional(),
assigneeIds: z
.array(z.number())
.optional()
.describe('User IDs to assign. Prefer assigneeNames unless you already have the IDs.'),
assigneeNames: z
.array(z.string())
.optional()
.describe(
'Usernames to assign (case-insensitive). Unknown names are skipped and returned in unknownAssigneeNames — check the response.'
),
recurrence: z.enum(['daily', 'weekly', 'monthly']).optional().describe('Recurrence pattern'),
recurrenceUntil: z.string().optional().describe('ISO date — last allowed recurrence (inclusive)')
},
async (params) => {
// A pickup window is what harvest-feed.js publishes to the shop as a
// bookable collection slot — day, clock, address, places. The HTTP route
// and the CalDAV create branch both ask for admin before writing one, and
// an MCP token is something any worker can mint through the OAuth flow,
// so this is the third door to the same room. Only that one category:
// meetings and inoculation days stay everyone's, as they are here and on
// the other two doors. delete_calendar_event is already requireAdminRole.
if (params.category === 'pickup') {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
}
try {
const id = 'ev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
const unknownAssigneeNames = [];
const resolvedFromNames = [];
if (Array.isArray(params.assigneeNames)) {
for (const name of params.assigneeNames) {
if (!name || typeof name !== 'string') continue;
const u = db.getUserByUsernameCaseInsensitive(database, name.trim());
if (u && typeof u.id === 'number') resolvedFromNames.push(u.id);
else unknownAssigneeNames.push(name);
}
}
const seen = new Set();
const assigneeIds = [];
for (const uid of [...(params.assigneeIds || []), ...resolvedFromNames]) {
if (typeof uid === 'number' && !seen.has(uid)) {
seen.add(uid);
assigneeIds.push(uid);
}
}
db.insertCalendarEvent(
database,
{
id,
title: params.title,
description: params.description || null,
startDate: params.startDate,
endDate: params.endDate || null,
allDay: params.allDay !== false,
startTime: params.startTime || null,
endTime: params.endTime || null,
category: params.category || 'custom',
color: params.color || null,
recurrence: params.recurrence || null,
recurrenceUntil: params.recurrenceUntil || null
},
assigneeIds
);
notify();
const result = { success: true, id, title: params.title, startDate: params.startDate, assigneeIds };
if (unknownAssigneeNames.length) result.unknownAssigneeNames = unknownAssigneeNames;
return json(result);
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'delete_calendar_event',
'Delete a calendar event. For recurring events, pass occurrence=YYYY-MM-DD to skip only a single date (adds an exception); omit occurrence to delete the entire series.',
{
id: z.string().describe('Calendar event ID'),
occurrence: z
.string()
.optional()
.describe('ISO date (YYYY-MM-DD) — delete only this occurrence of a recurring event')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
const ev = db.getCalendarEventById(database, params.id);
if (!ev) return errResult('event not found: ' + params.id);
if (params.occurrence && ev.recurrence) {
db.addCalendarEventException(database, params.id, params.occurrence);
notify();
return json({ success: true, id: params.id, mode: 'occurrence', occurrence: params.occurrence });
}
db.deleteCalendarEvent(database, params.id);
notify();
return json({ success: true, id: params.id, mode: 'series' });
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'log_harvest',
'Record a mushroom harvest with weight in grams. ALWAYS use this tool when grams, weight, or harvest amount is mentioned — NEVER record harvests as notes in update_batch. Species/strain auto-fill from the batch when omitted; pass strainId to resolve from mushroom_strains.',
{
batch: z.string().describe('Batch ID'),
grams: z.number().describe('Harvest weight in grams (>= 0)'),
bag: z.string().optional().describe('Specific bag ID'),
flush: z.number().optional().describe('Flush number (default: 1)'),
quality: z.enum(['A', 'B', 'C']).optional().describe('Quality grade: A (premium), B (standard), C (low)'),
notes: z.string().optional().describe('Harvest notes'),
strainId: z
.number()
.optional()
.describe('Pilzsorte id — when set, species/strain are resolved from mushroom_strains'),
species: z.string().optional().describe('Auto-filled from batch if omitted'),
strain: z.string().optional().describe('Auto-filled from batch if omitted')
},
async (params) => {
try {
let species = params.species;
let strain = params.strain;
if (params.strainId && (!species || !strain)) {
const strains = db.listMushroomStrains(database);
const ms = strains.find((s) => s.id === params.strainId);
if (!ms) return errResult('Pilzsorte not found: ' + params.strainId);
species = species || ms.name;
strain = strain || ms.kuerzel;
}
if (!species || !strain) {
const b = db.readBatchById(database, params.batch);
if (b) {
species = species || b.species;
strain = strain || b.strain;
}
}
const id = db.insertHarvest(database, {
time: new Date().toISOString(),
batch: params.batch,
bag: params.bag || null,
species: species || null,
strain: strain || null,
grams: params.grams,
flush: params.flush || 1,
quality: params.quality || null,
notes: params.notes || null
});
notify();
return json({
success: true,
id: Number(id),
batch: params.batch,
grams: params.grams,
flush: params.flush || 1,
quality: params.quality || null,
species,
strain
});
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'move_bags',
'Log physical bag movements between zones/racks. Use for ALL physical bag state changes: ADD (place bag in zone), MOVE (relocate — including to contamination zone for contaminated bags), REMOVE (permanently remove bag from zone tracking; scan history preserved). DESTRUCTIVE: REMOVE requires confirm: true. For contamination ALWAYS use MOVE to a contam zone first. Only use REMOVE when user explicitly says entsorgen/wegwerfen/löschen. NOT for recording harvests (→ log_harvest). NOT for batch metadata (→ update_batch).',
{
entries: z
.array(
z.object({
batch: z.string().describe('Batch ID'),
bag: z.string().describe('Bag ID'),
action: z.enum(['ADD', 'MOVE', 'REMOVE']).describe('Movement type'),
from: z.string().optional().describe('Source zone/rack (required for MOVE/REMOVE)'),
to: z.string().optional().describe('Destination zone/rack (required for ADD/MOVE)'),
reason: z
.string()
.optional()
.describe(
'Reason for MOVE/REMOVE (e.g. contam_tricho, contam_cobweb, contam_bacteria, contam_other, dried_out, damaged, other)'
)
})
)
.describe('Array of bag movements'),
confirm: z
.boolean()
.optional()
.describe('Required true for REMOVE actions. Safety confirmation for destructive operations.')
},
async ({ entries, confirm }) => {
try {
const hasRemove = entries.some((e) => e.action === 'REMOVE');
if (hasRemove && confirm !== true) {
return errResult(
'REMOVE entfernt Bags dauerhaft aus dem Zonen-Tracking (Scan-Historie bleibt erhalten). Bitte mit confirm: true bestätigen. Bei Kontamination besser MOVE in eine Kontam-Zone verwenden.'
);
}
const now = new Date().toISOString();
const enriched = entries.map((e) => {
const b = db.readBatchById(database, e.batch);
return {
time: now,
action: e.action,
batch: e.batch,
bag: e.bag,
from: e.from || null,
to: e.to || null,
species: b ? b.species : null,
strain: b ? b.strain : null,
reason: e.reason || null
};
});
// The acting user, the way create_batch and the other writes already
// pass it — move_bags was the one that did not. A hardcoded null puts a
// MOVE or a contamination REMOVE into scan_log with no user, so it reads
// back nameless out of the forensic query, while the same action through
// POST /api/scan-log carries its session's user. The legacy static token
// genuinely belongs to nobody; `|| null` keeps that a null instead of
// inventing an identity for it.
const ids = db.appendScanEntries(database, enriched, auth.userId || null);
notify();
return json({ success: true, count: entries.length, ids: ids.map(Number) });
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'update_inventory',
'Log a substrate inventory change: delivery (positive deltaKg), usage (negative deltaKg), or correction. Materials: hardwood, wheatbran, gypsum, grain. ALWAYS use this for stock changes — NEVER record inventory as task (→ create_task) or batch note (→ update_batch).',
{
material: z.enum(['hardwood', 'wheatbran', 'gypsum', 'grain']).describe('Material type'),
deltaKg: z.number().describe('Change in kg (positive for delivery, negative for usage)'),
type: z.string().optional().describe('Transaction type (e.g. delivery, batch, correction)'),
ref: z.string().optional().describe('Reference note (e.g. supplier name, batch ID)')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
// I-22: forward auth.userId so the inventory_log records the actor.
const newStock = db.applyInventoryDelta(
database,
params.material,
params.deltaKg,
params.type || null,
params.ref || null,
auth.userId || null
);
notify();
return json({ success: true, material: params.material, deltaKg: params.deltaKg, newStockKg: newStock });
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'update_culture',
"Update a mushroom culture's metadata: status, notes, Pilzsorte (strainId), species, strain, or source. For status changes like 'contam' use this. NOT for physical bag handling (→ move_bags). Pass strainId to switch Pilzsorte — species/strain auto-update.",
{
id: z.string().describe('Culture ID'),
status: z.string().optional().describe('New status (e.g. active, archived, contaminated)'),
notes: z.string().optional(),
strainId: z.number().optional().describe('Pilzsorte id (auto-fills species/strain from mushroom_strains)'),
species: z.string().optional(),
strain: z.string().optional(),
source: z.string().optional(),
ml: z.number().optional().describe('SY only: correct the syringe size, 10, 20 or 50 ml')
},
async (params) => {
try {
const { id, ...fields } = params;
const updates = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) updates[k] = v;
}
if (!Object.keys(updates).length) return errResult('No fields to update');
db.updateCulture(database, id, updates);
notify();
return json({ success: true, id, updated: Object.keys(updates) });
} catch (e) {
return safeErrResult(e);
}
}
);
// ──────────────────────────────────────────────────────────
// CULTURES: CREATE
// ──────────────────────────────────────────────────────────
server.tool(
'create_culture',
'Create a single mushroom culture (MC, PD, LC, or G2G). Prefer strainId (use list_mushroom_strains); when omitted, species is required. NOT for creating production batches (→ create_batch).',
{
id: z.string().describe('Culture ID (e.g. MC-KINGS-250301-01)'),
type: z.enum(['MC', 'PD', 'LC', 'G2G', 'SY', 'DU']).describe('Culture type'),
strainId: z
.number()
.optional()
.describe('Pilzsorte id. When set, species/strain are auto-filled from mushroom_strains.'),
species: z.string().optional().describe('Species (required when strainId is omitted)'),
strain: z.string().optional().describe('Strain kuerzel (free-text fallback)'),
parentId: z.string().optional().describe('Parent culture ID for lineage'),
source: z.string().optional().describe('Source note (e.g. clone, spore print, wild)'),
status: z
.enum(['active', 'stored', 'used', 'sold', 'contam'])
.optional()
.describe('Culture status (default: active)'),
notes: z.string().optional(),
created: z.string().optional().describe('ISO timestamp (default: now)'),
ml: z.number().optional().describe('SY only: syringe size in ml, 10, 20 or 50 (default 10)')
},
async (params) => {
try {
if (!params.strainId && !params.species) {
return errResult('Either strainId or species is required');
}
db.insertCultures(database, [
{
id: params.id,
type: params.type,
strainId: params.strainId || null,
species: params.species || null,
strain: params.strain || null,
parentId: params.parentId || null,
source: params.source || null,
status: params.status || 'active',
notes: params.notes || '',
created: params.created || new Date().toISOString(),
ml: params.ml != null ? params.ml : null
}
]);
notify();
const persisted = db.getAllCultures(database).find((c) => c.id === params.id);
return json({ success: true, id: params.id, culture: persisted });
} catch (e) {
return safeErrResult(e);
}
}
);
// ──────────────────────────────────────────────────────────
// MUSHROOM STRAINS (Pilzsorten) CRUD
// ──────────────────────────────────────────────────────────
server.tool(
'list_mushroom_strains',
'List all Pilzsorten (mushroom strains) with id, name, kuerzel, description. READ-ONLY. Use the returned id with create_batch, create_culture, update_batch, update_culture, or log_harvest to link to a strain.',
{},
async () => {
return json(db.listMushroomStrains(database));
}
);
server.tool(
'create_mushroom_strain',
'Create a new Pilzsorte (mushroom strain). Name and kuerzel are required and must be unique.',
{
name: z.string().describe('Full strain name, e.g. "Pleurotus ostreatus HK35"'),
kuerzel: z.string().describe('Short code, e.g. "HK35"'),
description: z.string().optional().describe('Free-text description')
},
async (params) => {
try {
const id = db.createMushroomStrain(database, {
name: params.name,
kuerzel: params.kuerzel,
description: params.description || ''
});
notify();
return json({ success: true, id: Number(id), name: params.name, kuerzel: params.kuerzel });
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'update_mushroom_strain',
'Update a Pilzsorte. Changes to name/kuerzel propagate to all batches and cultures that reference this strain.',
{
id: z.number().describe('Pilzsorte id'),
name: z.string().optional().describe('New name'),
kuerzel: z.string().optional().describe('New kuerzel'),
description: z.string().optional().describe('New description')
},
async (params) => {
try {
const { id, ...fields } = params;
const updates = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) updates[k] = v;
}
if (!Object.keys(updates).length) return errResult('No fields to update');
db.updateMushroomStrain(database, id, updates);
notify();
return json({ success: true, id, updated: Object.keys(updates) });
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'delete_mushroom_strain',
'Delete a Pilzsorte. DESTRUCTIVE — fails safely if still referenced by any batch or culture (delete protection). Cannot be undone.',
{
id: z.number().describe('Pilzsorte id')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
const removed = db.deleteMushroomStrain(database, params.id);
if (!removed) return errResult('Pilzsorte not found: ' + params.id);
notify();
return json({ success: true, id: params.id });
} catch (e) {
return safeErrResult(e);
}
}
);
// ──────────────────────────────────────────────────────────
// ZONE & RACK MANAGEMENT
// ──────────────────────────────────────────────────────────
// Example: manage_zones({ action: 'create', id: 'fruiting-2', name: 'Fruiting Room 2', role: 'fruiting' })
server.tool(
'manage_zones',
'Create, rename, delete, or reorder zones. Actions: create (new zone), rename (change name), delete (remove empty zone), reorder (set sort order). READ zones with get_zone_overview. NOT for racks (→ manage_racks).',
{
action: z.enum(['create', 'rename', 'delete', 'reorder']).describe('Action to perform'),
id: z.string().optional().describe('Zone ID (required for create/rename/delete)'),
name: z.string().optional().describe('Zone name (required for create/rename)'),
role: z
.string()
.optional()
.describe('Zone role: spawn, incubation, fruiting, contaminated, storage (for create)'),
color: z.string().optional().describe('Hex color e.g. #4CAF50 (for create)'),
maxCapacity: z.number().optional().describe('Max bag capacity (for create)'),
order: z.array(z.string()).optional().describe('Array of zone IDs in desired order (for reorder)')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
switch (params.action) {
case 'create': {
if (!params.id || !params.name) return errResult('id and name are required for create');
db.insertZone(database, {
id: params.id,
name: params.name,
role: params.role || null,
color: params.color || null,
maxCapacity: params.maxCapacity || null,
created: new Date().toISOString()
});
notify();
return json({ success: true, action: 'create', id: params.id, name: params.name });
}
case 'rename': {
if (!params.id || !params.name) return errResult('id and name are required for rename');
db.renameZoneName(database, params.id, params.name);
notify();
return json({ success: true, action: 'rename', id: params.id, newName: params.name });
}
case 'delete': {
if (!params.id) return errResult('id is required for delete');
db.deleteZone(database, params.id);
notify();
return json({ success: true, action: 'delete', id: params.id });
}
case 'reorder': {
if (!params.order || !params.order.length) return errResult('order array is required for reorder');
db.reorderZones(database, params.order);
notify();
return json({ success: true, action: 'reorder', order: params.order });
}
default:
return errResult('Unknown action: ' + params.action);
}
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: manage_racks({ action: 'create', id: 'R-FRUIT2-01', zoneId: 'fruiting-2' })
server.tool(
'manage_racks',
'Create or delete racks within zones. Actions: create (new rack in zone), delete (remove empty rack). View racks via get_zone_overview. NOT for zones (→ manage_zones).',
{
action: z.enum(['create', 'delete']).describe('Action to perform'),
id: z.string().optional().describe('Rack ID (required for create/delete)'),
zoneId: z.string().optional().describe('Zone ID (required for create)')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
switch (params.action) {
case 'create': {
if (!params.id || !params.zoneId) return errResult('id and zoneId are required for create');
db.insertRack(database, {
id: params.id,
zoneId: params.zoneId,
created: new Date().toISOString()
});
notify();
return json({ success: true, action: 'create', id: params.id, zoneId: params.zoneId });
}
case 'delete': {
if (!params.id) return errResult('id is required for delete');
db.deleteRack(database, params.id);
notify();
return json({ success: true, action: 'delete', id: params.id });
}
default:
return errResult('Unknown action: ' + params.action);
}
} catch (e) {
return safeErrResult(e);
}
}
);
// ──────────────────────────────────────────────────────────
// BATCH EXTENSIONS
// ──────────────────────────────────────────────────────────
// Example: add_bags_to_batch({ batchId: 'FB-2025-042', count: 5 })
server.tool(
'add_bags_to_batch',
'Add more bags to an existing batch. Generates new bag IDs sequentially. Use this instead of update_batch when you need more bags — it keeps inventory log consistent.',
{
batchId: z
.string()
.regex(/^[A-Za-z0-9_\-@.:]{1,100}$/, 'batchId: letters/digits/_-@.: only, max 100')
.describe('Batch ID'),
count: z.number().int().min(1).max(10000).describe('Number of bags to add (1–10000)')
},
async (params) => {
try {
const batch = db.readBatchById(database, params.batchId);
if (!batch) return errResult('Batch not found: ' + params.batchId);
const existingCount = batch.bags.length;
const newBags = [];
for (let i = 1; i <= params.count; i++) {
newBags.push(params.batchId + '-' + String(existingCount + i).padStart(2, '0'));
}
// I-22 + I-23: forward auth.userId; addBagsToBatch now also deducts inventory.
const result = db.addBagsToBatch(
database,
params.batchId,
newBags,
existingCount + params.count,
undefined,
auth.userId || null
);
notify();
return json({
success: true,
batchId: params.batchId,
newBags,
totalBags: existingCount + params.count,
bagBarcodes: result.bagBarcodes
});
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: delete_batch({ batchId: 'FB-2025-042', confirm: true })
server.tool(
'delete_batch',
'Delete a batch with ALL its bags, scan entries, and harvests. DESTRUCTIVE — cannot be undone. Reverses inventory deltas. Requires confirm: true.',
{
batchId: z.string().describe('Batch ID to delete'),
confirm: z.boolean().optional().describe('Must be true to confirm deletion')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
if (params.confirm !== true) {
return errResult(
'delete_batch löscht den Batch mit ALLEN Bags, Scan-Einträgen und Ernten unwiderruflich. Bitte mit confirm: true bestätigen.'
);
}
const batch = db.readBatchById(database, params.batchId);
if (!batch) return errResult('Batch not found: ' + params.batchId);
// I-22: forward acting user so the credit-back inventory rows record the actor.
db.deleteBatchById(database, params.batchId, auth.userId || null);
notify();
return json({ success: true, batchId: params.batchId, deletedBags: batch.bags.length });
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: rename_batch({ oldId: 'FB-2025-042', newId: 'FB-2025-043' })
server.tool(
'rename_batch',
'Rename a batch ID. Updates all references in bags, scan_log, harvests, and inventory_log. NOT for metadata changes (→ update_batch).',
{
oldId: z.string().describe('Current batch ID'),
newId: z
.string()
.regex(/^[A-Za-z0-9_\-@.:]{1,100}$/, 'newId: letters/digits/_-@.: only, max 100')
.describe('New batch ID')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
db.renameBatch(database, params.oldId, params.newId, { user_id: auth.userId, username: auth.username });
notify();
return json({ success: true, oldId: params.oldId, newId: params.newId });
} catch (e) {
return safeErrResult(e);
}
}
);
// ──────────────────────────────────────────────────────────
// CULTURE EXTENSIONS
// ──────────────────────────────────────────────────────────
// Example: delete_culture({ id: 'MC-KINGS-250301-01', confirm: true })
server.tool(
'delete_culture',
'Delete a mushroom culture. DESTRUCTIVE — cannot be undone. Requires confirm: true. Consider setting status to "contam" or "used" via update_culture instead.',
{
id: z.string().describe('Culture ID to delete'),
confirm: z.boolean().optional().describe('Must be true to confirm deletion')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
if (params.confirm !== true) {
return errResult(
'delete_culture löscht die Kultur unwiderruflich. Bitte mit confirm: true bestätigen. Alternativ: update_culture mit status "contam" oder "used".'
);
}
const removed = db.deleteCulture(database, params.id, { user_id: auth.userId, username: auth.username });
if (!removed) return errResult('Culture not found: ' + params.id);
notify();
return json({ success: true, id: params.id });
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: get_culture_details({ id: 'MC-KINGS-250301-01' })
server.tool(
'get_culture_details',
'Get full details for a single culture including strain info, lineage (parent + children), and batches created from it. READ-ONLY. To modify use update_culture.',
{
id: z.string().describe('Culture ID')
},
async (params) => {
const culture = db.getCultureById(database, params.id);
if (!culture) return errResult('Culture not found: ' + params.id);
return json(culture);
}
);
// ──────────────────────────────────────────────────────────
// BARCODES
// ──────────────────────────────────────────────────────────
// Example: lookup_barcode({ barcode: 1000042 })
server.tool(
'lookup_barcode',
'Resolve a numeric barcode to its entity (bag, culture, zone, rack). Returns entity_type and entity_id. READ-ONLY.',
{
barcode: z.number().describe('Numeric barcode to look up')
},
async (params) => {
const result = db.lookupBarcode(database, params.barcode);
if (!result) return errResult('Barcode not found: ' + params.barcode);
return json(result);
}
);
// Example: assign_barcode({ entityType: 'bag', entityId: 'FB-2025-042-01' })
server.tool(
'assign_barcode',
'Assign a numeric barcode to an entity (bag, culture, zone, rack). Returns existing barcode if already assigned (idempotent).',
{
entityType: z.enum(['bag', 'culture', 'zone', 'rack']).describe('Entity type'),
entityId: z.string().describe('Entity ID')
},
async (params) => {
try {
const barcode = db.assignBarcode(database, params.entityType, params.entityId);
notify();
return json({ success: true, barcode, entityType: params.entityType, entityId: params.entityId });
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: list_barcodes()
server.tool('list_barcodes', 'List all assigned barcodes with their entity type and ID. READ-ONLY.', {}, async () => {
return json(db.getAllBarcodes(database));
});
// ──────────────────────────────────────────────────────────
// SUPPLIERS
// ──────────────────────────────────────────────────────────
// Example: manage_suppliers({ action: 'list' })
// Example: manage_suppliers({ action: 'create', mat: 'hardwood', name: 'Beispiel GmbH', url: 'https://example.de' })
server.tool(
'manage_suppliers',
'Create, update, delete, or list substrate suppliers. Actions: list, create/update (upsert), delete. Suppliers are linked to materials: hardwood, wheatbran, gypsum, grain.',
{
action: z.enum(['list', 'create', 'update', 'delete']).describe('Action to perform'),
id: z.number().optional().describe('Supplier ID (required for update/delete)'),
mat: z
.enum(['hardwood', 'wheatbran', 'gypsum', 'grain'])
.optional()
.describe('Material type (required for create)'),
name: z.string().optional().describe('Supplier name (required for create)'),
url: z.string().optional().describe('Website URL'),
phone: z.string().optional().describe('Phone number'),
notes: z.string().optional().describe('Notes')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
switch (params.action) {
case 'list':
return json(db.listSuppliers(database));
case 'create':
case 'update': {
if (params.action === 'create' && (!params.mat || !params.name)) {
return errResult('mat and name are required for create');
}
const id = db.upsertSupplier(database, {
id: params.id || undefined,
mat: params.mat,
name: params.name,
url: params.url || null,
phone: params.phone || null,
notes: params.notes || null
});
notify();
return json({ success: true, action: params.action, id: Number(id) });
}
case 'delete': {
if (params.id == null) return errResult('id is required for delete');
db.deleteSupplier(database, params.id);
notify();
return json({ success: true, action: 'delete', id: params.id });
}
default:
return errResult('Unknown action: ' + params.action);
}
} catch (e) {
return safeErrResult(e);
}
}
);
// ──────────────────────────────────────────────────────────
// KPI
// ──────────────────────────────────────────────────────────
// Example: get_kpi_history({ limit: 30 })
server.tool(
'get_kpi_history',
'Get KPI snapshots: yield/bag, contamination rate, bags created, stock levels, pipeline counts over time. READ-ONLY historical data.',
{
limit: z.number().optional().describe('Max snapshots to return (default: all, most recent first)')
},
async (params) => {
return json(db.getKpiSnapshots(database, params.limit || undefined));
}
);
// ──────────────────────────────────────────────────────────
// USERS
// ──────────────────────────────────────────────────────────
// Example: list_users()
server.tool(
'list_users',
'List users. Everyone gets id and username, which is what assigneeIds need. Admins additionally get role, the can_ship / can_release flags and creation dates. READ-ONLY.',
{},
async () => {
// The web UI splits this on purpose: GET /api/usernames hands every
// logged-in user {id, username}, GET /api/users returns the whole row
// behind an admin check. This tool returned the whole row to everyone,
// and any worker can mint themselves an MCP token through /oauth —
// so role and the two capability flags were readable by exactly the
// people they exist to hold back. can_ship is the one that matters
// there: it reaches postage and the customer's name. Knowing who holds
// it is a target list.
//
// The split is mirrored rather than the tool refused: looking up an
// assignee is what it is for, and that works on the projection.
if (!isAdminCaller()) return json(db.listUsersPublic(database));
return json(db.listUsers(database));
}
);
// ──────────────────────────────────────────────────────────
// CONTAMINATION REPORT
// ──────────────────────────────────────────────────────────
// Example: get_contamination_report({ groupBy: 'species', startDate: '2025-01-01' })
server.tool(
'get_contamination_report',
'Get contamination statistics grouped by species, zone, or month. Shows contamination counts and reason breakdown. READ-ONLY. To log contamination use move_bags with reason.',
{
groupBy: z.enum(['species', 'zone', 'month']).optional().describe('Aggregation dimension (default: month)'),
startDate: z.string().optional().describe('ISO date — start of range'),
endDate: z.string().optional().describe('ISO date — end of range')
},
async (params) => {
return json(db.getContaminationReport(database, params.groupBy || 'month', params.startDate, params.endDate));
}
);
// ──────────────────────────────────────────────────────────
// RECIPES
// ──────────────────────────────────────────────────────────
// Example: manage_recipes({ action: 'create', name: 'Beispielrezept', hardwoodPct: 85, wheatbranPct: 15, rhPct: 60 })
server.tool(
'manage_recipes',
'Create, update, or delete reusable substrate recipes. Recipes store hardwood%, wheatbran%, gypsum%, rh% for quick batch creation. Use recipeId in create_batch to auto-fill substrate fields.',
{
action: z.enum(['create', 'update', 'delete']).describe('Action to perform'),
id: z.number().optional().describe('Recipe ID (required for update/delete)'),
name: z.string().optional().describe('Recipe name (required for create, unique)'),
hardwoodPct: z.number().optional().describe('Hardwood percentage'),
wheatbranPct: z.number().optional().describe('Wheat bran percentage'),
gypsumPct: z.number().optional().describe('Gypsum percentage'),
rhPct: z.number().optional().describe('Relative humidity percentage'),
notes: z.string().optional().describe('Notes')
},
async (params) => {
const adminErr = requireAdminRole();
if (adminErr) return adminErr;
try {
switch (params.action) {
case 'create': {
if (!params.name) return errResult('name is required for create');
const id = db.insertRecipe(database, {
name: params.name,
hardwood_pct: params.hardwoodPct || 0,
wheatbran_pct: params.wheatbranPct || 0,
gypsum_pct: params.gypsumPct || 0,
rh_pct: params.rhPct || 0,
notes: params.notes || null,
created: new Date().toISOString()
});
notify();
return json({ success: true, action: 'create', id: Number(id), name: params.name });
}
case 'update': {
if (params.id == null) return errResult('id is required for update');
db.updateRecipe(database, params.id, {
name: params.name,
hardwood_pct: params.hardwoodPct,
wheatbran_pct: params.wheatbranPct,
gypsum_pct: params.gypsumPct,
rh_pct: params.rhPct,
notes: params.notes
});
notify();
return json({ success: true, action: 'update', id: params.id });
}
case 'delete': {
if (params.id == null) return errResult('id is required for delete');
const removed = db.deleteRecipe(database, params.id);
if (!removed) return errResult('Recipe not found: ' + params.id);
notify();
return json({ success: true, action: 'delete', id: params.id });
}
default:
return errResult('Unknown action: ' + params.action);
}
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: list_recipes()
server.tool(
'list_recipes',
'List all substrate recipes with their compositions. READ-ONLY. Use manage_recipes to create/update/delete.',
{},
async () => {
return json(db.getAllRecipes(database));
}
);
// ──────────────────────────────────────────────────────────
// TRACEABILITY
// ──────────────────────────────────────────────────────────
// Example: trace_lineage({ entityType: 'batch', entityId: 'FB-2025-042' })
server.tool(
'trace_lineage',
'Trace the origin chain of a batch or culture backwards: batch → source culture → parent culture → ... Shows the full production genealogy. READ-ONLY.',
{
entityType: z.enum(['batch', 'culture']).describe('Type of entity to trace'),
entityId: z.string().describe('Batch ID or Culture ID')
},
async (params) => {
return json({ chain: db.traceLineageBack(database, params.entityType, params.entityId) });
}
);
// Example: trace_forward({ cultureId: 'MC-KINGS-250301-01' })
server.tool(
'trace_forward',
'Trace everything produced from a culture forward: child cultures, batches, and harvests. Shows the complete downstream output. READ-ONLY.',
{
cultureId: z.string().describe('Culture ID to trace from')
},
async (params) => {
return json(db.traceLineageForward(database, params.cultureId));
}
);
// ──────────────────────────────────────────────────────────
// PRODUCTION PIPELINE
// ──────────────────────────────────────────────────────────
// Example: get_production_pipeline()
server.tool(
'get_production_pipeline',
'Get a full production pipeline overview: active cultures by type/status, batches by type and phase (incubating vs ready), bags per zone with capacity utilization. READ-ONLY snapshot of the entire operation.',
{},
async () => {
return json(db.getProductionPipeline(database));
}
);
// ──────────────────────────────────────────────────────────
// MAINTENANCE
// ──────────────────────────────────────────────────────────
// Example: schedule_maintenance({ type: 'autoclave_cycle', assetId: 'AK-001', scheduledDate: '2025-04-15' })
server.tool(
'schedule_maintenance',
'Schedule a maintenance task for a piece of equipment or a zone (e.g. autoclave cycle, HEPA filter change, laminar flow cleaning). Creates an open maintenance entry with a due date.',
{
type: z.string().describe('Maintenance type (e.g. autoclave_cycle, hepa_filter, cleaning, calibration)'),
assetId: z
.string()
.optional()
.describe('Free-text equipment identifier, e.g. "AK-001" (for equipment maintenance)'),
zoneId: z.string().optional().describe('Zone ID (for room/zone maintenance)'),
description: z.string().optional().describe('Detailed description'),
scheduledDate: z.string().optional().describe('ISO date when maintenance is due'),
notes: z.string().optional().describe('Notes')
},
async (params) => {
try {
if (!params.assetId && !params.zoneId) {
return errResult('Either assetId or zoneId is required');
}
const id = db.insertMaintenance(database, {
assetId: params.assetId || null,
zoneId: params.zoneId || null,
type: params.type,
description: params.description || null,
scheduledDate: params.scheduledDate || null,
notes: params.notes || null
});
notify();
return json({ success: true, id: Number(id), type: params.type });
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: complete_maintenance({ id: 1, completedBy: 'Alice' })
server.tool(
'complete_maintenance',
'Mark a scheduled maintenance task as completed. Records who completed it and when.',
{
id: z.number().describe('Maintenance log entry ID'),
completedBy: z.string().optional().describe('Name of person who completed the maintenance'),
notes: z.string().optional().describe('Completion notes')
},
async (params) => {
try {
db.completeMaintenance(database, params.id, params.completedBy || null, params.notes || null);
notify();
return json({ success: true, id: params.id });
} catch (e) {
return safeErrResult(e);
}
}
);
// Example: get_maintenance_due()
server.tool(
'get_maintenance_due',
'Get all due/overdue maintenance tasks (not yet completed). READ-ONLY. To schedule new maintenance use schedule_maintenance, to complete use complete_maintenance.',
{
assetId: z.string().optional().describe('Filter by equipment identifier'),
zoneId: z.string().optional().describe('Filter by zone ID'),
limit: z.number().optional().describe('Max entries (for history)')
},
async (params) => {
if (params.assetId || params.zoneId || params.limit) {
return json(
db.getMaintenanceHistory(database, params.assetId || null, params.zoneId || null, params.limit || 50)
);
}
return json(db.getMaintenanceDue(database));
}
);
// ──────────────────────────────────────────────────────────
// LABEL PRINTING
// ──────────────────────────────────────────────────────────
server.tool(
'get_printer_status',
'Check whether the Zebra label printer is connected and available',
{},
async () => {
if (!printer || !printer.checkPrinterAvailable) {
return errResult('Printer functions not available (server not configured for printing)');
}
return new Promise((resolve) => {
printer.checkPrinterAvailable((err, found) => {
resolve(json({ available: !!found, printerName: 'ZDesigner GK420d' }));
});
});
}
);
server.tool(
'print_bag_labels',
'Generate and print bag labels for a batch. Set preview=true to return ZPL without printing.',
{
batchId: z.string().describe('Batch ID (e.g. FB-2025-042)'),
detail: z
.enum(['minimal', 'sorte', 'full'])
.optional()
.describe('Label detail level (default: sorte). minimal=barcode+ID, sorte=+strain/notes, full=+due date'),
qr: z.boolean().optional().describe('Use QR code instead of barcode (default: false)'),
bagFrom: z.number().optional().describe('Start of bag range (1-based, inclusive)'),
bagTo: z.number().optional().describe('End of bag range (1-based, inclusive)'),
preview: z.boolean().optional().describe('If true, return ZPL string without sending to printer (default: false)')
},
async (params) => {
try {
const batch = db.readBatchById(database, params.batchId);
if (!batch) return errResult('Batch not found: ' + params.batchId);
let bags = batch.bags;
if (params.bagFrom || params.bagTo) {
const from = (params.bagFrom || 1) - 1;
const to = params.bagTo || bags.length;
bags = bags.slice(from, to);
}
if (!bags.length) return errResult('No bags in selected range');
const barcodes = db.assignBarcodes(database, 'bag', bags);
const detail = params.detail || 'sorte';
const useQr = params.qr || false;
// Jeder Beutel traegt sein eigenes Gewicht -- siehe bagLabelKg in app.js.
const zpl = bags
.map((bagId) =>
itemsToZPL(bagLabelItems(bagId, batch, detail, barcodes[bagId], useQr, bagLabelKg(batch, bagId)))
)
.join('\n');
if (params.preview) {
return json({ success: true, batchId: params.batchId, labelCount: bags.length, zpl });
}
if (!printer || !printer.printZPL) {
return errResult('Printer functions not available (server not configured for printing)');
}
return new Promise((resolve) => {
printer.checkPrinterAvailable((_, found) => {
if (!found) {
resolve(errResult('Printer not found. Check that the Zebra printer is connected and powered on.'));
return;
}
printer.printZPL(zpl, (err) => {
if (err) {
resolve(errResult('Print failed: ' + err));
} else {
resolve(json({ success: true, batchId: params.batchId, labelCount: bags.length, printed: true }));
}
});
});
});
} catch (e) {
return safeErrResult(e);
}
}
);
server.tool(
'print_culture_labels',
'Generate and print labels for mushroom cultures. Set preview=true to return ZPL without printing.',
{
cultureIds: z.array(z.string()).describe('Array of culture IDs to print (e.g. ["MC-KINGS-250301-01"])'),
detail: z
.enum(['minimal', 'sorte', 'full'])
.optional()
.describe('Label detail level (default: sorte). minimal=barcode+ID, sorte=+species/strain, full=+date'),
qr: z.boolean().optional().describe('Use QR code instead of barcode (default: false)'),
preview: z.boolean().optional().describe('If true, return ZPL string without sending to printer (default: false)')
},
async (params) => {
try {
if (!params.cultureIds.length) return errResult('No culture IDs provided');
const allCultures = db.getAllCultures(database);
const cultureMap = new Map(allCultures.map((c) => [c.id, c]));
const missing = params.cultureIds.filter((id) => !cultureMap.has(id));
if (missing.length) return errResult('Cultures not found: ' + missing.join(', '));
const barcodes = db.assignBarcodes(database, 'culture', params.cultureIds);
const detail = params.detail || 'sorte';
const useQr = params.qr || false;
const zpl = params.cultureIds
.map((id) => {
const c = cultureMap.get(id);
return itemsToZPL(labLabelItems(id, c, detail, barcodes[id], useQr));
})
.join('\n');
if (params.preview) {
return json({ success: true, labelCount: params.cultureIds.length, zpl });
}
if (!printer || !printer.printZPL) {
return errResult('Printer functions not available (server not configured for printing)');
}
return new Promise((resolve) => {
printer.checkPrinterAvailable((_, found) => {
if (!found) {
resolve(errResult('Printer not found. Check that the Zebra printer is connected and powered on.'));
return;
}
printer.printZPL(zpl, (err) => {
if (err) {
resolve(errResult('Print failed: ' + err));
} else {
resolve(json({ success: true, labelCount: params.cultureIds.length, printed: true }));
}
});
});
});
} catch (e) {
return safeErrResult(e);
}
}
);
return server;
}
module.exports = {
createMcpServer,
buildBagLocationMap,
bcParams,
zplText,
itemsToZPL,
bagLabelItems,
labLabelItems,
labelTextW,
labelWrap,
labelGlyphs,
fmtDt
};