'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 };