cronometer-mcp 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cronometer-mcp
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: MCP server for Cronometer nutrition data — pull food logs, macros, and micronutrients via the Model Context Protocol
5
5
  Project-URL: Homepage, https://github.com/cphoskins/cronometer-mcp
6
6
  Project-URL: Repository, https://github.com/cphoskins/cronometer-mcp
@@ -1,6 +1,6 @@
1
1
  """Cronometer MCP Server — nutrition data from Cronometer via MCP."""
2
2
 
3
- __version__ = "2.1.2"
3
+ __version__ = "2.2.0"
4
4
 
5
5
  from .client import CronometerClient
6
6
  from .markdown import generate_food_log_md
@@ -2674,37 +2674,34 @@ class CronometerClient:
2674
2674
  def _parse_repeated_items(raw: str) -> list[dict]:
2675
2675
  """Parse a GWT-RPC getRepeatedItems response.
2676
2676
 
2677
- Response format example (1 item):
2678
- //OK[0,1055762,461776,658384,1,4,0,1,3,1,1,3.0,2,1,1,
2679
- ["java.util.ArrayList/...",
2680
- "com.cronometer.shared.repeatitems.RepeatItem/477684891",
2681
- "java.lang.Integer/3438268394",
2682
- "Wasa, Crispbread, Multi Grain"],0,7]
2683
-
2684
- The string table has: ArrayList, RepeatItem, Integer, and food names.
2685
- Data section has interleaved values for each RepeatItem.
2677
+ GWT writes the data section in reverse, so it is read back to front.
2678
+ Per response: ArrayList ref, item count, then for each RepeatItem:
2679
+
2680
+ RepeatItem ref, quantity (float), flag, day_count,
2681
+ day_count x (Integer ref, day value),
2682
+ unknown, food_name ref, flag,
2683
+ repeat_item_id, food_source_id, food_id, trailing
2684
+
2685
+ Two real responses confirm the layout::
2686
+
2687
+ coffee: 12.0, 1, 7 days[0-6], 0, name, 0, 658545, 5828, 16104
2688
+ wasa: 3.0, 1, 1 day [1], 0, name, 1, 658384, 461776, 1055762
2689
+
2690
+ and coffee's 5828 / 16104 match that food's ids from food search.
2691
+
2692
+ The previous implementation guessed positions by scanning for "large
2693
+ ints", which left repeat_item_id as 0 on every item and so made
2694
+ delete_repeat_item unable to target anything.
2686
2695
  """
2687
2696
  if not raw.startswith("//OK"):
2688
2697
  return []
2689
2698
 
2690
- # Extract string table
2691
- closing = ",0,7]"
2692
- st_close = len(raw) - len(closing) - 1
2693
- depth, pos, in_str = 1, st_close - 1, False
2694
- while pos >= 0 and depth > 0:
2695
- ch = raw[pos]
2696
- if ch == '"' and (pos == 0 or raw[pos - 1] != "\\"):
2697
- in_str = not in_str
2698
- elif not in_str:
2699
- if ch == "]":
2700
- depth += 1
2701
- elif ch == "[":
2702
- depth -= 1
2703
- pos -= 1
2704
- st_open = pos + 1
2705
- string_table = json.loads(raw[st_open:st_close + 1])
2699
+ try:
2700
+ string_table = CronometerClient._extract_gwt_string_table(raw)
2701
+ except Exception:
2702
+ return []
2706
2703
 
2707
- # Extract data tokens before string table
2704
+ st_open = raw.rindex('["')
2708
2705
  data_section = raw[5:st_open].rstrip(",")
2709
2706
  if not data_section:
2710
2707
  return []
@@ -2714,83 +2711,65 @@ class CronometerClient:
2714
2711
  part = part.strip()
2715
2712
  if not part:
2716
2713
  continue
2717
- if part.startswith('"') and part.endswith('"'):
2718
- tokens.append(part.strip('"'))
2719
- continue
2720
2714
  try:
