diff --git a/README.md b/README.md index 1f73d5e..b44f48b 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ cached session, so it also overrides a stale cached timezone. | `add_custom_food` | Create a custom food with specified nutrition and optional barcodes | | `update_custom_food` | Edit a custom food in place: name, nutrition, serving size, or barcodes | | `delete_custom_food` | Retire a custom food so it leaves search and the Custom Foods list (existing diary entries are kept) | +| `delete_recipe` | Delete a recipe or saved meal: retired by default (diary entries are kept), or permanently, which also removes the diary entries that logged it | | `add_recipe` | Create a recipe from existing foods referenced by ID and gram weight | | `import_recipe` | Create a recipe from a free-text ingredient list; Cronometer matches each line to a database food and converts the amount to grams | | `copy_day` | Copy all entries from the previous day | diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..d36547d 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -992,9 +992,7 @@ def retire_custom_food(self, food_id: int) -> dict: Sets `retired` on the food and saves it. A retired food drops out of search and the Custom Foods list but stays readable by id and keeps - existing diary entries intact. The app's own delete removes the food - outright through an endpoint this client doesn't know; retiring is the - closest the known API offers, and it is idempotent. + existing diary entries intact. It is idempotent. Returns {"food_id": int, "name": str, "retired": True}. """ @@ -1005,6 +1003,55 @@ def retire_custom_food(self, food_id: int) -> dict: logger.info("Retired custom food %r (id=%d)", food.get("name"), food_id) return {"food_id": food_id, "name": food.get("name"), "retired": True} + def delete_recipe(self, food_id: int, *, permanent: bool = False) -> dict: + """Delete a user-created recipe or saved meal. + + Uses /api/v2/del_food, the app's own delete. By default it sends + `retire: true`, which is what the app does for a food that is already + logged: the recipe leaves search and the My Foods lists, but stays + readable by id and every diary entry that uses it is kept. + + With permanent=True the flag is left off and the server deletes the + recipe outright. That also removes every diary entry that logged it + (verified against the live API), so it is only safe for a recipe that + was never logged. The app then drops the id from the recent-foods + list via /api/v2/delete_recent_foods; so does this method. + + Plain custom foods are refused here; retire_custom_food handles them. + + Returns {"food_id": int, "name": str, "meal": bool, "permanent": bool}. + """ + with self._food_lock(food_id): + food = self.get_food(food_id) + if food.get("source") != "Custom" or not food.get("ingredients"): + raise CronometerError( + f"Food {food_id} ({food.get('name')!r}) is not a recipe or " + f"meal you created; use delete_custom_food for custom foods" + ) + payload: dict = {"id": food_id, "config": {"call_version": 1}} + if not permanent: + payload["retire"] = True + data = self._request("/api/v2/del_food", payload) + if data.get("result") != "SUCCESS": + raise CronometerError(f"Failed to delete recipe {food_id}: {data}") + if permanent: + self._request( + "/api/v2/delete_recent_foods", + {"servingFoodIds": [food_id], "config": {"call_version": 2}}, + ) + logger.info( + "%s recipe %r (id=%d)", + "Deleted" if permanent else "Retired", + food.get("name"), + food_id, + ) + return { + "food_id": food_id, + "name": food.get("name"), + "meal": bool(food.get("meal")), + "permanent": permanent, + } + # ------------------------------------------------------------------ # Recipe creation # ------------------------------------------------------------------ diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..e388a9d 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -767,6 +767,31 @@ def delete_custom_food(food_id: int) -> str: return _err(e) +@mcp.tool(annotations=_DESTRUCTIVE) +def delete_recipe(food_id: int, permanent: bool = False) -> str: + """Delete a recipe or saved meal that you created. + + By default the recipe is retired, as the app does for a recipe that has + been logged: it disappears from search and from the recipe and meal + lists, and diary entries that already use it are kept. + + permanent=True deletes it outright. This ALSO REMOVES THE DIARY ENTRIES + that logged it. Use it only for a recipe that was never logged, or when + the user has explicitly asked for those entries to go. + + Plain custom foods are not accepted; use delete_custom_food for those. + + Args: + food_id: ID of the recipe or meal to delete. + permanent: Delete outright instead of retiring (see above). + """ + try: + client = _get_client() + return _ok(client.delete_recipe(food_id, permanent=permanent)) + except Exception as e: + return _err(e) + + # ------------------------------------------------------------------ # Recipe creation # ------------------------------------------------------------------ diff --git a/tests/test_custom_food_edit.py b/tests/test_custom_food_edit.py index 224a4fa..8856d3d 100644 --- a/tests/test_custom_food_edit.py +++ b/tests/test_custom_food_edit.py @@ -1,9 +1,9 @@ -"""update_custom_food / retire_custom_food. +"""update_custom_food / retire_custom_food / delete_recipe. -Both ride on /api/v2/add_food, which upserts: re-sending a food object with -its existing id edits it in place, and flipping `retired` hides it from search -and the Custom Foods list (the app's own delete hard-deletes via an endpoint we -haven't found; retiring is the closest the known API offers). +The first two ride on /api/v2/add_food, which upserts: re-sending a food +object with its existing id edits it in place, and flipping `retired` hides it +from search and the Custom Foods list. delete_recipe uses the app's own +/api/v2/del_food, which retires (`retire: true`) or deletes outright. """ from __future__ import annotations @@ -494,6 +494,74 @@ def test_retire_custom_food_refuses_recipes(tmp_path): assert len(state["payloads"]) == 1 +# --------------------------------------------------------------------------- +# delete_recipe +# --------------------------------------------------------------------------- + +DELETED = {"result": "SUCCESS", "messages": []} + + +def test_delete_recipe_retires_by_default(tmp_path): + client, state = make_cold_client(tmp_path, [recipe(), DELETED]) + + result = client.delete_recipe(FOOD_ID) + + sent = state["payloads"][1] + assert sent["id"] == FOOD_ID + assert sent["retire"] is True + assert sent["config"] == {"call_version": 1} + assert len(state["payloads"]) == 2 # recent foods untouched + assert result == { + "food_id": FOOD_ID, + "name": "Test Recipe", + "meal": False, + "permanent": False, + } + + +def test_delete_recipe_permanent_deletes_and_clears_recent_foods(tmp_path): + client, state = make_cold_client(tmp_path, [recipe(), DELETED, DELETED]) + + result = client.delete_recipe(FOOD_ID, permanent=True) + + assert "retire" not in state["payloads"][1] + assert state["payloads"][1]["id"] == FOOD_ID + assert state["payloads"][2]["servingFoodIds"] == [FOOD_ID] + assert state["payloads"][2]["config"] == {"call_version": 2} + assert result["permanent"] is True + + +def test_delete_recipe_accepts_saved_meals(tmp_path): + client, _ = make_cold_client(tmp_path, [recipe(meal=True), DELETED]) + + assert client.delete_recipe(FOOD_ID)["meal"] is True + + +@pytest.mark.parametrize( + "food", + [custom_food(), recipe(source="USDA")], + ids=["plain custom food", "database food"], +) +def test_delete_recipe_refuses_non_recipes(tmp_path, food): + client, state = make_cold_client(tmp_path, [food, DELETED]) + + with pytest.raises(CronometerError, match="not a recipe or meal"): + client.delete_recipe(FOOD_ID) + + assert len(state["payloads"]) == 1 + + +def test_delete_recipe_raises_when_server_fails(tmp_path): + client, state = make_cold_client( + tmp_path, [recipe(), {"result": "FAIL", "error": "nope"}] + ) + # A FAIL triggers one re-login and retry; keep failing so it surfaces. + with pytest.raises(CronometerError): + client.delete_recipe(FOOD_ID) + + assert all("servingFoodIds" not in p for p in state["payloads"]) + + # --------------------------------------------------------------------------- # Per-food locking # --------------------------------------------------------------------------- diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..431915f 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -17,6 +17,7 @@ "add_recipe", "copy_day", "delete_custom_food", + "delete_recipe", "get_biometrics", "get_daily_nutrition", "get_fasting_history", @@ -91,6 +92,7 @@ def test_tool_annotations_reach_wire_model(): assert tools["get_food_log"].annotations.read_only_hint is True assert tools["add_food_entry"].annotations.read_only_hint is False assert tools["delete_custom_food"].annotations.destructive_hint is True + assert tools["delete_recipe"].annotations.destructive_hint is True def test_no_client_constructed_at_import():