feat: implement Formulation REST API (v1), MCP Server, and full-fidelity database backup/restore system
This commit is contained in:
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user