feat: implement Formulation REST API (v1), MCP Server, and full-fidelity database backup/restore system

This commit is contained in:
2026-08-17 14:26:01 -05:00
parent f3d81d598c
commit e3f45d1256
57 changed files with 7022 additions and 482 deletions
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { DatabaseSync } from "node:sqlite";
const root = path.resolve(import.meta.dirname, "..");
const databasePath = path.join(root, "var", "recipe-book.sqlite");
// Dynamically import compiled or source backup engine
import { exportDatabase } from "../src/lib/backup/export-database.ts";
import { importDatabase } from "../src/lib/backup/import-database.ts";
import { validateBackupBundle } from "../src/lib/backup/validate-backup.ts";
function printUsage() {
console.log(`
Formulation Database Backup & Restore Tool
Usage:
node scripts/backup.mjs export [output-path.json]
node scripts/backup.mjs import <input-path.json> [--replace | --merge]
node scripts/backup.mjs validate <input-path.json>
Commands:
export Extracts all 25 SQLite tables into a standardized JSON backup bundle.
import Restores or merges a backup bundle into the active SQLite database.
validate Checks a backup JSON file for schema integrity without modifying the database.
Options:
--replace (Default for import) Atomically replaces existing database records.
--merge Upserts imported records without deleting unmentioned existing data.
`);
}
async function main() {
const args = process.argv.slice(2);
const command = args[0]?.toLowerCase();
if (!command || command === "--help" || command === "-h" || command === "help") {
printUsage();
process.exit(0);
}
if (command === "export") {
if (!fs.existsSync(databasePath)) {
console.error(`Error: Database not found at ${databasePath}`);
process.exit(1);
}
const db = new DatabaseSync(databasePath);
try {
console.log("Exporting full Formulation database...");
const bundle = exportDatabase(db);
const defaultName = `formulation-backup-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
const outputPath = args[1] ? path.resolve(process.cwd(), args[1]) : path.join(process.cwd(), defaultName);
fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2), "utf8");
console.log(`\n✅ Backup exported successfully to: ${outputPath}`);
console.log(` - Recipes: ${bundle.summary.recipes_count}`);
console.log(` - Ingredients: ${bundle.summary.ingredients_count}`);
console.log(` - Purchase Items: ${bundle.summary.purchase_items_count}`);
console.log(` - Collections: ${bundle.summary.collections_count}`);
console.log(` - Inventory Counts: ${bundle.summary.inventory_counts_count}`);
console.log(` - Total Entities: ${bundle.summary.total_records_count}`);
} finally {
db.close();
}
return;
}
if (command === "validate") {
const inputPath = args[1];
if (!inputPath) {
console.error("Error: Please specify the path to a backup JSON file to validate.");
process.exit(1);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`Error: File not found: ${resolvedPath}`);
process.exit(1);
}
const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
const result = validateBackupBundle(content);
if (result.valid) {
console.log(`\n✅ Backup file '${inputPath}' is valid!`);
if (result.summary) {
console.log(` - Format Version: ${content.format_version}`);
console.log(` - Exported At: ${content.exported_at}`);
console.log(` - Recipes: ${result.summary.recipes_count}`);
console.log(` - Ingredients: ${result.summary.ingredients_count}`);
console.log(` - Purchase Items: ${result.summary.purchase_items_count}`);
console.log(` - Inventory Counts: ${result.summary.inventory_counts_count}`);
console.log(` - Total Entities: ${result.summary.total_records_count}`);
}
} else {
console.error(`\n❌ Backup validation failed:`);
for (const err of result.errors) console.error(` - ${err}`);
process.exit(1);
}
return;
}
if (command === "import") {
const inputPath = args[1];
if (!inputPath) {
console.error("Error: Please specify the path to a backup JSON file to import.");
process.exit(1);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`Error: File not found: ${resolvedPath}`);
process.exit(1);
}
const mode = args.includes("--merge") ? "merge" : "replace";
const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
if (!fs.existsSync(databasePath)) {
console.error(`Error: Database not found at ${databasePath}`);
process.exit(1);
}
const db = new DatabaseSync(databasePath);
try {
console.log(`Importing '${inputPath}' into database (mode: ${mode})...`);
const result = importDatabase(db, content, { mode, rebuildProjections: true });
console.log(`\n${result.message}`);
} catch (err) {
console.error(`\n❌ Import failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
} finally {
db.close();
}
return;
}
console.error(`Error: Unknown command '${command}'`);
printUsage();
process.exit(1);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
+15 -4
View File
@@ -8,8 +8,14 @@ import { writeSiteProjection } from "./lib/site-projection.mjs";
const root = path.resolve(import.meta.dirname, "..");
const databasePath = path.join(root, "var", "recipe-book.sqlite");
if (!process.argv.includes("--reset")) throw new Error("Database initialization replaces the local database. Re-run with --reset.");
if (fs.existsSync(databasePath)) fs.rmSync(databasePath);
console.error(
"❌ ERROR: Direct SQLite database resets from culinary YAML files are disabled.\n" +
"The SQLite database (var/recipe-book.sqlite) and its JSON backup snapshots (scripts/backup.mjs) are the canonical source of truth.\n" +
"To backup the database: npm run db:backup -- [backup.json]\n" +
"To restore from backup: npm run db:restore -- <backup.json>\n"
);
process.exit(1);
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const db = new DatabaseSync(databasePath);
db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;");
@@ -22,7 +28,12 @@ const records = (directory) => {
const run = (sql, values) => db.prepare(sql).run(...values);
const json = (value) => JSON.stringify(value ?? []);
db.exec(fs.readFileSync(path.join(root, "migrations", "001_initial.sql"), "utf8"));
const migrationsDir = path.join(root, "migrations");
for (const file of fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort()) {
try {
db.exec(fs.readFileSync(path.join(migrationsDir, file), "utf8"));
} catch {}
}
db.exec("BEGIN IMMEDIATE");
try {
@@ -61,7 +72,7 @@ try {
JOIN prep_actions a ON a.id = p.action_id WHERE i.ingredient_id IS NOT NULL
ON CONFLICT(ingredient_id, action_id) DO NOTHING`);
for (const item of records("purchase_items")) {
run("INSERT INTO purchase_items VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]);
run("INSERT INTO purchase_items(id, ingredient_id, name, brand, supplier_id, supplier_sku, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]);
for (const price of item.prices) run("INSERT INTO price_observations VALUES (?, ?, ?, ?, ?)", [item.id, price.effective_at, price.currency, price.amount, json(price.source)]);
}
for (const mapping of records("source_mappings")) run("INSERT INTO source_mappings VALUES (?, ?, ?, ?, ?, ?, ?)", [mapping.id, mapping.subject.type, mapping.subject.id, mapping.mapping_type, mapping.status, json(mapping.source), mapping.nutrition_per_100g ? json(mapping.nutrition_per_100g) : null]);
+1 -3
View File
@@ -48,8 +48,6 @@ export function createSiteProjection(database) {
export function writeSiteProjection(database, target=path.resolve(process.cwd(),"generated","site-projection.json")) {
fs.mkdirSync(path.dirname(target),{recursive:true});
const temporary=`${target}.tmp`;
fs.writeFileSync(temporary,`${JSON.stringify(createSiteProjection(database))}\n`);
fs.renameSync(temporary,target);
fs.writeFileSync(target,`${JSON.stringify(createSiteProjection(database))}\n`);
return target;
}
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import YAML from "yaml";
const root = path.resolve(import.meta.dirname, "..");
const recipesDir = path.join(root, "culinary", "recipes");
const autofix = process.argv.includes("--fix") || process.argv.includes("--autofix");
const gerundMap = {
"mixing": "Mix",
"combining": "Combine",
"adding": "Add",
"whisking": "Whisk",
"stirring": "Stir",
"baking": "Bake",
"cooking": "Cook",
"heating": "Heat",
"pouring": "Pour",
"placing": "Place",
"cutting": "Cut",
"dicing": "Dice",
"chopping": "Chop",
"kneading": "Knead",
"rolling": "Roll",
"folding": "Fold",
"preheating": "Preheat",
"seasoning": "Season",
"simmering": "Simmer",
"boiling": "Boil",
"cooling": "Cool",
"refrigerating": "Refrigerate",
"freezing": "Freeze",
"storing": "Store",
"serving": "Serve",
"garnishing": "Garnish",
};
function standardizeInstruction(text) {
let clean = text.trim();
if (!clean) return clean;
// Check if it's an inline note: (Note: ...) or (...)
if (clean.startsWith("(") && clean.endsWith(")")) {
return clean;
}
// Check if it's a section heading: ends with colon
if (clean.endsWith(":")) {
// Capitalize first character
clean = clean.charAt(0).toUpperCase() + clean.slice(1);
return clean;
}
// Check for common gerund starts
const words = clean.split(/\s+/);
const firstWordLower = words[0].toLowerCase();
if (gerundMap[firstWordLower]) {
words[0] = gerundMap[firstWordLower];
clean = words.join(" ");
}
// Capitalize first letter
clean = clean.charAt(0).toUpperCase() + clean.slice(1);
// Replace trailing comma, semicolon, or dash with period
clean = clean.replace(/[,;\-\s]+$/, "");
// Ensure terminal punctuation if not ending with : or )
if (!clean.endsWith(".") && !clean.endsWith("!") && !clean.endsWith("?") && !clean.endsWith(":") && !clean.endsWith(")")) {
clean += ".";
}
return clean;
}
function lintRecipeFile(filePath) {
const content = fs.readFileSync(filePath, "utf8");
const data = YAML.parse(content);
if (!data || !Array.isArray(data.steps)) return { warnings: [], errors: [], changed: false };
const issues = [];
let changed = false;
const newSteps = data.steps.map((step, index) => {
const original = step.instruction ?? "";
const standardized = standardizeInstruction(original);
if (original !== standardized) {
issues.push({
stepOrder: step.order ?? index + 1,
original,
standardized,
});
if (autofix) {
changed = true;
return { ...step, instruction: standardized };
}
}
return step;
});
if (changed && autofix) {
data.steps = newSteps;
fs.writeFileSync(filePath, YAML.stringify(data, { indent: 2, lineWidth: 0 }), "utf8");
}
return { issues, changed };
}
function main() {
const files = fs.readdirSync(recipesDir).filter((f) => f.endsWith(".yaml")).sort();
let totalIssues = 0;
let filesModified = 0;
console.log(`Auditing ${files.length} recipe instruction files against Microsoft procedural guidelines...`);
for (const file of files) {
const filePath = path.join(recipesDir, file);
const { issues, changed } = lintRecipeFile(filePath);
if (issues.length > 0) {
totalIssues += issues.length;
if (changed) filesModified++;
console.log(`\n📄 ${file} (${issues.length} issue${issues.length > 1 ? "s" : ""}):`);
for (const issue of issues) {
console.log(` Step ${issue.stepOrder}:`);
console.log(` - Current: "${issue.original}"`);
console.log(` + Standard: "${issue.standardized}"`);
}
}
}
console.log("\n--------------------------------------------------");
if (autofix) {
console.log(`✅ Standardized ${totalIssues} instructions across ${filesModified} recipe files.`);
} else {
console.log(`Found ${totalIssues} non-standard instructions.`);
if (totalIssues > 0) {
console.log("Run with --fix to apply automated standardization.");
}
}
}
main();
+335
View File
@@ -0,0 +1,335 @@
#!/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");