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 @@ -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

Expand Down
24 changes: 24 additions & 0 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
40 changes: 40 additions & 0 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
62 changes: 61 additions & 1 deletion tests/test_barcode.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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"] == []
1 change: 1 addition & 0 deletions tests/test_server_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"get_nutrition_scores",
"import_recipe",
"list_biometrics",
"lookup_barcode",
"mark_day_complete",
"remove_food_entry",
"search_foods",
Expand Down