2721
2715
  tokens.append(float(part) if "." in part else int(part))
2722
2716
  except ValueError:
2723
2717
  tokens.append(None)
2724
2718
 
2725
- # Find food names in string table (not type references)
2726
- type_prefixes = ("java.", "com.cronometer.")
2727
- food_names = [
2728
- s for s in string_table
2729
- if not any(s.startswith(p) for p in type_prefixes)
2730
- ]
2719
+ rev = tokens[::-1]
2731
2720
 
2732
- # Find the RepeatItem type index
2733
- repeat_type_idx = None
2734
- for i, s in enumerate(string_table):
2735
- if "RepeatItem/" in s:
2736
- repeat_type_idx = i + 1
2737
- break
2738
-
2739
- if repeat_type_idx is None:
2740
- return []
2741
-
2742
- # Count items: look for the item count in the data
2743
- # The response starts with type refs then item data
2744
- # Find float values (quantities) to determine item count
2745
- float_tokens = [t for t in tokens if isinstance(t, float)]
2746
- item_count = len(float_tokens) # each item has exactly one float (quantity)
2747
-
2748
- if item_count == 0:
2749
- return []
2750
-
2751
- items = []
2752
- # Parse items from the token stream
2753
- # Key pattern: large ints are food_source_id, measure_id, repeat_item_id
2754
- # Small ints include day counts, day-of-week values, diary group
2755
- # The float is the quantity
2756
-
2757
- # Simple heuristic: split tokens into blocks per item
2758
- # Each item has: food_source_id, measure_id, repeat_item_id,
2759
- # day info, quantity, and references to food name
2760
-
2761
- # Find positions of float values to split blocks
2762
- float_positions = [i for i, t in enumerate(tokens) if isinstance(t, float)]
2763
-
2764
- for item_idx, fpos in enumerate(float_positions):
2765
- item = {
2766
- "repeat_item_id": 0,
2767
- "food_name": food_names[item_idx] if item_idx < len(food_names) else "",
2768
- "food_source_id": 0,
2769
- "measure_id": 0,
2770
- "quantity": tokens[fpos],
2771
- "diary_group": 0,
2772
- "days_of_week": [],
2773
- }
2774
-
2775
- # Look backwards from the float to find large ints
2776
- # (food_source_id, measure_id, repeat_item_id)
2777
- large_ints = []
2778
- start = float_positions[item_idx - 1] + 1 if item_idx > 0 else 0
2779
- block = tokens[start:fpos]
2721
+ def name_at(ref) -> str:
2722
+ if isinstance(ref, int) and 0 < ref <= len(string_table):
2723
+ return string_table[ref - 1]
2724
+ return ""
2780
2725
 
