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.
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/PKG-INFO +1 -1
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/cronometer_mcp/__init__.py +1 -1
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/cronometer_mcp/client.py +76 -97
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/pyproject.toml +1 -1
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/server.json +2 -2
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/tests/test_client.py +72 -2
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/.github/workflows/publish.yml +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/.gitignore +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/LICENSE +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/README.md +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/RESEARCH-copy-repeat-gwt-rpc.md +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/SPEC-diary-groups.md +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/cronometer_mcp/markdown.py +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/cronometer_mcp/server.py +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/release.sh +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/tests/__init__.py +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/tests/test_diary_groups.py +0 -0
- {cronometer_mcp-2.1.2 → cronometer_mcp-2.2.0}/tests/test_markdown.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cronometer-mcp
|
|
3
|
-
Version: 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
|
|
@@ -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
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
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
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
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
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "pypi",
|
|
13
13
|
"identifier": "cronometer-mcp",
|
|
14
|
-
"version": "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"] ==
|
|
1459
|
-
assert item["
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|