Initial commit: Mealie MCP server

Ports a working Hermes agent skill to an MCP server. The skill's value was not
its Mealie endpoints but ~25 operational rules found by running imports against
the live instance; those are now code with tests rather than prompt text.

The server owns deterministic mechanics — auth, the non-default User-Agent
Cloudflare requires, the `extension` field on cover uploads, PATCH instead of
the 500-ing bulk-action endpoints, decimal-comma normalization, and restoring
Swedish display text after parsing. Language and taste judgement stay with the
model, fed by the four reference notes shipped as MCP resources.

verify_recipe runs the finished-import definition as code so an agent cannot
report success on a recipe with an English ingredient line, a missing cover, or
ingredients still in the "Click Parse" state.

Home Assistant is optional and isolated; the Obsidian meal plan is out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 07:31:10 +02:00
commit e5c134b045
17 changed files with 1643 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
"""The 'definition of a finished import' as executable checks.
This exists so an agent cannot report a successful import while the recipe still
has an English title, a missing cover image, or unparsed ingredients. Pure
functions over a recipe dict — see resources/import-pitfalls.md for the field
observations these encode.
"""
from __future__ import annotations
from typing import Any, Literal
from .normalize import (
find_english_leftovers,
has_extraction_failure,
ingredient_display_lines,
looks_mangled,
)
Status = Literal["pass", "fail", "warn"]
def _check(check_id: str, status: Status, detail: str) -> dict[str, str]:
return {"check": check_id, "status": status, "detail": detail}
def is_parsed(recipe: dict[str, Any]) -> bool:
"""Whether Mealie will stop showing 'your ingredients aren't parsed yet'.
Mealie treats ingredients as parsed once they carry structured data; an
ingredient with only free text keeps the recipe in the 'Click Parse' state.
"""
ingredients = recipe.get("recipeIngredient") or []
if not ingredients:
return False
return any(
(item.get("food") or {}).get("id") or (item.get("unit") or {}).get("id")
for item in ingredients
)
def verify_recipe(
recipe: dict[str, Any],
*,
image_verified: bool = False,
taxonomy_skipped_reason: str | None = None,
) -> dict[str, Any]:
"""Run every finished-import check and return a pass/fail report.
``image_verified`` should only be set by a caller that actually ran an image
step, because a non-empty ``image`` field alone does not prove the UI shows a
cover (verified failure mode on Allrecipes imports).
``taxonomy_skipped_reason`` records a deliberate decision to leave a recipe
uncategorized, which turns that check into a pass instead of a failure.
"""
checks: list[dict[str, str]] = []
name = (recipe.get("name") or "").strip()
checks.append(
_check("title_present", "pass" if name else "fail", name or "Recipe has no title")
)
description = (recipe.get("description") or "").strip()
checks.append(
_check(
"description_present",
"pass" if description else "fail",
"Description set" if description else "Description is empty",
)
)
instructions = [
(step.get("text") or "").strip() for step in recipe.get("recipeInstructions") or []
]
instructions = [step for step in instructions if step]
checks.append(
_check(
"instructions_present",
"pass" if instructions else "fail",
f"{len(instructions)} step(s)" if instructions else "No instruction steps",
)
)
leftovers = find_english_leftovers(" ".join([name, description, *instructions]))
checks.append(
_check(
"swedish_prose",
"pass" if not leftovers else "fail",
"No English unit/label words found"
if not leftovers
else f"English leftovers in title/description/instructions: {', '.join(leftovers)}",
)
)
lines = ingredient_display_lines(recipe)
empty_lines = [index for index, line in enumerate(lines) if not line.strip()]
checks.append(
_check(
"ingredient_lines_present",
"pass" if lines and not empty_lines else "fail",
f"{len(lines)} ingredient line(s)"
if lines and not empty_lines
else f"Missing display text at index {empty_lines}"
if lines
else "Recipe has no ingredients",
)
)
ingredient_leftovers = find_english_leftovers(" ".join(lines))
checks.append(
_check(
"swedish_ingredients",
"pass" if not ingredient_leftovers else "fail",
"Ingredient lines are Swedish"
if not ingredient_leftovers
else f"English leftovers in ingredient lines: {', '.join(ingredient_leftovers)}",
)
)
mangled = [line for line in lines if looks_mangled(line)]
checks.append(
_check(
"ingredient_lines_readable",
"pass" if not mangled else "fail",
"Display text is clean"
if not mangled
else f"Parser-mangled display text: {mangled}",
)
)
parsed = is_parsed(recipe)
checks.append(
_check(
"ingredients_parsed",
"pass" if parsed else "fail",
"Structured food/unit data present"
if parsed
else "Recipe still shows the 'Click Parse' state",
)
)
checks.append(
_check(
"no_extraction_placeholders",
"fail" if has_extraction_failure(recipe) else "pass",
"Contains 'Could not detect ...' placeholder text"
if has_extraction_failure(recipe)
else "No scraper placeholders",
)
)
has_image_field = bool(recipe.get("image"))
if image_verified:
image_status: Status = "pass"
image_detail = "Image step ran and was verified by the caller"
elif has_image_field:
image_status = "warn"
image_detail = (
"image field is set but no image step was verified; a non-empty image "
"does not prove the UI shows a cover"
)
else:
image_status = "fail"
image_detail = "No cover image"
checks.append(_check("cover_image", image_status, image_detail))
categories = recipe.get("recipeCategory") or []
tags = recipe.get("tags") or []
if categories or tags:
checks.append(
_check(
"taxonomy",
"pass",
f"{len(categories)} category/categories, {len(tags)} tag(s)",
)
)
elif taxonomy_skipped_reason:
checks.append(_check("taxonomy", "pass", f"Deliberately skipped: {taxonomy_skipped_reason}"))
else:
checks.append(_check("taxonomy", "fail", "No categories or tags, and no skip reason given"))
source = recipe.get("orgURL") or (recipe.get("extras") or {}).get("source")
checks.append(
_check(
"attribution",
"pass" if source else "warn",
f"Source recorded: {source}" if source else "No orgURL or extras.source",
)
)
failed = [c for c in checks if c["status"] == "fail"]
warned = [c for c in checks if c["status"] == "warn"]
return {
"recipe": name or recipe.get("slug"),
"complete": not failed,
"failed": [c["check"] for c in failed],
"warnings": [c["check"] for c in warned],
"checks": checks,
}