2781
- for t in block:
2782
- if isinstance(t, int) and t > 10000:
2783
- large_ints.append(t)
2784
-
2785
- # Typical order: food_source_id, measure_id, repeat_item_id
2786
- if len(large_ints) >= 3:
2787
- item["food_source_id"] = large_ints[0]
2788
- item["measure_id"] = large_ints[1]
2789
- item["repeat_item_id"] = large_ints[2]
2790
- elif len(large_ints) == 2:
2791
- item["food_source_id"] = large_ints[0]
2792
- item["measure_id"] = large_ints[1]
2793
-
2794
- items.append(item)
2726
+ items: list[dict] = []
2727
+ try:
2728
+ i = 1 # skip the ArrayList type ref
2729
+ count = rev[i]; i += 1
2730
+ if not isinstance(count, int) or count < 0:
2731
+ return []
2732
+ for _ in range(count):
2733
+ i += 1 # RepeatItem type ref
2734
+ quantity = rev[i]; i += 1
2735
+ i += 1 # flag
2736
+ day_count = rev[i]; i += 1
2737
+ days: list = []
2738
+ for _ in range(day_count):
2739
+ tok = rev[i]
2740
+ if isinstance(tok, int) and tok < 0:
2741
+ # GWT back-reference to an already-serialized Integer.
2742
+ # One token, not a (type ref, value) pair. Resolving it
2743
+ # would mean reproducing GWT's object table; the value
2744
+ # is reported as None rather than guessed. Getting the
2745
+ # token count right is what matters, since miscounting
2746
+ # here desynchronises every later field in the item.
2747
+ days.append(None); i += 1
2748
+ else:
2749
+ i += 1 # Integer type ref
2750
+ days.append(rev[i]); i += 1
2751
+ i += 1 # unknown
2752
+ food_name = name_at(rev[i]); i += 1
2753
+ # The addRepeatItem payload carries diary_group in this slot,
2754
+ # but the response does NOT echo it back. Two live experiments:
2755
+ # sent wire 2, read 1; sent wire 4, read 1. It is 1 for every
2756
+ # item created through the API and 0 for one created in the
2757
+ # Cronometer UI, so it behaves like a flag, not a group. Not
2758
+ # reported rather than reported wrong.
2759
+ i += 1
2760
+ repeat_item_id = rev[i]; i += 1
2761
+ food_source_id = rev[i]; i += 1
2762
+ food_id = rev[i]; i += 1
2763
+ i += 1 # trailing
2764
+ items.append({
2765
+ "repeat_item_id": repeat_item_id,
2766
+ "food_name": food_name,
2767
+ "food_source_id": food_source_id,
2768
+ "food_id": food_id,
2769
+ "quantity": quantity,
2770
+ "days_of_week": days,
2771
+ })
2772
+ except (IndexError, TypeError):
2773
+ return items
2795
2774
 
2796
2775
  return items
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cronometer-mcp"
7
- version = "2.1.2"
7
+ version = "2.2.0"
8
8
  description = "MCP server for Cronometer nutrition data — pull food logs, macros, and micronutrients via the Model Context Protocol"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/cphoskins/cronometer-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.1.2",
9
+ "version": "2.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "pypi",
13
13
  "identifier": "cronometer-mcp",
14
- "version": "2.1.2",
14
+ "version": "2.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -1451,14 +1451,84 @@ class TestParseRepeatedItems:
1451
1451
  result = CronometerClient._parse_repeated_items(self.SAMPLE_RESPONSE)
1452
1452
  assert len(result) == 1
1453
1453
 
1454
+ # Coffee item, which the old heuristic parser got wrong: it scanned for
1455
+ # ints > 10000 and this food's food_source_id is 5828, so it found only
1456
+ # two of the three ids and left repeat_item_id as 0 - making
1457
+ # delete_repeat_item unable to target it.
1458
+ COFFEE_RESPONSE = (
1459
+ '//OK[0,16104,5828,658545,0,4,0,6,3,5,3,4,3,3,3,2,3,1,3,0,3,7,1,12.0,2,1,1,'
1460
+ '["java.util.ArrayList/4159755760",'
1461
+ '"com.cronometer.shared.repeatitems.RepeatItem/477684891",'
1462
+ '"java.lang.Integer/3438268394",'
1463
+ '"Beverages, Coffee, Brewed, Prepared with Tap Water"],0,7]'
1464
+ )
1465
+
1454
1466
  def test_item_fields(self):
1467
+ """Ids verified against live food search, not against the old parser.
1468
+
1469
+ The old test asserted food_source_id == 1055762, which is actually
1470
+ this food's food_id. Searching Cronometer for "Wasa, Crispbread,
1471
+ Multi Grain" returns food_source_id=461776, food_id=1055762.
1472
+ """
1455
1473
  result = CronometerClient._parse_repeated_items(self.SAMPLE_RESPONSE)
1456
1474
  item = result[0]
1457
1475
  assert item["food_name"] == "Wasa, Crispbread, Multi Grain"
