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:
@@ -0,0 +1,99 @@
|
||||
"""Cases taken from failures verified against the live Mealie instance."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from mealie_mcp.normalize import (
|
||||
clean_title,
|
||||
find_english_leftovers,
|
||||
has_extraction_failure,
|
||||
ingredient_display_lines,
|
||||
looks_mangled,
|
||||
normalize_for_parser,
|
||||
slugify,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeForParser:
|
||||
def test_converts_swedish_decimal_comma(self):
|
||||
assert normalize_for_parser("4,7 dl basmatiris") == "4.7 dl basmatiris"
|
||||
|
||||
def test_leaves_list_commas_alone(self):
|
||||
assert normalize_for_parser("salt, peppar") == "salt, peppar"
|
||||
|
||||
def test_collapses_whitespace(self):
|
||||
assert normalize_for_parser(" 2 msk olja ") == "2 msk olja"
|
||||
|
||||
def test_is_idempotent(self):
|
||||
once = normalize_for_parser("0,6 dl grädde")
|
||||
assert normalize_for_parser(once) == once
|
||||
|
||||
|
||||
class TestCleanTitle:
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("Fläskfilé med svampsås - se & gör", "Fläskfilé med svampsås"),
|
||||
("Kycklinggryta | Köket.se", "Kycklinggryta"),
|
||||
("Pannkakor - se och gör", "Pannkakor"),
|
||||
("Lax i ugn", "Lax i ugn"),
|
||||
],
|
||||
)
|
||||
def test_strips_campaign_suffixes(self, raw, expected):
|
||||
assert clean_title(raw) == expected
|
||||
|
||||
def test_strips_stacked_suffixes(self):
|
||||
assert clean_title("Ugnslax - se & gör | Köket.se") == "Ugnslax"
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_transliterates_swedish_characters(self):
|
||||
assert slugify("Kycklinggryta med äpple och lök") == "kycklinggryta-med-apple-och-lok"
|
||||
|
||||
def test_expands_ampersand(self):
|
||||
assert slugify("Förrätter & tilltugg") == "forratter-och-tilltugg"
|
||||
|
||||
|
||||
class TestFindEnglishLeftovers:
|
||||
def test_flags_untranslated_units(self):
|
||||
assert set(find_english_leftovers("2 cups flour, 1 tbsp butter")) == {"cups", "tbsp"}
|
||||
|
||||
def test_clean_swedish_line_passes(self):
|
||||
assert find_english_leftovers("2 dl vetemjöl, 1 msk smör") == []
|
||||
|
||||
def test_does_not_match_inside_swedish_words(self):
|
||||
# "klyfta" contains no English token; substring matching would be wrong here.
|
||||
assert find_english_leftovers("1 klyfta vitlök") == []
|
||||
|
||||
|
||||
class TestLooksMangled:
|
||||
def test_flags_composed_fraction_from_parser(self):
|
||||
assert looks_mangled("47⁄10 liter basmatiris")
|
||||
|
||||
def test_flags_duplicated_unit(self):
|
||||
assert looks_mangled("2 dl dl grädde")
|
||||
|
||||
def test_clean_line_is_not_mangled(self):
|
||||
assert not looks_mangled("4,7 dl basmatiris")
|
||||
|
||||
|
||||
class TestExtractionFailure:
|
||||
def test_detects_scraper_placeholder_in_ingredients(self):
|
||||
recipe = {"recipeIngredient": [{"display": "Could not detect ingredients"}]}
|
||||
assert has_extraction_failure(recipe)
|
||||
|
||||
def test_clean_recipe_passes(self):
|
||||
recipe = {"recipeIngredient": [{"display": "2 dl grädde"}]}
|
||||
assert not has_extraction_failure(recipe)
|
||||
|
||||
|
||||
class TestIngredientDisplayLines:
|
||||
def test_prefers_display_then_note_then_food(self):
|
||||
recipe = {
|
||||
"recipeIngredient": [
|
||||
{"display": "2 dl grädde", "note": "ignored"},
|
||||
{"display": "", "note": "1 gul lök"},
|
||||
{"display": "", "note": "", "food": {"name": "salt"}},
|
||||
]
|
||||
}
|
||||
assert ingredient_display_lines(recipe) == ["2 dl grädde", "1 gul lök", "salt"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from mealie_mcp.server import scale_ingredients
|
||||
|
||||
|
||||
def test_module_imports():
|
||||
assert callable(scale_ingredients)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Server-layer behaviour, driven through a mocked HTTP transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mealie_mcp import server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mealie(monkeypatch):
|
||||
"""Install a fake Mealie and record every request the server makes."""
|
||||
def install(handler):
|
||||
recorded: list[httpx.Request] = []
|
||||
|
||||
def wrapped(request: httpx.Request) -> httpx.Response:
|
||||
recorded.append(request)
|
||||
return handler(request)
|
||||
|
||||
def fake_client() -> httpx.Client:
|
||||
return httpx.Client(
|
||||
base_url="https://mealie.test",
|
||||
headers={"Authorization": "Bearer test", "User-Agent": server.UA},
|
||||
transport=httpx.MockTransport(wrapped),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(server, "_client", fake_client)
|
||||
return recorded
|
||||
|
||||
return install
|
||||
|
||||
|
||||
class TestErrorMapping:
|
||||
def test_401_is_reported_as_an_auth_problem(self, mock_mealie):
|
||||
mock_mealie(lambda r: httpx.Response(401, json={"detail": "Could not validate credentials"}))
|
||||
result = server.check_auth()
|
||||
assert result["ok"] is False
|
||||
assert result["reason"] == "auth"
|
||||
assert "token" in result["detail"]
|
||||
|
||||
def test_cloudflare_1010_is_not_mistaken_for_bad_credentials(self, mock_mealie):
|
||||
# Verified failure: the external hostname returns 403 + "error code: 1010"
|
||||
# for blocked client fingerprints. Calling that an auth error sends
|
||||
# debugging down the wrong path.
|
||||
mock_mealie(lambda r: httpx.Response(403, text="error code: 1010"))
|
||||
result = server.check_auth()
|
||||
assert result["reason"] == "waf"
|
||||
assert "1010" in result["detail"]
|
||||
|
||||
def test_healthy_instance_reports_ok(self, mock_mealie):
|
||||
mock_mealie(lambda r: httpx.Response(200, json={"username": "fredrik"}))
|
||||
result = server.check_auth()
|
||||
assert result["ok"] is True
|
||||
assert result["user"] == "fredrik"
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
def test_requests_never_use_the_default_user_agent(self, mock_mealie):
|
||||
recorded = mock_mealie(lambda r: httpx.Response(200, json={"username": "fredrik"}))
|
||||
server.check_auth()
|
||||
agent = recorded[0].headers["user-agent"]
|
||||
assert agent == server.UA
|
||||
assert not agent.startswith(("python-httpx", "Python-urllib"))
|
||||
|
||||
|
||||
class TestDuplicateDetection:
|
||||
def test_import_stops_when_the_source_url_already_exists(self, mock_mealie):
|
||||
existing = {"slug": "kycklinggryta", "name": "Kycklinggryta", "orgURL": "https://koket.se/a"}
|
||||
mock_mealie(lambda r: httpx.Response(200, json={"items": [existing]}))
|
||||
result = server.import_recipe_url("https://koket.se/a")
|
||||
assert result["imported"] is False
|
||||
assert result["reason"] == "duplicate"
|
||||
|
||||
def test_trailing_slash_does_not_hide_a_duplicate(self, mock_mealie):
|
||||
existing = {"slug": "x", "name": "X", "orgURL": "https://koket.se/a/"}
|
||||
mock_mealie(lambda r: httpx.Response(200, json={"items": [existing]}))
|
||||
assert server.find_by_source_url("https://koket.se/a")
|
||||
|
||||
|
||||
class TestImportReport:
|
||||
def test_report_flags_failed_extraction_and_junk_title(self):
|
||||
recipe = {
|
||||
"slug": "flaskfile",
|
||||
"name": "Fläskfilé med svampsås - se & gör",
|
||||
"recipeIngredient": [{"display": "Could not detect ingredients"}],
|
||||
"recipeInstructions": [],
|
||||
"image": "",
|
||||
}
|
||||
report = server._import_report(recipe)
|
||||
assert report["extraction_failed"] is True
|
||||
assert report["suggested_title"] == "Fläskfilé med svampsås"
|
||||
assert report["has_image_field"] is False
|
||||
|
||||
def test_clean_import_suggests_no_title_change(self):
|
||||
recipe = {"slug": "lax", "name": "Lax i ugn", "recipeIngredient": [], "image": "img"}
|
||||
assert server._import_report(recipe)["suggested_title"] is None
|
||||
|
||||
|
||||
class TestParseIngredients:
|
||||
def test_swedish_display_text_survives_the_parser(self, mock_mealie):
|
||||
canonical = "4,7 dl basmatiris"
|
||||
recipe = {
|
||||
"slug": "ris",
|
||||
"recipeIngredient": [{"display": canonical, "note": canonical}],
|
||||
}
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
if request.url.path == "/api/parser/ingredients":
|
||||
seen["sent"] = _json.loads(request.content)["ingredients"]
|
||||
# The parser returns mangled display text; it must not be stored.
|
||||
return httpx.Response(200, json=[{
|
||||
"ingredient": {
|
||||
"quantity": 4.7,
|
||||
"unit": {"id": "u", "name": "dl"},
|
||||
"food": {"id": "f", "name": "basmatiris"},
|
||||
"display": "47⁄10 liter basmatiris",
|
||||
}
|
||||
}])
|
||||
if request.method == "PATCH":
|
||||
seen["patched"] = _json.loads(request.content)["recipeIngredient"]
|
||||
return httpx.Response(200, json={})
|
||||
return httpx.Response(200, json=recipe)
|
||||
|
||||
mock_mealie(handler)
|
||||
server.parse_ingredients("ris")
|
||||
|
||||
# Decimal comma is normalized for the parser only...
|
||||
assert seen["sent"] == ["4.7 dl basmatiris"]
|
||||
patched = seen["patched"][0]
|
||||
# ...while the stored human-facing line stays the original Swedish text.
|
||||
assert patched["display"] == canonical
|
||||
assert patched["note"] == canonical
|
||||
# Structured data from the parser is still applied.
|
||||
assert patched["quantity"] == 4.7
|
||||
assert patched["food"]["name"] == "basmatiris"
|
||||
|
||||
def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie):
|
||||
recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/api/parser/ingredients":
|
||||
return httpx.Response(200, json=[{"ingredient": {"quantity": 2}}])
|
||||
if request.method == "PATCH":
|
||||
# Verified: this PATCH can 500 with a ValueError on this instance.
|
||||
return httpx.Response(500, json={"detail": "ValueError"})
|
||||
return httpx.Response(200, json=recipe)
|
||||
|
||||
mock_mealie(handler)
|
||||
result = server.parse_ingredients("ris")
|
||||
assert result["parsed"] is False
|
||||
assert result["canonical_lines"] == ["2 dl grädde"]
|
||||
|
||||
|
||||
class TestCoverImage:
|
||||
def test_local_upload_sends_the_required_extension_field(self, mock_mealie, tmp_path):
|
||||
# Verified: without the multipart `extension` field the upload fails validation.
|
||||
image = tmp_path / "cover.webp"
|
||||
image.write_bytes(b"fake")
|
||||
recorded = mock_mealie(lambda r: httpx.Response(200, json={"slug": "x", "image": "img"}))
|
||||
server.set_cover_image("x", image_path=str(image))
|
||||
upload = next(r for r in recorded if r.method == "PUT")
|
||||
body = upload.content.decode("utf-8", errors="replace")
|
||||
assert 'name="extension"' in body
|
||||
assert "webp" in body
|
||||
|
||||
def test_requires_a_source(self):
|
||||
with pytest.raises(ValueError):
|
||||
server.set_cover_image("x")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Boundary contracts for the verified Mealie instance quirks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mealie_mcp import server
|
||||
|
||||
|
||||
def response(status: int, text: str = "") -> httpx.Response:
|
||||
return httpx.Response(status, text=text, request=httpx.Request("GET", "https://example.test"))
|
||||
|
||||
|
||||
class TestAuthenticationFailures:
|
||||
def test_401_is_an_auth_problem(self):
|
||||
with pytest.raises(server.MealieAuthError):
|
||||
server._raise_for_status(response(401))
|
||||
|
||||
def test_cloudflare_1010_is_not_reported_as_bad_token(self):
|
||||
with pytest.raises(server.MealieBlockedError):
|
||||
server._raise_for_status(response(403, "error code: 1010"))
|
||||
|
||||
|
||||
class TestUrlImportResponse:
|
||||
def test_slug_only_import_response_is_resolved_before_reporting(self, monkeypatch):
|
||||
recipe = {"slug": "lax-med-citron", "name": "Lax med citron", "recipeIngredient": []}
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def request(method, path, **kwargs):
|
||||
calls.append((method, path))
|
||||
assert kwargs["json"] == {"url": "https://example.test/lax"}
|
||||
return "lax-med-citron"
|
||||
|
||||
monkeypatch.setattr(server, "find_by_source_url", lambda _url: [])
|
||||
monkeypatch.setattr(server, "_request", request)
|
||||
monkeypatch.setattr(server, "get_recipe", lambda slug: recipe if slug == "lax-med-citron" else None)
|
||||
|
||||
result = server.import_recipe_url("https://example.test/lax")
|
||||
|
||||
assert calls == [("POST", "/api/recipes/create/url")]
|
||||
assert result["imported"] is True
|
||||
assert result["recipe"] == recipe
|
||||
assert result["report"]["slug"] == "lax-med-citron"
|
||||
|
||||
def test_duplicate_source_url_stops_before_creating(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"find_by_source_url",
|
||||
lambda _url: [{"slug": "redan-finns", "name": "Redan finns"}],
|
||||
)
|
||||
monkeypatch.setattr(server, "_request", lambda *_args, **_kwargs: pytest.fail("must not import"))
|
||||
|
||||
result = server.import_recipe_url("https://example.test/recept")
|
||||
|
||||
assert result == {
|
||||
"imported": False,
|
||||
"reason": "duplicate",
|
||||
"existing": [{"slug": "redan-finns", "name": "Redan finns"}],
|
||||
}
|
||||
|
||||
|
||||
class TestPatchContract:
|
||||
def test_patch_recipe_reads_back_the_final_object(self, monkeypatch):
|
||||
seen = []
|
||||
updated = {"slug": "soppa", "name": "Soppa", "tags": [{"name": "📅 Vardag"}]}
|
||||
|
||||
def request(method, path, **kwargs):
|
||||
seen.append((method, path, kwargs))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(server, "_request", request)
|
||||
monkeypatch.setattr(server, "get_recipe", lambda slug: updated if slug == "soppa" else None)
|
||||
|
||||
assert server.patch_recipe("soppa", {"tags": updated["tags"]}) == updated
|
||||
assert seen == [("PATCH", "/api/recipes/soppa", {"json": {"tags": updated["tags"]}})]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""The finished-import checklist must fail loudly on every verified failure mode."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from mealie_mcp.verify import is_parsed, verify_recipe
|
||||
|
||||
|
||||
def complete_recipe(**overrides):
|
||||
"""A recipe that satisfies every check, so each test can break exactly one thing."""
|
||||
recipe = {
|
||||
"name": "Kycklinggryta med kokos",
|
||||
"slug": "kycklinggryta-med-kokos",
|
||||
"description": "Krämig gryta med kokosmjölk och röd curry.",
|
||||
"recipeInstructions": [{"text": "Bryn kycklingen."}, {"text": "Häll i kokosmjölken."}],
|
||||
"recipeIngredient": [
|
||||
{
|
||||
"display": "400 g kycklinglårfilé",
|
||||
"quantity": 400,
|
||||
"unit": {"id": "u1", "name": "g"},
|
||||
"food": {"id": "f1", "name": "kycklinglårfilé"},
|
||||
},
|
||||
{
|
||||
"display": "1 burk kokosmjölk",
|
||||
"quantity": 1,
|
||||
"unit": {"id": "u2", "name": "burk"},
|
||||
"food": {"id": "f2", "name": "kokosmjölk"},
|
||||
},
|
||||
],
|
||||
"image": "abc123",
|
||||
"recipeCategory": [{"name": "Huvudrätter"}],
|
||||
"tags": [{"name": "Källa: Köket"}],
|
||||
"orgURL": "https://www.koket.se/kycklinggryta",
|
||||
}
|
||||
recipe.update(overrides)
|
||||
return recipe
|
||||
|
||||
|
||||
def status_of(report, check):
|
||||
return next(c["status"] for c in report["checks"] if c["check"] == check)
|
||||
|
||||
|
||||
class TestCompleteRecipe:
|
||||
def test_fully_finished_import_passes(self):
|
||||
report = verify_recipe(complete_recipe(), image_verified=True)
|
||||
assert report["complete"] is True
|
||||
assert report["failed"] == []
|
||||
|
||||
|
||||
class TestCoverImage:
|
||||
def test_image_field_alone_is_only_a_warning_not_a_pass(self):
|
||||
# Verified: a non-empty image field does not prove the UI shows a cover.
|
||||
report = verify_recipe(complete_recipe(), image_verified=False)
|
||||
assert status_of(report, "cover_image") == "warn"
|
||||
|
||||
def test_missing_image_fails(self):
|
||||
report = verify_recipe(complete_recipe(image=None))
|
||||
assert status_of(report, "cover_image") == "fail"
|
||||
assert report["complete"] is False
|
||||
|
||||
|
||||
class TestSwedishContent:
|
||||
def test_english_ingredient_line_fails(self):
|
||||
recipe = complete_recipe()
|
||||
recipe["recipeIngredient"][0]["display"] = "2 cups flour"
|
||||
report = verify_recipe(recipe, image_verified=True)
|
||||
assert status_of(report, "swedish_ingredients") == "fail"
|
||||
assert report["complete"] is False
|
||||
|
||||
def test_english_instructions_fail(self):
|
||||
recipe = complete_recipe(recipeInstructions=[{"text": "Add 2 tbsp of butter"}])
|
||||
report = verify_recipe(recipe, image_verified=True)
|
||||
assert status_of(report, "swedish_prose") == "fail"
|
||||
|
||||
def test_swedish_title_only_is_not_enough(self):
|
||||
# Rule: translation is not done until the ingredient lines themselves are Swedish.
|
||||
recipe = complete_recipe()
|
||||
recipe["recipeIngredient"][1]["display"] = "1 clove garlic"
|
||||
report = verify_recipe(recipe, image_verified=True)
|
||||
assert report["complete"] is False
|
||||
|
||||
|
||||
class TestParseState:
|
||||
def test_unstructured_ingredients_leave_recipe_unparsed(self):
|
||||
recipe = complete_recipe(
|
||||
recipeIngredient=[{"display": "400 g kycklinglårfilé"}, {"display": "1 burk kokosmjölk"}]
|
||||
)
|
||||
assert is_parsed(recipe) is False
|
||||
report = verify_recipe(recipe, image_verified=True)
|
||||
assert status_of(report, "ingredients_parsed") == "fail"
|
||||
|
||||
def test_empty_recipe_is_not_parsed(self):
|
||||
assert is_parsed({"recipeIngredient": []}) is False
|
||||
|
||||
def test_structured_ingredients_are_parsed(self):
|
||||
assert is_parsed(complete_recipe()) is True
|
||||
|
||||
|
||||
class TestReadability:
|
||||
def test_parser_mangled_display_fails_even_when_structured(self):
|
||||
# Structure is worthless if the human-facing line is garbage.
|
||||
recipe = complete_recipe()
|
||||
recipe["recipeIngredient"][0]["display"] = "47⁄10 liter basmatiris"
|
||||
report = verify_recipe(recipe, image_verified=True)
|
||||
assert status_of(report, "ingredient_lines_readable") == "fail"
|
||||
|
||||
|
||||
class TestExtractionPlaceholders:
|
||||
def test_could_not_detect_placeholder_fails(self):
|
||||
recipe = complete_recipe()
|
||||
recipe["recipeIngredient"][0]["display"] = "Could not detect ingredients"
|
||||
report = verify_recipe(recipe, image_verified=True)
|
||||
assert status_of(report, "no_extraction_placeholders") == "fail"
|
||||
|
||||
|
||||
class TestTaxonomy:
|
||||
def test_untagged_recipe_fails_without_a_reason(self):
|
||||
report = verify_recipe(
|
||||
complete_recipe(recipeCategory=[], tags=[]), image_verified=True
|
||||
)
|
||||
assert status_of(report, "taxonomy") == "fail"
|
||||
|
||||
def test_deliberate_skip_passes(self):
|
||||
report = verify_recipe(
|
||||
complete_recipe(recipeCategory=[], tags=[]),
|
||||
image_verified=True,
|
||||
taxonomy_skipped_reason="Fredrik sorterar den manuellt",
|
||||
)
|
||||
assert status_of(report, "taxonomy") == "pass"
|
||||
assert report["complete"] is True
|
||||
|
||||
|
||||
class TestAttribution:
|
||||
def test_missing_source_warns_but_does_not_block(self):
|
||||
report = verify_recipe(
|
||||
complete_recipe(orgURL=None, extras={}), image_verified=True
|
||||
)
|
||||
assert status_of(report, "attribution") == "warn"
|
||||
assert report["complete"] is True
|
||||
Reference in New Issue
Block a user