Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
53 changes: 50 additions & 3 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
"""
Expand All @@ -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
# ------------------------------------------------------------------
Expand Down
25 changes: 25 additions & 0 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
78 changes: 73 additions & 5 deletions tests/test_custom_food_edit.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions tests/test_server_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"add_recipe",
"copy_day",
"delete_custom_food",
"delete_recipe",
"get_biometrics",
"get_daily_nutrition",
"get_fasting_history",
Expand Down Expand Up @@ -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():
Expand Down