From e45156f11bbc89fc80a8c297665d04aa38c0189e Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 31 Jul 2026 09:34:43 +0200 Subject: [PATCH] Add verified rollback for failed recipe imports Co-authored-by: Bumble Co-authored-by: fredamn76 Signed-off-by: fredamn76 --- README.md | 1 + mealie_mcp/server.py | 33 +++++++++++++++++++++++++++++++++ tests/test_server.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/README.md b/README.md index e600618..3b449c8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ campaign junk in the title. A response is not proof of a good import. **Write** - `patch_recipe(slug_or_id, patch)` +- `delete_recipe(slug_or_id, confirm_slug)` — exact-slug confirmation plus 404 read-back proof - `parse_ingredients(slug_or_id)` - `set_cover_image(slug_or_id, source_url=None, image_path=None)` - `shopping_list_add(items)` diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index ac9a52d..2617136 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -335,6 +335,39 @@ def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]: return get_recipe(slug_or_id) +@mcp.tool() +def delete_recipe(slug_or_id: str, confirm_slug: str) -> dict[str, Any]: + """Delete exactly one recipe, with explicit slug confirmation and read-back proof. + + This is primarily the rollback path for failed imports and marked test recipes. + The caller must first read the recipe and pass its exact stored slug as + ``confirm_slug``. Names and ids are not accepted as confirmation. + """ + recipe = get_recipe(slug_or_id) + stored_slug = recipe.get("slug") or "" + if confirm_slug != stored_slug: + raise ValueError( + f"Deletion confirmation does not match stored slug {stored_slug!r}" + ) + + with _client() as client: + response = client.delete(f"/api/recipes/{stored_slug}") + _raise_for_status(response) + probe = client.get(f"/api/recipes/{stored_slug}") + if probe.status_code != 404: + raise RuntimeError( + f"Delete returned {response.status_code}, but read-back returned " + f"{probe.status_code} instead of 404" + ) + return { + "deleted": True, + "slug": stored_slug, + "name": recipe.get("name"), + "delete_status": response.status_code, + "readback_status": probe.status_code, + } + + def _unit_records() -> list[dict[str, Any]]: """The instance's own unit table — the authority for what `dl` and `msk` mean.""" data = _request("GET", "/api/units", params={"perPage": 200}) diff --git a/tests/test_server.py b/tests/test_server.py index 4e71bb3..725b856 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -126,6 +126,48 @@ class TestTextImport: assert server.import_recipe_text(text)["recipe"] == recipe +class TestDeleteRecipe: + def test_requires_the_exact_stored_slug(self, monkeypatch): + monkeypatch.setattr( + server, + "get_recipe", + lambda _slug: {"slug": "stored-slug", "name": "Test"}, + ) + monkeypatch.setattr( + server, + "_client", + lambda: pytest.fail("must not delete without exact confirmation"), + ) + + with pytest.raises(ValueError, match="stored-slug"): + server.delete_recipe("recipe-id", "wrong-slug") + + def test_deletes_and_proves_the_recipe_is_gone(self, mock_mealie): + recipe = {"slug": "zzz-test", "name": "ZZZ Test"} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "DELETE": + return httpx.Response(200, json={}) + if request.url.path == "/api/recipes/zzz-test": + # First GET is get_recipe; second GET is the deletion proof. + if getattr(handler, "read_once", False): + return httpx.Response(404, json={"detail": "Not Found"}) + handler.read_once = True + return httpx.Response(200, json=recipe) + return httpx.Response(500) + + mock_mealie(handler) + result = server.delete_recipe("zzz-test", "zzz-test") + + assert result == { + "deleted": True, + "slug": "zzz-test", + "name": "ZZZ Test", + "delete_status": 200, + "readback_status": 404, + } + + class TestParseIngredients: def test_reverts_structure_when_mealie_rewords_the_swedish_line(self, mock_mealie): canonical = "4,7 dl basmatiris"