From 7c911921f6b490a8fbf340c2427f5edc8d7d86bb Mon Sep 17 00:00:00 2001 From: Paul Tristan Wagner Date: Fri, 16 Oct 2026 11:20:27 +0200 Subject: [PATCH 1/2] Add delete_recipe tool for recipes and saved meals The app deletes foods through /api/v2/del_food. With retire: true (the default here) a recipe or meal leaves search and the My Foods lists while diary entries that logged it are kept. Without the flag the server deletes it outright, along with every diary entry that logged it, so that path is behind permanent=True and the tool description warns about it. Claude-Session: https://claude.ai/code/session_01MCJFDo71qoSqTRtcGfkpXJ --- README.md | 1 + src/cronometer_api_mcp/client.py | 53 ++++++++++++++++++++-- src/cronometer_api_mcp/server.py | 25 ++++++++++ tests/test_custom_food_edit.py | 78 ++++++++++++++++++++++++++++++-- tests/test_server_import.py | 2 + 5 files changed, 151 insertions(+), 8 deletions(-) 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..85e8f41 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 EVERY DIARY ENTRY + that logged it, on every day. 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(): From 7228e968b49f7080f69b74a8f0d5ebad699314c1 Mon Sep 17 00:00:00 2001 From: Paul Tristan Wagner Date: Fri, 16 Oct 2026 11:20:51 +0200 Subject: [PATCH 2/2] delete_recipe: don't overstate what a permanent delete removes Claude-Session: https://claude.ai/code/session_01MCJFDo71qoSqTRtcGfkpXJ --- src/cronometer_api_mcp/server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index 85e8f41..e388a9d 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -775,9 +775,9 @@ def delete_recipe(food_id: int, permanent: bool = False) -> str: 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 EVERY DIARY ENTRY - that logged it, on every day. Use it only for a recipe that was never - logged, or when the user has explicitly asked for those entries to go. + 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.