Reviewed-on: #14 Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
336 lines
18 KiB
JavaScript
336 lines
18 KiB
JavaScript
#!/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");
|