Files
formulation/scripts/mcp-server.mjs
T
nicholasandnicholas 2a1e16ed30
Build & Deploy Formulation / Build & Push Image (push) Failing after 15s
Build & Deploy Formulation / deploy (push) Skipped
add core features (#14)
Reviewed-on: #14
Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
2026-08-18 18:22:34 -05:00

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");