1458
- assert item["food_source_id"] == 1055762
1459
- assert item["measure_id"] == 461776
1476
+ assert item["food_source_id"] == 461776
1477
+ assert item["food_id"] == 1055762
1460
1478
  assert item["repeat_item_id"] == 658384
1461
1479
  assert item["quantity"] == 3.0
1480
+ assert item["days_of_week"] == [1]
1481
+ # diary_group is deliberately absent: the response does not echo it.
1482
+ assert "diary_group" not in item
1483
+
1484
+ def test_low_food_source_id_still_yields_an_id(self):
1485
+ """Regression: coffee's food_source_id (5828) broke the old heuristic."""
1486
+ item = CronometerClient._parse_repeated_items(self.COFFEE_RESPONSE)[0]
1487
+ assert item["repeat_item_id"] == 658545
1488
+ assert item["food_source_id"] == 5828 # verified via food search
1489
+ assert item["food_id"] == 16104 # verified via food search
1490
+ assert item["quantity"] == 12.0
1491
+ assert item["days_of_week"] == [0, 1, 2, 3, 4, 5, 6]
1492
+
1493
+ # Real two-item response. The second item's day is the GWT back-reference
1494
+ # "-5" (one token, not a type-ref/value pair); miscounting it desynced the
1495
+ # parser and produced food_source_id as the repeat_item_id.
1496
+ TWO_ITEM_RESPONSE = (
1497
+ '//OK[0,66395420,24272784,852756,1,5,0,-5,1,1,2.0,2,0,16104,5828,658545,'
1498
+ '0,4,0,6,3,5,3,4,3,3,3,2,3,1,3,0,3,7,1,12.0,2,2,1,'
1499
+ '["java.util.ArrayList/4159755760",'
1500
+ '"com.cronometer.shared.repeatitems.RepeatItem/477684891",'
1501
+ '"java.lang.Integer/3438268394",'
1502
+ '"Beverages, Coffee, Brewed, Prepared with Tap Water",'
1503
+ '"Safe Catch, Wild Elite Pure Tuna"],0,7]'
1504
+ )
1505
+
1506
+ def test_parses_two_items_with_back_reference(self):
1507
+ items = CronometerClient._parse_repeated_items(self.TWO_ITEM_RESPONSE)
1508
+ assert len(items) == 2
1509
+
1510
+ coffee, tuna = items
1511
+ assert coffee["repeat_item_id"] == 658545
1512
+ assert coffee["food_source_id"] == 5828
1513
+ assert coffee["food_id"] == 16104
1514
+
1515
+ # Ids confirmed live: this item was created with exactly these values
1516
+ # and deleted afterwards using the id parsed here.
1517
+ assert tuna["food_name"] == "Safe Catch, Wild Elite Pure Tuna"
1518
+ assert tuna["repeat_item_id"] == 852756
1519
+ assert tuna["food_source_id"] == 24272784
1520
+ assert tuna["food_id"] == 66395420
1521
+ assert tuna["quantity"] == 2.0
1522
+
1523
+ def test_back_referenced_day_is_none_not_a_guess(self):
1524
+ tuna = CronometerClient._parse_repeated_items(self.TWO_ITEM_RESPONSE)[1]
1525
+ assert tuna["days_of_week"] == [None]
1526
+
1527
+ def test_repeat_item_id_is_never_silently_zero(self):
1528
+ """A zero id is unusable: delete_repeat_item cannot target it."""
1529
+ for raw in (self.SAMPLE_RESPONSE, self.COFFEE_RESPONSE):
1530
+ for item in CronometerClient._parse_repeated_items(raw):
1531
+ assert item["repeat_item_id"], "id must be usable for deletion"
1462
1532
 
1463
1533
  def test_returns_empty_for_invalid(self):
1464
1534
  assert CronometerClient._parse_repeated_items("//EX[err]") == []
File without changes
File without changes