diff --git a/README.md b/README.md index 1f73d5e..d285d71 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ cached session, so it also overrides a stale cached timezone. |------|-------------| | `search_foods` | Search the Cronometer food database by name | | `get_food_details` | Full nutrition profile and serving sizes for a food | +| `lookup_barcode` | Find foods by the barcode on their package (EAN, UPC or GTIN), like the app's scanner | ### Diary Management diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..7b5f9ca 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -642,6 +642,30 @@ def get_foods(self, food_ids: list[int]) -> list[dict]: logger.info("Batch-fetched %d/%d foods", len(foods), len(food_ids)) return foods + def lookup_barcode(self, barcode: str) -> list[dict]: + """Find foods by product barcode, as the app's scanner does. + + Uses /api/v2/barcode, which answers with full food objects (the same + shape as get_food). The server matches loosely: leading zeros are + ignored (UPC-A "049000050103" finds foods stored as "49000050103") + and a placeholder code like "0000000000000" returns unrelated foods + whose stored barcode is "0". So the code is validated first (all zeros + is refused) and only foods that actually carry it are returned. An unknown code returns + an empty list. + """ + (code,) = normalize_barcodes([barcode]) + if not code.strip("0"): + raise ValueError(f"barcode {barcode!r} is all zeros, not a product code") + payload = {"query": code, "config": {"call_version": 2}} + data = self._request("/api/v2/barcode", payload) + foods = [ + f + for f in data.get("foods") or [] + if any(b.lstrip("0") == code.lstrip("0") for b in f.get("barcodes") or []) + ] + logger.info("Barcode %s matched %d foods", code, len(foods)) + return foods + # ------------------------------------------------------------------ # Custom food creation # ------------------------------------------------------------------ diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..5277255 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -571,6 +571,46 @@ def get_food_details(food_id: int) -> str: return _err(e) +@mcp.tool(annotations=_READ_ONLY) +def lookup_barcode(barcode: str) -> str: + """Find foods by the barcode on their package (EAN, UPC or GTIN). + + The same lookup the app's barcode scanner does. Returns each matching + food with the measure_ids needed for add_food_entry; an empty list means + Cronometer doesn't know the code (search_foods by name instead). + + Args: + barcode: The digits under the bars, e.g. "4006381333931". Spaces and + dashes are fine; the check digit is verified. + """ + try: + client = _get_client() + foods = client.lookup_barcode(barcode) + results = [] + for f in foods: + results.append( + { + "food_id": f.get("id"), + "name": f.get("name"), + "source": f.get("source"), + "default_measure_id": f.get("defaultMeasureId"), + "measures": [ + { + "measure_id": m.get("id"), + "name": m.get("name"), + "grams": m.get("value"), + "quantity": m.get("amount"), + } + for m in f.get("measures", []) + ], + "barcodes": f.get("barcodes", []), + } + ) + return _ok({"barcode": barcode, "count": len(results), "foods": results}) + except Exception as e: + return _err(e) + + # ------------------------------------------------------------------ # Custom food creation # ------------------------------------------------------------------ diff --git a/tests/test_barcode.py b/tests/test_barcode.py index 41fc159..1e0509c 100644 --- a/tests/test_barcode.py +++ b/tests/test_barcode.py @@ -1,4 +1,4 @@ -"""Tests for barcode validation of custom foods. +"""Tests for barcode validation and lookup. normalize_barcodes accepts the GTIN family (EAN-13, UPC-A, EAN-8, UPC-E, GTIN-14), strips the spaces and dashes codes are printed with, and verifies @@ -8,6 +8,7 @@ from __future__ import annotations import pytest +from test_client import make_cold_client from cronometer_api_mcp.barcode import normalize_barcodes @@ -66,3 +67,62 @@ def test_normalize_barcodes_drops_duplicates(): assert normalize_barcodes(["4006381333931", "4006 3813 3393 1"]) == [ "4006381333931" ] + + +# --------------------------------------------------------------------------- +# lookup_barcode +# --------------------------------------------------------------------------- + + +def _food(food_id: int, barcodes: list[str]) -> dict: + return {"id": food_id, "name": f"Food {food_id}", "barcodes": barcodes} + + +def test_lookup_barcode_sends_normalized_code(tmp_path): + hit = _food(1, ["4006381333931"]) + client, state = make_cold_client(tmp_path, [{"foodId": 1, "foods": [hit]}]) + + assert client.lookup_barcode("400 6381 33393 1") == [hit] + assert state["payloads"][0]["query"] == "4006381333931" + assert state["payloads"][0]["config"] == {"call_version": 2} + + +def test_lookup_barcode_matches_codes_stored_without_leading_zeros(tmp_path): + stored = _food(1, ["10694026", "49000050103"]) + client, _ = make_cold_client(tmp_path, [{"foodId": 1, "foods": [stored]}]) + + assert client.lookup_barcode("049000050103") == [stored] + + +def test_lookup_barcode_drops_foods_without_the_code(tmp_path): + """The server answers a placeholder-like code with unrelated foods whose + stored barcode is "0"; those must not come back as matches.""" + hit = _food(1, ["4006381333931"]) + junk = _food(2, ["0"]) + client, _ = make_cold_client(tmp_path, [{"foodId": 2, "foods": [junk, hit]}]) + + assert client.lookup_barcode("4006381333931") == [hit] + + +def test_lookup_barcode_unknown_code_returns_empty(tmp_path): + client, _ = make_cold_client(tmp_path, [{"result": "SUCCESS", "messages": []}]) + + assert client.lookup_barcode("4006381333931") == [] + + +def test_lookup_barcode_rejects_bad_check_digit_before_request(tmp_path): + client, state = make_cold_client(tmp_path, [{"foods": []}]) + + with pytest.raises(ValueError, match="check digit"): + client.lookup_barcode("4006381333932") + assert state["payloads"] == [] + + +def test_lookup_barcode_rejects_all_zeros(tmp_path): + """All zeros passes the check digit but would match every food stored + with the placeholder barcode "0".""" + client, state = make_cold_client(tmp_path, [{"foods": []}]) + + with pytest.raises(ValueError, match="all zeros"): + client.lookup_barcode("0000000000000") + assert state["payloads"] == [] diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..74b482b 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -27,6 +27,7 @@ "get_nutrition_scores", "import_recipe", "list_biometrics", + "lookup_barcode", "mark_day_complete", "remove_food_entry", "search_foods",