#!/usr/bin/env node /** * Formulation MCP Server — standalone stdio entry point. * * This script opens the SQLite database directly (bypassing Astro runtime) * and starts the Model Context Protocol server on stdin/stdout. * * Usage: * node scripts/mcp-server.mjs * npm run mcp */ import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { createSiteProjection } from "./lib/site-projection.mjs"; // --------------------------------------------------------------------------- // Database helpers (standalone, no Astro dependency) // --------------------------------------------------------------------------- const root = path.resolve(import.meta.dirname, ".."); const databasePath = path.join(root, "var", "recipe-book.sqlite"); function openDb(readOnly = true) { if (!fs.existsSync(databasePath)) throw new Error(`Database not found at ${databasePath}`); const db = new DatabaseSync(databasePath, { readOnly }); db.exec("PRAGMA foreign_keys = ON"); return db; } function loadCatalogs() { const db = openDb(true); try { const projection = createSiteProjection(db); const map = (values) => new Map(values.map((v) => [v.id, v])); return { ingredients: map(projection.ingredients), recipes: map(projection.recipes), units: map(projection.units), equipment: map(projection.equipment), prepActions: map(projection.prepActions), purchaseItems: map(projection.purchaseItems), sourceMappings: map(projection.sourceMappings), }; } finally { db.close(); } } // --------------------------------------------------------------------------- // Dynamic imports for domain modules (TypeScript, processed by Node's ESM) // --------------------------------------------------------------------------- const { calculateCost } = await import("../src/lib/costing.ts"); const { calculateNutrition } = await import("../src/lib/nutrition.ts"); const { convert, convertWithIngredientMeasures } = await import("../src/lib/measurement.ts"); const { exportDatabase } = await import("../src/lib/backup/export-database.ts"); const { titleCase } = await import("../src/lib/format.ts"); const { getInventoryCountDetail, getInventoryCounts } = await import("../src/lib/repository/inventory-repository.ts"); const { recipeQualityRows, restoreArchivedItems } = await import("../src/lib/database.ts"); // --------------------------------------------------------------------------- // Tool implementations // --------------------------------------------------------------------------- function searchRecipes(args) { const catalogs = loadCatalogs(); const q = (args.query ?? "").trim().toLowerCase(); const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); let results = [...catalogs.recipes.values()]; if (q) results = results.filter((r) => r.title.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || (r.summary ?? "").toLowerCase().includes(q)); if (args.category) results = results.filter((r) => r.categories.includes(args.category)); if (args.tag) results = results.filter((r) => (r.tags ?? []).includes(args.tag)); return results.slice(0, limit).map((r) => ({ id: r.id, title: r.title, summary: r.summary ?? null, categories: r.categories, tags: r.tags, yield: { quantity: r.yield.amount.quantity, unit_id: r.yield.amount.unit_id, servings: r.yield.servings ?? null }, component_count: r.components.length, item_count: r.components.reduce((n, c) => n + c.items.length, 0), step_count: r.steps.length, })); } function getRecipe(args) { const catalogs = loadCatalogs(); const recipe = catalogs.recipes.get(args.id); if (!recipe) throw new Error(`Recipe not found: ${args.id}`); let sf = 1; if (args.scale_factor > 0) sf = args.scale_factor; else if (args.target_yield > 0 && recipe.yield.amount.quantity > 0) { if (args.target_yield_unit && args.target_yield_unit !== recipe.yield.amount.unit_id) { try { sf = convert(args.target_yield, args.target_yield_unit, recipe.yield.amount.unit_id, catalogs.units) / recipe.yield.amount.quantity; } catch { sf = args.target_yield / recipe.yield.amount.quantity; } } else sf = args.target_yield / recipe.yield.amount.quantity; } return { id: recipe.id, title: recipe.title, summary: recipe.summary ?? null, categories: recipe.categories, tags: recipe.tags, station: recipe.station ?? null, yield: { quantity: recipe.yield.amount.quantity * sf, base_quantity: recipe.yield.amount.quantity, unit_id: recipe.yield.amount.unit_id, servings: recipe.yield.servings ? recipe.yield.servings * sf : null, basis: recipe.yield.basis ?? null }, scale_factor: sf, scaling: recipe.scaling ?? null, components: recipe.components.map((c) => ({ id: c.id, name: c.name, notes: c.notes ?? [], items: c.items.map((item) => { const ref = item.reference; const isSub = "recipe_id" in ref; const sid = isSub ? ref.recipe_id : ref.ingredient_id; const subject = isSub ? catalogs.recipes.get(sid) : catalogs.ingredients.get(sid); return { id: item.id, ingredient_id: isSub ? undefined : sid, subrecipe_id: isSub ? sid : undefined, name: subject ? (isSub ? subject.title : titleCase(subject.name)) : sid, is_subrecipe: isSub, quantity: item.amount.quantity * sf, base_quantity: item.amount.quantity, unit_id: item.amount.unit_id, percentage: item.percentage ?? null, basis_member: item.basis_member ?? false, optional: item.optional ?? false, notes: item.notes ?? null, prep: item.prep ?? [], }; }), })), steps: recipe.steps.map((s, i) => ({ id: s.id, order: i + 1, instruction: s.instruction, critical_control_point: s.critical_control_point ?? false, equipment_ids: s.equipment_ids ?? [] })), equipment_ids: recipe.equipment_ids ?? [], notes: recipe.notes ?? [], shelf_life: recipe.shelf_life ?? null, }; } function calcCost(args) { const catalogs = loadCatalogs(); const recipe = catalogs.recipes.get(args.recipe_id); if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); return calculateCost(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, purchaseItems: catalogs.purchaseItems, prepActions: catalogs.prepActions }, args.currency ?? "USD"); } function calcNutrition(args) { const catalogs = loadCatalogs(); const recipe = catalogs.recipes.get(args.recipe_id); if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); return calculateNutrition(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, mappings: catalogs.sourceMappings }); } function searchIngredients(args) { const catalogs = loadCatalogs(); const q = (args.query ?? "").trim().toLowerCase(); const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); let results = [...catalogs.ingredients.values()]; if (q) results = results.filter((ing) => ing.name.toLowerCase().includes(q) || ing.id.toLowerCase().includes(q) || (ing.aliases ?? []).some((a) => a.name.toLowerCase().includes(q))); if (args.category) results = results.filter((ing) => ing.categories.includes(args.category)); if (args.missing_cost) { const priced = new Set([...catalogs.purchaseItems.values()].filter((pi) => pi.status === "active").map((pi) => pi.ingredient_id)); results = results.filter((ing) => !priced.has(ing.id)); } return results.slice(0, limit).map((ing) => ({ id: ing.id, name: titleCase(ing.name), status: ing.status, categories: ing.categories, alias_count: (ing.aliases ?? []).length, has_nutrition: (ing.nutrition_mapping_ids ?? []).length > 0, has_cost: [...catalogs.purchaseItems.values()].some((pi) => pi.ingredient_id === ing.id && pi.status === "active"), })); } function getIngredientDetail(args) { const catalogs = loadCatalogs(); const ing = catalogs.ingredients.get(args.id); if (!ing) throw new Error(`Ingredient not found: ${args.id}`); const purchases = [...catalogs.purchaseItems.values()].filter((pi) => pi.ingredient_id === args.id); return { id: ing.id, name: titleCase(ing.name), raw_name: ing.name, status: ing.status, categories: ing.categories, tags: ing.tags ?? [], aliases: ing.aliases ?? [], density_measurements: (ing.density_measurements ?? []).map((d) => ({ id: d.id, mass: d.mass, volume: d.volume, state: d.state ?? null })), measure_conversions: (ing.measure_conversions ?? []).map((c) => ({ id: c.id, from: c.from, to: c.to, state: c.state ?? null })), prep_actions: ing.prep_actions ?? [], nutrition_mapping_ids: ing.nutrition_mapping_ids ?? [], purchase_items: purchases.map((p) => ({ id: p.id, name: p.name, brand: p.brand ?? null, status: p.status, package_quantity: p.package.quantity, package_unit_id: p.package.unit_id, latest_price: p.prices.length > 0 ? p.prices[p.prices.length - 1].amount : null, currency: p.prices.length > 0 ? p.prices[p.prices.length - 1].currency : null })), }; } function convertUnits(args) { const catalogs = loadCatalogs(); const units = catalogs.units; const fromUnit = units.get(args.from_unit); const toUnit = units.get(args.to_unit); if (!fromUnit) throw new Error(`Unknown unit: ${args.from_unit}`); if (!toUnit) throw new Error(`Unknown unit: ${args.to_unit}`); let result, method; if (fromUnit.dimension === toUnit.dimension) { result = convert(args.quantity, args.from_unit, args.to_unit, units); method = "dimension_conversion"; } else if (args.ingredient_id) { const ing = catalogs.ingredients.get(args.ingredient_id); if (!ing) throw new Error(`Unknown ingredient: ${args.ingredient_id}`); const converted = convertWithIngredientMeasures({ quantity: args.quantity, unit_id: args.from_unit }, args.to_unit, ing, units); result = converted.quantity; method = "ingredient_measure_conversion"; } else { throw new Error(`Cannot convert ${fromUnit.dimension} to ${toUnit.dimension} without an ingredient_id for density lookup.`); } return { from: { quantity: args.quantity, unit_id: args.from_unit }, to: { quantity: result, unit_id: args.to_unit }, ingredient_id: args.ingredient_id ?? null, method }; } function listUnits(args) { const catalogs = loadCatalogs(); let units = [...catalogs.units.values()]; if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension); if (args?.system) units = units.filter((u) => u.system === args.system); return units; } function listEquipment(args) { const catalogs = loadCatalogs(); let items = [...catalogs.equipment.values()]; if (args?.category) items = items.filter((e) => e.category === args.category); return items; } function listPrepActions() { const catalogs = loadCatalogs(); return [...catalogs.prepActions.values()]; } function listPurchaseItems(args) { const db = openDb(true); try { let query = ` SELECT p.id, p.ingredient_id, i.name as ingredient_name, p.name, p.brand, p.supplier_id, p.supplier_sku, p.status, p.package_quantity, p.package_unit_id, p.units_per_case, p.usable_yield_factor FROM purchase_items p JOIN ingredients i ON i.id = p.ingredient_id WHERE p.deleted_at IS NULL `; const params = []; if (args.ingredient_id) { query += " AND p.ingredient_id = ?"; params.push(args.ingredient_id); } if (args.status) { query += " AND p.status = ?"; params.push(args.status); } query += " ORDER BY p.name LIMIT ?"; params.push(Math.min(Math.max(args.limit ?? 50, 1), 200)); const rows = db.prepare(query).all(...params); const priceStmt = db.prepare("SELECT amount, currency, effective_at FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC LIMIT 1"); return rows.map((r) => { const p = priceStmt.get(r.id); return { ...r, ingredient_name: titleCase(r.ingredient_name), latest_price: p?.amount ?? null, currency: p?.currency ?? null }; }); } finally { db.close(); } } function getDatabaseStats() { const db = openDb(true); try { const c = (t) => db.prepare(`SELECT count(*) as c FROM ${t}`).get().c; return { recipes: c("recipes"), ingredients: c("ingredients"), units: c("units"), equipment: c("equipment"), purchase_items: c("purchase_items"), collections: c("collections"), inventory_counts: c("inventory_counts") }; } finally { db.close(); } } function listRecipeBooks() { const db = openDb(true); try { return db.prepare( `SELECT c.id, c.name, c.description, COUNT(cr.recipe_id) as recipe_count FROM collections c LEFT JOIN collection_recipes cr ON cr.collection_id = c.id WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.name` ).all(); } finally { db.close(); } } function getRecipeBook(args) { const db = openDb(true); try { const book = db.prepare("SELECT id, name, description FROM collections WHERE id = ? AND deleted_at IS NULL").get(args.id); if (!book) throw new Error(`Recipe book not found: ${args.id}`); const recipes = db.prepare( `SELECT r.id, r.title, r.summary, cr.position FROM collection_recipes cr JOIN recipes r ON r.id = cr.recipe_id WHERE cr.collection_id = ? AND r.deleted_at IS NULL ORDER BY cr.position, r.title` ).all(args.id); return { ...book, recipes }; } finally { db.close(); } } function auditQuality() { const db = openDb(true); try { const rows = recipeQualityRows(db); const clean = rows.filter((r) => r.placeholder_steps === 0 && r.unpriced_items === 0 && r.yield_basis !== null).length; return { summary: { total: rows.length, clean, needs_attention: rows.length - clean }, recipes: rows }; } finally { db.close(); } } // --------------------------------------------------------------------------- // MCP Server setup // --------------------------------------------------------------------------- const server = new McpServer({ name: "formulation", version: "2.0.0" }); function wrap(fn) { return async (args) => { try { const result = fn(args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (err) { return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true }; } }; } server.tool("search_recipes", "Search recipes by keyword, category, or tag.", { query: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchRecipes)); server.tool("get_recipe", "Get full recipe formulation with optional scaling.", { id: z.string(), scale_factor: z.number().positive().optional(), target_yield: z.number().positive().optional(), target_yield_unit: z.string().optional() }, wrap(getRecipe)); server.tool("audit_recipe_quality", "Run automated quality audit on all recipes.", {}, wrap(auditQuality)); server.tool("calculate_recipe_cost", "Compute itemized cost breakdown for a recipe.", { recipe_id: z.string(), currency: z.string().default("USD") }, wrap(calcCost)); server.tool("calculate_recipe_nutrition", "Calculate nutrition facts per 100g and per serving.", { recipe_id: z.string(), serving_size_g: z.number().positive().optional() }, wrap(calcNutrition)); server.tool("search_ingredients", "Search ingredients with cost/nutrition status.", { query: z.string().optional(), category: z.string().optional(), missing_cost: z.boolean().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchIngredients)); server.tool("get_ingredient", "Get ingredient detail with density, equivalencies, and prices.", { id: z.string() }, wrap(getIngredientDetail)); server.tool("list_purchase_items", "List purchase items with current prices.", { ingredient_id: z.string().optional(), status: z.string().optional(), limit: z.number().int().default(50) }, wrap(listPurchaseItems)); server.tool("convert_units", "Convert culinary units using ingredient density data.", { ingredient_id: z.string().optional(), quantity: z.number().positive(), from_unit: z.string(), to_unit: z.string() }, wrap(convertUnits)); server.tool("list_units", "List measurement units with conversion factors.", { dimension: z.string().optional(), system: z.string().optional() }, wrap(listUnits)); server.tool("list_equipment", "List kitchen equipment.", { category: z.string().optional() }, wrap(listEquipment)); server.tool("list_prep_actions", "List culinary prep actions.", {}, wrap(listPrepActions)); server.tool("list_inventory_counts", "List inventory counting sessions.", { status: z.enum(["all", "open", "completed"]).default("all") }, wrap((args) => { const db = openDb(true); try { let counts = getInventoryCounts(db); if (args.status !== "all") counts = counts.filter((c) => c.status === args.status); return counts; } finally { db.close(); } })); server.tool("get_inventory_count", "Get full inventory count sheet with items and valuations.", { id: z.string() }, wrap((args) => { const db = openDb(true); try { const d = getInventoryCountDetail(db, args.id); if (!d) throw new Error(`Count not found: ${args.id}`); return d; } finally { db.close(); } })); server.tool("list_recipe_books", "List recipe books with recipe counts.", {}, wrap(listRecipeBooks)); server.tool("get_recipe_book", "Get recipe book detail with included recipes.", { id: z.string() }, wrap(getRecipeBook)); server.tool("export_database_backup", "Export complete JSON backup of all 25 tables.", {}, wrap(() => { const db = openDb(true); try { return exportDatabase(db); } finally { db.close(); } })); server.tool("get_database_stats", "Get entity count statistics.", {}, wrap(getDatabaseStats)); const transport = new StdioServerTransport(); await server.connect(transport); console.error("Formulation MCP Server running on stdio");